Compare commits

...

93 Commits

Author SHA1 Message Date
Eric Normand
7e064be3a9 Use cljc 2026-02-18 15:14:58 -06:00
Eric Normand
0dab7b40ba Rename tests file 2026-02-18 15:11:20 -06:00
Eric Normand
1efbe7fd4d Move upgrade field ref stuff to new namespace 2026-02-18 15:04:40 -06:00
Eric Normand
2b917af84a Upgrade field refs in lib 2026-02-18 14:14:40 -06:00
Bryan Maass
2db9fe082e add {orders,products}_c 2026-02-17 10:39:44 -07:00
Bryan Maass
74d6b67559 Add kitchen sink test for native card ref slug preservation
Tests that swapping one card ref while leaving others unchanged works
correctly with multiple slugged card refs, nested optionals, and
other template params.
2026-02-16 17:08:20 -07:00
Bryan Maass
fbdeab0cbe Preserve slug format when swapping card references in native queries
When a native query has card references with slugs like `{{#42-my-query}}`,
swapping the source now generates the new slug from the target card's name
instead of stripping it to just `{{#99}}`.

This applies to both:
- The SQL text (via `replace-card-refs-in-parsed`)
- The template-tags map keys (via `replace-template-tags`)

Previously, a query like `SELECT * FROM {{#1118-all-orders-a}}` would become
`SELECT * FROM {{#1119}}` after swapping, but the template-tags key would be
`#1119` while the SQL expected `#1119-all-orders-b`, causing a "missing required
parameters" error.

