diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml index 3000db23459b5dd0a15f2dcaadc24834e2d5b6b6..74de023f826ba32b8866fcb146589b869db20913 100644 --- a/resources/migrations/001_update_migrations.yaml +++ b/resources/migrations/001_update_migrations.yaml @@ -6542,14 +6542,6 @@ databaseChangeLog: - customChange: class: "metabase.db.custom_migrations.CreateSampleContent" - - changeSet: - id: v50.2024-04-09T15:55:23 - author: piranha - comment: Backfill query_field for native queries - changes: - - customChange: - class: "metabase.db.custom_migrations.BackfillQueryField" - - changeSet: id: v50.2024-04-12T12:33:09 author: piranha @@ -6569,6 +6561,14 @@ databaseChangeLog: relativeToChangelogFile: true rollback: # no rollback necessary, we're not removing the columns yet + - changeSet: + id: v50.2024-04-18T14:33:19 + author: piranha + comment: Backfill query_field + changes: + - customChange: + class: "metabase.db.custom_migrations.BackfillQueryField" + - changeSet: id: v50.2024-04-19T17:04:04 author: noahmoss diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index fc987517da9f1e07e5345d1de7411f859db85648..db22d302fe18de38f359915aabea77fef6d1af2e 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -541,7 +541,7 @@ (defn- do-update-dashcards! [dashboard current-cards new-cards] - (let [{:keys [to-create to-update to-delete]} (u/classify-changes current-cards new-cards)] + (let [{:keys [to-create to-update to-delete]} (u/row-diff current-cards new-cards)] (when (seq to-update) (update-dashcards! dashboard to-update)) {:deleted-dashcards (when (seq to-delete) diff --git a/src/metabase/db/custom_migrations.clj b/src/metabase/db/custom_migrations.clj index 383b6fac9737334d59143d27a0558386209a6d41..fc9958b77065b3dc1ee142c9688a830cbda8d948 100644 --- a/src/metabase/db/custom_migrations.clj +++ b/src/metabase/db/custom_migrations.clj @@ -1259,13 +1259,39 @@ (qs/shutdown scheduler))) (define-migration BackfillQueryField - (let [update-query-fields! (requiring-resolve 'metabase.native-query-analyzer/update-query-fields-for-card!) - cards (t2/select :model/Card :id [:in {:from [[:report_card :c]] - :left-join [[:query_field :f] [:= :f.card_id :c.id]] - :select [:c.id] - :where [:and - [:not :c.archived] - [:= :c.query_type "native"] - [:= :f.id nil]]}])] - (doseq [card cards] - (update-query-fields! card)))) + (let [field-ids-for-sql (requiring-resolve 'metabase.native-query-analyzer/field-ids-for-sql) + ;; practically #'metabase.models.query-field/field-ids-for-mbql + mbql-fields-xf (comp + (map #(match % + ["field" (id :guard integer?) opts] [id (:source-field opts)] + :else nil)) + cat + (remove nil?)) + field-ids-for-mbql (fn [query] + {:direct (->> (tree-seq coll? seq query) + (into #{} mbql-fields-xf) + not-empty)}) + cards (t2/select :report_card :id [:in {:from [[:report_card :c]] + :left-join [[:query_field :f] [:= :f.card_id :c.id]] + :select [:c.id] + :where [:and + [:not :c.archived] + [:= :f.id nil]]}])] + (doseq [{card-id :id query :dataset_query :as card} cards] + (let [query (json/parse-string query true) + {:keys [direct indirect] :as res} (if (= (:type query) "native") + (field-ids-for-sql query) + (field-ids-for-mbql query)) + id->record (fn [direct? field-id] + {:card_id card-id + :field_id field-id + :direct_reference direct?}) + records (concat + (map (partial id->record true) direct) + (map (partial id->record false) indirect)) + known (set + (when (seq records) + (t2/select-fn-set :id :metabase_field :id [:in (map :field_id records)]))) + records (filterv (comp known :field_id) records)] + (when (seq records) + (t2/insert! :query_field records)))))) diff --git a/src/metabase/lib/util.cljc b/src/metabase/lib/util.cljc index 783b1e90b0fdddd890bc9da767daee6cabd0b2e3..0985e0a80afafc4459c0429ac04fcb048e768e75 100644 --- a/src/metabase/lib/util.cljc +++ b/src/metabase/lib/util.cljc @@ -584,7 +584,10 @@ (when (#{:mbql/query :query :native :internal} query-type) query-type)))) -(mu/defn referenced-field-ids :- [:maybe [:sequential ::lib.schema.id/field]] - "Find all the integer field IDs in ``, Which can arbitrarily be anything that is part of MLv2 query schema." +(mu/defn referenced-field-ids :- [:maybe [:set ::lib.schema.id/field]] + "Find all the integer field IDs in `coll`, Which can arbitrarily be anything that is part of MLv2 query schema." [coll] - (lib.util.match/match coll [:field _ (id :guard int?)] id)) + (not-empty + (into #{} + (comp cat (filter some?)) + (lib.util.match/match coll [:field opts (id :guard int?)] [id (:source-field opts)])))) diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 72f11d09316f574522493fe9eb8eed0412a4e211..2eb9bbbd72ad5fd6c2ab948be4033ceabf5fc0ee 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -29,10 +29,10 @@ [metabase.models.permissions :as perms] [metabase.models.pulse :as pulse] [metabase.models.query :as query] + [metabase.models.query-field :as query-field] [metabase.models.revision :as revision] [metabase.models.serialization :as serdes] [metabase.moderation :as moderation] - [metabase.native-query-analyzer :as query-analyzer] [metabase.public-settings :as public-settings] [metabase.public-settings.premium-features :as premium-features @@ -527,7 +527,7 @@ (log/info "Card references Fields in params:" field-ids) (field-values/update-field-values-for-on-demand-dbs! field-ids)) (parameter-card/upsert-or-delete-from-parameters! "card" (:id card) (:parameters card)) - (query-analyzer/update-query-fields-for-card! card))) + (query-field/update-query-fields-for-card! card))) (t2/define-before-update :model/Card [{:keys [verified-result-metadata?] :as card}] @@ -555,7 +555,7 @@ [card] (u/prog1 card (when (contains? (t2/changes card) :dataset_query) - (query-analyzer/update-query-fields-for-card! card)))) + (query-field/update-query-fields-for-card! card)))) ;; Cards don't normally get deleted (they get archived instead) so this mostly affects tests (t2/define-before-delete :model/Card diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj index 827e63c03f76b74169649f3efddbb511fcaf5990..7ee1091dd95883fb2824b8d998b6dc054aa0bb06 100644 --- a/src/metabase/models/dashboard.clj +++ b/src/metabase/models/dashboard.clj @@ -268,7 +268,7 @@ :model/DashboardCard :dashboard_id dashboard-id) id->current-card (zipmap (map :id current-cards) current-cards) - {:keys [to-create to-update to-delete]} (u/classify-changes current-cards serialized-cards)] + {:keys [to-create to-update to-delete]} (u/row-diff current-cards serialized-cards)] (when (seq to-delete) (dashboard-card/delete-dashboard-cards! (map :id to-delete))) (when (seq to-create) diff --git a/src/metabase/models/dashboard_tab.clj b/src/metabase/models/dashboard_tab.clj index 1ec24497a4e1da2d3ff50d4e94ae51213125d550..91a07c59ca8311b6fb9860bb20b0da5b96893d74 100644 --- a/src/metabase/models/dashboard_tab.clj +++ b/src/metabase/models/dashboard_tab.clj @@ -118,7 +118,7 @@ [dashboard-id current-tabs new-tabs] (let [{:keys [to-create to-update - to-delete]} (u/classify-changes current-tabs new-tabs) + to-delete]} (u/row-diff current-tabs new-tabs) to-delete-ids (map :id to-delete) _ (when-let [to-delete-ids (seq to-delete-ids)] (delete-tabs! to-delete-ids)) diff --git a/src/metabase/models/field_usage.clj b/src/metabase/models/field_usage.clj index c2819a6dc7d300cc58b819374eb1c507d995a815..1a4aa78c222c080810b58b910bf6b1719e2c2da7 100644 --- a/src/metabase/models/field_usage.clj +++ b/src/metabase/models/field_usage.clj @@ -56,8 +56,8 @@ :breakout_binning_num_bins (:num-bins binning-option)})))) (defn- expression->field-usage - [expresison-clause] - (when-let [field-ids (seq (lib.util/referenced-field-ids expresison-clause))] + [expression-clause] + (when-let [field-ids (seq (lib.util/referenced-field-ids expression-clause))] (for [field-id field-ids] {:field_id field-id :used_in :expression}))) @@ -66,7 +66,7 @@ (defn- join->field-usages [query join] - (let [join-query (fetch-source-query/resolve-source-cards (assoc query :stages (get join :stages)))] + (let [join-query (fetch-source-query/resolve-source-cards (assoc query :stages (:stages join)))] ;; treat the source query as a :mbql/query (pmbql->field-usages join-query))) diff --git a/src/metabase/models/query_field.clj b/src/metabase/models/query_field.clj index 0d243ba1ad39a396d0cb6e1926e39f80d237b1e1..c40c2b01e8b691c1b9b3523dfe85aca4535ae94f 100644 --- a/src/metabase/models/query_field.clj +++ b/src/metabase/models/query_field.clj @@ -1,5 +1,9 @@ (ns metabase.models.query-field (:require + [metabase.legacy-mbql.util :as mbql.u] + [metabase.native-query-analyzer :as query-analyzer] + [metabase.util :as u] + [metabase.util.log :as log] [methodical.core :as methodical] [toucan2.core :as t2])) @@ -7,3 +11,61 @@ (doto :model/QueryField (derive :metabase/model)) + +;;; Updating QueryField from card + +(defn- field-ids-for-mbql + "Find out ids of all fields used in a query. Conforms to the same protocol as [[query-analyzer/field-ids-for-sql]], + so returns `{:direct #{...int ids}}` map. + + Does not track wildcards for queries rendered as tables afterwards." + [query] + {:direct (mbql.u/referenced-field-ids query)}) + +(defn update-query-fields-for-card! + "Clears QueryFields associated with this card and creates fresh, up-to-date-ones. + + Any card is accepted, but this functionality only works for ones with a native query. + + If you're invoking this from a test, be sure to turn on [[*parse-queries-in-test?*]]. + + Returns `nil` (and logs the error) if there was a parse error." + [{card-id :id, query :dataset_query}] + (when query + (try + (let [{:keys [direct indirect] :as res} (case (:type query) + :native (try + (query-analyzer/field-ids-for-sql query) + (catch Exception e + (log/error e "Error parsing SQL" query))) + :query (field-ids-for-mbql query) + nil nil) + id->row (fn [direct? field-id] + {:card_id card-id + :field_id field-id + :direct_reference direct?}) + query-field-rows (concat + (map (partial id->row true) direct) + (map (partial id->row false) indirect))] + ;; when response is `nil`, it's a disabled parser, not unknown columns + (when (some? res) + (t2/with-transaction [_conn] + (let [existing (t2/select :model/QueryField :card_id card-id) + {:keys [to-update + to-create + to-delete]} (u/row-diff existing query-field-rows + {:id-fn :field_id + :cleanup #(dissoc % :id :card_id :field_id)})] + (when (seq to-delete) + ;; this delete seems to break transaction (implicit commit or something) on MySQL, and this `diff` + ;; algo drops its frequency by a lot - which should help with transactions affecting each other a + ;; lot. Parallel tests in `metabase.models.query.permissions-test` were breaking when delete was + ;; executed unconditionally on every query change. + (t2/delete! :model/QueryField :card_id card-id :field_id [:in (map :field_id to-delete)])) + (when (seq to-create) + (t2/insert! :model/QueryField to-create)) + (doseq [item to-update] + (t2/update! :model/QueryField {:card_id card-id :field_id (:field_id item)} + (select-keys item [:direct_reference]))))))) + (catch Exception e + (log/error e "Error parsing native query"))))) diff --git a/src/metabase/native_query_analyzer.clj b/src/metabase/native_query_analyzer.clj index 81696d5fcacd9b2ab2e7cf9ca3b1ca251e62a976..e43b69011995bc55c8dd06487ea5bb5bde228002 100644 --- a/src/metabase/native_query_analyzer.clj +++ b/src/metabase/native_query_analyzer.clj @@ -17,7 +17,6 @@ [metabase.native-query-analyzer.parameter-substitution :as nqa.sub] [metabase.public-settings :as public-settings] [metabase.util :as u] - [metabase.util.log :as log] [toucan2.core :as t2])) (def ^:dynamic *parse-queries-in-test?* @@ -94,53 +93,25 @@ ;; limit to the named tables (seq table-wildcards) (active-fields-from-tables table-wildcards)))) -(defn- field-ids-for-card +(defn field-ids-for-sql "Returns a `{:direct #{...} :indirect #{...}}` map with field IDs that (may) be referenced in the given cards's query. Errs on the side of optimism: i.e., it may return fields that are *not* in the query, and is unlikely to fail to return fields that are in the query. Direct references are columns that are named in the query; indirect ones are from wildcards. If a field could be both direct and indirect, it will *only* show up in the `:direct` set." - [card] - (let [query (:dataset_query card) - db-id (:database query) - sql-string (:query (nqa.sub/replace-tags query)) - parsed-query (macaw/query->components (macaw/parsed-query sql-string)) - direct-ids (direct-field-ids-for-query parsed-query db-id) - indirect-ids (set/difference - (indirect-field-ids-for-query parsed-query db-id) - direct-ids)] - {:direct direct-ids - :indirect indirect-ids})) - -(defn update-query-fields-for-card! - "Clears QueryFields associated with this card and creates fresh, up-to-date-ones. - - Any card is accepted, but this functionality only works for ones with a native query. - - If you're invoking this from a test, be sure to turn on [[*parse-queries-in-test?*]]. - - Returns `nil` (and logs the error) if there was a parse error." - [{card-id :id, query :dataset_query :as card}] + [query] (when (and (active?) - (= :native (:type query))) - (try - (let [{:keys [direct indirect]} (field-ids-for-card card) - id->record (fn [direct? field-id] - {:card_id card-id - :field_id field-id - :direct_reference direct?}) - query-field-records (concat - (map (partial id->record true) direct) - (map (partial id->record false) indirect))] - ;; This feels inefficient at first glance, but the number of records should be quite small and doing some sort - ;; of upsert-or-delete would involve comparisons in Clojure-land that are more expensive than just "killing and - ;; filling" the records. - (t2/with-transaction [_conn] - (t2/delete! :model/QueryField :card_id card-id) - (t2/insert! :model/QueryField query-field-records))) - (catch Exception e - (log/error e "Error parsing native query"))))) + (:native query)) + (let [db-id (:database query) + sql-string (:query (nqa.sub/replace-tags query)) + parsed-query (macaw/query->components (macaw/parsed-query sql-string)) + direct-ids (direct-field-ids-for-query parsed-query db-id) + indirect-ids (set/difference + (indirect-field-ids-for-query parsed-query db-id) + direct-ids)] + {:direct direct-ids + :indirect indirect-ids}))) ;; TODO: does not support template tags (defn replace-names diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc index 2f1234ab9131dc1fd4a9287f4be61044e17c990d..0d13b09e3de8a32434b86c3c6abaf63521cd013c 100644 --- a/src/metabase/util.cljc +++ b/src/metabase/util.cljc @@ -841,20 +841,38 @@ {:kvs kvs}))) ret)))) -(defn classify-changes +(defn row-diff "Given 2 lists of seq maps of changes, where each map an has an `id` key, return a map of 3 keys: `:to-create`, `:to-update`, `:to-delete`. Where: - :to-create is a list of maps that ids in `new-items` - :to-update is a list of maps that has ids in both `current-items` and `new-items` - :to delete is a list of maps that has ids only in `current-items`" - [current-items new-items] - (let [[delete-ids create-ids update-ids] (diff (set (map :id current-items)) - (set (map :id new-items)))] - {:to-create (when (seq create-ids) (filter #(create-ids (:id %)) new-items)) - :to-delete (when (seq delete-ids) (filter #(delete-ids (:id %)) current-items)) - :to-update (when (seq update-ids) (filter #(update-ids (:id %)) new-items))})) + - `:to-create` is a list of maps that ids in `new-rows` + - `:to-delete` is a list of maps that has ids only in `current-rows` + - `:to-skip` is a list of identical maps that has ids in both lists + - `:to-update` is a list of different maps that has ids in both lists + + Optional arguments: + - `id-fn` - function to get row-matching identifiers + - `cleanup` - function to get rows into a comparable state + " + [current-rows new-rows & {:keys [id-fn cleanup] + :or {id-fn :id + cleanup identity}}] + (let [[delete-ids + create-ids + update-ids] (diff (set (map id-fn current-rows)) + (set (map id-fn new-rows))) + known-map (m/index-by id-fn current-rows) + {to-update false + to-skip true} (when (seq update-ids) + (group-by (fn [x] + (let [y (get known-map (id-fn x))] + (= (cleanup x) (cleanup y)))) + (filter #(update-ids (id-fn %)) new-rows)))] + {:to-create (when (seq create-ids) (filter #(create-ids (id-fn %)) new-rows)) + :to-delete (when (seq delete-ids) (filter #(delete-ids (id-fn %)) current-rows)) + :to-update to-update + :to-skip to-skip})) (defn empty-or-distinct? "True if collection `xs` is either [[empty?]] or all values are [[distinct?]]." diff --git a/test/metabase/db/custom_migrations_test.clj b/test/metabase/db/custom_migrations_test.clj index b3f1f0911797a059ee2317cd3d4ec590f153fcb1..41f9c157cfc69873c61242242ef8f5ec180261ee 100644 --- a/test/metabase/db/custom_migrations_test.clj +++ b/test/metabase/db/custom_migrations_test.clj @@ -1675,16 +1675,22 @@ (is (nil? (:cache_field_values_schedule db)))))))))) (deftest backfill-query-field-test - (impl/test-migrations "v50.2024-04-09T15:55:23" [migrate!] + (impl/test-migrations "v50.2024-04-18T14:33:19" [migrate!] (let [user-id (:id (new-instance-with-default :core_user)) - ;; it is already `false`, but binding it anyway to indicate it's important - card-id (binding [query-analyzer/*parse-queries-in-test?* false] - (:id (new-instance-with-default - :report_card - {:creator_id user-id - :database_id (mt/id) - :query_type "native" - :dataset_query (json/generate-string (mt/native-query {:query "SELECT id FROM venues"}))}))) + c1-id (:id (new-instance-with-default + :report_card + {:creator_id user-id + :database_id (mt/id) + :query_type "native" + :dataset_query (json/generate-string + (mt/native-query {:query "SELECT id FROM venues"}))})) + c2-id (:id (new-instance-with-default + :report_card + {:creator_id user-id + :database_id (mt/id) + :query_type "query" + :dataset_query (json/generate-string + (mt/mbql-query venues {:aggregation [[:distinct $name]]}))})) archived-id (binding [query-analyzer/*parse-queries-in-test?* false] (:id (new-instance-with-default :report_card @@ -1692,15 +1698,20 @@ :creator_id user-id :database_id (mt/id) :query_type "native" - :dataset_query (json/generate-string (mt/native-query {:query "SELECT id FROM venues"}))}))) + :dataset_query (json/generate-string + (mt/native-query {:query "SELECT id FROM venues"}))}))) ;; (first (vals %)) are necessary since h2 generates :count(id) as name for column get-count #(t2/select-one-fn (comp first vals) [:model/QueryField [[:count :id]]] :card_id %)] (testing "QueryField is empty - queries weren't analyzed" - (is (zero? (get-count card-id))) + ;; they were not analyzed since `new-instance-with-default` inserts directly into the table, omitting model + ;; hooks + (is (zero? (get-count c1-id))) + (is (zero? (get-count c2-id))) (is (zero? (get-count archived-id)))) (binding [query-analyzer/*parse-queries-in-test?* true] (migrate!)) (testing "QueryField is filled now" - (is (pos? (get-count card-id))) + (is (pos? (get-count c1-id))) + (is (pos? (get-count c2-id))) (testing "but not for archived card" (is (zero? (get-count archived-id)))))))) diff --git a/test/metabase/models/query_field_test.clj b/test/metabase/models/query_field_test.clj index af97442b5549ec4d52a8b3c3213b334dd576cf3c..9cffad1b2dae1e60f9f7b7f7c9421bd575b55837 100644 --- a/test/metabase/models/query_field_test.clj +++ b/test/metabase/models/query_field_test.clj @@ -2,6 +2,8 @@ (:require [clojure.set :as set] [clojure.test :refer :all] + [metabase.models :refer [Card]] + [metabase.models.query-field :as query-field] [metabase.native-query-analyzer :as query-analyzer] [metabase.test :as mt] [toucan2.core :as t2] @@ -129,3 +131,24 @@ ;; subset since it also includes the PKs/FKs (is (set/subset? #{total-qf tax-qf} (t2/select-fn-set qf->map :model/QueryField :card_id card-id :direct_reference true)))))))) + +(deftest parse-mbql-test + (testing "Parsing MBQL query returns correct used fields" + (mt/with-temp [Card c1 {:dataset_query (mt/mbql-query venues + {:aggregation [[:distinct $name] + [:distinct $price]] + :limit 5})} + Card c2 {:dataset_query {:query {:source-table (str "card__" (:id c1))} + :database (:id (mt/db)) + :type :query}} + Card c3 {:dataset_query (mt/mbql-query checkins + {:joins [{:source-table (str "card__" (:id c2)) + :alias "Venues" + :condition [:= $checkins.venue_id $venues.id]}]})}] + (mt/$ids + (is (= {:direct #{%venues.name %venues.price}} + (#'query-field/field-ids-for-mbql (:dataset_query c1)))) + (is (= {:direct nil} + (#'query-field/field-ids-for-mbql (:dataset_query c2)))) + (is (= {:direct #{%venues.id %checkins.venue_id}} + (#'query-field/field-ids-for-mbql (:dataset_query c3)))))))) diff --git a/test/metabase/pulse/pulse_integration_test.clj b/test/metabase/pulse/pulse_integration_test.clj index 52e212caa64520840da88dd104350d3f7b83b332..4dea84c50335fee40ffe171dd7565ae228a9d0ae 100644 --- a/test/metabase/pulse/pulse_integration_test.clj +++ b/test/metabase/pulse/pulse_integration_test.clj @@ -292,7 +292,7 @@ [date-str n] (format "WITH T AS (SELECT CAST('%s' AS TIMESTAMP) AS example_timestamp), - SAMPLE AS (SELECT T.example_timestamp AS full_datetime_utc, + \"SAMPLE\" AS (SELECT T.example_timestamp AS full_datetime_utc, T.example_timestamp AT TIME ZONE 'US/Pacific' AS full_datetime_pacific, CAST(T.example_timestamp AS TIMESTAMP) AS example_timestamp, CAST(T.example_timestamp AS TIMESTAMP WITH TIME ZONE) AS example_timestamp_with_time_zone, @@ -308,7 +308,7 @@ EXTRACT(SECOND FROM T.example_timestamp) AS example_second FROM T) SELECT * - FROM SAMPLE + FROM \"SAMPLE\" CROSS JOIN generate_series(1, %s);" date-str n)) diff --git a/test/metabase/util_test.cljc b/test/metabase/util_test.cljc index 4a7eac5fb04b1bcd5c20b1339eb8051b9364d8b3..2f5532631e210a6fc8453d45d8b6189dc0a22050 100644 --- a/test/metabase/util_test.cljc +++ b/test/metabase/util_test.cljc @@ -426,14 +426,27 @@ (is (= {:m true} (meta (u/assoc-default ^:m {:x 0} :y 1 :z 2 :a nil)))))) -(deftest ^:parallel classify-changes-test +(deftest ^:parallel row-diff-test (testing "classify correctly" - (is (= {:to-update [{:id 2 :name "c3"} {:id 4 :name "c4"}] + (is (= {:to-update [{:id 2 :name "c3"}] :to-delete [{:id 1 :name "c1"} {:id 3 :name "c3"}] - :to-create [{:id -1 :name "-c1"}]} - (u/classify-changes - [{:id 1 :name "c1"} {:id 2 :name "c2"} {:id 3 :name "c3"} {:id 4 :name "c4"}] - [{:id -1 :name "-c1"} {:id 2 :name "c3"} {:id 4 :name "c4"}]))))) + :to-create [{:id -1 :name "-c1"}] + :to-skip [{:id 4 :name "c4"}]} + (u/row-diff + [{:id 1 :name "c1"} {:id 2 :name "c2"} {:id 3 :name "c3"} {:id 4 :name "c4"}] + [{:id -1 :name "-c1"} {:id 2 :name "c3"} {:id 4 :name "c4"}]))) + (is (= {:to-skip [{:god_id 10, :name "Zeus", :job "God of Thunder"}] + :to-delete [{:id 2, :god_id 20, :name "Odin", :job "God of Thunder"}] + :to-update [{:god_id 30, :name "Osiris", :job "God of Afterlife"}] + :to-create [{:god_id 40, :name "Perun", :job "God of Thunder"}]} + (u/row-diff [{:id 1 :god_id 10 :name "Zeus" :job "God of Thunder"} + {:id 2 :god_id 20 :name "Odin" :job "God of Thunder"} + {:id 3 :god_id 30 :name "Osiris" :job "God of Fertility"}] + [{:god_id 10 :name "Zeus" :job "God of Thunder"} + {:god_id 30 :name "Osiris" :job "God of Afterlife"} + {:god_id 40 :name "Perun" :job "God of Thunder"}] + {:id-fn :god_id + :cleanup #(dissoc % :id :god_id)}))))) (deftest ^:parallel empty-or-distinct?-test (are [xs expected] (= expected