From 42dc5239db63f233dd86181f4e7ad0487c5a6d04 Mon Sep 17 00:00:00 2001
From: Ngoc Khuat <qn.khuat@gmail.com>
Date: Fri, 23 Aug 2024 19:59:02 +0700
Subject: [PATCH] [serialization] extract subtree should hydrate entities
 (#47171)

---
 .../serialization/v2/extract.clj              |  3 +-
 .../serialization/v2/extract_test.clj         | 77 +++++++++++++------
 src/metabase/models/serialization.clj         | 18 +++--
 3 files changed, 69 insertions(+), 29 deletions(-)

diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj
index ca594b81536..c7fc6b08da3 100644
--- a/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj
+++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj
@@ -179,8 +179,7 @@ Eg. if Dashboard B includes a Card A that is derived from a
                             (select-keys models)
                             (update-vals #(set (map second %))))
             extract-ids (fn [[model ids]]
-                          (eduction (map #(serdes/log-and-extract-one model opts %))
-                                    (t2/reducible-select (symbol model) :id [:in ids])))]
+                          (serdes/extract-all model (merge opts {:where [:in :id ids]})))]
         (eduction cat
                   [(eduction (map extract-ids) cat by-model)
                    ;; extract all non-content entities like data model and settings if necessary
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 8cb6e11bd62..8f923e747c9 100644
--- a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj
@@ -35,9 +35,11 @@
 (defn- by-model [model-name extraction]
   (->> extraction
        (into [])
-       (map (comp last :serdes/meta))
-       (filter #(= model-name (:model %)))
-       (map :id)
+       (filter #(= model-name ((comp :model last :serdes/meta) %)))))
+
+(defn- ids-by-model [model-name extraction]
+  (->> (by-model model-name extraction)
+       (map (comp :id last :serdes/meta))
        set))
 
 (deftest fundamentals-test
@@ -98,15 +100,15 @@
       (testing "overall extraction returns the expected set"
         (testing "no user specified"
           (is (= #{coll-eid child-eid}
-                 (by-model "Collection" (extract/extract nil)))))
+                 (ids-by-model "Collection" (extract/extract nil)))))
 
         (testing "valid user specified"
           (is (= #{coll-eid child-eid pc-eid}
-                 (by-model "Collection" (extract/extract {:user-id mark-id})))))
+                 (ids-by-model "Collection" (extract/extract {:user-id mark-id})))))
 
         (testing "invalid user specified"
           (is (= #{coll-eid child-eid}
-                 (by-model "Collection" (extract/extract {:user-id 218921})))))))))
+                 (ids-by-model "Collection" (extract/extract {:user-id 218921})))))))))
 
 #_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
 (deftest dashboard-and-cards-test
@@ -539,27 +541,27 @@
           (is (= #{coll-eid mark-coll-eid}
                  (->> {:collection-set (#'extract/collection-set-for-user mark-id)}
                       (serdes/extract-all "Collection")
-                      (by-model "Collection"))))
+                      (ids-by-model "Collection"))))
           (is (= #{coll-eid dave-coll-eid}
                  (->> {:collection-set (#'extract/collection-set-for-user dave-id)}
                       (serdes/extract-all "Collection")
-                      (by-model "Collection"))))))
+                      (ids-by-model "Collection"))))))
 
       (testing "dashboards are filtered based on :user"
         (testing "dashboards in unowned collections are always returned"
           (is (= #{dash-eid}
                  (->> {:collection-set #{coll-id}}
                       (serdes/extract-all "Dashboard")
-                      (by-model "Dashboard"))))
+                      (ids-by-model "Dashboard"))))
           (is (= #{dash-eid}
                  (->> {:collection-set (#'extract/collection-set-for-user mark-id)}
                       (serdes/extract-all "Dashboard")
-                      (by-model "Dashboard")))))
+                      (ids-by-model "Dashboard")))))
         (testing "dashboards in personal collections are returned for the :user"
           (is (= #{dash-eid other-dash param-dash}
                  (->> {:collection-set (#'extract/collection-set-for-user dave-id)}
                       (serdes/extract-all "Dashboard")
-                      (by-model "Dashboard")))))))))
+                      (ids-by-model "Dashboard")))))))))
 
 (deftest dashboard-card-series-test
   (mt/with-empty-h2-app-db
@@ -1007,7 +1009,7 @@
       (testing "extract-metabase behavior"
         (testing "without :include-field-values"
           (is (= #{}
-                 (by-model "FieldValues" (extract/extract {})))))
+                 (ids-by-model "FieldValues" (extract/extract {})))))
         (testing "with :include-field-values true"
           (let [models (->> {:include-field-values true} extract/extract (map (comp :model last :serdes/meta)))]
             ;; why 14?
@@ -1398,15 +1400,11 @@
 
       (testing "fields that reference foreign keys are properly exported as Field references"
         (is (= ["My Database" nil "Schemaless Table" "Some Field"]
-               (->> (t2/select-one Field :id fk-id)
-                    (serdes/extract-one "Field" {})
-                    :fk_target_field_id))))
+               (:fk_target_field_id (ts/extract-one "Field" fk-id)))))
 
       (testing "Fields that reference parents are properly exported as Field references"
         (is (= ["My Database" "PUBLIC" "Schema'd Table" "Other Field"]
-               (->> (t2/select-one Field :id nested-id)
-                    (serdes/extract-one "Field" {})
-                    :parent_id)))))))
+               (:parent_id (ts/extract-one "Field" nested-id))))))))
 
 (deftest escape-report-test
   (mt/with-empty-h2-app-db
@@ -1456,9 +1454,9 @@
                                   :no-settings   true
                                   :no-data-model true})]
         (is (= #{parent-eid middle-eid nested-eid}
-               (by-model "Collection" ser)))
+               (ids-by-model "Collection" ser)))
         (is (= #{ncard-eid}
-               (by-model "Card" ser)))))))
+               (ids-by-model "Card" ser)))))))
 
 (deftest skip-analytics-collections-test
   (testing "Collections in 'analytics' namespace should not be extracted, see #37453"
@@ -1469,7 +1467,7 @@
         (is (some? (audit/default-custom-reports-collection))))
       (let [ser (extract/extract {:no-settings   true
                                   :no-data-model true})]
-        (is (= #{} (by-model "Collection" ser)))))))
+        (is (= #{} (ids-by-model "Collection" ser)))))))
 
 (deftest entity-id-in-targets-test
   (mt/with-temp [Collection c {:name "Top-Level Collection"}]
@@ -1481,7 +1479,7 @@
                                   :no-settings   true
                                   :no-data-model true})]
         (is (= #{(:entity_id c)}
-               (by-model "Collection" ser)))))))
+               (ids-by-model "Collection" ser)))))))
 
 (deftest extract-nested-test
   (testing "extract-nested working"
@@ -1560,3 +1558,38 @@
                  :field_ref          [:field [string? "PUBLIC" "VENUES" "CATEGORY_ID"] nil]}
                 (->> (:result_metadata ser)
                      (u/seek #(= (:display_name %) "Category ID")))))))))
+
+(deftest extract-single-collection-test
+  (mt/with-empty-h2-app-db
+    (ts/with-temp-dpc
+      [:model/Collection    {coll-id :id}            {:name "Top-Level Collection"}
+       :model/Dashboard     {dash-id :id
+                             dash-eid :entity_id}    {:name "Top Dash"
+                                                      :collection_id coll-id}
+       :model/Card          {card-id-1 :id
+                             card-eid-1 :entity_id} {:name "Some Card"
+                                                     :collection_id coll-id}
+       :model/Card          {card-id-2 :id
+                             card-eid-2 :entity_id} {:name "Some Inner Card"
+                                                     :collection_id coll-id}
+       :model/DashboardTab  {tab-id-1 :id
+                             tab-eid-1 :entity_id}  {:dashboard_id dash-id
+                                                     :name "Tab 1"}
+       :model/DashboardCard  _                      {:dashboard_id dash-id
+                                                     :dashboard_tab_id tab-id-1
+                                                     :card_id card-id-1}
+       :model/DashboardTab  {tab-id-2 :id
+                             tab-eid-2 :entity_id}  {:dashboard_id dash-id
+                                                     :name "Tab 2"}
+       :model/DashboardCard _                       {:dashboard_id dash-id
+                                                     :dashboard_tab_id tab-id-2
+                                                     :card_id card-id-2}]
+      (let [extraction (extract/extract {:targets [["Collection" coll-id]] :no-settings true :no-data-model true})]
+        (is (=? [{:name "Top Dash"
+                  :dashcards [{:dashboard_tab_id [dash-eid tab-eid-1]
+                               :card_id          card-eid-1}
+                              {:dashboard_tab_id [dash-eid tab-eid-2]
+                               :card_id          card-eid-2}]
+                  :tabs [{:name "Tab 1"}
+                         {:name "Tab 2"}]}]
+                (by-model "Dashboard" extraction)))))))
diff --git a/src/metabase/models/serialization.clj b/src/metabase/models/serialization.clj
index 9c96d7f1d85..8f5a122bbec 100644
--- a/src/metabase/models/serialization.clj
+++ b/src/metabase/models/serialization.clj
@@ -333,7 +333,14 @@
             (into (for [[k transform] (:transform spec)
                         :let  [res ((:export transform) (get instance k))]
                         :when (not= res ::skip)]
-                    [k res])))))
+                    (do
+                      (when-not (contains? instance k)
+                        (throw (ex-info (format "Key %s not found, make sure it was hydrated" k)
+                                        {:model    model-name
+                                         :key      k
+                                         :instance instance})))
+
+                      [k res]))))))
     (catch Exception e
       (throw (ex-info (format "Error extracting %s %s" model-name (:id instance))
                       (assoc (ex-data e) :model model-name :id (:id instance))
@@ -446,10 +453,11 @@
             (nil? (-> spec :transform :collection_id)))
       ;; either no collections specified or our model has no collection
       (t2/reducible-select model {:where (or where true)})
-      (t2/reducible-select model {:where [:or
-                                          [:in :collection_id collection-set]
-                                          (when (contains? collection-set nil)
-                                            [:= :collection_id nil])
+      (t2/reducible-select model {:where [:and
+                                          [:or
+                                           [:in :collection_id collection-set]
+                                           (when (contains? collection-set nil)
+                                             [:= :collection_id nil])]
                                           (when where
                                             where)]}))))
 
-- 
GitLab