Also fixes `swap-native-card-source!` which was calling `update-entity` with
the wrong signature.
2026-02-16 16:49:04 -07:00
Eric Normand
5dca75813e Update field refs in click_behavior in visualization_settings (#69658) 2026-02-16 14:30:06 -06:00
Alexander Polyankin
d38163b3c4 Fix 2026-02-13 22:18:10 -05:00
Alexander Polyankin
41aefd43ef wip 2026-02-13 21:47:27 -05:00
Alexander Polyankin
3fc7ff2dd7 fix 2026-02-13 21:42:56 -05:00
Alexander Polyankin
4119de0efb fixes 2026-02-13 21:38:22 -05:00
Alexander Polyankin
d2a16a941c Merge branch 'source-replacement' of github.com:metabase/metabase into source-replacement 2026-02-13 21:31:14 -05:00
Alexander Polyankin
f4ddd4fc5f wip fe 2026-02-13 21:31:08 -05:00
Bryan Maass
407c01ae12 Add a test for MBQL -> native -> MBQL source swaps 2026-02-13 16:53:40 -07:00
Bryan Maass
7d7f4949b6 Add replacement/usages namespace and refactor swap-source API
Separates "finding usages" from "applying replacements" for source swapping.

- Adds `replacement/usages.clj` with `usages` function that returns all
  entities depending on a given source
- Wrap swap-source in a transaction for atomicity
- Return {:swapped [...]} describing what got updated
- Add ::source-ref malli schema for [:card id] / [:table id] tuples
- Make internal functions private

Example:
  (swap-source [:card 1] [:card 2])
  ;; => {:swapped ([:card 456] [:transform 12])}
2026-02-13 16:48:40 -07:00
dan sutton
f8dacfd625 upgrade dashboard parameters
```clojure
source-swap=> (map (fn [param]
                     (let [query (toucan2.core/select-one-fn :dataset_query :model/Card :id (:card_id param))]
                       (upgrade-parameter-target query (:target param))))
                   [{:parameter_id "c21b1482", :card_id 2,
                     :target [:dimension [:field 7 nil] {:stage-number 0}]}
                    {:parameter_id "c21b1482", :card_id 3,
                     :target [:dimension [:field 7 nil] {:stage-number 0}]}
                    {:parameter_id "c21b1482", :card_id 3,
                     :target [:dimension [:field 7 {:join-alias "Question 1"}] {:stage-number 0}]}
                    {:parameter_id "c21b1482", :card_id 4,
                     :target [:dimension [:field 7 nil] {:stage-number 0}]}
                    {:parameter_id "c21b1482", :card_id 4,
                     :target [:dimension [:field 7 {:join-alias "Orders"}] {:stage-number 0}]}])
([:dimension [:field "TOTAL" {:base-type :type/Float}] {:stage-number 0}] ;; 7 became "TOTAL"
 [:dimension [:field 7 {:base-type :type/Float}] {:stage-number 0}]
 [:dimension [:field 7 {:base-type :type/Float, :join-alias "Question 1"}] {:stage-number 0}]
 [:dimension [:field "TOTAL" {:base-type :type/Float}] {:stage-number 0}] ;; 7 became "TOTAL:
 [:dimension [:field 7 {:base-type :type/Float, :join-alias "Orders"}] {:stage-number 0}])
```
2026-02-13 16:48:11 -06:00
Alexander Polyankin
359a734d6a fixes 2026-02-13 16:40:51 -05:00
Alexander Polyankin
fb57a8a25b comment 2026-02-13 16:36:54 -05:00
Alexander Polyankin
b30595a039 hack fix 2026-02-13 16:34:48 -05:00
Alexander Polyankin
8c5584eabf wip fe 2026-02-13 16:20:19 -05:00
Alexander Polyankin
bcad4cb7ea wip fe 2026-02-13 16:15:17 -05:00
Alexander Polyankin
cab3185150 fe wip 2026-02-13 16:07:29 -05:00
Alexander Polyankin
c3577ead7c wip fe 2026-02-13 16:02:03 -05:00
Alexander Polyankin
33dd058e87 wip fe 2026-02-13 15:58:43 -05:00
Alexander Polyankin
3544ee8d74 wip fe 2026-02-13 15:54:23 -05:00
Alexander Polyankin
2fee466fee wip fe 2026-02-13 15:06:51 -05:00
Alexander Polyankin
0b509bae5d wip fe 2026-02-13 14:47:35 -05:00
Alexander Polyankin
0d5a8bb0fa wip fe 2026-02-13 14:45:48 -05:00
Alexander Polyankin
48839d4557 wip fe 2026-02-13 14:38:35 -05:00
Alexander Polyankin
6f64a8ee1a Merge branch 'source-replacement' of github.com:metabase/metabase into source-replacement 2026-02-13 12:57:38 -05:00
Alexander Polyankin
1186c1d776 fix 2026-02-13 12:57:33 -05:00
Alexander Polyankin
f1648d2b44 wip fe 2026-02-13 12:55:39 -05:00
Alexander Polyankin
c98ad951f1 wip fe 2026-02-13 12:54:26 -05:00
Eric Normand
ade04feca2 Source replacement transforms card to card (#69643)
Transforms card -> card
2026-02-13 11:39:16 -06:00
Alexander Polyankin
0e6863f82c wip fe 2026-02-13 12:38:38 -05:00
Alexander Polyankin
5068d1ea63 wip fe 2026-02-13 12:28:11 -05:00
Alexander Polyankin
cb06a45335 wip fe 2026-02-13 12:00:16 -05:00
Alexander Polyankin
5c2f06c5c0 wip fe 2026-02-13 10:23:38 -05:00
Alexander Polyankin
863745dffa wip fe 2026-02-13 10:22:32 -05:00
Alexander Polyankin
ab17347d52 fixes 2026-02-13 10:09:32 -05:00
Alexander Polyankin
bf3a4c4511 wip fe 2026-02-13 01:38:50 -05:00
Alexander Polyankin
e1b0b932ab wip fe 2026-02-13 01:32:09 -05:00
Alexander Polyankin
203d655b67 wip fe 2026-02-13 01:12:42 -05:00
Alexander Polyankin
f1d7ca6fc8 wip fe 2026-02-13 01:07:43 -05:00
Alexander Polyankin
ddae2986fd wip fe 2026-02-13 01:05:29 -05:00
Alexander Polyankin
bb1a7e858e wip fe 2026-02-13 01:00:07 -05:00
Alexander Polyankin
068b3ae555 wip fe 2026-02-13 00:58:09 -05:00
Alexander Polyankin
b054fc7506 wip fe 2026-02-13 00:48:21 -05:00
Alexander Polyankin
9a24de6a7f wip fe 2026-02-13 00:41:37 -05:00
Alexander Polyankin
efb1ca35e0 Merge branch 'source-replacement' of github.com:metabase/metabase into source-replacement 2026-02-13 00:28:34 -05:00
Alexander Polyankin
41fcf86cea wip fe 2026-02-13 00:28:27 -05:00
Alexander Polyankin
b926a7cfe1 wip fe 2026-02-13 00:20:41 -05:00
Alexander Polyankin
2115ba1003 wip fe 2026-02-13 00:18:02 -05:00
Alexander Polyankin
f1fc09066f wip fe 2026-02-13 00:10:19 -05:00
Alexander Polyankin
d1a09ea96b wip fe 2026-02-12 23:59:33 -05:00
Alexander Polyankin
c08af08644 wip fe 2026-02-12 23:41:06 -05:00
Alexander Polyankin
c332891abe wip fe 2026-02-12 23:31:16 -05:00
Alexander Polyankin
38d3ba25da wip fe 2026-02-12 23:29:45 -05:00
Alexander Polyankin
fe636e407c wip fe 2026-02-12 23:25:32 -05:00
Alexander Polyankin
0b05432c83 wip fe 2026-02-12 23:21:36 -05:00
Alexander Polyankin
87f692d322 wip fe 2026-02-12 23:10:32 -05:00
Alexander Polyankin
1c109fbe47 wip fe 2026-02-12 22:54:06 -05:00
Alexander Polyankin
1e72879686 wip fe 2026-02-12 22:45:59 -05:00
Alexander Polyankin
44abc72e41 wip fe 2026-02-12 22:32:06 -05:00
Alexander Polyankin
203f4634a4 wip fe 2026-02-12 21:50:58 -05:00
Alexander Polyankin
d628faede4 wip fe 2026-02-12 21:45:55 -05:00
Alexander Polyankin
6e0f26ad93 fixes 2026-02-12 21:28:14 -05:00
Alexander Polyankin
948b195147 wip fe 2026-02-12 21:10:37 -05:00
Alexander Polyankin
6dd0902524 wip fe 2026-02-12 21:09:15 -05:00
Alexander Polyankin
ff07319188 wip fe 2026-02-12 21:05:50 -05:00
Alexander Polyankin
fee278f513 wip fe 2026-02-12 21:00:58 -05:00
Alexander Polyankin
fc712fc408 wip fe 2026-02-12 20:55:17 -05:00
Alexander Polyankin
f5d7a6fb7f wip fe 2026-02-12 20:43:31 -05:00
Alexander Polyankin
7495f22d30 wip fe 2026-02-12 20:06:19 -05:00
Alexander Polyankin
5b9b39e258 wip fe 2026-02-12 20:04:09 -05:00
Bryan Maass
875a6cdd48 Add tests for native query card reference swapping
Adds 9 comprehensive tests for swap-card-in-native-query covering:
- Card refs with slugs: {{#42-my-query}}
- Whitespace handling: {{ #42 }}
- Multiple refs in one query
- CTEs with card references
- Preserving table aliases
- Preserving other parameters
- Different card IDs (non-matching refs)
- Subqueries with card refs
- Optional clauses: [[...{{#42}}...]]

Also updates replace-card-refs-in-parsed to handle slug suffixes
using str/starts-with? for proper matching.
2026-02-12 17:05:24 -07:00
Eric Normand
c52388ff9e Merge branch 'source-replacement' of github.com:metabase/metabase into source-replacement 2026-02-12 16:28:12 -06:00
Eric Normand
1b3467dac6 Use better parser for sql snippet card references. 2026-02-12 16:21:53 -06:00
dan sutton
f11b036052 swap dataset
```clojure
source-swap-test=> (mt/dataset source-swap
                     (metabase.driver/describe-database (:engine (mt/db)) (mt/db)))
{:tables #{{:name "ORDERS_A",
            :schema "PUBLIC",
            :description nil,
            :is_writable true}
           {:name "ORDERS_B",
            :schema "PUBLIC",
            :description nil,
            :is_writable true}
           {:name "PRODUCTS_A",
            :schema "PUBLIC",
            :description nil,
            :is_writable true}
           {:name "PRODUCTS_B",
            :schema "PUBLIC",
            :description nil,
            :is_writable true}}}
```

These are tables where the tables are identical and suitable for replacing
2026-02-12 15:46:46 -06:00
Alexander Polyankin
39be6138e2 wip fe 2026-02-12 16:19:39 -05:00
Alexander Polyankin
da25997533 FE 2026-02-12 15:52:47 -05:00
Alexander Polyankin
19031cdf23 FE 2026-02-12 15:50:15 -05:00
Alexander Polyankin
a1473efa6a Merge remote-tracking branch 'origin/master' into source-replacement 2026-02-12 15:33:37 -05:00
dan sutton
ea5e224c38 most module tests are passing
still failing because we aren't using public dependencies api but
reaching in.

```clojure
modules-test=> (run-tests)

Testing metabase.core.modules-test

FAIL in (modules-config-up-to-date-test) (modules_test.clj:168)
Please update .clj-kondo/config/modules/config.edn 🥰
[Pro Tip: use (dev.deps-graph/print-kondo-config-diff) to see the changes you need to make in a nicer format]
 Add #{metabase-enterprise.dependencies.models.dependency} to [enterprise/dependencies :api]
used by {metabase-enterprise.dependencies.models.dependency #{metabase-enterprise.replacement.source-swap}}
expected: (empty? missing)
  actual: (not (empty? #{metabase-enterprise.dependencies.models.dependency}))

Ran 5 tests containing 1263 assertions.
1 failures, 0 errors.
{:test 5, :pass 1262, :fail 1, :error 0, :type :summary}
```

So once we can figure out what api deps should have and export it from
core, we can fix this up and go on our merry way
2026-02-12 11:30:52 -06:00
Eric Normand
84dd785de9 More replacements (#69489)
source.clj — Core compatibility checking

check-replace-source signature changed to matchswap-source
Database mismatch check added inside check-replace-source
api.clj — API endpoints

fetch-source moved to source.clj
/replace-source now validates before swapping — calls check-replace-source and throws 400 if errors found (was a TODO before)
source_swap.clj — Swap execution

Table sources now supported
nil dataset_query guard
source_swap_test.clj — New file (198 lines)

Tests for swap-source calling the function directly (not through the API)
9 swap-source tests covering all combinations: card→card, card→table, card→native, native→card, native→native, native→table, table→card, table→table, table→native
3 swap-native-card-source! tests for the native query text/template-tag rewriting
Helpers: wait-for-result-metadata, card-with-query, native-card-with-query, card-sourced-from, with-restored-card-queries (cleanup macro for table-source tests)
api_test.clj — API-level tests

Added 4 tests for /replace-source: 204 success, 400 incompatible, 400 database mismatch, 402 premium feature required
Database mismatch tests rewritten — use a real temp :model/Database instead of with-redefs on fetch-source
2026-02-11 11:36:31 -06:00
Eric Normand
31877fdc03 Source replacement be api tests (#69367)
Add api tests
2026-02-10 11:46:42 -06:00
Eric Normand
996a08ff06 Source replacement card template tags (#69360)
* Initial swap-native-card-source!

* Test swap-native-card-source!
2026-02-10 10:37:29 -06:00
Eric Normand
250a13dba2 More tests 2026-02-09 16:47:21 -06:00
William Zimrin
20eaed0063 can swap card sources 2026-02-09 16:14:31 -06:00
Eric Normand
7001d662ab Add swappability tests (#69301)
A table should be swappable with itself.

MBQL and Legacy should be swappable with table.

Native query should be swappable (without fk or hidden columns).
2026-02-09 13:54:58 -06:00
Eric Normand
abebd8c72d Add check endpoint (#69297) 2026-02-09 12:39:38 -06:00
William Zimrin
7d8ca0f76d Initial source replacement commit 2026-02-06 17:07:04 -06:00
142 changed files with 3670 additions and 232 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -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}))))

View File

@@ -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]))

View File

@@ -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]))

View File

@@ -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}))))

