From 5ad50f50d73e2351bd01a76b92fe6a774b80c142 Mon Sep 17 00:00:00 2001 From: Braden Shepherdson <Braden.Shepherdson@gmail.com> Date: Mon, 11 Jul 2022 12:30:29 -0400 Subject: [PATCH] Serdes v2 for Cards (#23803) --- .../serialization/v2/models.clj | 3 +- .../serialization/v2/extract_test.clj | 92 +++++++++++++++---- .../serialization/v2/yaml_test.clj | 27 +++++- src/metabase/models/card.clj | 55 +++++++++++ src/metabase/models/serialization/base.clj | 20 ++-- test/metabase/test/generate.clj | 6 +- 6 files changed, 173 insertions(+), 30 deletions(-) diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj index 11ebe6e4808..6a79f894f91 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj @@ -2,7 +2,8 @@ (def exported-models "The list of models which are exported by serialization. Used for production code and by tests." - ["Collection" + ["Card" + "Collection" "Database" "Field" "Setting" diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj index f5edd4fca58..9b500dd9e9c 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj @@ -2,12 +2,20 @@ (:require [clojure.test :refer :all] [metabase-enterprise.serialization.test-util :as ts] [metabase-enterprise.serialization.v2.extract :as extract] - [metabase.models :refer [Collection User]] + [metabase.models :refer [Card Collection Database Table User]] [metabase.models.serialization.base :as serdes.base])) (defn- select-one [model-name where] (first (into [] (serdes.base/raw-reducible-query model-name {:where where})))) +(defn- by-model [model-name extraction] + (->> extraction + (into []) + (map (comp last :serdes/meta)) + (filter #(= model-name (:model %))) + (map :id) + set)) + (deftest fundamentals-test (ts/with-empty-h2-app-db (ts/with-temp-dpc [Collection [{coll-id :id @@ -52,20 +60,72 @@ (is (= "mark@direstrai.ts" (:personal_owner_id ser))))) (testing "overall extraction returns the expected set" - (letfn [(collections [extraction] (->> extraction - (into []) - (map (comp last :serdes/meta)) - (filter #(= "Collection" (:model %))) - (map :id) - set))] - (testing "no user specified" - (is (= #{coll-eid child-eid} - (collections (extract/extract-metabase nil))))) + (testing "no user specified" + (is (= #{coll-eid child-eid} + (by-model "Collection" (extract/extract-metabase nil))))) + + (testing "valid user specified" + (is (= #{coll-eid child-eid pc-eid} + (by-model "Collection" (extract/extract-metabase {:user mark-id}))))) + + (testing "invalid user specified" + (is (= #{coll-eid child-eid} + (by-model "Collection" (extract/extract-metabase {:user 218921}))))))))) - (testing "valid user specified" - (is (= #{coll-eid child-eid pc-eid} - (collections (extract/extract-metabase {:user mark-id}))))) +(deftest dashboard-and-cards-test + (ts/with-empty-h2-app-db + (ts/with-temp-dpc [Collection [{coll-id :id + coll-eid :entity_id + coll-slug :slug} {:name "Some Collection"}] + User [{mark-id :id} {:first_name "Mark" + :last_name "Knopfler" + :email "mark@direstrai.ts"}] + Database [{db-id :id} {:name "My Database"}] + Table [{no-schema-id :id} {:name "Schemaless Table" :db_id db-id}] + Table [{schema-id :id} {:name "Schema'd Table" + :db_id db-id + :schema "PUBLIC"}] + Card [{c1-id :id + c1-eid :entity_id} {:name "Some Question" + :database_id db-id + :table_id no-schema-id + :collection_id coll-id + :creator_id mark-id + :dataset_query "{\"json\": \"string values\"}"}] + Card [{c2-id :id + c2-eid :entity_id} {:name "Second Question" + :database_id db-id + :table_id schema-id + :collection_id coll-id + :creator_id mark-id}]] + (testing "table and database are extracted as [db schema table] triples" + (let [ser (serdes.base/extract-one "Card" {} (select-one "Card" [:= :id c1-id]))] + (is (= {:serdes/meta [{:model "Card" :id c1-eid}] + :table ["My Database" nil "Schemaless Table"] + :creator_id "mark@direstrai.ts" + :collection_id coll-eid + :dataset_query "{\"json\": \"string values\"}"} ; Undecoded, still a string. + (select-keys ser [:serdes/meta :table :creator_id :collection_id :dataset_query]))) + (is (not (contains? ser :id))) + + (testing "cards depend on their Table and Collection" + (is (= #{[{:model "Database" :id "My Database"} + {:model "Table" :id "Schemaless Table"}] + [{:model "Collection" :id coll-eid}]} + (set (serdes.base/serdes-dependencies ser)))))) + + (let [ser (serdes.base/extract-one "Card" {} (select-one "Card" [:= :id c2-id]))] + (is (= {:serdes/meta [{:model "Card" :id c2-eid}] + :table ["My Database" "PUBLIC" "Schema'd Table"] + :creator_id "mark@direstrai.ts" + :collection_id coll-eid + :dataset_query "{}"} ; Undecoded, still a string. + (select-keys ser [:serdes/meta :table :creator_id :collection_id :dataset_query]))) + (is (not (contains? ser :id))) - (testing "invalid user specified" - (is (= #{coll-eid child-eid} - (collections (extract/extract-metabase {:user 218921})))))))))) + (testing "cards depend on their Table and Collection" + (is (= #{[{:model "Database" :id "My Database"} + {:model "Schema" :id "PUBLIC"} + {:model "Table" :id "Schema'd Table"}] + [{:model "Collection" :id coll-eid}]} + (set (serdes.base/serdes-dependencies ser)))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/yaml_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/yaml_test.clj index 031bd911677..5079237a6bd 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/yaml_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/yaml_test.clj @@ -12,6 +12,7 @@ [metabase.test.generate :as test-gen] [metabase.util.date-2 :as u.date] [reifyhealth.specmonstah.core :as rs] + [toucan.db :as db] [yaml.core :as yaml])) (defn- dir->file-set [dir] @@ -95,6 +96,9 @@ (assoc :serdes/meta (mapv #(dissoc % :label) abs-path))) (ingest/ingest-one ingestable abs-path)))))))) +(defn- random-key [prefix n] + (keyword (str prefix (rand-int n)))) + (deftest e2e-storage-ingestion-test (ts/with-random-dump-dir [dump-dir "serdesv2-"] (ts/with-empty-h2-app-db @@ -104,7 +108,14 @@ [10 {:refs {:db_id db}}])) :field (into [] (for [n (range 100) :let [table (keyword (str "t" n))]] - [10 {:refs {:table_id table}}]))}) + [10 {:refs {:table_id table}}])) + :core-user [[10]] + :card [[100 {:refs (let [db (rand-int 10) + t (rand-int 10)] + {:database_id (keyword (str "db" db)) + :table_id (keyword (str "t" (+ t (* 10 db)))) + :collection_id (random-key "coll" 100) + :creator_id (random-key "u" 10)})}]]}) (let [extraction (into [] (extract/extract-metabase {})) entities (reduce (fn [m entity] (update m (-> entity :serdes/meta last :model) @@ -162,6 +173,20 @@ (update :updated_at u.date/format)) (yaml/from-file (io/file dump-dir "Database" db "Table" table "Field" (str name ".yaml"))))))) + (testing "for cards" + (is (= 100 (count (dir->file-set (io/file dump-dir "Card"))))) + (doseq [{[db-name schema table] :table + :keys [collection_id creator_id entity_id] + :as card} (get entities "Card") + :let [filename (str entity_id ".yaml") + db (db/select-one 'Database :name db-name) + table (db/select-one 'Table :db_id (:id db) :name table :schema schema)]] + (is (= (-> card + (dissoc :serdes/meta) + (update :created_at u.date/format) + (update :updated_at u.date/format)) + (yaml/from-file (io/file dump-dir "Card" filename)))))) + (testing "for settings" (is (= (into {} (for [{:keys [key value]} (get entities "Setting")] [key value])) diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 045c1a2fbf4..59c6cf207ff 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -13,6 +13,7 @@ [metabase.models.permissions :as perms] [metabase.models.query :as query] [metabase.models.revision :as revision] + [metabase.models.serialization.base :as serdes.base] [metabase.models.serialization.hash :as serdes.hash] [metabase.moderation :as moderation] [metabase.plugins.classloader :as classloader] @@ -325,3 +326,57 @@ serdes.hash/IdentityHashable {:identity-hash-fields (constantly [:name (serdes.hash/hydrated-hash :collection)])}) + +;;; ------------------------------------------------- Serialization -------------------------------------------------- +(defmethod serdes.base/extract-query "Card" [_ {:keys [user]}] + (serdes.base/raw-reducible-query + "Card" + {:select [:card.*] + :from [[:report_card :card]] + :left-join [[:collection :coll] [:= :coll.id :card.collection_id]] + :where (if user + [:or [:= :coll.personal_owner_id user] [:is :coll.personal_owner_id nil]] + [:is :coll.personal_owner_id nil])})) + +(defmethod serdes.base/extract-one "Card" + [_ _ {:keys [table_id database_id collection_id creator_id] :as card}] + ;; Cards have :table_id, :database_id, :collection_id, :creator_id that need conversion. + ;; :table_id and :database_id are extracted as just :table [database_name schema table_name]. + ;; :collection_id is extracted as its entity_id or identity-hash. + ;; :creator_id as the user's email + (let [db-name (db/select-one-field :name 'Database :id database_id) + {:keys [schema name]} (db/select-one 'Table :id table_id) + coll (db/select-one 'Collection :id collection_id) + {collection-eid :id} (serdes.base/infer-self-path "Collection" coll) + email (db/select-one-field :email 'User :id creator_id)] + (-> (serdes.base/extract-one-basics "Card" card) + (dissoc :table_id :database_id) + (assoc :table [db-name schema name] + :collection_id collection-eid + :creator_id email)))) + +(defmethod serdes.base/load-xform "Card" + [{[db-name schema table-name] :table + email :creator_id + collection-eid :collection_id + :as card}] + (let [db-id (db/select-one-id 'Database :name db-name) + table-id (db/select-one-id 'Table :database_id db-id :schema schema :name table-name) + coll-id (serdes.base/lookup-by-id 'Collection collection-eid) + user-id (db/select-one-id 'User :email email)] + (-> card + serdes.base/load-xform-basics + (dissoc :table) + (assoc :database_id db-id + :table_id table-id + :collection_id coll-id + :creator_id user-id)))) + +(defmethod serdes.base/serdes-dependencies "Card" + [{[db-name schema table-name] :table + :keys [collection_id]}] + ;; The Table implicitly depends on the Database. + [(filterv some? [{:model "Database" :id db-name} + (when schema {:model "Schema" :id schema}) + {:model "Table" :id table-name}]) + [{:model "Collection" :id collection_id}]]) diff --git a/src/metabase/models/serialization/base.clj b/src/metabase/models/serialization/base.clj index 99912438296..ce9b7618448 100644 --- a/src/metabase/models/serialization/base.clj +++ b/src/metabase/models/serialization/base.clj @@ -194,6 +194,14 @@ (eduction (map (partial extract-one model opts)) (extract-query model opts))) +(defn- model-name->table + "The model name is not necessarily the table name. This pulls the table name from the Toucan model." + [model-name] + (-> model-name + symbol + db/resolve-model + :table)) + (defn raw-reducible-query "Helper for calling Toucan's raw [[db/reducible-query]]. With just the model name, fetches everything. You can filter with a HoneySQL map like `{:where [:= :archived true]}`. @@ -202,19 +210,11 @@ ([model-name] (raw-reducible-query model-name nil)) ([model-name honeysql-form] - (db/reducible-query (merge {:select [:*] :from [(symbol model-name)]} + (db/reducible-query (merge {:select [:*] :from [(model-name->table model-name)]} honeysql-form)))) -(defn- model-name->table - "The model name is not necessarily the table name. This pulls the table name from the Toucan model." - [model-name] - (-> model-name - symbol - db/resolve-model - :table)) - (defmethod extract-query :default [model-name _] - (raw-reducible-query (model-name->table model-name))) + (raw-reducible-query model-name)) (defn extract-one-basics "A helper for writing [[extract-one]] implementations. It takes care of the basics: diff --git a/test/metabase/test/generate.clj b/test/metabase/test/generate.clj index 8f6d4f6a0c5..9e2b88c256d 100644 --- a/test/metabase/test/generate.clj +++ b/test/metabase/test/generate.clj @@ -187,8 +187,10 @@ :card {:prefix :c :spec ::card :insert! {:model Card} - :relations {:creator_id [:core-user :id] - :database_id [:database :id]}} + :relations {:creator_id [:core-user :id] + :database_id [:database :id] + :table_id [:table :id] + :collection_id [:collection :id]}} :dashboard {:prefix :d :spec ::dashboard :insert! {:model Dashboard} -- GitLab