Compare commits
93 Commits
python-sec
...
source-rep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e064be3a9 | ||
|
|
0dab7b40ba | ||
|
|
1efbe7fd4d | ||
|
|
2b917af84a | ||
|
|
2db9fe082e | ||
|
|
74d6b67559 | ||
|
|
fbdeab0cbe | ||
|
|
5dca75813e | ||
|
|
d38163b3c4 | ||
|
|
41aefd43ef | ||
|
|
3fc7ff2dd7 | ||
|
|
4119de0efb | ||
|
|
d2a16a941c | ||
|
|
f4ddd4fc5f | ||
|
|
407c01ae12 | ||
|
|
7d7f4949b6 | ||
|
|
f8dacfd625 | ||
|
|
359a734d6a | ||
|
|
fb57a8a25b | ||
|
|
b30595a039 | ||
|
|
8c5584eabf | ||
|
|
bcad4cb7ea | ||
|
|
cab3185150 | ||
|
|
c3577ead7c | ||
|
|
33dd058e87 | ||
|
|
3544ee8d74 | ||
|
|
2fee466fee | ||
|
|
0b509bae5d | ||
|
|
0d5a8bb0fa | ||
|
|
48839d4557 | ||
|
|
6f64a8ee1a | ||
|
|
1186c1d776 | ||
|
|
f1648d2b44 | ||
|
|
c98ad951f1 | ||
|
|
ade04feca2 | ||
|
|
0e6863f82c | ||
|
|
5068d1ea63 | ||
|
|
cb06a45335 | ||
|
|
5c2f06c5c0 | ||
|
|
863745dffa | ||
|
|
ab17347d52 | ||
|
|
bf3a4c4511 | ||
|
|
e1b0b932ab | ||
|
|
203d655b67 | ||
|
|
f1d7ca6fc8 | ||
|
|
ddae2986fd | ||
|
|
bb1a7e858e | ||
|
|
068b3ae555 | ||
|
|
b054fc7506 | ||
|
|
9a24de6a7f | ||
|
|
efb1ca35e0 | ||
|
|
41fcf86cea | ||
|
|
b926a7cfe1 | ||
|
|
2115ba1003 | ||
|
|
f1fc09066f | ||
|
|
d1a09ea96b | ||
|
|
c08af08644 | ||
|
|
c332891abe | ||
|
|
38d3ba25da | ||
|
|
fe636e407c | ||
|
|
0b05432c83 | ||
|
|
87f692d322 | ||
|
|
1c109fbe47 | ||
|
|
1e72879686 | ||
|
|
44abc72e41 | ||
|
|
203f4634a4 | ||
|
|
d628faede4 | ||
|
|
6e0f26ad93 | ||
|
|
948b195147 | ||
|
|
6dd0902524 | ||
|
|
ff07319188 | ||
|
|
fee278f513 | ||
|
|
fc712fc408 | ||
|
|
f5d7a6fb7f | ||
|
|
7495f22d30 | ||
|
|
5b9b39e258 | ||
|
|
875a6cdd48 | ||
|
|
c52388ff9e | ||
|
|
1b3467dac6 | ||
|
|
f11b036052 | ||
|
|
39be6138e2 | ||
|
|
da25997533 | ||
|
|
19031cdf23 | ||
|
|
a1473efa6a | ||
|
|
ea5e224c38 | ||
|
|
84dd785de9 | ||
|
|
31877fdc03 | ||
|
|
996a08ff06 | ||
|
|
250a13dba2 | ||
|
|
20eaed0063 | ||
|
|
7001d662ab | ||
|
|
abebd8c72d | ||
|
|
7d8ca0f76d |
@@ -2256,6 +2256,16 @@
|
||||
enterprise/serialization
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/replacement
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.replacement.api}
|
||||
:uses #{api
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
util
|
||||
enterprise/dependencies}}
|
||||
|
||||
enterprise/sandbox
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.sandbox.api.routes
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
[metabase-enterprise.metabot-v3.tools.api]
|
||||
[metabase-enterprise.permission-debug.api]
|
||||
[metabase-enterprise.remote-sync.api]
|
||||
[metabase-enterprise.replacement.api]
|
||||
[metabase-enterprise.sandbox.api.routes]
|
||||
[metabase-enterprise.scim.routes]
|
||||
[metabase-enterprise.semantic-search.api]
|
||||
@@ -121,6 +122,7 @@
|
||||
"/dependencies" (premium-handler metabase-enterprise.dependencies.api/routes :dependencies)
|
||||
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
|
||||
"/remote-sync" (premium-handler metabase-enterprise.remote-sync.api/routes :remote-sync)
|
||||
"/replacement" (premium-handler metabase-enterprise.replacement.api/routes :dependencies)
|
||||
"/embedding-hub" (premium-handler metabase-enterprise.embedding-hub.api/routes :embedding)
|
||||
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
|
||||
(premium-handler :attached-dwh)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
(ns metabase-enterprise.replacement.api
|
||||
"`/api/ee/replacement/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.replacement.source :as replacement.source]
|
||||
[metabase-enterprise.replacement.source-swap :as replacement.source-swap]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private entity-type-enum
|
||||
[:enum :card :table])
|
||||
|
||||
(mr/def ::column
|
||||
[:map
|
||||
[:name :string]
|
||||
[:display_name :string]
|
||||
[:base_type :string]
|
||||
[:effective_type :string]
|
||||
[:semantic_type [:maybe :string]]
|
||||
[:description [:maybe :string]]])
|
||||
|
||||
(mr/def ::error
|
||||
[:map
|
||||
[:type [:enum :missing-column :column-type-mismatch :missing-primary-key :extra-primary-key :missing-foreign-key :foreign-key-mismatch]]
|
||||
[:columns [:sequential ::column]]])
|
||||
|
||||
(mr/def ::check-replace-source-response
|
||||
[:map
|
||||
[:success :boolean]
|
||||
[:errors {:optional true} [:sequential ::error]]])
|
||||
|
||||
(api.macros/defendpoint :post "/check-replace-source" :- ::check-replace-source-response
|
||||
"Check whether a source entity can be replaced by a target entity. Returns compatibility
|
||||
errors describing column mismatches, type mismatches, primary key mismatches, and foreign
|
||||
key mismatches."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [source_entity_id source_entity_type target_entity_id target_entity_type]}
|
||||
:- [:map
|
||||
[:source_entity_id ms/PositiveInt]
|
||||
[:source_entity_type entity-type-enum]
|
||||
[:target_entity_id ms/PositiveInt]
|
||||
[:target_entity_type entity-type-enum]]]
|
||||
(let [errors (replacement.source/check-replace-source
|
||||
[source_entity_type source_entity_id]
|
||||
[target_entity_type target_entity_id])]
|
||||
(if (empty? errors)
|
||||
{:success true}
|
||||
{:success false :errors errors})))
|
||||
|
||||
(api.macros/defendpoint :post "/replace-source" :- [:map [:swapped [:sequential :any]]]
|
||||
"Replace all usages of a particular table or card with a different table or card"
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [source_entity_id source_entity_type target_entity_id target_entity_type]}
|
||||
:- [:map
|
||||
[:source_entity_id ms/PositiveInt]
|
||||
[:source_entity_type entity-type-enum]
|
||||
[:target_entity_id ms/PositiveInt]
|
||||
[:target_entity_type entity-type-enum]]]
|
||||
(let [errors (replacement.source/check-replace-source
|
||||
[source_entity_type source_entity_id]
|
||||
[target_entity_type target_entity_id])]
|
||||
(when (seq errors)
|
||||
(throw (ex-info "Sources are not replaceable" {:status-code 400
|
||||
:errors errors}))))
|
||||
(replacement.source-swap/swap-source [source_entity_type source_entity_id] [target_entity_type target_entity_id]))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/replacement` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,126 @@
|
||||
(ns metabase-enterprise.replacement.source
|
||||
(:require
|
||||
[clojure.data :as data]
|
||||
[medley.core :as m]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private swappable-sources
|
||||
"The types of sources that can be swapped"
|
||||
#{:metadata/card :metadata/table})
|
||||
|
||||
(def ^:private required-matched-column-keys
|
||||
"When checking if a column is equivalent, these keys must match"
|
||||
[:lib/desired-column-alias :fk-target-field-id])
|
||||
|
||||
(def ^:private matchable-types
|
||||
"When checking if a column is equivalent, if one of the column's types is in this set, the other column must have the
|
||||
same type"
|
||||
#{:type/PK :type/FK})
|
||||
|
||||
(defn equivalent-column? [old-col new-col]
|
||||
(and (every? #(= (% old-col) (% new-col))
|
||||
required-matched-column-keys)
|
||||
(or (and (not (matchable-types (:effective-type old-col)))
|
||||
(not (matchable-types (:effective-type old-col))))
|
||||
(= (:effective-type old-col)
|
||||
(:effective-type new-col)))))
|
||||
|
||||
(defmulti ^:private columns (fn [_mp metadata]
|
||||
(:lib/type metadata)))
|
||||
|
||||
(defmethod columns :metadata/card
|
||||
[mp card]
|
||||
(lib/returned-columns (lib/query mp card)))
|
||||
|
||||
(defmethod columns :metadata/table
|
||||
[mp table]
|
||||
(lib/fields mp (:id table)))
|
||||
|
||||
(defn- format-column [col]
|
||||
{:name (or (:lib/desired-column-alias col) (:name col))
|
||||
:display_name (or (:display-name col) "")
|
||||
:base_type (some-> (:base-type col) u/qualified-name)
|
||||
:effective_type (some-> (:effective-type col) u/qualified-name)
|
||||
:semantic_type (some-> (:semantic-type col) u/qualified-name)
|
||||
:description (:description col)})
|
||||
|
||||
(defn- missing-semantic-type-columns
|
||||
"Returns formatted columns that have `sem-type` in `from-by-name` but not in `to-by-name`
|
||||
for columns present in both maps."
|
||||
[sem-type from-by-name to-by-name]
|
||||
(let [common-names (filter to-by-name (keys from-by-name))]
|
||||
(into []
|
||||
(comp (filter #(= sem-type (:semantic-type (from-by-name %))))
|
||||
(remove #(= sem-type (:semantic-type (to-by-name %))))
|
||||
(map from-by-name)
|
||||
(map format-column))
|
||||
common-names)))
|
||||
|
||||
(defn- fetch-source
|
||||
"Fetch source metadata and its database ID."
|
||||
[entity-type entity-id]
|
||||
(case entity-type
|
||||
:card (let [mp (lib-be/application-database-metadata-provider
|
||||
(t2/select-one-fn :database_id :model/Card :id entity-id))
|
||||
card (lib.metadata/card mp entity-id)]
|
||||
{:mp mp :source card :database-id (:database-id card)})
|
||||
:table (let [db-id (t2/select-one-fn :db_id :model/Table :id entity-id)
|
||||
mp (lib-be/application-database-metadata-provider db-id)
|
||||
table (lib.metadata/table mp entity-id)]
|
||||
{:mp mp :source table :database-id (:db-id table)})))
|
||||
|
||||
(defn check-replace-source
|
||||
"Check whether `old-source` can be replaced by `new-source`. Returns a sequence of error
|
||||
maps describing incompatibilities. An empty sequence means the sources are compatible.
|
||||
Arguments match `swap-source`: each is a `[entity-type entity-id]` pair."
|
||||
[[old-type old-id] [new-type new-id]]
|
||||
(let [{source-mp :mp old-source :source} (fetch-source old-type old-id)
|
||||
{new-source :source} (fetch-source new-type new-id)
|
||||
;; TODO (Alex P 2026-02-13): Perhaps filtering out remaps should go into the lib
|
||||
old-cols (into [] (remove :remapped-from) (lib/returned-columns (lib/query source-mp old-source)))
|
||||
new-cols (into [] (remove :remapped-from) (lib/returned-columns (lib/query source-mp new-source)))
|
||||
old-by-name (m/index-by :lib/desired-column-alias old-cols)
|
||||
new-by-name (m/index-by :lib/desired-column-alias new-cols)
|
||||
[missing-names _] (data/diff (set (keys old-by-name)) (set (keys new-by-name)))
|
||||
missing-cols (mapv (comp format-column old-by-name) missing-names)
|
||||
common-names (filter new-by-name (keys old-by-name))
|
||||
type-mismatches (into []
|
||||
(comp (remove #(= (:effective-type (old-by-name %))
|
||||
(:effective-type (new-by-name %))))
|
||||
(map new-by-name)
|
||||
(map format-column))
|
||||
common-names)
|
||||
missing-pks (missing-semantic-type-columns :type/PK old-by-name new-by-name)
|
||||
extra-pks (missing-semantic-type-columns :type/PK new-by-name old-by-name)
|
||||
missing-fks (missing-semantic-type-columns :type/FK old-by-name new-by-name)
|
||||
fk? #(= :type/FK (:semantic-type %))
|
||||
fk-mismatches (into []
|
||||
(comp (filter (comp fk? old-by-name))
|
||||
(filter (comp fk? new-by-name))
|
||||
(remove #(= (:fk-target-field-id (old-by-name %))
|
||||
(:fk-target-field-id (new-by-name %))))
|
||||
(map new-by-name)
|
||||
(map format-column))
|
||||
common-names)]
|
||||
(cond-> []
|
||||
(seq missing-cols)
|
||||
(conj {:type :missing-column :columns missing-cols})
|
||||
|
||||
(seq type-mismatches)
|
||||
(conj {:type :column-type-mismatch :columns type-mismatches})
|
||||
|
||||
(seq missing-pks)
|
||||
(conj {:type :missing-primary-key :columns missing-pks})
|
||||
|
||||
(seq extra-pks)
|
||||
(conj {:type :extra-primary-key :columns extra-pks})
|
||||
|
||||
(seq missing-fks)
|
||||
(conj {:type :missing-foreign-key :columns missing-fks})
|
||||
|
||||
(seq fk-mismatches)
|
||||
(conj {:type :foreign-key-mismatch :columns fk-mismatches}))))
|
||||
@@ -0,0 +1,297 @@
|
||||
(ns metabase-enterprise.replacement.source-swap
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.walk]
|
||||
[metabase-enterprise.replacement.usages :as usages]
|
||||
[metabase.driver.common.parameters :as params]
|
||||
[metabase.driver.common.parameters.parse :as params.parse]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib.convert :as lib.convert]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.schema :as lib.schema]
|
||||
[metabase.lib.schema.parameter :as lib.schema.parameter]
|
||||
[metabase.lib.walk :as lib.walk]
|
||||
[metabase.models.visualization-settings :as vs]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(mr/def ::source-ref
|
||||
"A reference to a card or table, e.g. [:card 123] or [:table 45].
|
||||
|
||||
Called 'source-ref' because these are things that can be a query's :source-card or
|
||||
:source-table. This is distinct from 'entity keys' in the dependency system —
|
||||
dashboards, transforms, etc. can *depend on* sources (and appear in `usages` output)
|
||||
but cannot themselves *be* sources."
|
||||
[:tuple
|
||||
[:enum :card :table]
|
||||
pos-int?])
|
||||
|
||||
(defn- normalize-mbql-stages [query]
|
||||
(lib.walk/walk-clauses
|
||||
query
|
||||
(fn [query path-type path clause]
|
||||
(when (lib/is-field-clause? clause)
|
||||
(-> (lib.walk/apply-f-for-stage-at-path lib/metadata query path clause)
|
||||
lib/ref)))))
|
||||
|
||||
;; see [QUE-3121: update parameters](https://linear.app/metabase/issue/QUE-3121/update-parameters)
|
||||
(mu/defn- upgrade-parameter-target :- ::lib.schema.parameter/target
|
||||
"Upgrades parameter target refs to use strings where appropriate
|
||||
|
||||
(upgrade-parameter-target query [:dimension [:field 7 nil] {:stage-number 0}])
|
||||
-> [:dimension [:field \"TOTAL\" {:base-type :type/Float}] {:stage-number 0}]"
|
||||
[query :- ::lib.schema/query
|
||||
target :- ::lib.schema.parameter/target]
|
||||
(or (when-let [field-ref (lib/parameter-target-field-ref target)]
|
||||
(let [{:keys [stage-number], :as options, :or {stage-number -1}} (lib/parameter-target-dimension-options target)
|
||||
stage-count (lib/stage-count query)]
|
||||
(when (and (>= stage-number -1)
|
||||
(< stage-number stage-count))
|
||||
(let [filterable-columns (lib/filterable-columns query stage-number)]
|
||||
(when-let [matching-column (lib/find-matching-column query stage-number field-ref filterable-columns)]
|
||||
#_{:clj-kondo/ignore [:discouraged-var]} ;; ignore ->legacy-MBQL
|
||||
[:dimension (-> matching-column lib/ref lib/->legacy-MBQL) options])))))
|
||||
target))
|
||||
|
||||
(defn- normalize-native-stages [query]
|
||||
;; TODO: make this work
|
||||
query)
|
||||
|
||||
(defn- normalize-query [query]
|
||||
(cond-> query
|
||||
(lib/any-native-stage? query) normalize-native-stages
|
||||
(not (lib/native-only-query? query)) normalize-mbql-stages))
|
||||
|
||||
(def ^:private source-type->stage-key
|
||||
{:card :source-card
|
||||
:table :source-table})
|
||||
|
||||
(defn- update-mbql-stages [query [old-source-type old-source-id] [new-source-type new-source-id] id-updates]
|
||||
(let [old-key (source-type->stage-key old-source-type)
|
||||
new-key (source-type->stage-key new-source-type)]
|
||||
(metabase.lib.walk/walk
|
||||
query
|
||||
(fn [query path-type path stage-or-join]
|
||||
(cond-> stage-or-join
|
||||
(= (old-key stage-or-join) old-source-id) (-> (dissoc old-key)
|
||||
(assoc new-key new-source-id)))))))
|
||||
|
||||
(defn- replace-template-tags
|
||||
"Replaces references to `old-card-id` with `new-card-id` in a template-tags map.
|
||||
Preserves slug format: if old key was `#42-my-query`, new key will be `#99-new-query-name`."
|
||||
[tags old-card-id new-card-id new-card-name]
|
||||
(let [old-tag (str "#" old-card-id)
|
||||
new-slug (when new-card-name
|
||||
(-> (u/slugify new-card-name)
|
||||
(str/replace "_" "-")))]
|
||||
(reduce-kv
|
||||
(fn [acc k v]
|
||||
(if (= (:card-id v) old-card-id)
|
||||
(let [;; Check if old key had a slug (e.g., "#42-my-query" vs "#42")
|
||||
had-slug? (str/starts-with? k (str old-tag "-"))
|
||||
new-key (if (and had-slug? new-slug)
|
||||
(str "#" new-card-id "-" new-slug)
|
||||
(str "#" new-card-id))]
|
||||
(assoc acc new-key
|
||||
(assoc v
|
||||
:card-id new-card-id
|
||||
:name new-key
|
||||
:display-name new-key)))
|
||||
(assoc acc k v)))
|
||||
{}
|
||||
tags)))
|
||||
|
||||
(defn- replace-card-refs-in-parsed
|
||||
"Walk parsed SQL tokens, replacing card references to old-card-id with new-card-id.
|
||||
Returns the reconstructed SQL string.
|
||||
Handles card refs with slugs like {{#42-my-query-name}}.
|
||||
When a slugged ref is found, generates a new slug from `new-card-name`."
|
||||
[parsed old-card-id new-card-id new-card-name]
|
||||
(let [old-tag (str "#" old-card-id)
|
||||
new-slug (when new-card-name
|
||||
(-> (u/slugify new-card-name)
|
||||
(str/replace "_" "-")))]
|
||||
(apply str
|
||||
(for [token parsed]
|
||||
(cond
|
||||
(string? token)
|
||||
token
|
||||
|
||||
(params/Param? token)
|
||||
(let [k (:k token)]
|
||||
(cond
|
||||
;; Match exact tag (no slug) -> replace with plain format
|
||||
(= k old-tag)
|
||||
(str "{{#" new-card-id "}}")
|
||||
|
||||
;; Match tag with slug suffix (e.g., #42-my-query) -> replace with new slug
|
||||
(str/starts-with? k (str old-tag "-"))
|
||||
(if new-slug
|
||||
(str "{{#" new-card-id "-" new-slug "}}")
|
||||
(str "{{#" new-card-id "}}"))
|
||||
|
||||
:else
|
||||
(str "{{" k "}}")))
|
||||
|
||||
(params/Optional? token)
|
||||
(str "[[" (replace-card-refs-in-parsed (:args token) old-card-id new-card-id new-card-name) "]]")
|
||||
|
||||
:else
|
||||
(str token))))))
|
||||
|
||||
(defn- swap-card-in-native-query
|
||||
"Pure transformation: replaces references to `old-card-id` with `new-card-id`
|
||||
in a native dataset-query's query text and template-tag map.
|
||||
Handles pMBQL format ([:stages 0 :native] and [:stages 0 :template-tags]).
|
||||
|
||||
Uses the Metabase parameter parser to properly identify card references,
|
||||
handling edge cases like:
|
||||
- Card refs with slugs: {{#42-my-query}}
|
||||
- Card refs with whitespace: {{ #42 }}
|
||||
- Card refs in optional clauses: [[...{{#42}}...]]"
|
||||
[dataset-query old-card-id new-card-id]
|
||||
(let [sql (get-in dataset-query [:stages 0 :native])
|
||||
new-card-name (:name (t2/select-one [:model/Card :name] :id new-card-id))
|
||||
parsed (params.parse/parse sql)
|
||||
new-sql (replace-card-refs-in-parsed parsed old-card-id new-card-id new-card-name)]
|
||||
(-> dataset-query
|
||||
(assoc-in [:stages 0 :native] new-sql)
|
||||
(update-in [:stages 0 :template-tags] replace-template-tags old-card-id new-card-id new-card-name))))
|
||||
|
||||
(defn- update-native-stages [query [_old-source-type old-source-id] [_new-source-type new-source-id] _id-updates]
|
||||
(swap-card-in-native-query query old-source-id new-source-id))
|
||||
|
||||
(defn- update-query [query old-source new-source id-updates]
|
||||
(cond-> query
|
||||
(lib/any-native-stage? query)
|
||||
(update-native-stages old-source new-source id-updates)
|
||||
|
||||
(not (lib/native-only-query? query))
|
||||
(update-mbql-stages old-source new-source id-updates)))
|
||||
|
||||
(defn- upgrade-legacy-field-ref
|
||||
"Given a card's dataset_query (pMBQL) and a legacy field ref
|
||||
([\"field\" 42 {...}]), resolve it through the metadata system and return
|
||||
an upgraded version."
|
||||
[query field-ref]
|
||||
(let [pmbql-ref (lib.convert/legacy-ref->pMBQL query field-ref)
|
||||
col-meta (lib/metadata query 0 pmbql-ref)
|
||||
upgraded (lib/ref col-meta)
|
||||
legacy-back (lib/->legacy-MBQL upgraded)]
|
||||
legacy-back))
|
||||
|
||||
(defn- upgrade-column-settings-keys
|
||||
"Given a card's dataset_query (pMBQL) and a column_settings map (from visualization_settings),
|
||||
return a new column_settings map with upgraded parameter-mapping. Keys are JSON-encoded strings."
|
||||
[query column-settings]
|
||||
(when (some? column-settings)
|
||||
(clojure.walk/postwalk
|
||||
(fn [form]
|
||||
(if (lib/is-field-clause? form)
|
||||
(upgrade-legacy-field-ref query form)
|
||||
form))
|
||||
column-settings)))
|
||||
|
||||
(defn- swap-field-ref [mp field-ref old-table new-table]
|
||||
(let [field-ref (lib.convert/legacy-ref->pMBQL mp field-ref)]
|
||||
(if-some [field-id (lib/field-ref-id field-ref)]
|
||||
(if-some [field-meta (t2/select-one :model/Field :id field-id :table_id old-table)]
|
||||
(let [field-name (:name field-meta)
|
||||
new-field (t2/select-one :model/Field :name field-name :table_id new-table)]
|
||||
(when (nil? new-field)
|
||||
(throw (ex-info "Could not find field with matching name." {:name field-name
|
||||
:original-field field-meta
|
||||
:table_id new-table})))
|
||||
(let [new-field-meta (lib.metadata/field mp (:id new-field))]
|
||||
(lib/->legacy-MBQL (lib/ref new-field-meta))))
|
||||
;; Can be here for two reasons:
|
||||
;; 1. field-id doesn't exist. Oh, well. We just give up.
|
||||
;; 2. field is not from the table we're swapping.
|
||||
field-ref)
|
||||
field-ref)))
|
||||
|
||||
(defn- ultimate-table-id [[source-type source-id]]
|
||||
(case source-type
|
||||
:table source-id))
|
||||
|
||||
(defn- swap-field-refs [mp form old-source [new-source-type new-source-id]]
|
||||
(case new-source-type
|
||||
:table
|
||||
(let [new-table-id new-source-id
|
||||
old-table-id (ultimate-table-id old-source)]
|
||||
(clojure.walk/postwalk
|
||||
(fn [exp]
|
||||
(if (lib/is-field-clause? exp)
|
||||
(swap-field-ref mp exp old-table-id new-table-id)
|
||||
exp))
|
||||
form))
|
||||
|
||||
:card
|
||||
form))
|
||||
|
||||
(defn- update-dashcards-column-settings!
|
||||
"After a card's query has been updated, upgrade the column_settings keys on all
|
||||
DashboardCards that display this card."
|
||||
[card-id query old-source new-source]
|
||||
(doseq [dashcard (t2/select :model/DashboardCard :card_id card-id)]
|
||||
(let [viz (vs/db->norm (:visualization_settings dashcard))
|
||||
col-sets (::vs/column-settings viz)]
|
||||
(when (seq col-sets)
|
||||
(let [upgraded (upgrade-column-settings-keys query col-sets)
|
||||
swapped (swap-field-refs query col-sets old-source new-source)]
|
||||
(when (not= col-sets upgraded)
|
||||
(t2/update! :model/DashboardCard (:id dashcard)
|
||||
{:visualization_settings (-> viz
|
||||
(assoc ::vs/column-settings swapped)
|
||||
vs/norm->db)})))))))
|
||||
|
||||
(defn- update-entity [entity-type entity-id old-source new-source]
|
||||
(case entity-type
|
||||
:card (let [card (t2/select-one :model/Card :id entity-id)]
|
||||
(when-let [query (:dataset_query card)]
|
||||
(let [new-query (-> query normalize-query (update-query old-source new-source {}))
|
||||
updated (assoc card :dataset_query new-query)]
|
||||
(t2/update! :model/Card entity-id {:dataset_query new-query})
|
||||
(update-dashcards-column-settings! entity-id new-query old-source new-source)
|
||||
;; TODO: not sure we really want this code to have to know about dependency tracking
|
||||
;; TODO: publishing this event twice per update seems bad
|
||||
(events/publish-event! :event/card-dependency-backfill
|
||||
{:object updated}))))
|
||||
;; TODO (eric 2026-02-13): Convert field refs in query.
|
||||
:transform (let [transform (t2/select-one :model/Transform :id entity-id)]
|
||||
(when-let [query (get-in transform [:source :query])]
|
||||
(let [new-query (-> query normalize-query (update-query old-source new-source {}))]
|
||||
(when (not= query new-query)
|
||||
(t2/update! :model/Transform entity-id
|
||||
{:source (assoc (:source transform) :query new-query)})))))
|
||||
nil))
|
||||
|
||||
(mu/defn swap-source
|
||||
"Replace all usages of `old-source` with `new-source` across all dependent entities.
|
||||
|
||||
Both arguments are [type id] pairs like [:card 123] or [:table 45].
|
||||
|
||||
Example:
|
||||
(swap-source [:card 123] [:card 789])
|
||||
|
||||
This finds all entities that depend on the old source and updates their queries
|
||||
to reference the new source instead.
|
||||
|
||||
Returns {:swapped [...]} with the list of entities that were updated."
|
||||
[old-source :- ::source-ref
|
||||
new-source :- ::source-ref]
|
||||
(let [found-usages (usages/usages old-source)]
|
||||
(t2/with-transaction [_conn]
|
||||
(doseq [[entity-type entity-id] found-usages]
|
||||
(update-entity entity-type entity-id old-source new-source)))
|
||||
{:swapped (vec found-usages)}))
|
||||
|
||||
(defn swap-native-card-source!
|
||||
"Updates a single card's native query, replacing references to `old-card-id`
|
||||
with `new-card-id` in both the query text and template tags. Persists the
|
||||
change and publishes a dependency-backfill event."
|
||||
[card-id old-card-id new-card-id]
|
||||
(update-entity :card card-id [:card old-card-id] [:card new-card-id]))
|
||||
@@ -0,0 +1,20 @@
|
||||
(ns metabase-enterprise.replacement.usages
|
||||
"Functions for finding all usages of a given source (card or table)."
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.models.dependency :as deps]))
|
||||
|
||||
(defn usages
|
||||
"Returns a seq of [entity-type entity-id] for all entities that use the given source.
|
||||
|
||||
`source` is a [type id] pair like [:table 1] or [:card 42].
|
||||
|
||||
Example:
|
||||
(usages [:card 123])
|
||||
;; => ([:card 456] [:card 789] [:transform 12])
|
||||
|
||||
These are the entities whose queries reference the given source and would need
|
||||
to be updated if the source were replaced."
|
||||
[[source-type source-id]]
|
||||
(for [[entity-type entity-ids] (deps/transitive-dependents {source-type [{:id source-id}]})
|
||||
entity-id entity-ids]
|
||||
[entity-type entity-id]))
|
||||
@@ -0,0 +1,153 @@
|
||||
(ns metabase-enterprise.replacement.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.queries.models.card :as card]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(defn- card-with-query
|
||||
"Create a card map for the given table keyword."
|
||||
[card-name table-kw]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :query
|
||||
:type :question
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id table-kw)))
|
||||
:visualization_settings {}}))
|
||||
|
||||
(defn- card-sourced-from
|
||||
"Create a card map whose query sources `inner-card`."
|
||||
[card-name inner-card]
|
||||
(let [mp (mt/metadata-provider)
|
||||
card-meta (lib.metadata/card mp (:id inner-card))]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :query
|
||||
:type :question
|
||||
:dataset_query (lib/query mp card-meta)
|
||||
:visualization_settings {}}))
|
||||
|
||||
;;; ------------------------------------------------ check-replace-source ------------------------------------------------
|
||||
|
||||
(deftest check-replace-source-compatible-test
|
||||
(testing "POST /api/ee/replacement/check-replace-source — compatible cards on the same table"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/Card card-a (card-with-query "Card A" :products)
|
||||
:model/Card card-b (card-with-query "Card B" :products)]
|
||||
(let [response (mt/user-http-request :crowberto :post 200 "ee/replacement/check-replace-source"
|
||||
{:source_entity_id (:id card-a)
|
||||
:source_entity_type :card
|
||||
:target_entity_id (:id card-b)
|
||||
:target_entity_type :card})]
|
||||
(is (true? (:success response)))
|
||||
(is (empty? (:errors response)))))))))
|
||||
|
||||
(deftest check-replace-source-incompatible-test
|
||||
(testing "POST /api/ee/replacement/check-replace-source — incompatible cards (different tables)"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/Card card-a (card-with-query "Products card" :products)
|
||||
:model/Card card-b (card-with-query "Orders card" :orders)]
|
||||
(let [response (mt/user-http-request :crowberto :post 200 "ee/replacement/check-replace-source"
|
||||
{:source_entity_id (:id card-a)
|
||||
:source_entity_type :card
|
||||
:target_entity_id (:id card-b)
|
||||
:target_entity_type :card})]
|
||||
(is (false? (:success response)))
|
||||
(is (some #(= "column-mismatch" (:type %)) (:errors response)))))))))
|
||||
|
||||
(deftest check-replace-source-database-mismatch-test
|
||||
(testing "POST /api/ee/replacement/check-replace-source — database mismatch short-circuits"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/Database other-db {:engine :h2
|
||||
:details (:details (mt/db))}
|
||||
:model/Card card-a (card-with-query "Card A" :products)
|
||||
:model/Card card-b {:name "Card B"
|
||||
:database_id (:id other-db)
|
||||
:type :question
|
||||
:dataset_query {:database (:id other-db)
|
||||
:type :native
|
||||
:native {:query "SELECT 1"}}
|
||||
:visualization_settings {}}]
|
||||
(let [response (mt/user-http-request :crowberto :post 200 "ee/replacement/check-replace-source"
|
||||
{:source_entity_id (:id card-a)
|
||||
:source_entity_type :card
|
||||
:target_entity_id (:id card-b)
|
||||
:target_entity_type :card})]
|
||||
(is (false? (:success response)))
|
||||
(is (some #(= "database-mismatch" (:type %)) (:errors response)))))))))
|
||||
|
||||
(deftest check-replace-source-requires-premium-feature-test
|
||||
(testing "POST /api/ee/replacement/check-replace-source — requires :dependencies premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/user-http-request :crowberto :post 402 "ee/replacement/check-replace-source"
|
||||
{:source_entity_id 1
|
||||
:source_entity_type :card
|
||||
:target_entity_id 2
|
||||
:target_entity_type :card}))))
|
||||
|
||||
;;; ------------------------------------------------ replace-source ------------------------------------------------
|
||||
|
||||
(deftest replace-source-returns-204-test
|
||||
(testing "POST /api/ee/replacement/replace-source — returns 204 on success"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "replacement-test@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
_child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(mt/user-http-request :crowberto :post 204 "ee/replacement/replace-source"
|
||||
{:source_entity_id (:id old-source)
|
||||
:source_entity_type :card
|
||||
:target_entity_id (:id new-source)
|
||||
:target_entity_type :card}))))))))
|
||||
|
||||
(deftest replace-source-incompatible-returns-400-test
|
||||
(testing "POST /api/ee/replacement/replace-source — incompatible sources return 400"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "replacement-incompat@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Products card" :products) user)
|
||||
new-source (card/create-card! (card-with-query "Orders card" :orders) user)]
|
||||
(mt/user-http-request :crowberto :post 400 "ee/replacement/replace-source"
|
||||
{:source_entity_id (:id old-source)
|
||||
:source_entity_type :card
|
||||
:target_entity_id (:id new-source)
|
||||
:target_entity_type :card}))))))))
|
||||
|
||||
(deftest replace-source-database-mismatch-returns-400-test
|
||||
(testing "POST /api/ee/replacement/replace-source — database mismatch returns 400"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/Database other-db {:engine :h2
|
||||
:details (:details (mt/db))}
|
||||
:model/Card card-a (card-with-query "Card A" :products)
|
||||
:model/Card card-b {:name "Card B"
|
||||
:database_id (:id other-db)
|
||||
:type :question
|
||||
:dataset_query {:database (:id other-db)
|
||||
:type :native
|
||||
:native {:query "SELECT 1"}}
|
||||
:visualization_settings {}}]
|
||||
(mt/user-http-request :crowberto :post 400 "ee/replacement/replace-source"
|
||||
{:source_entity_id (:id card-a)
|
||||
:source_entity_type :card
|
||||
:target_entity_id (:id card-b)
|
||||
:target_entity_type :card}))))))
|
||||
|
||||
(deftest replace-source-requires-premium-feature-test
|
||||
(testing "POST /api/ee/replacement/replace-source — requires :dependencies premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/user-http-request :crowberto :post 402 "ee/replacement/replace-source"
|
||||
{:source_entity_id 1
|
||||
:source_entity_type :card
|
||||
:target_entity_id 2
|
||||
:target_entity_type :card}))))
|
||||
@@ -0,0 +1,778 @@
|
||||
(ns metabase-enterprise.replacement.source-swap-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[metabase-enterprise.dependencies.events]
|
||||
[metabase-enterprise.replacement.source-swap :as source-swap]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.queries.models.card :as card]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.json :as json]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(comment
|
||||
metabase-enterprise.dependencies.events/keep-me)
|
||||
|
||||
(defn- wait-for-result-metadata
|
||||
"Poll until `result_metadata` is populated on the card, up to `timeout-ms` (default 5000)."
|
||||
([card-id] (wait-for-result-metadata card-id 5000))
|
||||
([card-id timeout-ms]
|
||||
(let [deadline (+ (System/currentTimeMillis) timeout-ms)]
|
||||
(loop []
|
||||
(let [metadata (t2/select-one-fn :result_metadata :model/Card :id card-id)]
|
||||
(if (seq metadata)
|
||||
metadata
|
||||
(if (< (System/currentTimeMillis) deadline)
|
||||
(do (Thread/sleep 200)
|
||||
(recur))
|
||||
(throw (ex-info "Timed out waiting for result_metadata" {:card-id card-id})))))))))
|
||||
|
||||
(defn- card-with-query
|
||||
"Create a card map for the given table keyword."
|
||||
[card-name table-kw]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :query
|
||||
:type :question
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id table-kw)))
|
||||
:visualization_settings {}}))
|
||||
|
||||
(defn- native-card-with-query
|
||||
"Create a native card map for the given table keyword."
|
||||
[card-name table-kw]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :native
|
||||
:type :question
|
||||
:dataset_query (lib/native-query mp (str "SELECT * FROM " (name table-kw) " LIMIT 1"))
|
||||
:visualization_settings {}}))
|
||||
|
||||
(defn- card-sourced-from
|
||||
"Create a card map whose query sources `inner-card`."
|
||||
[card-name inner-card]
|
||||
(let [mp (mt/metadata-provider)
|
||||
card-meta (lib.metadata/card mp (:id inner-card))]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :query
|
||||
:type :question
|
||||
:dataset_query (lib/query mp card-meta)
|
||||
:visualization_settings {}}))
|
||||
|
||||
(defn- native-card-sourced-from
|
||||
"Create a native card map that references `inner-card` via {{#id}}."
|
||||
[card-name inner-card]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :native
|
||||
:type :question
|
||||
:dataset_query (lib/native-query mp (str "SELECT * FROM {{#" (:id inner-card) "}}"))
|
||||
:visualization_settings {}}))
|
||||
|
||||
(defmacro ^:private with-restored-card-queries
|
||||
"Snapshots every card's `dataset_query` before `body` and restores them
|
||||
afterwards, so that swap-source side-effects on pre-existing cards don't
|
||||
leak between tests."
|
||||
[& body]
|
||||
`(let [snapshot# (into {} (t2/select-fn->fn :id :dataset_query :model/Card))]
|
||||
(try
|
||||
~@body
|
||||
(finally
|
||||
(doseq [[id# old-query#] snapshot#
|
||||
:let [current# (t2/select-one-fn :dataset_query :model/Card :id id#)]
|
||||
:when (and (some? old-query#) (not= old-query# current#))]
|
||||
(t2/update! :model/Card id# {:dataset_query old-query#}))))))
|
||||
|
||||
;;; ----------------------------------------- swap-card-in-native-query (pure) ------------------------------------------
|
||||
|
||||
(defn- make-native-query
|
||||
"Build a minimal pMBQL native dataset-query with the given SQL and template-tags."
|
||||
[sql template-tags]
|
||||
{:stages [{:lib/type :mbql.stage/native
|
||||
:native sql
|
||||
:template-tags template-tags}]})
|
||||
|
||||
(deftest swap-card-in-native-query-basic-test
|
||||
(testing "Simple card tag replacement"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}}"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}}"
|
||||
(get-in result [:stages 0 :native])))
|
||||
(is (= 99 (get-in result [:stages 0 :template-tags "#99" :card-id])))))
|
||||
|
||||
(testing "Multiple card tags, only matching one is replaced"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}} JOIN {{#7}} ON 1=1"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}
|
||||
"#7" {:type :card :card-id 7 :name "#7" :display-name "#7"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}} JOIN {{#7}} ON 1=1"
|
||||
(get-in result [:stages 0 :native])))
|
||||
(is (= 99 (get-in result [:stages 0 :template-tags "#99" :card-id])))
|
||||
(is (= 7 (get-in result [:stages 0 :template-tags "#7" :card-id]))))))
|
||||
|
||||
(deftest swap-card-in-native-query-with-field-filters-test
|
||||
(testing "Card tag with field filter tags present"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}} WHERE {{created_at}}"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}
|
||||
"created_at" {:type :dimension :name "created_at" :display-name "Created At"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}} WHERE {{created_at}}"
|
||||
(get-in result [:stages 0 :native])))
|
||||
(is (= 99 (get-in result [:stages 0 :template-tags "#99" :card-id])))
|
||||
(is (= "created_at"
|
||||
(get-in result [:stages 0 :template-tags "created_at" :name]))))))
|
||||
|
||||
(deftest swap-card-in-native-query-with-optional-clauses-test
|
||||
(testing "Card tag with optional clause containing field filter"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}} [[WHERE {{created_at}}]]"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}
|
||||
"created_at" {:type :dimension :name "created_at" :display-name "Created At"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}} [[WHERE {{created_at}}]]"
|
||||
(get-in result [:stages 0 :native])))
|
||||
(is (= 99 (get-in result [:stages 0 :template-tags "#99" :card-id])))))
|
||||
|
||||
(testing "Card tag inside optional clause"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM foo [[JOIN {{#3}} ON 1=1]]"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM foo [[JOIN {{#99}} ON 1=1]]"
|
||||
(get-in result [:stages 0 :native])))
|
||||
(is (= 99 (get-in result [:stages 0 :template-tags "#99" :card-id]))))))
|
||||
|
||||
(deftest swap-card-in-native-query-comment-test
|
||||
(testing "Card tag inside a line comment should NOT be replaced"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}}\n-- old: {{#3}}"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}}\n-- old: {{#3}}"
|
||||
(get-in result [:stages 0 :native]))
|
||||
"The tag in the comment should be left alone")))
|
||||
|
||||
(testing "Card tag inside a block comment should NOT be replaced"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}} /* see also {{#3}} */"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}} /* see also {{#3}} */"
|
||||
(get-in result [:stages 0 :native]))
|
||||
"The tag in the block comment should be left alone"))))
|
||||
|
||||
(deftest swap-card-in-native-query-string-literal-test
|
||||
(testing "Card tag inside a SQL string literal is also replaced (parser does not distinguish string literals)"
|
||||
(let [query (make-native-query
|
||||
"SELECT * FROM {{#3}} WHERE col = '{{#3}}'"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT * FROM {{#99}} WHERE col = '{{#99}}'"
|
||||
(get-in result [:stages 0 :native]))
|
||||
"Both tags are replaced since the parser treats string literal tags as params too"))))
|
||||
|
||||
(deftest swap-card-in-native-query-multiple-cards-test
|
||||
(testing "Multiple different card tags, replace only the target"
|
||||
(let [query (make-native-query
|
||||
"SELECT a.* FROM {{#3}} a JOIN {{#5}} b ON a.id = b.id JOIN {{#3}} c ON a.id = c.id"
|
||||
{"#3" {:type :card :card-id 3 :name "#3" :display-name "#3"}
|
||||
"#5" {:type :card :card-id 5 :name "#5" :display-name "#5"}})
|
||||
result (#'source-swap/swap-card-in-native-query query 3 99)]
|
||||
(is (= "SELECT a.* FROM {{#99}} a JOIN {{#5}} b ON a.id = b.id JOIN {{#99}} c ON a.id = c.id"
|
||||
(get-in result [:stages 0 :native])))
|
||||
(is (= 99 (get-in result [:stages 0 :template-tags "#99" :card-id])))
|
||||
(is (= 5 (get-in result [:stages 0 :template-tags "#5" :card-id]))))))
|
||||
|
||||
;;; ------------------------------------------------ swap-native-card-source! ------------------------------------------------
|
||||
|
||||
(deftest swap-native-card-source!-updates-query-test
|
||||
(testing "Card referencing the old card gets its query text and template tags updated"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{#999}}")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)
|
||||
tags (lib/template-tags updated-query)]
|
||||
(is (str/includes? query "{{#888}}"))
|
||||
(is (not (str/includes? query "{{#999}}")))
|
||||
(is (contains? tags "#888"))
|
||||
(is (not (contains? tags "#999")))
|
||||
(is (= 888 (get-in tags ["#888" :card-id])))
|
||||
(is (= "#888" (get-in tags ["#888" :name])))
|
||||
(is (= "#888" (get-in tags ["#888" :display-name])))))))))
|
||||
|
||||
(deftest swap-native-card-source!-no-op-test
|
||||
(testing "Card NOT referencing the old card is unchanged"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{#777}}")}]
|
||||
(let [before (t2/select-one :model/Card :id card-id)]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [after (t2/select-one :model/Card :id card-id)]
|
||||
(is (= (:dataset_query before) (:dataset_query after))))))))))
|
||||
|
||||
(deftest swap-native-card-source!-updates-dependencies-test
|
||||
(testing "Dependencies are updated after swap"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-test-user :rasta
|
||||
(let [mp (mt/metadata-provider)
|
||||
products (lib.metadata/table mp (mt/id :products))
|
||||
orders (lib.metadata/table mp (mt/id :orders))]
|
||||
(mt/with-temp [:model/Card {old-source-id :id} {:dataset_query (lib/query mp products)}
|
||||
:model/Card {new-source-id :id} {:dataset_query (lib/query mp orders)}
|
||||
:model/Card {native-card-id :id :as native-card}
|
||||
{:dataset_query (lib/native-query mp (str "SELECT * FROM {{#" old-source-id "}}"))}]
|
||||
;; Seed initial dependencies
|
||||
(events/publish-event! :event/card-dependency-backfill {:object native-card})
|
||||
(is (contains?
|
||||
(into #{} (map #(select-keys % [:to_entity_type :to_entity_id])
|
||||
(t2/select :model/Dependency
|
||||
:from_entity_id native-card-id
|
||||
:from_entity_type :card)))
|
||||
{:to_entity_type :card :to_entity_id old-source-id})
|
||||
"Before swap: dependency on old source should exist")
|
||||
;; Perform the swap
|
||||
(source-swap/swap-native-card-source! native-card-id old-source-id new-source-id)
|
||||
(let [deps (into #{} (map #(select-keys % [:to_entity_type :to_entity_id])
|
||||
(t2/select :model/Dependency
|
||||
:from_entity_id native-card-id
|
||||
:from_entity_type :card)))]
|
||||
(is (contains? deps {:to_entity_type :card :to_entity_id new-source-id})
|
||||
"After swap: dependency on new source should exist")
|
||||
(is (not (contains? deps {:to_entity_type :card :to_entity_id old-source-id}))
|
||||
"After swap: dependency on old source should be gone"))))))))
|
||||
|
||||
;;; ------------------------------------------------ swap-source: card -> X ------------------------------------------------
|
||||
|
||||
(deftest swap-source-card-to-card-test
|
||||
(testing "swap-source card -> card: child card's source-card is updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-card-card@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id new-source)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (:id new-source) (get-in updated-query [:stages 0 :source-card])))))))))))
|
||||
|
||||
(deftest swap-source-card-to-table-test
|
||||
(testing "swap-source card -> table: child card's source changes to source-table"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-card-table@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:table (mt/id :products)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (mt/id :products) (get-in updated-query [:stages 0 :source-table])))
|
||||
(is (nil? (get-in updated-query [:stages 0 :source-card])))))))))))
|
||||
|
||||
(deftest swap-source-card-to-native-card-test
|
||||
(testing "swap-source card -> native card: child card's source-card is updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-card-native@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
native-card (card/create-card! (native-card-with-query "Native target" :products) user)
|
||||
_ (wait-for-result-metadata (:id native-card))
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id native-card)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (:id native-card) (get-in updated-query [:stages 0 :source-card])))))))))))
|
||||
|
||||
;;; ------------------------------------------------ swap-source: native card -> X ------------------------------------------------
|
||||
|
||||
(deftest swap-source-native-card-to-card-test
|
||||
(testing "swap-source native card -> card: child card's source-card is updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-native-card@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [native-card (card/create-card! (native-card-with-query "Native source" :products) user)
|
||||
_ (wait-for-result-metadata (:id native-card))
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" native-card) user)]
|
||||
(source-swap/swap-source [:card (:id native-card)] [:card (:id new-source)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (:id new-source) (get-in updated-query [:stages 0 :source-card])))))))))))
|
||||
|
||||
(deftest swap-source-native-card-to-native-card-test
|
||||
(testing "swap-source native card -> native card: child card's source-card is updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-native-native@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-native (card/create-card! (native-card-with-query "Old native" :products) user)
|
||||
_ (wait-for-result-metadata (:id old-native))
|
||||
new-native (card/create-card! (native-card-with-query "New native" :products) user)
|
||||
_ (wait-for-result-metadata (:id new-native))
|
||||
child (card/create-card! (card-sourced-from "Child card" old-native) user)]
|
||||
(source-swap/swap-source [:card (:id old-native)] [:card (:id new-native)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (:id new-native) (get-in updated-query [:stages 0 :source-card])))))))))))
|
||||
|
||||
(deftest swap-source-native-card-to-table-test
|
||||
(testing "swap-source native card -> table: child card's source changes to source-table"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-native-table@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [native-card (card/create-card! (native-card-with-query "Native source" :products) user)
|
||||
_ (wait-for-result-metadata (:id native-card))
|
||||
child (card/create-card! (card-sourced-from "Child card" native-card) user)]
|
||||
(source-swap/swap-source [:card (:id native-card)] [:table (mt/id :products)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (mt/id :products) (get-in updated-query [:stages 0 :source-table])))
|
||||
(is (nil? (get-in updated-query [:stages 0 :source-card])))))))))))
|
||||
|
||||
;;; ------------------------------------------------ swap-source: table -> X ------------------------------------------------
|
||||
|
||||
(deftest swap-source-table-to-card-test
|
||||
(testing "swap-source table -> card: child card's source changes to source-card"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-table-card@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(with-restored-card-queries
|
||||
(let [new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-with-query "Child card" :products) user)]
|
||||
(source-swap/swap-source [:table (mt/id :products)] [:card (:id new-source)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (:id new-source) (get-in updated-query [:stages 0 :source-card])))
|
||||
(is (nil? (get-in updated-query [:stages 0 :source-table]))))))))))))
|
||||
|
||||
(deftest swap-source-table-to-table-test
|
||||
(testing "swap-source table -> table: child card's source-table is updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-table-table@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(with-restored-card-queries
|
||||
(let [child (card/create-card! (card-with-query "Child card" :products) user)]
|
||||
(source-swap/swap-source [:table (mt/id :products)] [:table (mt/id :products)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (mt/id :products) (get-in updated-query [:stages 0 :source-table]))))))))))))
|
||||
|
||||
(deftest swap-source-table-to-native-card-test
|
||||
(testing "swap-source table -> native card: child card's source changes to source-card"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-table-native@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(with-restored-card-queries
|
||||
(let [native-card (card/create-card! (native-card-with-query "Native target" :products) user)
|
||||
_ (wait-for-result-metadata (:id native-card))
|
||||
child (card/create-card! (card-with-query "Child card" :products) user)]
|
||||
(source-swap/swap-source [:table (mt/id :products)] [:card (:id native-card)])
|
||||
(let [updated-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))]
|
||||
(is (= (:id native-card) (get-in updated-query [:stages 0 :source-card])))
|
||||
(is (nil? (get-in updated-query [:stages 0 :source-table]))))))))))))
|
||||
|
||||
;;; ------------------------------------------------ Native Query Card Reference Tests ------------------------------------------------
|
||||
;;; These tests cover card→card replacement specifically in native SQL queries using {{#id}} syntax
|
||||
|
||||
(deftest swap-native-card-ref-with-slug-test
|
||||
(testing "Card reference with slug ({{#42-my-query-name}}) is replaced with new card's slug"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {new-card-id :id} {:name "New Target Query"}
|
||||
:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{#999-old-query-name}} WHERE x > 1")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 new-card-id)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
;; Should have new card ID with slugified name
|
||||
(is (str/includes? query (str "{{#" new-card-id "-new-target-query}}")))
|
||||
(is (not (str/includes? query "{{#999")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-with-slug-template-tags-test
|
||||
(testing "Template tags are updated with slug to match SQL when swapping slugged card refs"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {products-id :id} {:name "All Products"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :products)))}
|
||||
:model/Card {orders-a-id :id} {:name "All Orders A"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :orders)))}
|
||||
:model/Card {orders-b-id :id} {:name "All Orders B"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :orders)))}
|
||||
:model/Card {native-card-id :id}
|
||||
{:dataset_query
|
||||
(lib/native-query mp (str "SELECT p.*, o.* "
|
||||
"FROM {{#" products-id "-all-products}} p "
|
||||
"JOIN {{#" orders-a-id "-all-orders-a}} o ON p.id = o.product_id"))}]
|
||||
;; Swap orders A -> orders B
|
||||
(source-swap/swap-native-card-source! native-card-id orders-a-id orders-b-id)
|
||||
(let [updated-card (t2/select-one :model/Card :id native-card-id)
|
||||
updated-query (:dataset_query updated-card)
|
||||
sql (lib/raw-native-query updated-query)
|
||||
template-tags (get-in updated-query [:stages 0 :template-tags])]
|
||||
;; SQL should have the new slugged reference
|
||||
(is (str/includes? sql (str "{{#" orders-b-id "-all-orders-b}}")))
|
||||
(is (not (str/includes? sql (str "{{#" orders-a-id))))
|
||||
;; Template tags should have matching key
|
||||
(is (contains? template-tags (str "#" orders-b-id "-all-orders-b")))
|
||||
(is (not (contains? template-tags (str "#" orders-a-id "-all-orders-a"))))
|
||||
;; Products reference should be unchanged
|
||||
(is (str/includes? sql (str "{{#" products-id "-all-products}}")))
|
||||
(is (contains? template-tags (str "#" products-id "-all-products")))))))))
|
||||
|
||||
(deftest swap-native-kitchen-sink-test
|
||||
(testing "Kitchen sink: multiple card refs with slugs, nested optionals, swapping one source"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {products-a-id :id} {:name "All Products A"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :products)))}
|
||||
:model/Card {products-b-id :id} {:name "All Products B"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :products)))}
|
||||
:model/Card {orders-a-id :id} {:name "All Orders A"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :orders)))}
|
||||
:model/Card {orders-b-id :id} {:name "All Orders B"
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id :orders)))}
|
||||
:model/Card {kitchen-sink-id :id}
|
||||
{:dataset_query
|
||||
(lib/native-query mp (str "WITH filtered_products AS ("
|
||||
" SELECT * FROM {{#" products-a-id "-all-products-a}}"
|
||||
" WHERE 1=1"
|
||||
" [[AND price >= {{min_price}}]]"
|
||||
"),"
|
||||
"order_data AS ("
|
||||
" SELECT product_id, COUNT(*) as cnt"
|
||||
" FROM {{#" orders-a-id "-all-orders-a}}"
|
||||
" WHERE 1=1"
|
||||
" [[AND quantity >= {{min_qty}}"
|
||||
" [[AND total >= {{min_total}}]]"
|
||||
" ]]"
|
||||
" GROUP BY product_id"
|
||||
")"
|
||||
"SELECT p.*, o.cnt FROM filtered_products p "
|
||||
"LEFT JOIN order_data o ON p.id = o.product_id"))}]
|
||||
;; Swap products A -> products B (orders should stay as-is)
|
||||
(source-swap/swap-native-card-source! kitchen-sink-id products-a-id products-b-id)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id kitchen-sink-id))
|
||||
sql (lib/raw-native-query updated-query)
|
||||
template-tags (get-in updated-query [:stages 0 :template-tags])]
|
||||
;; Products A should be replaced with Products B (with slug)
|
||||
(is (str/includes? sql (str "{{#" products-b-id "-all-products-b}}")))
|
||||
(is (not (str/includes? sql (str "{{#" products-a-id))))
|
||||
(is (contains? template-tags (str "#" products-b-id "-all-products-b")))
|
||||
;; Orders A should be unchanged
|
||||
(is (str/includes? sql (str "{{#" orders-a-id "-all-orders-a}}")))
|
||||
(is (contains? template-tags (str "#" orders-a-id "-all-orders-a")))
|
||||
;; Other params should be unchanged
|
||||
(is (contains? template-tags "min_price"))
|
||||
(is (contains? template-tags "min_qty"))
|
||||
(is (contains? template-tags "min_total"))))))))
|
||||
|
||||
(deftest swap-native-card-ref-with-whitespace-test
|
||||
(testing "Card reference with whitespace ({{ #42 }}) is replaced correctly"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{ #999 }} WHERE x > 1")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (str/includes? query "{{#888}}"))
|
||||
(is (not (str/includes? query "#999")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-multiple-test
|
||||
(testing "Multiple card references to the same card are all replaced"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{#999}} a JOIN {{#999}} b ON a.id = b.id")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (= 2 (count (re-seq #"\{\{#888\}\}" query))))
|
||||
(is (not (str/includes? query "{{#999}}")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-in-cte-test
|
||||
(testing "Card reference in CTE is replaced correctly"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "WITH base AS {{#999}} SELECT * FROM base WHERE x > 1")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (str/includes? query "WITH base AS {{#888}}"))
|
||||
(is (not (str/includes? query "{{#999}}")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-with-alias-test
|
||||
(testing "Card reference with alias ({{#42}} AS t) is replaced correctly"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT t.* FROM {{#999}} AS t WHERE t.x > 1")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (str/includes? query "{{#888}} AS t"))
|
||||
(is (not (str/includes? query "{{#999}}")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-preserves-other-params-test
|
||||
(testing "Other template params are preserved when replacing card reference"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{#999}} WHERE status = {{status}} AND total > {{min_total}}")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)
|
||||
tags (lib/template-tags updated-query)]
|
||||
(is (str/includes? query "{{#888}}"))
|
||||
(is (str/includes? query "{{status}}"))
|
||||
(is (str/includes? query "{{min_total}}"))
|
||||
(is (contains? tags "status"))
|
||||
(is (contains? tags "min_total"))))))))
|
||||
|
||||
(deftest swap-native-card-ref-different-cards-test
|
||||
(testing "Only the specified card reference is replaced, others are preserved"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM {{#999}} a JOIN {{#777}} b ON a.id = b.id")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (str/includes? query "{{#888}}"))
|
||||
(is (str/includes? query "{{#777}}"))
|
||||
(is (not (str/includes? query "{{#999}}")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-in-subquery-test
|
||||
(testing "Card reference in subquery is replaced correctly"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM orders WHERE product_id IN (SELECT id FROM {{#999}})")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (str/includes? query "FROM {{#888}}"))
|
||||
(is (not (str/includes? query "{{#999}}")))))))))
|
||||
|
||||
(deftest swap-native-card-ref-in-optional-clause-test
|
||||
(testing "Card reference inside optional clause [[...{{#42}}...]] is replaced correctly"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query
|
||||
(lib/native-query mp "SELECT * FROM orders WHERE 1=1 [[AND product_id IN (SELECT id FROM {{#999}})]]\n")}]
|
||||
(source-swap/swap-native-card-source! card-id 999 888)
|
||||
(let [updated-query (:dataset_query (t2/select-one :model/Card :id card-id))
|
||||
query (lib/raw-native-query updated-query)]
|
||||
(is (str/includes? query "{{#888}}"))
|
||||
(is (not (str/includes? query "{{#999}}")))))))))
|
||||
|
||||
;;; ------------------------------------------------ swap-source: transforms ------------------------------------------------
|
||||
|
||||
(defn- transform-sourced-from-card
|
||||
"Create a Transform map whose query sources `inner-card`."
|
||||
[transform-name inner-card]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:source {:type "query"
|
||||
:query (lib/query mp (lib.metadata/card mp (:id inner-card)))}
|
||||
:name transform-name
|
||||
:target {:database (mt/id) :table transform-name}}))
|
||||
|
||||
(defn- transform-sourced-from-table
|
||||
"Create a Transform map whose query sources a table."
|
||||
[transform-name table-kw]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:source {:type "query"
|
||||
:query (lib/query mp (lib.metadata/table mp (mt/id table-kw)))}
|
||||
:name transform-name
|
||||
:target {:database (mt/id) :table transform-name}}))
|
||||
|
||||
(deftest swap-source-card-to-card-with-transform-test
|
||||
(testing "swap-source card -> card: transform's source query is updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-card-card-transform@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency :model/Transform]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} (transform-sourced-from-card "test_transform_c2c" old-source)]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id new-source)])
|
||||
(let [updated-source (t2/select-one-fn :source :model/Transform :id transform-id)]
|
||||
(is (= (:id new-source) (get-in updated-source [:query :stages 0 :source-card]))))))))))))
|
||||
|
||||
(deftest swap-source-table-to-card-with-transform-test
|
||||
(testing "swap-source table -> card: transform's source query changes to source-card"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-table-card-transform@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency :model/Transform]
|
||||
(with-restored-card-queries
|
||||
(let [new-source (card/create-card! (card-with-query "New source" :products) user)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} (transform-sourced-from-table "test_transform_t2c" :products)]
|
||||
(source-swap/swap-source [:table (mt/id :products)] [:card (:id new-source)])
|
||||
(let [updated-source (t2/select-one-fn :source :model/Transform :id transform-id)]
|
||||
(is (= (:id new-source) (get-in updated-source [:query :stages 0 :source-card])))
|
||||
(is (nil? (get-in updated-source [:query :stages 0 :source-table])))))))))))))
|
||||
|
||||
(deftest swap-source-card-to-table-with-transform-test
|
||||
(testing "swap-source card -> table: transform's source query changes to source-table"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-card-table-transform@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency :model/Transform]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} (transform-sourced-from-card "test_transform_c2t" old-source)]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:table (mt/id :products)])
|
||||
(let [updated-source (t2/select-one-fn :source :model/Transform :id transform-id)]
|
||||
(is (= (mt/id :products) (get-in updated-source [:query :stages 0 :source-table])))
|
||||
(is (nil? (get-in updated-source [:query :stages 0 :source-card]))))))))))))
|
||||
|
||||
(deftest swap-source-card-to-card-with-card-and-transform-test
|
||||
(testing "swap-source card -> card: both child card and transform are updated"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-card-card-both@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency :model/Transform]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} (transform-sourced-from-card "test_transform_both" old-source)]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id new-source)])
|
||||
(let [updated-card-query (t2/select-one-fn :dataset_query :model/Card :id (:id child))
|
||||
updated-source (t2/select-one-fn :source :model/Transform :id transform-id)]
|
||||
(is (= (:id new-source) (get-in updated-card-query [:stages 0 :source-card]))
|
||||
"Child card's source-card should be updated")
|
||||
(is (= (:id new-source) (get-in updated-source [:query :stages 0 :source-card]))
|
||||
"Transform's source query source-card should be updated"))))))))))
|
||||
|
||||
;;; ----------------------------------------- DashboardCard column_settings upgrade ------------------------------------------
|
||||
|
||||
(def vis-settings {:column_settings
|
||||
{"[\"name\",\"name\"]"
|
||||
{:click_behavior
|
||||
{:parameterMapping
|
||||
;; yes, this really is a keyword in the data that comes back
|
||||
{(keyword (pr-str "[\"dimension\",[\"field\",37,{\"base-type\":\"type{:Text\",\"source-field\":25}],{\"stage-number\":0}]"))
|
||||
{:source
|
||||
{:type "column", :id "name", :name "name"},
|
||||
|
||||
:target
|
||||
{:type "dimension",
|
||||
:id
|
||||
"[\"dimension\",[\"field\",37,{\"base-type\":\"type/Text\",\"source-field\":25}],{\"stage-number\":0}]",
|
||||
:dimension
|
||||
["dimension"
|
||||
[:field 37 {:base-type :type/Text, :source-field 25}]
|
||||
{:stage-number 0}]},
|
||||
:id
|
||||
"[\"dimension\",[\"field\",37,{\"base-type\":\"type/Text\",\"source-field\":25}],{\"stage-number\":0}]"}},
|
||||
:targetId 7048,
|
||||
:linkType "question",
|
||||
:type "link"}}}})
|
||||
|
||||
(deftest swap-source-card-to-card-updates-dashcard-column-settings-test
|
||||
(testing "swap-source card -> card: DashboardCard column_settings keys are upgraded"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-dashcard-cs@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"}
|
||||
:model/DashboardCard {dashcard-id :id}
|
||||
{:dashboard_id dashboard-id
|
||||
:card_id (:id child)
|
||||
:visualization_settings vis-settings}]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id new-source)])
|
||||
;; TODO (eric): Add assertions
|
||||
))))))))
|
||||
|
||||
(deftest swap-source-no-column-settings-test
|
||||
(testing "swap-source: DashboardCards without column_settings are unaffected"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-dashcard-no-cs@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)]
|
||||
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"}
|
||||
:model/DashboardCard {dashcard-id :id}
|
||||
{:dashboard_id dashboard-id
|
||||
:card_id (:id child)
|
||||
:visualization_settings {:some_setting "value"}}]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id new-source)])
|
||||
(let [updated-viz (t2/select-one-fn :visualization_settings :model/DashboardCard :id dashcard-id)]
|
||||
(is (= {:some_setting "value"} updated-viz)
|
||||
"Visualization settings without column_settings should be unchanged"))))))))))
|
||||
|
||||
(deftest swap-source-name-based-column-settings-keys-preserved-test
|
||||
(testing "swap-source: name-based column_settings keys are not modified"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-dashcard-name-cs@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [old-source (card/create-card! (card-with-query "Old source" :products) user)
|
||||
new-source (card/create-card! (card-with-query "New source" :products) user)
|
||||
child (card/create-card! (card-sourced-from "Child card" old-source) user)
|
||||
name-key (json/encode ["name" "MyColumn"])]
|
||||
(mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "Test Dashboard"}
|
||||
:model/DashboardCard {dashcard-id :id}
|
||||
{:dashboard_id dashboard-id
|
||||
:card_id (:id child)
|
||||
:visualization_settings {:column_settings {name-key {:column_title "Custom"}}}}]
|
||||
(source-swap/swap-source [:card (:id old-source)] [:card (:id new-source)])
|
||||
(let [updated-viz (t2/select-one-fn :visualization_settings :model/DashboardCard :id dashcard-id)
|
||||
updated-cs (:column_settings updated-viz)]
|
||||
(is (contains? updated-cs name-key)
|
||||
"Name-based column settings key should be preserved")
|
||||
(is (= {:column_title "Custom"} (get updated-cs name-key))
|
||||
"Name-based column settings value should be preserved"))))))))))
|
||||
|
||||
;;; ------------------------------------------------ Mixed Chain Tests ------------------------------------------------
|
||||
;;; These tests cover chains that mix MBQL and native queries
|
||||
|
||||
(deftest swap-source-mixed-chain-test
|
||||
(testing "swap-source propagates through: MBQL Model → Native Card → MBQL Card"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "swap-mixed-chain@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [ ;; Chain: old-model → native-card ({{#id}}) → mbql-card (source-card)
|
||||
old-model (card/create-card! (card-with-query "Old Model" :products) user)
|
||||
new-model (card/create-card! (card-with-query "New Model" :products) user)
|
||||
_ (wait-for-result-metadata (:id old-model))
|
||||
native-card (card/create-card! (native-card-sourced-from "Native Card" old-model) user)
|
||||
_ (wait-for-result-metadata (:id native-card))
|
||||
mbql-card (card/create-card! (card-sourced-from "MBQL Card" native-card) user)]
|
||||
;; Swap the model at the root
|
||||
(source-swap/swap-source [:card (:id old-model)] [:card (:id new-model)])
|
||||
;; Native card's {{#old-id}} should be updated to {{#new-id}}
|
||||
(let [native-query (t2/select-one-fn :dataset_query :model/Card :id (:id native-card))
|
||||
native-sql (get-in native-query [:stages 0 :native])]
|
||||
(is (str/includes? native-sql (str "{{#" (:id new-model) "}}"))
|
||||
"Native card should reference new model")
|
||||
(is (not (str/includes? native-sql (str "{{#" (:id old-model) "}}")))
|
||||
"Native card should not reference old model"))
|
||||
;; MBQL card should still reference native-card (unchanged, it's not a direct dependent)
|
||||
(let [mbql-query (t2/select-one-fn :dataset_query :model/Card :id (:id mbql-card))]
|
||||
(is (= (:id native-card) (get-in mbql-query [:stages 0 :source-card]))
|
||||
"MBQL card should still reference native card (unchanged)")))))))))
|
||||
@@ -0,0 +1,201 @@
|
||||
(ns metabase-enterprise.replacement.source-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.replacement.source :as replacement.source]
|
||||
[metabase.lib.convert :as lib.convert]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.queries.models.card :as card]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- wait-for-result-metadata
|
||||
"Poll until `result_metadata` is populated on the card, up to `timeout-ms` (default 5000)."
|
||||
([card-id] (wait-for-result-metadata card-id 5000))
|
||||
([card-id timeout-ms]
|
||||
(let [deadline (+ (System/currentTimeMillis) timeout-ms)]
|
||||
(loop []
|
||||
(let [metadata (t2/select-one-fn :result_metadata :model/Card :id card-id)]
|
||||
(if (seq metadata)
|
||||
metadata
|
||||
(if (< (System/currentTimeMillis) deadline)
|
||||
(do (Thread/sleep 200)
|
||||
(recur))
|
||||
(throw (ex-info "Timed out waiting for result_metadata" {:card-id card-id})))))))))
|
||||
|
||||
(deftest table-replaceable-with-itself-test
|
||||
(testing "Every table in test-data is replaceable with itself"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
tables (lib.metadata/tables mp)]
|
||||
(doseq [table tables]
|
||||
(testing (str "table: " (:name table))
|
||||
(is (empty? (replacement.source/check-replace-source [:table (:id table)] [:table (:id table)])))))))))
|
||||
|
||||
(deftest card-swappable-with-underlying-table-test
|
||||
(testing "A card built on a table is swappable with that table in both directions"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
tables (lib.metadata/tables mp)]
|
||||
(doseq [table tables
|
||||
legacy? [true false]]
|
||||
(let [query (cond-> (lib/query mp table)
|
||||
legacy? lib.convert/->legacy-MBQL)]
|
||||
(mt/with-temp [:model/Card card {:dataset_query query
|
||||
:database_id (mt/id)
|
||||
:type :question}]
|
||||
(testing (str "table: " (:name table))
|
||||
(testing "card -> table"
|
||||
(is (empty? (replacement.source/check-replace-source [:card (:id card)] [:table (:id table)]))))
|
||||
(testing "table -> card"
|
||||
(is (empty? (replacement.source/check-replace-source [:table (:id table)] [:card (:id card)]))))))))))))
|
||||
|
||||
(deftest two-cards-on-same-table-swappable-test
|
||||
(testing "Two cards built on the same table are swappable in both directions"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
tables (lib.metadata/tables mp)]
|
||||
(doseq [table tables
|
||||
legacy-original? [true false]
|
||||
legacy-replacement? [true false]]
|
||||
(mt/with-temp [:model/Card card-a {:dataset_query (cond-> (lib/query mp table)
|
||||
legacy-original? lib.convert/->legacy-MBQL)
|
||||
:database_id (mt/id)
|
||||
:type :question}
|
||||
:model/Card card-b {:dataset_query (cond-> (lib/query mp table)
|
||||
legacy-replacement? lib.convert/->legacy-MBQL)
|
||||
:database_id (mt/id)
|
||||
:type :question}]
|
||||
(testing (str "table: " (:name table))
|
||||
(testing "card-a -> card-b"
|
||||
(is (empty? (replacement.source/check-replace-source [:card (:id card-a)] [:card (:id card-b)]))))
|
||||
(testing "card-b -> card-a"
|
||||
(is (empty? (replacement.source/check-replace-source [:card (:id card-b)] [:card (:id card-a)])))))))))))
|
||||
|
||||
(deftest card-with-expression-reports-extra-column-test
|
||||
(testing "A card with an added expression column reports :column-mismatch with :extra_columns"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
table (lib.metadata/table mp (mt/id :products))
|
||||
query (-> (lib/query mp table)
|
||||
(lib/expression "double_price" (lib/* (lib/ref (m/find-first #(= "PRICE" (:name %))
|
||||
(lib/returned-columns (lib/query mp table))))
|
||||
2)))]
|
||||
(mt/with-temp [:model/Card card {:dataset_query query
|
||||
:database_id (mt/id)
|
||||
:type :question}]
|
||||
(let [errors (replacement.source/check-replace-source [:table (:id table)] [:card (:id card)])]
|
||||
(testing "table -> card with expression: extra column reported"
|
||||
(is (some #(= :column-mismatch (:type %)) errors))
|
||||
(is (some (fn [err]
|
||||
(and (= :column-mismatch (:type err))
|
||||
(some #(= "double_price" (:name %)) (:extra_columns err))))
|
||||
errors)))
|
||||
(testing "card with expression -> table: missing column reported"
|
||||
(let [reverse-errors (replacement.source/check-replace-source [:card (:id card)] [:table (:id table)])]
|
||||
(is (some #(= :column-mismatch (:type %)) reverse-errors))
|
||||
(is (some (fn [err]
|
||||
(and (= :column-mismatch (:type err))
|
||||
(some #(= "double_price" (:name %)) (:missing_columns err))))
|
||||
reverse-errors))))))))))
|
||||
|
||||
(deftest card-with-subset-of-fields-reports-missing-columns-test
|
||||
(testing "A card selecting a subset of fields reports :column-mismatch with :missing_columns"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
table (lib.metadata/table mp (mt/id :products))
|
||||
query (lib/query mp table)
|
||||
cols (lib/returned-columns query)
|
||||
;; Select only the first two columns
|
||||
query (lib/with-fields query (mapv lib/ref (take 2 cols)))]
|
||||
(mt/with-temp [:model/Card card {:dataset_query query
|
||||
:database_id (mt/id)
|
||||
:type :question}]
|
||||
(let [dropped-names (set (map #(or (:lib/desired-column-alias %) (:name %)) (drop 2 cols)))
|
||||
errors (replacement.source/check-replace-source [:table (:id table)] [:card (:id card)])]
|
||||
(testing "table -> card with fewer fields: extra columns reported (card is missing them)"
|
||||
(is (some #(= :column-mismatch (:type %)) errors))
|
||||
(is (some (fn [err]
|
||||
(and (= :column-mismatch (:type err))
|
||||
(every? #(contains? dropped-names (:name %)) (:missing_columns err))))
|
||||
errors)))
|
||||
(testing "card with fewer fields -> table: extra columns reported"
|
||||
(let [reverse-errors (replacement.source/check-replace-source [:card (:id card)] [:table (:id table)])]
|
||||
(is (some #(= :column-mismatch (:type %)) reverse-errors))
|
||||
(is (some (fn [err]
|
||||
(and (= :column-mismatch (:type err))
|
||||
(every? #(contains? dropped-names (:name %)) (:extra_columns err))))
|
||||
reverse-errors))))))))))
|
||||
|
||||
(defn- table-has-fks?
|
||||
"Returns true if any column of `table` has :type/FK semantic type."
|
||||
[mp table]
|
||||
(some #(= :type/FK (:semantic-type %))
|
||||
(lib/returned-columns (lib/query mp table))))
|
||||
|
||||
(defn- table-has-hidden-columns?
|
||||
"Returns true if the table has columns that are hidden (not returned by `lib/returned-columns`)
|
||||
but would still appear in a `SELECT *` native query."
|
||||
[mp table]
|
||||
(let [visible-count (count (lib/returned-columns (lib/query mp table)))
|
||||
total-count (t2/count :model/Field :table_id (:id table) :active true)]
|
||||
(not= visible-count total-count)))
|
||||
|
||||
(deftest native-card-on-fk-table-reports-fk-mismatch-test
|
||||
;; Native query result_metadata does not include :type/FK semantic types or
|
||||
;; :fk-target-field-id, so a native card on a table with FK columns should
|
||||
;; produce fk-mismatch errors.
|
||||
;;
|
||||
;; Note: native queries DO preserve :type/PK semantic types, so PK columns
|
||||
;; are not a problem — only FKs are lost.
|
||||
(testing "A native card on a table with FK columns reports :fk-mismatch"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
tables (lib.metadata/tables mp)]
|
||||
(doseq [table tables
|
||||
:when (and (table-has-fks? mp table)
|
||||
(not (table-has-hidden-columns? mp table)))]
|
||||
(let [native-query (lib/native-query mp (str "SELECT * FROM " (:name table) " LIMIT 1"))]
|
||||
(mt/with-model-cleanup [:model/Card]
|
||||
(let [card (card/create-card! {:name (str "Native FK " (:name table))
|
||||
:display :table
|
||||
:visualization_settings {}
|
||||
:dataset_query native-query}
|
||||
{:id (mt/user->id :rasta)})
|
||||
_ (wait-for-result-metadata (:id card))]
|
||||
(testing (str "table: " (:name table))
|
||||
(testing "table -> native card: fk-mismatch reported"
|
||||
(is (some #(= :fk-mismatch (:type %))
|
||||
(replacement.source/check-replace-source [:table (:id table)] [:card (:id card)]))))
|
||||
(testing "native card -> table: fk-mismatch reported"
|
||||
(is (some #(= :fk-mismatch (:type %))
|
||||
(replacement.source/check-replace-source [:card (:id card)] [:table (:id table)])))))))))))))
|
||||
|
||||
(deftest native-card-swappable-with-table-test
|
||||
;; We only test tables without FK columns because native query result_metadata
|
||||
;; (as computed by the QP) does not include :type/FK semantic types or
|
||||
;; :fk-target-field-id. Tables with FK columns would produce fk-mismatch errors.
|
||||
;;
|
||||
;; We also exclude tables with hidden columns (e.g. USERS.PASSWORD) because
|
||||
;; `SELECT *` returns all columns while `lib/returned-columns` omits hidden ones.
|
||||
(testing "A native query card with matching result_metadata is swappable with its table (no-FK tables only)"
|
||||
(mt/dataset test-data
|
||||
(let [mp (mt/metadata-provider)
|
||||
tables (lib.metadata/tables mp)]
|
||||
(doseq [table tables
|
||||
:when (and (not (table-has-fks? mp table))
|
||||
(not (table-has-hidden-columns? mp table)))]
|
||||
(let [native-query (lib/native-query mp (str "SELECT * FROM " (:name table) " LIMIT 1"))]
|
||||
(mt/with-model-cleanup [:model/Card]
|
||||
(let [card (card/create-card! {:name (str "Native " (:name table))
|
||||
:display :table
|
||||
:visualization_settings {}
|
||||
:dataset_query native-query}
|
||||
{:id (mt/user->id :rasta)})
|
||||
_ (wait-for-result-metadata (:id card))]
|
||||
(testing (str "table: " (:name table))
|
||||
(testing "table -> native card"
|
||||
(is (empty? (replacement.source/check-replace-source [:table (:id table)] [:card (:id card)]))))
|
||||
(testing "native card -> table"
|
||||
(is (empty? (replacement.source/check-replace-source [:card (:id card)] [:table (:id table)])))))))))))))
|
||||
@@ -0,0 +1,86 @@
|
||||
(ns metabase-enterprise.replacement.usages-test
|
||||
(:require
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[metabase-enterprise.dependencies.events]
|
||||
[metabase-enterprise.replacement.usages :as usages]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.queries.models.card :as card]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(comment
|
||||
metabase-enterprise.dependencies.events/keep-me)
|
||||
|
||||
(defn- card-with-query
|
||||
"Create a card map for the given table keyword."
|
||||
[card-name table-kw]
|
||||
(let [mp (mt/metadata-provider)]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :query
|
||||
:type :question
|
||||
:dataset_query (lib/query mp (lib.metadata/table mp (mt/id table-kw)))
|
||||
:visualization_settings {}}))
|
||||
|
||||
(defn- card-sourced-from
|
||||
"Create a card map whose query sources `inner-card`."
|
||||
[card-name inner-card]
|
||||
(let [mp (mt/metadata-provider)
|
||||
card-meta (lib.metadata/card mp (:id inner-card))]
|
||||
{:name card-name
|
||||
:database_id (mt/id)
|
||||
:display :table
|
||||
:query_type :query
|
||||
:type :question
|
||||
:dataset_query (lib/query mp card-meta)
|
||||
:visualization_settings {}}))
|
||||
|
||||
(deftest usages-returns-dependent-cards-test
|
||||
(testing "usages returns cards that depend on a source card"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "usages-test@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [source-card (card/create-card! (card-with-query "Source card" :products) user)
|
||||
child-card (card/create-card! (card-sourced-from "Child card" source-card) user)
|
||||
found-usages (usages/usages [:card (:id source-card)])]
|
||||
(is (some #(= [:card (:id child-card)] %) found-usages)
|
||||
"Child card should appear in usages"))))))))
|
||||
|
||||
(deftest usages-returns-transitive-dependents-test
|
||||
(testing "usages returns transitive dependents (grandchildren)"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "usages-transitive@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [source-card (card/create-card! (card-with-query "Source card" :products) user)
|
||||
child-card (card/create-card! (card-sourced-from "Child card" source-card) user)
|
||||
grandchild-card (card/create-card! (card-sourced-from "Grandchild card" child-card) user)
|
||||
found-usages (usages/usages [:card (:id source-card)])]
|
||||
(is (some #(= [:card (:id child-card)] %) found-usages)
|
||||
"Child card should appear in usages")
|
||||
(is (some #(= [:card (:id grandchild-card)] %) found-usages)
|
||||
"Grandchild card should appear in usages"))))))))
|
||||
|
||||
(deftest usages-returns-empty-for-no-dependents-test
|
||||
(testing "usages returns empty seq for cards with no dependents"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "usages-empty@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [lonely-card (card/create-card! (card-with-query "Lonely card" :products) user)
|
||||
found-usages (usages/usages [:card (:id lonely-card)])]
|
||||
(is (empty? found-usages)
|
||||
"Card with no dependents should have empty usages"))))))))
|
||||
|
||||
(deftest usages-returns-table-dependents-test
|
||||
(testing "usages returns cards that depend on a table"
|
||||
(mt/dataset test-data
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-temp [:model/User user {:email "usages-table@test.com"}]
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [card-on-table (card/create-card! (card-with-query "Card on products" :products) user)
|
||||
found-usages (usages/usages [:table (mt/id :products)])]
|
||||
(is (some #(= [:card (:id card-on-table)] %) found-usages)
|
||||
"Card using table should appear in usages"))))))))
|
||||
@@ -10,6 +10,7 @@ export * from "./gdrive";
|
||||
export * from "./library";
|
||||
export * from "./metabot";
|
||||
export * from "./remote-sync";
|
||||
export * from "./replacement";
|
||||
export * from "./saml";
|
||||
export * from "./scim";
|
||||
export * from "./support-access-grants";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ReplaceSourceInfo,
|
||||
ReplaceSourceRequest,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
import { idTag, invalidateTags, tag } from "./tags";
|
||||
|
||||
export const replacementApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
checkReplaceSource: builder.query<ReplaceSourceInfo, ReplaceSourceRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/replacement/check-replace-source",
|
||||
body,
|
||||
}),
|
||||
providesTags: (_response, _error, request) => [
|
||||
idTag(request.source_entity_type, request.source_entity_id),
|
||||
idTag(request.target_entity_type, request.target_entity_id),
|
||||
],
|
||||
}),
|
||||
replaceSource: builder.mutation<void, ReplaceSourceRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/replacement/replace-source",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_response, error) =>
|
||||
invalidateTags(error, [tag("table"), tag("card")]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useCheckReplaceSourceQuery, useReplaceSourceMutation } =
|
||||
replacementApi;
|
||||
@@ -18,14 +18,17 @@ import type {
|
||||
} from "../../types";
|
||||
import { getCardTypes, getDependencyTypes, isSameNode } from "../../utils";
|
||||
|
||||
import { DependencyFilterBar } from "./DependencyFilterBar";
|
||||
import { DependencyHeader } from "./DependencyHeader";
|
||||
import S from "./DependencyList.module.css";
|
||||
import { DependencyPagination } from "./DependencyPagination";
|
||||
import { DependencySidebar } from "./DependencySidebar";
|
||||
import { DependencyTable } from "./DependencyTable";
|
||||
import S from "./DependencyDiagnostics.module.css";
|
||||
import { DiagnosticsFilterBar } from "./DiagnosticsFilterBar";
|
||||
import { DiagnosticsHeader } from "./DiagnosticsHeader";
|
||||
import { DiagnosticsPagination } from "./DiagnosticsPagination";
|
||||
import { DiagnosticsSidebar } from "./DiagnosticsSidebar";
|
||||
import { DiagnosticsTable } from "./DiagnosticsTable";
|
||||
import { PAGE_SIZE } from "./constants";
|
||||
import type { DependencyListMode, DependencyListParamsOptions } from "./types";
|
||||
import type {
|
||||
DependencyDiagnosticsMode,
|
||||
DependencyDiagnosticsParamsOptions,
|
||||
} from "./types";
|
||||
import {
|
||||
getAvailableGroupTypes,
|
||||
getFilterOptions,
|
||||
@@ -33,22 +36,22 @@ import {
|
||||
getSortOptions,
|
||||
} from "./utils";
|
||||
|
||||
type DependencyListProps = {
|
||||
mode: DependencyListMode;
|
||||
params: Urls.DependencyListParams;
|
||||
type DependencyDiagnosticsProps = {
|
||||
mode: DependencyDiagnosticsMode;
|
||||
params: Urls.DependencyDiagnosticsParams;
|
||||
isLoadingParams: boolean;
|
||||
onParamsChange: (
|
||||
params: Urls.DependencyListParams,
|
||||
options?: DependencyListParamsOptions,
|
||||
params: Urls.DependencyDiagnosticsParams,
|
||||
options?: DependencyDiagnosticsParamsOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function DependencyList({
|
||||
export function DependencyDiagnostics({
|
||||
mode,
|
||||
params,
|
||||
isLoadingParams,
|
||||
onParamsChange,
|
||||
}: DependencyListProps) {
|
||||
}: DependencyDiagnosticsProps) {
|
||||
const { ref: containerRef, width: containerWidth } = useElementSize();
|
||||
const [isResizing, { open: startResizing, close: stopResizing }] =
|
||||
useDisclosure();
|
||||
@@ -100,8 +103,8 @@ export function DependencyList({
|
||||
: undefined;
|
||||
|
||||
const handleParamsChange = (
|
||||
params: Urls.DependencyListParams,
|
||||
options?: DependencyListParamsOptions,
|
||||
params: Urls.DependencyDiagnosticsParams,
|
||||
options?: DependencyDiagnosticsParamsOptions,
|
||||
) => {
|
||||
onParamsChange(getParamsWithoutDefaults(mode, params), options);
|
||||
};
|
||||
@@ -157,8 +160,8 @@ export function DependencyList({
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack className={S.main} flex={1} px="3.5rem" pb="md" gap="md">
|
||||
<DependencyHeader />
|
||||
<DependencyFilterBar
|
||||
<DiagnosticsHeader />
|
||||
<DiagnosticsFilterBar
|
||||
mode={mode}
|
||||
query={query}
|
||||
filterOptions={getFilterOptions(mode, params)}
|
||||
@@ -172,7 +175,7 @@ export function DependencyList({
|
||||
<DelayedLoadingAndErrorWrapper loading={isLoading} error={error} />
|
||||
</Center>
|
||||
) : (
|
||||
<DependencyTable
|
||||
<DiagnosticsTable
|
||||
nodes={nodes}
|
||||
mode={mode}
|
||||
sortOptions={getSortOptions(params)}
|
||||
@@ -182,7 +185,7 @@ export function DependencyList({
|
||||
/>
|
||||
)}
|
||||
{!isLoading && error == null && (
|
||||
<DependencyPagination
|
||||
<DiagnosticsPagination
|
||||
page={page}
|
||||
pageNodesCount={nodes.length}
|
||||
totalNodesCount={totalNodesCount}
|
||||
@@ -191,7 +194,7 @@ export function DependencyList({
|
||||
)}
|
||||
</Stack>
|
||||
{selectedNode != null && (
|
||||
<DependencySidebar
|
||||
<DiagnosticsSidebar
|
||||
node={selectedNode}
|
||||
mode={mode}
|
||||
containerWidth={containerWidth}
|
||||
@@ -3,11 +3,11 @@ import { t } from "ttag";
|
||||
import EmptyDashboardBot from "assets/img/dashboard-empty.svg";
|
||||
import { Stack, Text } from "metabase/ui";
|
||||
|
||||
type DependencyEmptyStateProps = {
|
||||
type DiagnosticsEmptyStateProps = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function DependencyEmptyState({ label }: DependencyEmptyStateProps) {
|
||||
export function DiagnosticsEmptyState({ label }: DiagnosticsEmptyStateProps) {
|
||||
return (
|
||||
<Stack p="xl" align="center">
|
||||
<img
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiagnosticsEmptyState";
|
||||
@@ -8,11 +8,11 @@ import { FixedSizeIcon, Group, Loader, TextInput } from "metabase/ui";
|
||||
import type { DependencyFilterOptions } from "../../../types";
|
||||
import { areFilterOptionsEqual, getSearchQuery } from "../../../utils";
|
||||
import { FilterOptionsPicker } from "../../FilterOptionsPicker";
|
||||
import type { DependencyListMode } from "../types";
|
||||
import type { DependencyDiagnosticsMode } from "../types";
|
||||
import { getAvailableGroupTypes, getDefaultFilterOptions } from "../utils";
|
||||
|
||||
type DependencyFilterBarProps = {
|
||||
mode: DependencyListMode;
|
||||
type DiagnosticsFilterBarProps = {
|
||||
mode: DependencyDiagnosticsMode;
|
||||
query?: string;
|
||||
filterOptions: DependencyFilterOptions;
|
||||
isFetching: boolean;
|
||||
@@ -21,7 +21,7 @@ type DependencyFilterBarProps = {
|
||||
onFilterOptionsChange: (filterOptions: DependencyFilterOptions) => void;
|
||||
};
|
||||
|
||||
export const DependencyFilterBar = memo(function DependencyFilterBar({
|
||||
export const DiagnosticsFilterBar = memo(function DiagnosticsFilterBar({
|
||||
mode,
|
||||
query,
|
||||
filterOptions,
|
||||
@@ -29,7 +29,7 @@ export const DependencyFilterBar = memo(function DependencyFilterBar({
|
||||
isLoading,
|
||||
onQueryChange,
|
||||
onFilterOptionsChange,
|
||||
}: DependencyFilterBarProps) {
|
||||
}: DiagnosticsFilterBarProps) {
|
||||
const [searchValue, setSearchValue] = useState(query ?? "");
|
||||
const hasLoader = isFetching && !isLoading;
|
||||
const hasDefaultFilterOptions = areFilterOptionsEqual(
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiagnosticsFilterBar";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "metabase/data-studio/common/components/PaneHeader";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
|
||||
export const DependencyHeader = memo(function DependencyHeader() {
|
||||
export const DiagnosticsHeader = memo(function DiagnosticsHeader() {
|
||||
const tabs: PaneHeaderTab[] = [
|
||||
{
|
||||
label: t`Broken dependencies`,
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiagnosticsHeader";
|
||||
@@ -3,19 +3,19 @@ import { Flex } from "metabase/ui";
|
||||
|
||||
import { PAGE_SIZE } from "../constants";
|
||||
|
||||
type DependencyPaginationProps = {
|
||||
type DiagnosticsPaginationProps = {
|
||||
page: number;
|
||||
pageNodesCount: number;
|
||||
totalNodesCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export function DependencyPagination({
|
||||
export function DiagnosticsPagination({
|
||||
page,
|
||||
pageNodesCount,
|
||||
totalNodesCount,
|
||||
onPageChange,
|
||||
}: DependencyPaginationProps) {
|
||||
}: DiagnosticsPaginationProps) {
|
||||
return (
|
||||
<Flex justify="end">
|
||||
<PaginationControls
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiagnosticsPagination";
|
||||
@@ -3,10 +3,10 @@ import { memo } from "react";
|
||||
import { Stack } from "metabase/ui";
|
||||
import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
import type { DependencyListMode } from "../types";
|
||||
import type { DependencyDiagnosticsMode } from "../types";
|
||||
|
||||
import { BrokenDependentsSection } from "./BrokenDependentsSection";
|
||||
import S from "./DependencySidebar.module.css";
|
||||
import S from "./DiagnosticsSidebar.module.css";
|
||||
import { ErrorsSection } from "./ErrorsSection";
|
||||
import { FieldsSection } from "./FieldsSection";
|
||||
import { InfoSection } from "./InfoSection";
|
||||
@@ -14,23 +14,23 @@ import { LocationSection } from "./LocationSection";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
import { SidebarResizableBox } from "./SidebarResizableBox";
|
||||
|
||||
type DependencySidebarProps = {
|
||||
type DiagnosticsSidebarProps = {
|
||||
node: DependencyNode;
|
||||
mode: DependencyListMode;
|
||||
mode: DependencyDiagnosticsMode;
|
||||
containerWidth: number;
|
||||
onResizeStart: () => void;
|
||||
onResizeStop: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DependencySidebar = memo(function DependencySidebar({
|
||||
export const DiagnosticsSidebar = memo(function DiagnosticsSidebar({
|
||||
node,
|
||||
mode,
|
||||
containerWidth,
|
||||
onResizeStart,
|
||||
onResizeStop,
|
||||
onClose,
|
||||
}: DependencySidebarProps) {
|
||||
}: DiagnosticsSidebarProps) {
|
||||
return (
|
||||
<SidebarResizableBox
|
||||
containerWidth={containerWidth}
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
createMockTableDependencyNodeData,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import type { DependencyListMode } from "../types";
|
||||
import type { DependencyDiagnosticsMode } from "../types";
|
||||
|
||||
import { DependencySidebar } from "./DependencySidebar";
|
||||
import { DiagnosticsSidebar } from "./DiagnosticsSidebar";
|
||||
|
||||
type SetupOpts = {
|
||||
node?: DependencyNode;
|
||||
mode?: DependencyListMode;
|
||||
mode?: DependencyDiagnosticsMode;
|
||||
};
|
||||
|
||||
function setup({
|
||||
@@ -33,7 +33,7 @@ function setup({
|
||||
<Route
|
||||
path="/"
|
||||
component={() => (
|
||||
<DependencySidebar
|
||||
<DiagnosticsSidebar
|
||||
node={node}
|
||||
mode={mode}
|
||||
containerWidth={1000}
|
||||
@@ -49,7 +49,7 @@ function setup({
|
||||
return { onResizeStart, onResizeStop, onClose };
|
||||
}
|
||||
|
||||
describe("DependencySidebar", () => {
|
||||
describe("DiagnosticsSidebar", () => {
|
||||
it("should render the sidebar container", () => {
|
||||
setup();
|
||||
expect(screen.getByTestId("dependency-list-sidebar")).toBeInTheDocument();
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiagnosticsSidebar";
|
||||
@@ -11,8 +11,8 @@ import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
import type { DependencySortOptions } from "../../../types";
|
||||
import { getNodeId } from "../../../utils";
|
||||
import { DependencyEmptyState } from "../DependencyEmptyState";
|
||||
import type { DependencyListMode } from "../types";
|
||||
import { DiagnosticsEmptyState } from "../DiagnosticsEmptyState";
|
||||
import type { DependencyDiagnosticsMode } from "../types";
|
||||
|
||||
import {
|
||||
getColumnWidths,
|
||||
@@ -22,23 +22,23 @@ import {
|
||||
getSortingState,
|
||||
} from "./utils";
|
||||
|
||||
type DependencyTableProps = {
|
||||
type DiagnosticsTableProps = {
|
||||
nodes: DependencyNode[];
|
||||
mode: DependencyListMode;
|
||||
mode: DependencyDiagnosticsMode;
|
||||
sortOptions: DependencySortOptions | undefined;
|
||||
isLoading?: boolean;
|
||||
onSelect: (node: DependencyNode) => void;
|
||||
onSortOptionsChange: (sortOptions: DependencySortOptions | undefined) => void;
|
||||
};
|
||||
|
||||
export const DependencyTable = function DependencyTable({
|
||||
export const DiagnosticsTable = function DiagnosticsTable({
|
||||
nodes,
|
||||
mode,
|
||||
sortOptions,
|
||||
isLoading = false,
|
||||
onSelect,
|
||||
onSortOptionsChange,
|
||||
}: DependencyTableProps) {
|
||||
}: DiagnosticsTableProps) {
|
||||
const columns = useMemo(() => getColumns(mode), [mode]);
|
||||
const sortingState = useMemo(
|
||||
() => getSortingState(sortOptions),
|
||||
@@ -82,7 +82,9 @@ export const DependencyTable = function DependencyTable({
|
||||
) : (
|
||||
<TreeTable
|
||||
instance={treeTableInstance}
|
||||
emptyState={<DependencyEmptyState label={getNotFoundMessage(mode)} />}
|
||||
emptyState={
|
||||
<DiagnosticsEmptyState label={getNotFoundMessage(mode)} />
|
||||
}
|
||||
onRowClick={handleRowActivate}
|
||||
/>
|
||||
)}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiagnosticsTable";
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { DEPENDENCY_SORT_COLUMNS } from "metabase-types/api";
|
||||
|
||||
import type { DependencySortOptions } from "../../../types";
|
||||
import {
|
||||
getDependentsErrorsColumn,
|
||||
getDependentsWithErrorsColumn,
|
||||
getLocationColumn,
|
||||
getNameColumn,
|
||||
} from "../../DependencyTable";
|
||||
import type { DependencyDiagnosticsMode } from "../types";
|
||||
|
||||
export function getColumns(mode: DependencyDiagnosticsMode) {
|
||||
return [
|
||||
getNameColumn(mode === "broken" ? t`Dependency` : t`Name`),
|
||||
getLocationColumn(),
|
||||
...(mode === "broken" ? [getDependentsErrorsColumn()] : []),
|
||||
...(mode === "broken" ? [getDependentsWithErrorsColumn()] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function getColumnWidths(mode: DependencyDiagnosticsMode): number[] {
|
||||
if (mode === "broken") {
|
||||
return [0.3, 0.3, 0.3, 0.1];
|
||||
} else {
|
||||
return [0.5, 0.5];
|
||||
}
|
||||
}
|
||||
|
||||
export function getSortingState(
|
||||
sortOptions: DependencySortOptions | undefined,
|
||||
): SortingState {
|
||||
return sortOptions?.column != null
|
||||
? [{ id: sortOptions.column, desc: sortOptions.direction === "desc" }]
|
||||
: [];
|
||||
}
|
||||
|
||||
export function getSortingOptions(
|
||||
sortingState: SortingState,
|
||||
): DependencySortOptions | undefined {
|
||||
if (sortingState.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { id, desc } = sortingState[0];
|
||||
const column = DEPENDENCY_SORT_COLUMNS.find((column) => column === id);
|
||||
if (column == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
column,
|
||||
direction: desc ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
|
||||
export function getNotFoundMessage(mode: DependencyDiagnosticsMode) {
|
||||
return mode === "broken"
|
||||
? t`No broken dependencies found`
|
||||
: t`No unreferenced entities found`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DependencyDiagnostics";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type DependencyDiagnosticsMode = "broken" | "unreferenced";
|
||||
|
||||
export type DependencyDiagnosticsParamsOptions = {
|
||||
withSetLastUsedParams?: boolean;
|
||||
};
|
||||
@@ -7,18 +7,18 @@ import type {
|
||||
} from "../../types";
|
||||
|
||||
import { BROKEN_GROUP_TYPES, UNREFERENCED_GROUP_TYPES } from "./constants";
|
||||
import type { DependencyListMode } from "./types";
|
||||
import type { DependencyDiagnosticsMode } from "./types";
|
||||
|
||||
export function getAvailableGroupTypes(mode: DependencyListMode) {
|
||||
export function getAvailableGroupTypes(mode: DependencyDiagnosticsMode) {
|
||||
return mode === "broken" ? BROKEN_GROUP_TYPES : UNREFERENCED_GROUP_TYPES;
|
||||
}
|
||||
|
||||
export function getFilterOptions(
|
||||
mode: DependencyListMode,
|
||||
mode: DependencyDiagnosticsMode,
|
||||
{
|
||||
groupTypes = getAvailableGroupTypes(mode),
|
||||
includePersonalCollections = DEFAULT_INCLUDE_PERSONAL_COLLECTIONS,
|
||||
}: Urls.DependencyListParams,
|
||||
}: Urls.DependencyDiagnosticsParams,
|
||||
): DependencyFilterOptions {
|
||||
return {
|
||||
groupTypes,
|
||||
@@ -27,7 +27,7 @@ export function getFilterOptions(
|
||||
}
|
||||
|
||||
export function getDefaultFilterOptions(
|
||||
mode: DependencyListMode,
|
||||
mode: DependencyDiagnosticsMode,
|
||||
): DependencyFilterOptions {
|
||||
return getFilterOptions(mode, {});
|
||||
}
|
||||
@@ -35,21 +35,21 @@ export function getDefaultFilterOptions(
|
||||
export function getSortOptions({
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
}: Urls.DependencyListParams): DependencySortOptions | undefined {
|
||||
}: Urls.DependencyDiagnosticsParams): DependencySortOptions | undefined {
|
||||
return sortColumn != null && sortDirection != null
|
||||
? { column: sortColumn, direction: sortDirection }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getParamsWithoutDefaults(
|
||||
mode: DependencyListMode,
|
||||
mode: DependencyDiagnosticsMode,
|
||||
{
|
||||
page,
|
||||
groupTypes,
|
||||
includePersonalCollections,
|
||||
...params
|
||||
}: Urls.DependencyListParams,
|
||||
): Urls.DependencyListParams {
|
||||
}: Urls.DependencyDiagnosticsParams,
|
||||
): Urls.DependencyDiagnosticsParams {
|
||||
const defaultGroupTypes = getAvailableGroupTypes(mode);
|
||||
|
||||
return {
|
||||
@@ -15,6 +15,7 @@ import { useMetadataToasts } from "metabase/metadata/hooks";
|
||||
import { Group, useColorScheme } from "metabase/ui";
|
||||
import { useGetDependencyGraphQuery } from "metabase-enterprise/api";
|
||||
import type {
|
||||
DependencyEntry,
|
||||
DependencyGraph,
|
||||
WorkspaceDependencyGraph,
|
||||
} from "metabase-types/api";
|
||||
@@ -47,10 +48,10 @@ const PRO_OPTIONS = {
|
||||
type DependencyGraphProps = {
|
||||
graph?: DependencyGraph | WorkspaceDependencyGraph | null;
|
||||
isFetching?: boolean;
|
||||
error?: any;
|
||||
getGraphUrl: (entry?: any) => string;
|
||||
error?: unknown;
|
||||
getGraphUrl: (entry?: DependencyEntry) => string;
|
||||
withEntryPicker?: boolean;
|
||||
entry?: any;
|
||||
entry?: DependencyEntry;
|
||||
nodeTypes?: typeof NODE_TYPES;
|
||||
edgeTypes?: typeof EDGE_TYPES;
|
||||
openLinksInNewTab?: boolean;
|
||||
@@ -72,8 +73,8 @@ export function DependencyGraph({
|
||||
shouldFetch ? entry : skipToken,
|
||||
);
|
||||
const isFetching = isFetchingExternally || dependencyGraph.isFetching;
|
||||
const graph = externalGraph || dependencyGraph.data;
|
||||
const error = externalError || dependencyGraph.error;
|
||||
const graph = externalGraph ?? dependencyGraph.data;
|
||||
const error = externalError ?? dependencyGraph.error;
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<NodeType>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
@@ -65,7 +65,7 @@ function ListItem({ node, getGraphUrl }: ListItemProps) {
|
||||
{getNodeViewCountLabel(viewCount)}
|
||||
</Box>
|
||||
) : link != null ? (
|
||||
<GraphExternalLink label={link.label} url={link.url} />
|
||||
<GraphExternalLink label={link.label} url={link.url} isCompact />
|
||||
) : null}
|
||||
</Group>
|
||||
{(location != null || (link != null && viewCount != null)) && (
|
||||
@@ -77,7 +77,7 @@ function ListItem({ node, getGraphUrl }: ListItemProps) {
|
||||
<GraphBreadcrumbs links={location.links} ml="1rem" pl="sm" />
|
||||
)}
|
||||
{link != null && viewCount != null && (
|
||||
<GraphExternalLink label={link.label} url={link.url} />
|
||||
<GraphExternalLink label={link.label} url={link.url} isCompact />
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -9,9 +9,14 @@ import { GraphContext } from "../GraphContext";
|
||||
type GraphExternalLinkProps = {
|
||||
label: string;
|
||||
url: string;
|
||||
isCompact?: boolean;
|
||||
};
|
||||
|
||||
export function GraphExternalLink({ label, url }: GraphExternalLinkProps) {
|
||||
export function GraphExternalLink({
|
||||
label,
|
||||
url,
|
||||
isCompact,
|
||||
}: GraphExternalLinkProps) {
|
||||
const { openLinksInNewTab } = useContext(GraphContext);
|
||||
|
||||
return (
|
||||
@@ -20,7 +25,7 @@ export function GraphExternalLink({ label, url }: GraphExternalLinkProps) {
|
||||
component={ForwardRefLink}
|
||||
to={url}
|
||||
target={openLinksInNewTab ? "_blank" : undefined}
|
||||
m="-sm"
|
||||
m={isCompact ? "-sm" : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
<FixedSizeIcon name="external" />
|
||||
|
||||
@@ -124,6 +124,7 @@ function TableSection({ node, getGraphUrl }: TableSectionProps) {
|
||||
<GraphExternalLink
|
||||
label={info.metadata.label}
|
||||
url={info.metadata.url}
|
||||
isCompact
|
||||
/>
|
||||
</Group>
|
||||
{info.location && (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { t } from "ttag";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { PLUGIN_REPLACEMENT } from "metabase/plugins";
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
getNodeLabel,
|
||||
getNodeLink,
|
||||
getNodeLocationInfo,
|
||||
getNodeReplaceSourceEntry,
|
||||
} from "../../../../utils";
|
||||
import { GraphBreadcrumbs } from "../../GraphBreadcrumbs";
|
||||
import { GraphExternalLink } from "../../GraphExternalLink";
|
||||
@@ -30,26 +34,50 @@ type PanelHeaderProps = {
|
||||
export function PanelHeader({ node, onClose }: PanelHeaderProps) {
|
||||
const link = getNodeLink(node);
|
||||
const location = getNodeLocationInfo(node);
|
||||
const replaceSource = getNodeReplaceSourceEntry(node);
|
||||
const [
|
||||
isReplaceModalOpened,
|
||||
{ open: openReplaceModal, close: closeReplaceModal },
|
||||
] = useDisclosure();
|
||||
|
||||
return (
|
||||
<Group className={S.root} p="lg" gap="0.75rem" wrap="nowrap">
|
||||
<Center w="2.75rem" h="2.75rem" bdrs="50%" bg="background-secondary">
|
||||
<FixedSizeIcon name={getNodeIcon(node)} c="brand" size={20} />
|
||||
</Center>
|
||||
<Stack gap="xs" flex={1}>
|
||||
<Title className={CS.textWrap} order={3} lh="1.5rem">
|
||||
{getNodeLabel(node)}
|
||||
</Title>
|
||||
{location != null && <GraphBreadcrumbs links={location.links} />}
|
||||
</Stack>
|
||||
<Group m="-sm" gap="xs" wrap="nowrap">
|
||||
{link != null && (
|
||||
<GraphExternalLink label={link.label} url={link.url} />
|
||||
)}
|
||||
<ActionIcon aria-label={t`Close`} onClick={onClose}>
|
||||
<FixedSizeIcon name="close" />
|
||||
</ActionIcon>
|
||||
<>
|
||||
<Group className={S.root} p="lg" gap="0.75rem" wrap="nowrap">
|
||||
<Center w="2.75rem" h="2.75rem" bdrs="50%" bg="background-secondary">
|
||||
<FixedSizeIcon name={getNodeIcon(node)} c="brand" size={20} />
|
||||
</Center>
|
||||
<Stack gap="xs" flex={1}>
|
||||
<Title className={CS.textWrap} order={3} lh="1.5rem">
|
||||
{getNodeLabel(node)}
|
||||
</Title>
|
||||
{location != null && <GraphBreadcrumbs links={location.links} />}
|
||||
</Stack>
|
||||
<Group m="-sm" gap="xs" wrap="nowrap">
|
||||
{link != null && (
|
||||
<GraphExternalLink label={link.label} url={link.url} />
|
||||
)}
|
||||
{PLUGIN_REPLACEMENT.isEnabled && replaceSource != null && (
|
||||
<Tooltip label={t`Find and replace`}>
|
||||
<ActionIcon
|
||||
aria-label={t`Replace data source`}
|
||||
onClick={openReplaceModal}
|
||||
>
|
||||
<FixedSizeIcon name="find_replace" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ActionIcon aria-label={t`Close`} onClick={onClose}>
|
||||
<FixedSizeIcon name="close" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
{replaceSource != null && (
|
||||
<PLUGIN_REPLACEMENT.ReplaceDataSourceModal
|
||||
isOpened={isReplaceModalOpened}
|
||||
initialSource={replaceSource}
|
||||
onClose={closeReplaceModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyEmptyState";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyFilterBar";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyHeader";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyPagination";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencySidebar";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyTable";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyList";
|
||||
@@ -1,5 +0,0 @@
|
||||
export type DependencyListMode = "broken" | "unreferenced";
|
||||
|
||||
export type DependencyListParamsOptions = {
|
||||
withSetLastUsedParams?: boolean;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
import { getDependencyErrorInfo, getDependencyErrors } from "../../../../utils";
|
||||
import { getDependencyErrorInfo, getDependencyErrors } from "../../../utils";
|
||||
|
||||
type ErrorsCellProps = {
|
||||
node: DependencyNode;
|
||||
@@ -2,7 +2,7 @@ import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { FixedSizeIcon, Group } from "metabase/ui";
|
||||
import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
import { getNodeLocationInfo } from "../../../../utils";
|
||||
import { getNodeLocationInfo } from "../../../utils";
|
||||
|
||||
type LocationCellProps = {
|
||||
node: DependencyNode;
|
||||
@@ -2,7 +2,7 @@ import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { FixedSizeIcon, Group } from "metabase/ui";
|
||||
import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
import { getNodeIcon, getNodeLabel } from "../../../../utils";
|
||||
import { getNodeIcon, getNodeLabel } from "../../../utils";
|
||||
|
||||
type NameCellProps = {
|
||||
node: DependencyNode;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./utils";
|
||||
@@ -1,31 +1,29 @@
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { DateTime } from "metabase/common/components/DateTime";
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { getUserName } from "metabase/lib/user";
|
||||
import type { TreeTableColumnDef } from "metabase/ui";
|
||||
import {
|
||||
DEPENDENCY_SORT_COLUMNS,
|
||||
type DependencyNode,
|
||||
type DependencySortColumn,
|
||||
} from "metabase-types/api";
|
||||
import type { DependencyNode, DependencySortColumn } from "metabase-types/api";
|
||||
|
||||
import type { DependencySortOptions } from "../../../types";
|
||||
import {
|
||||
getDependentErrorNodesCount,
|
||||
getNodeLabel,
|
||||
getNodeLastEditedAt,
|
||||
getNodeLastEditedBy,
|
||||
getNodeLocationInfo,
|
||||
} from "../../../utils";
|
||||
import type { DependencyListMode } from "../types";
|
||||
} from "../../utils";
|
||||
|
||||
import { ErrorsCell } from "./ErrorsCell";
|
||||
import { LocationCell } from "./LocationCell";
|
||||
import { NameCell } from "./NameCell";
|
||||
|
||||
function getNameColumn(
|
||||
mode: DependencyListMode,
|
||||
export function getNameColumn(
|
||||
header: string = t`Name`,
|
||||
): TreeTableColumnDef<DependencyNode> {
|
||||
return {
|
||||
id: "name" satisfies DependencySortColumn,
|
||||
header: mode === "broken" ? t`Dependency` : t`Name`,
|
||||
header,
|
||||
width: "auto",
|
||||
maxAutoWidth: 520,
|
||||
enableSorting: true,
|
||||
@@ -37,7 +35,7 @@ function getNameColumn(
|
||||
};
|
||||
}
|
||||
|
||||
function getLocationColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
export function getLocationColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
return {
|
||||
id: "location" satisfies DependencySortColumn,
|
||||
header: t`Location`,
|
||||
@@ -56,7 +54,7 @@ function getLocationColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
};
|
||||
}
|
||||
|
||||
function getDependentsErrorsColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
export function getDependentsErrorsColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
return {
|
||||
id: "dependents-errors" satisfies DependencySortColumn,
|
||||
header: t`Problems`,
|
||||
@@ -75,7 +73,7 @@ function getDependentsErrorsColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
};
|
||||
}
|
||||
|
||||
function getDependentsWithErrorsColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
export function getDependentsWithErrorsColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
return {
|
||||
id: "dependents-with-errors" satisfies DependencySortColumn,
|
||||
header: t`Broken dependents`,
|
||||
@@ -91,54 +89,36 @@ function getDependentsWithErrorsColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
};
|
||||
}
|
||||
|
||||
export function getColumns(
|
||||
mode: DependencyListMode,
|
||||
): TreeTableColumnDef<DependencyNode>[] {
|
||||
return [
|
||||
getNameColumn(mode),
|
||||
getLocationColumn(),
|
||||
...(mode === "broken" ? [getDependentsErrorsColumn()] : []),
|
||||
...(mode === "broken" ? [getDependentsWithErrorsColumn()] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function getColumnWidths(mode: DependencyListMode): number[] {
|
||||
if (mode === "broken") {
|
||||
return [0.3, 0.3, 0.3, 0.1];
|
||||
} else {
|
||||
return [0.5, 0.5];
|
||||
}
|
||||
}
|
||||
|
||||
export function getSortingState(
|
||||
sortOptions: DependencySortOptions | undefined,
|
||||
): SortingState {
|
||||
return sortOptions?.column != null
|
||||
? [{ id: sortOptions.column, desc: sortOptions.direction === "desc" }]
|
||||
: [];
|
||||
}
|
||||
|
||||
export function getSortingOptions(
|
||||
sortingState: SortingState,
|
||||
): DependencySortOptions | undefined {
|
||||
if (sortingState.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { id, desc } = sortingState[0];
|
||||
const column = DEPENDENCY_SORT_COLUMNS.find((column) => column === id);
|
||||
if (column == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getLastEditedByColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
return {
|
||||
column,
|
||||
direction: desc ? "desc" : "asc",
|
||||
id: "last-edited-by",
|
||||
header: t`Last edited by`,
|
||||
width: "auto",
|
||||
maxAutoWidth: 520,
|
||||
accessorFn: (node) => getUserName(getNodeLastEditedBy(node) ?? undefined),
|
||||
cell: ({ row }) => {
|
||||
const user = getNodeLastEditedBy(row.original);
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
return <Ellipsified>{getUserName(user)}</Ellipsified>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getNotFoundMessage(mode: DependencyListMode) {
|
||||
return mode === "broken"
|
||||
? t`No broken dependencies found`
|
||||
: t`No unreferenced entities found`;
|
||||
export function getLastEditedAtColumn(): TreeTableColumnDef<DependencyNode> {
|
||||
return {
|
||||
id: "last-edited-at",
|
||||
header: t`Last edited at`,
|
||||
width: "auto",
|
||||
maxAutoWidth: 520,
|
||||
accessorFn: (node) => getNodeLastEditedAt(node),
|
||||
cell: ({ row }) => {
|
||||
const timestamp = getNodeLastEditedAt(row.original);
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return <DateTime value={timestamp} unit="minute" />;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { useUserKeyValue } from "metabase/common/hooks/use-user-key-value";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import type * as Urls from "metabase/lib/urls";
|
||||
|
||||
import { DependencyList } from "../../components/DependencyList";
|
||||
import { DependencyDiagnostics } from "../../components/DependencyDiagnostics";
|
||||
import type {
|
||||
DependencyListMode,
|
||||
DependencyListParamsOptions,
|
||||
} from "../../components/DependencyList/types";
|
||||
DependencyDiagnosticsMode,
|
||||
DependencyDiagnosticsParamsOptions,
|
||||
} from "../../components/DependencyDiagnostics/types";
|
||||
|
||||
import {
|
||||
getPageUrl,
|
||||
@@ -20,15 +20,18 @@ import {
|
||||
parseUserParams,
|
||||
} from "./utils";
|
||||
|
||||
type DependencyListPageProps = {
|
||||
type DependencyDiagnosticsPageProps = {
|
||||
location: Location;
|
||||
};
|
||||
|
||||
type DependencyListPageOwnProps = DependencyListPageProps & {
|
||||
mode: DependencyListMode;
|
||||
type DependencyDiagnosticsPageOwnProps = DependencyDiagnosticsPageProps & {
|
||||
mode: DependencyDiagnosticsMode;
|
||||
};
|
||||
|
||||
function DependencyListPage({ mode, location }: DependencyListPageOwnProps) {
|
||||
function DependencyDiagnosticsPage({
|
||||
mode,
|
||||
location,
|
||||
}: DependencyDiagnosticsPageOwnProps) {
|
||||
const isInitializingRef = useRef(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -48,8 +51,8 @@ function DependencyListPage({ mode, location }: DependencyListPageOwnProps) {
|
||||
}, [location, rawLastUsedParams]);
|
||||
|
||||
const handleParamsChange = (
|
||||
params: Urls.DependencyListParams,
|
||||
{ withSetLastUsedParams = false }: DependencyListParamsOptions = {},
|
||||
params: Urls.DependencyDiagnosticsParams,
|
||||
{ withSetLastUsedParams = false }: DependencyDiagnosticsParamsOptions = {},
|
||||
) => {
|
||||
if (withSetLastUsedParams) {
|
||||
setLastUsedParams(getUserParams(params));
|
||||
@@ -65,7 +68,7 @@ function DependencyListPage({ mode, location }: DependencyListPageOwnProps) {
|
||||
}, [mode, params, isLoadingParams, dispatch]);
|
||||
|
||||
return (
|
||||
<DependencyList
|
||||
<DependencyDiagnostics
|
||||
mode={mode}
|
||||
params={params}
|
||||
isLoadingParams={isLoadingParams}
|
||||
@@ -74,14 +77,14 @@ function DependencyListPage({ mode, location }: DependencyListPageOwnProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function BrokenDependencyListPage({
|
||||
export function BrokenDependencyDiagnosticsPage({
|
||||
location,
|
||||
}: DependencyListPageProps) {
|
||||
return <DependencyListPage mode="broken" location={location} />;
|
||||
}: DependencyDiagnosticsPageProps) {
|
||||
return <DependencyDiagnosticsPage mode="broken" location={location} />;
|
||||
}
|
||||
|
||||
export function UnreferencedDependencyListPage({
|
||||
export function UnreferencedDependencyDiagnosticsPage({
|
||||
location,
|
||||
}: DependencyListPageProps) {
|
||||
return <DependencyListPage mode="unreferenced" location={location} />;
|
||||
}: DependencyDiagnosticsPageProps) {
|
||||
return <DependencyDiagnosticsPage mode="unreferenced" location={location} />;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "__support__/ui";
|
||||
import type * as Urls from "metabase/lib/urls";
|
||||
import type {
|
||||
DependencyListUserParams,
|
||||
DependencyDiagnosticsUserParams,
|
||||
DependencyNode,
|
||||
} from "metabase-types/api";
|
||||
import {
|
||||
@@ -25,12 +25,12 @@ import {
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import type { DependencyListMode } from "../../components/DependencyList/types";
|
||||
import type { DependencyDiagnosticsMode } from "../../components/DependencyDiagnostics/types";
|
||||
|
||||
import {
|
||||
BrokenDependencyListPage,
|
||||
UnreferencedDependencyListPage,
|
||||
} from "./DependencyListPage";
|
||||
BrokenDependencyDiagnosticsPage,
|
||||
UnreferencedDependencyDiagnosticsPage,
|
||||
} from "./DependencyDiagnosticsPage";
|
||||
import { getPageUrl } from "./utils";
|
||||
|
||||
const CARD_NODES = [
|
||||
@@ -45,11 +45,11 @@ const CARD_NODES = [
|
||||
];
|
||||
|
||||
type SetupOpts = {
|
||||
mode?: DependencyListMode;
|
||||
mode?: DependencyDiagnosticsMode;
|
||||
nodes?: DependencyNode[];
|
||||
total?: number;
|
||||
urlParams?: Urls.DependencyListParams;
|
||||
lastUsedParams?: DependencyListUserParams;
|
||||
urlParams?: Urls.DependencyDiagnosticsParams;
|
||||
lastUsedParams?: DependencyDiagnosticsUserParams;
|
||||
};
|
||||
|
||||
function setup({
|
||||
@@ -85,8 +85,8 @@ function setup({
|
||||
|
||||
const PageComponent =
|
||||
mode === "broken"
|
||||
? BrokenDependencyListPage
|
||||
: UnreferencedDependencyListPage;
|
||||
? BrokenDependencyDiagnosticsPage
|
||||
: UnreferencedDependencyDiagnosticsPage;
|
||||
|
||||
const { history } = renderWithProviders(
|
||||
<Route path={getPageUrl(mode, {})} component={PageComponent} />,
|
||||
@@ -118,7 +118,7 @@ async function waitForListToLoad() {
|
||||
expect(await screen.findByRole("treegrid")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
describe("DependencyListPage", () => {
|
||||
describe("DependencyDiagnosticsPage", () => {
|
||||
describe("list", () => {
|
||||
it("renders provided nodes in the list", async () => {
|
||||
setup({ nodes: CARD_NODES });
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DependencyDiagnosticsPage";
|
||||
@@ -4,22 +4,24 @@ import * as Urls from "metabase/lib/urls";
|
||||
import {
|
||||
DEPENDENCY_GROUP_TYPES,
|
||||
DEPENDENCY_SORT_COLUMNS,
|
||||
type DependencyListUserParams,
|
||||
type DependencyDiagnosticsUserParams,
|
||||
SORT_DIRECTIONS,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { DependencyListMode } from "../../components/DependencyList/types";
|
||||
import type { DependencyDiagnosticsMode } from "../../components/DependencyDiagnostics/types";
|
||||
|
||||
export function getPageUrl(
|
||||
mode: DependencyListMode,
|
||||
params: Urls.DependencyListParams,
|
||||
mode: DependencyDiagnosticsMode,
|
||||
params: Urls.DependencyDiagnosticsParams,
|
||||
): string {
|
||||
return mode === "broken"
|
||||
? Urls.brokenDependencies(params)
|
||||
: Urls.unreferencedDependencies(params);
|
||||
}
|
||||
|
||||
export function parseUrlParams(location: Location): Urls.DependencyListParams {
|
||||
export function parseUrlParams(
|
||||
location: Location,
|
||||
): Urls.DependencyDiagnosticsParams {
|
||||
const {
|
||||
page,
|
||||
query,
|
||||
@@ -45,8 +47,8 @@ export function parseUrlParams(location: Location): Urls.DependencyListParams {
|
||||
|
||||
// when the value is not previously set, the BE returns an empty string
|
||||
export function parseUserParams(
|
||||
params: DependencyListUserParams | undefined | "",
|
||||
): Urls.DependencyListParams {
|
||||
params: DependencyDiagnosticsUserParams | undefined | "",
|
||||
): Urls.DependencyDiagnosticsParams {
|
||||
if (typeof params !== "object" || params == null) {
|
||||
return {};
|
||||
}
|
||||
@@ -60,8 +62,8 @@ export function parseUserParams(
|
||||
}
|
||||
|
||||
export function getUserParams(
|
||||
params: Urls.DependencyListParams,
|
||||
): DependencyListUserParams {
|
||||
params: Urls.DependencyDiagnosticsParams,
|
||||
): DependencyDiagnosticsUserParams {
|
||||
return {
|
||||
group_types: params.groupTypes,
|
||||
include_personal_collections: params.includePersonalCollections,
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./DependencyListPage";
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IndexRedirect, IndexRoute, Route } from "react-router";
|
||||
|
||||
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
|
||||
import {
|
||||
BrokenDependencyListPage,
|
||||
UnreferencedDependencyListPage,
|
||||
} from "./pages/DependencyListPage";
|
||||
BrokenDependencyDiagnosticsPage,
|
||||
UnreferencedDependencyDiagnosticsPage,
|
||||
} from "./pages/DependencyDiagnosticsPage";
|
||||
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
|
||||
|
||||
export function getDataStudioDependencyRoutes() {
|
||||
return <IndexRoute component={DependencyGraphPage} />;
|
||||
@@ -14,8 +14,11 @@ export function getDataStudioDependencyDiagnosticsRoutes() {
|
||||
return (
|
||||
<>
|
||||
<IndexRedirect to="broken" />
|
||||
<Route path="broken" component={BrokenDependencyListPage} />
|
||||
<Route path="unreferenced" component={UnreferencedDependencyListPage} />
|
||||
<Route path="broken" component={BrokenDependencyDiagnosticsPage} />
|
||||
<Route
|
||||
path="unreferenced"
|
||||
component={UnreferencedDependencyDiagnosticsPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
DependencyNode,
|
||||
DependencyType,
|
||||
Field,
|
||||
ReplaceSourceEntry,
|
||||
Transform,
|
||||
VisualizationDisplay,
|
||||
} from "metabase-types/api";
|
||||
@@ -488,6 +489,22 @@ export function getNodeFieldsLabelWithCount(fieldCount: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeReplaceSourceEntry(
|
||||
node: DependencyNode,
|
||||
): ReplaceSourceEntry | null {
|
||||
switch (node.type) {
|
||||
case "table":
|
||||
return { id: node.id, type: node.type };
|
||||
case "card":
|
||||
if (node.data.type === "question" || node.data.type === "model") {
|
||||
return { id: node.id, type: node.type };
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCardType(groupType: DependencyGroupType): CardType | null {
|
||||
switch (groupType) {
|
||||
case "question":
|
||||
|
||||
@@ -35,6 +35,7 @@ import { initializePlugin as initializeMetabot } from "./metabot";
|
||||
import { initializePlugin as initializeModelPersistence } from "./model_persistence";
|
||||
import { initializePlugin as initializeModeration } from "./moderation";
|
||||
import { initializePlugin as initializeRemoteSync } from "./remote_sync";
|
||||
import { initializePlugin as initializeReplacement } from "./replacement";
|
||||
import { initializePlugin as initializeResourceDownloads } from "./resource_downloads";
|
||||
import { initializePlugin as initializeSandboxes } from "./sandboxes";
|
||||
import { initializePlugin as initializeSemanticSearch } from "./semantic_search";
|
||||
@@ -65,6 +66,7 @@ export function initializePlugins() {
|
||||
initializeContentVerification();
|
||||
initializeDatabaseRouting();
|
||||
initializeRemoteSync();
|
||||
initializeReplacement();
|
||||
initializeWhitelabel();
|
||||
initializeEmbedding();
|
||||
initializeEmbeddingSdk();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Card, TreeTable, useTreeTableInstance } from "metabase/ui";
|
||||
import {
|
||||
getLocationColumn,
|
||||
getNameColumn,
|
||||
} from "metabase-enterprise/dependencies/components/DependencyTable";
|
||||
import { getNodeId } from "metabase-enterprise/dependencies/utils";
|
||||
import type { DependencyNode } from "metabase-types/api";
|
||||
|
||||
type DependentsTableProps = {
|
||||
nodes: DependencyNode[];
|
||||
};
|
||||
|
||||
function getColumns() {
|
||||
return [getNameColumn(), getLocationColumn()];
|
||||
}
|
||||
|
||||
export function DependentsTable({ nodes }: DependentsTableProps) {
|
||||
const columns = useMemo(() => getColumns(), []);
|
||||
|
||||
const treeTableInstance = useTreeTableInstance<DependencyNode>({
|
||||
data: nodes,
|
||||
columns,
|
||||
getNodeId: (node) => getNodeId(node.id, node.type),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card p={0} shadow="none" withBorder>
|
||||
<TreeTable instance={treeTableInstance} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DependentsTable";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { getColumnIcon } from "metabase/common/utils/columns";
|
||||
import { FixedSizeIcon, Group } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type { ReplaceSourceColumnInfo } from "metabase-types/api";
|
||||
|
||||
type ColumnNameCellProps = {
|
||||
column: ReplaceSourceColumnInfo;
|
||||
};
|
||||
|
||||
export function ColumnNameCell({ column }: ColumnNameCellProps) {
|
||||
const columnInfo = Lib.legacyColumnTypeInfo(column);
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<FixedSizeIcon name={getColumnIcon(columnInfo)} />
|
||||
<Ellipsified>{column.display_name}</Ellipsified>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ColumnNameCell";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Card, TreeTable, useTreeTableInstance } from "metabase/ui";
|
||||
import type { ReplaceSourceError } from "metabase-types/api";
|
||||
|
||||
import type { ErrorItem } from "./types";
|
||||
import { getColumns, getRows } from "./utils";
|
||||
|
||||
type ErrorsTableProps = {
|
||||
error: ReplaceSourceError;
|
||||
};
|
||||
|
||||
export function ErrorsTable({ error }: ErrorsTableProps) {
|
||||
const rows = useMemo(() => getRows(error.columns), [error.columns]);
|
||||
const columns = useMemo(() => getColumns(), []);
|
||||
|
||||
const treeTableInstance = useTreeTableInstance<ErrorItem>({
|
||||
data: rows,
|
||||
columns,
|
||||
getNodeId: (item) => item.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card p={0} shadow="none" withBorder>
|
||||
<TreeTable instance={treeTableInstance} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ErrorsTable";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReplaceSourceColumnInfo } from "metabase-types/api";
|
||||
|
||||
export type ErrorItem = {
|
||||
id: string;
|
||||
column: ReplaceSourceColumnInfo;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import type { TreeTableColumnDef } from "metabase/ui";
|
||||
import type { ReplaceSourceColumnInfo } from "metabase-types/api";
|
||||
|
||||
import { ColumnNameCell } from "./ColumnNameCell";
|
||||
import type { ErrorItem } from "./types";
|
||||
|
||||
export function getRows(columns: ReplaceSourceColumnInfo[]): ErrorItem[] {
|
||||
return columns.map((column) => ({
|
||||
id: column.name,
|
||||
column,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getColumns(): TreeTableColumnDef<ErrorItem>[] {
|
||||
return [
|
||||
{
|
||||
id: "name",
|
||||
header: t`Field`,
|
||||
width: "auto",
|
||||
maxAutoWidth: 520,
|
||||
accessorFn: (item) => item.column.display_name,
|
||||
cell: ({ row }) => <ColumnNameCell column={row.original.column} />,
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: t`Description`,
|
||||
width: "auto",
|
||||
maxAutoWidth: 520,
|
||||
accessorFn: (item) => item.column.description ?? "",
|
||||
cell: ({ row }) => (
|
||||
<Ellipsified>{row.original.column.description}</Ellipsified>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.body {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Box, Center, Flex, Text } from "metabase/ui";
|
||||
|
||||
import { MAX_WIDTH } from "../constants";
|
||||
import type { EmptyStateType, TabInfo } from "../types";
|
||||
|
||||
import { DependentsTable } from "./DependentsTable";
|
||||
import { ErrorsTable } from "./ErrorsTable";
|
||||
import S from "./ModalBody.module.css";
|
||||
|
||||
type ModalBodyProps = {
|
||||
selectedTab: TabInfo | undefined;
|
||||
emptyStateType: EmptyStateType;
|
||||
};
|
||||
|
||||
export function ModalBody({ selectedTab, emptyStateType }: ModalBodyProps) {
|
||||
return (
|
||||
<Flex
|
||||
className={S.body}
|
||||
p="lg"
|
||||
flex={1}
|
||||
direction="column"
|
||||
align="center"
|
||||
bg="background-secondary"
|
||||
>
|
||||
{selectedTab != null ? (
|
||||
<Box w="100%" maw={MAX_WIDTH}>
|
||||
{selectedTab.type === "descendants" ? (
|
||||
<DependentsTable nodes={selectedTab.nodes} />
|
||||
) : (
|
||||
<ErrorsTable error={selectedTab.error} />
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Center flex={1}>
|
||||
<Text c="text-secondary">{getMessage(emptyStateType)}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function getMessage(emptyStateType: EmptyStateType) {
|
||||
switch (emptyStateType) {
|
||||
case "no-dependents":
|
||||
return t`No queries found using the original source data source.`;
|
||||
case "default":
|
||||
return t`The items that will be affected will show up here.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ModalBody";
|
||||
@@ -0,0 +1,3 @@
|
||||
.footer {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user