View File

@@ -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)")))))))))

View File

@@ -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)])))))))))))))

View File

@@ -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"))))))))

View File

@@ -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";

View File

@@ -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;

View File

@@ -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}

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./DiagnosticsEmptyState";

View File

@@ -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(

View File

@@ -0,0 +1 @@
export * from "./DiagnosticsFilterBar";

View File

@@ -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`,

View File

@@ -0,0 +1 @@
export * from "./DiagnosticsHeader";

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from "./DiagnosticsPagination";

View File

@@ -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}

View File

@@ -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();

View File

@@ -0,0 +1 @@
export * from "./DiagnosticsSidebar";

View File

@@ -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}
/>
)}

View File

@@ -0,0 +1 @@
export * from "./DiagnosticsTable";

View File

@@ -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`;
}

View File

@@ -0,0 +1 @@
export * from "./DependencyDiagnostics";

View File

@@ -0,0 +1,5 @@
export type DependencyDiagnosticsMode = "broken" | "unreferenced";
export type DependencyDiagnosticsParamsOptions = {
withSetLastUsedParams?: boolean;
};

View File

@@ -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 {

View File

@@ -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>([]);

View File

@@ -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>
)}

View File

@@ -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" />

View File

@@ -124,6 +124,7 @@ function TableSection({ node, getGraphUrl }: TableSectionProps) {
<GraphExternalLink
label={info.metadata.label}
url={info.metadata.url}
isCompact
/>
</Group>
{info.location && (

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -1 +0,0 @@
export * from "./DependencyList";

View File

@@ -1,5 +0,0 @@
export type DependencyListMode = "broken" | "unreferenced";
export type DependencyListParamsOptions = {
withSetLastUsedParams?: boolean;
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from "./utils";

View File

@@ -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" />;
},
};
}

View File

@@ -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} />;
}

View File

@@ -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 });

View File

@@ -0,0 +1 @@
export * from "./DependencyDiagnosticsPage";

View File

@@ -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,

View File

@@ -1 +0,0 @@
export * from "./DependencyListPage";

View File

@@ -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}
/>
</>
);
}

View File

@@ -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":

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,6 @@
import type { ReplaceSourceColumnInfo } from "metabase-types/api";
export type ErrorItem = {
id: string;
column: ReplaceSourceColumnInfo;
};

View File

@@ -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>
),
},
];
}

View File

@@ -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.`;
}
}

View File

@@ -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