From aa3b331bd3163894d09a9cfda1afc1668a8946be Mon Sep 17 00:00:00 2001
From: Braden Shepherdson <Braden.Shepherdson@gmail.com>
Date: Mon, 22 Aug 2022 09:25:44 -0400
Subject: [PATCH] Add serdes v2 for FieldValues, which were previously excluded
 (#24855)

FieldValues can be rebuilt by the sync process, but they are portable
and including them aids in the plans for content moderation via git.
---
 .../serialization/v2/models.clj               |   1 +
 .../serialization/v2/extract_test.clj         |  45 ++++++-
 .../serialization/v2/load_test.clj            | 110 +++++++++++++++++-
 src/metabase/models/field.clj                 |  10 +-
 src/metabase/models/field_values.clj          |  32 +++++
 5 files changed, 186 insertions(+), 12 deletions(-)

diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
index 93ed0f165b4..b1da6cc9d58 100644
--- a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
+++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
@@ -9,6 +9,7 @@
    "Database"
    "Dimension"
    "Field"
+   "FieldValues"
    "Metric"
    "NativeQuerySnippet"
    "Pulse"
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 71819189ead..8046f0b7154 100644
--- a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj
@@ -3,7 +3,7 @@
             [clojure.test :refer :all]
             [metabase-enterprise.serialization.test-util :as ts]
             [metabase-enterprise.serialization.v2.extract :as extract]
-            [metabase.models :refer [Card Collection Dashboard DashboardCard Database Dimension Field Metric
+            [metabase.models :refer [Card Collection Dashboard DashboardCard Database Dimension Field FieldValues Metric
                                      NativeQuerySnippet Pulse PulseCard Segment Table Timeline TimelineEvent User]]
             [metabase.models.serialization.base :as serdes.base]
             [schema.core :as s])
@@ -649,6 +649,49 @@
                       {:model "Field"      :id "Some Field"}]}
                    (set (serdes.base/serdes-dependencies ser))))))))))
 
+(deftest field-values-test
+  (ts/with-empty-h2-app-db
+    (ts/with-temp-dpc [Database   [{db-id        :id}        {:name "My Database"}]
+                       Table      [{no-schema-id :id}        {:name "Schemaless Table" :db_id db-id}]
+                       Field      [{field-id     :id}        {:name "Some Field"
+                                                              :table_id no-schema-id
+                                                              :fingerprint {:global {:distinct-count 75 :nil% 0.0}
+                                                                            :type   {:type/Text {:percent-json   0.0
+                                                                                                 :percent-url    0.0
+                                                                                                 :percent-email  0.0
+                                                                                                 :percent-state  0.0
+                                                                                                 :average-length 8.333333333333334}}}}]
+                       FieldValues [{fv-id       :id
+                                     values      :values}
+                                    {:field_id              field-id
+                                     :hash_key              nil
+                                     :has_more_values       false
+                                     :type                  :full
+                                     :human_readable_values []
+                                     :values ["Artisan" "Asian" "BBQ" "Bakery" "Bar" "Brewery" "Burger" "Coffee Shop"
+                                              "Diner" "Indian" "Italian" "Japanese" "Mexican" "Middle Eastern" "Pizza"
+                                              "Seafood" "Steakhouse" "Tea Room" "Winery"]}]]
+      (testing "field values"
+        (let [ser (serdes.base/extract-one "FieldValues" {} (select-one "FieldValues" [:= :id fv-id]))]
+          (is (schema= {:serdes/meta                 (s/eq [{:model "Database" :id "My Database"}
+                                                            {:model "Table"    :id "Schemaless Table"}
+                                                            {:model "Field"    :id "Some Field"}
+                                                            {:model "FieldValues" :id "0"}]) ; Always 0.
+                        :created_at                  LocalDateTime
+                        (s/optional-key :updated_at) OffsetDateTime
+                        :values                      (s/eq (json/generate-string values))
+                        s/Keyword                    s/Any}
+                       ser))
+          (is (not (contains? ser :id)))
+          (is (not (contains? ser :field_id))
+              ":field_id is dropped; its implied by the path")
+
+          (testing "depend on the parent Field"
+            (is (= #{[{:model "Database"   :id "My Database"}
+                      {:model "Table"      :id "Schemaless Table"}
+                      {:model "Field"      :id "Some Field"}]}
+                   (set (serdes.base/serdes-dependencies ser))))))))))
+
 (deftest pulses-test
   (ts/with-empty-h2-app-db
     (ts/with-temp-dpc [User       [{ann-id       :id}        {:first_name "Ann"
diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
index 4f86606031e..bf635ac89b9 100644
--- a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
@@ -4,8 +4,8 @@
             [metabase-enterprise.serialization.v2.extract :as serdes.extract]
             [metabase-enterprise.serialization.v2.ingest :as serdes.ingest]
             [metabase-enterprise.serialization.v2.load :as serdes.load]
-            [metabase.models :refer [Card Collection Dashboard DashboardCard Database Field Metric Pulse PulseChannel
-                                     PulseChannelRecipient Segment Table User]]
+            [metabase.models :refer [Card Collection Dashboard DashboardCard Database Field FieldValues Metric Pulse
+                                     PulseChannel PulseChannelRecipient Segment Table User]]
             [metabase.models.serialization.base :as serdes.base]
             [metabase.models.serialization.hash :as serdes.hash]
             [toucan.db :as db]))
@@ -153,7 +153,7 @@
           t1s        (atom nil)
           t2s        (atom nil)]
       (ts/with-source-and-dest-dbs
-        (testing "serializing the two collections"
+        (testing "serializing the two databases"
           (ts/with-source-db
             (reset! db1s (ts/create! Database :name "db1"))
             (reset! t1s  (ts/create! Table    :name "posts" :db_id (:id @db1s)))
@@ -659,3 +659,107 @@
                        :card_id      (:id @card1d)
                        :target       [:dimension [:field (:id @field1d) {:source-field (:id @field2d)}]]}]
                      (:parameter_mappings @dashcard1d))))))))))
+
+(deftest field-values-test
+  ;; FieldValues are a bit special - they map 1-1 with Fields but are a separate table serialized separately.
+  ;; The main special thing to test here is that the custom load-find-local correctly finds an existing FieldValues.
+  ;; This test creates:
+  ;; - in src: a database, table, and two fields each with field values.
+  ;; - in dst: a different database, table and field, to fiddle the IDs; plus the same database, table, both fields, but
+  ;;   only one FieldValues. The existing and new FieldValues should both work correctly.
+  ;; Another thing tested here is that the :field_id is properly reconstructed from the serdes path.
+  (testing "FieldValues are portable"
+    (let [serialized (atom nil)
+          db1s       (atom nil)
+          table1s    (atom nil)
+          field1s    (atom nil)
+          field2s    (atom nil)
+          fv1s       (atom nil)
+          fv2s       (atom nil)
+
+          db1d       (atom nil)
+          table1d    (atom nil)
+          field1d    (atom nil)
+          field2d    (atom nil)
+          fv1d       (atom nil)
+          fv2d       (atom nil)
+          db2d       (atom nil)
+          table2d    (atom nil)
+          field3d    (atom nil)]
+
+      (testing "serializing the original database, table, field and fieldvalues"
+        (ts/with-empty-h2-app-db
+          (reset! db1s     (ts/create! Database :name "my-db"))
+          (reset! table1s  (ts/create! Table :name "CUSTOMERS" :db_id (:id @db1s)))
+          (reset! field1s  (ts/create! Field :name "STATE" :table_id (:id @table1s)))
+          (reset! field2s  (ts/create! Field :name "CATEGORY" :table_id (:id @table1s)))
+          (reset! fv1s     (ts/create! FieldValues :field_id (:id @field1s) :values ["AZ" "CA" "NY" "TX"]))
+          (reset! fv2s     (ts/create! FieldValues :field_id (:id @field2s)
+                                       :values ["CONSTRUCTION" "DAYLIGHTING" "DELIVERY" "HAULING"]))
+
+          (reset! serialized (into [] (serdes.extract/extract-metabase {})))
+
+          (testing "the expected fields are serialized"
+            (is (= 1
+                   (->> @serialized
+                        (filter #(= (:serdes/meta %)
+                                    [{:model "Database" :id "test-data"}
+                                     {:model "Schema"   :id "PUBLIC"}
+                                     {:model "Table"    :id "VENUES"}
+                                     {:model "Field"    :id "NAME"}]))
+                        count))))
+
+          (testing "FieldValues are serialized under their fields, with their own ID always 0"
+            (let [fvs (by-model @serialized "FieldValues")]
+              (is (= #{[{:model "Database"    :id "my-db"}
+                        {:model "Table"       :id "CUSTOMERS"}
+                        {:model "Field"       :id "STATE"}
+                        {:model "FieldValues" :id "0"}]
+                       [{:model "Database"    :id "my-db"}
+                        {:model "Table"       :id "CUSTOMERS"}
+                        {:model "Field"       :id "CATEGORY"}
+                        {:model "FieldValues" :id "0"}]}
+                     (->> fvs
+                          (map serdes.base/serdes-path)
+                          (filter #(-> % first :id (= "my-db")))
+                          set)))))))
+
+      (testing "deserializing finds existing FieldValues properly"
+        (ts/with-empty-h2-app-db
+          ;; A different database and tables, so the IDs don't match.
+          (reset! db2d    (ts/create! Database :name "other-db"))
+          (reset! table2d (ts/create! Table    :name "ORDERS" :db_id (:id @db2d)))
+          (reset! field3d (ts/create! Field    :name "SUBTOTAL" :table_id (:id @table2d)))
+          (ts/create! Field :name "DISCOUNT" :table_id (:id @table2d))
+          (ts/create! Field :name "UNITS"    :table_id (:id @table2d))
+
+          ;; Now the database, table, fields and *one* of the FieldValues from the src side.
+          (reset! db1d     (ts/create! Database :name "my-db"))
+          (reset! table1d  (ts/create! Table :name "CUSTOMERS" :db_id (:id @db1d)))
+          (reset! field1d  (ts/create! Field :name "STATE" :table_id (:id @table1d)))
+          (reset! field2d  (ts/create! Field :name "CATEGORY" :table_id (:id @table1d)))
+          ;; The :values are different here; they should get overwritten by the update.
+          (reset! fv1d     (ts/create! FieldValues :field_id (:id @field1d) :values ["WA" "NC" "NM" "WI"]))
+
+          ;; Load the serialized content.
+          (serdes.load/load-metabase (ingestion-in-memory @serialized))
+
+          ;; Fetch the relevant bits
+          (reset! fv1d (db/select-one FieldValues :field_id (:id @field1d)))
+          (reset! fv2d (db/select-one FieldValues :field_id (:id @field2d)))
+
+          (testing "the main Database, Table, and Field have different IDs now"
+            (is (not= (:id @db1s)    (:id @db1d)))
+            (is (not= (:id @table1s) (:id @table1d)))
+            (is (not= (:id @field1s) (:id @field1d)))
+            (is (not= (:id @field2s) (:id @field2d))))
+
+          (testing "there are 2 FieldValues defined under fields of table1d"
+            (let [fields (db/select-ids Field :table_id (:id @table1d))]
+              (is (= 2 (db/count FieldValues :field_id [:in fields])))))
+
+          (testing "existing FieldValues are properly found and updated"
+            (is (= (:values @fv1s) (:values @fv1d))))
+          (testing "new FieldValues are properly added"
+            (is (= (dissoc @fv2s :id :field_id)
+                   (dissoc @fv2d :id :field_id)))))))))
diff --git a/src/metabase/models/field.clj b/src/metabase/models/field.clj
index cf4cc156d0e..f42fe2b062d 100644
--- a/src/metabase/models/field.clj
+++ b/src/metabase/models/field.clj
@@ -418,11 +418,5 @@
 
 (defmethod serdes.base/load-find-local "Field"
   [path]
-  (let [db-name            (-> path first :id)
-        schema-name        (when (= 3 (count path))
-                             (-> path second :id))
-        [{table-name :id}
-         {field-name :id}] (take-last 2 path)
-        db-id              (db/select-one-field :id 'Database :name db-name)
-        table-id           (db/select-one-field :id 'Table :name table-name :db_id db-id :schema schema-name)]
-    (db/select-one-field :id Field :name field-name :table_id table-id)))
+  (let [table-id (serdes.base/load-find-local (pop path))]
+    (db/select-one-field :id Field :name (-> path last :id) :table_id table-id)))
diff --git a/src/metabase/models/field_values.clj b/src/metabase/models/field_values.clj
index a9496f20051..e6dcb0d823c 100644
--- a/src/metabase/models/field_values.clj
+++ b/src/metabase/models/field_values.clj
@@ -18,7 +18,9 @@
     But they will also be automatically deleted when the Full FieldValues of the same Field got updated."
   (:require [clojure.tools.logging :as log]
             [java-time :as t]
+            [metabase.models.serialization.base :as serdes.base]
             [metabase.models.serialization.hash :as serdes.hash]
+            [metabase.models.serialization.util :as serdes.util]
             [metabase.plugins.classloader :as classloader]
             [metabase.public-settings.premium-features :refer [defenterprise]]
             [metabase.util :as u]
@@ -413,3 +415,33 @@
          (trs "Field {0} ''{1}'' should have FieldValues and belongs to a Database with On-Demand FieldValues updating."
                  (u/the-id field) (:name field)))
         (create-or-update-full-field-values! field)))))
+
+;;; +----------------------------------------------------------------------------------------------------------------+
+;;; |                                              Serialization                                                     |
+;;; +----------------------------------------------------------------------------------------------------------------+
+(defmethod serdes.base/serdes-generate-path "FieldValues" [_ {:keys [field_id]}]
+  (let [field (db/select-one 'Field :id field_id)]
+    (conj (serdes.base/serdes-generate-path "Field" field)
+          {:model "FieldValues" :id "0"})))
+
+(defmethod serdes.base/serdes-dependencies "FieldValues" [fv]
+  ;; Take the path, but drop the FieldValues section at the end, to get the parent Field's path instead.
+  [(pop (serdes.base/serdes-path fv))])
+
+(defmethod serdes.base/extract-one "FieldValues" [_model-name _opts fv]
+  (-> (serdes.base/extract-one-basics "FieldValues" fv)
+      (dissoc :field_id)))
+
+(defmethod serdes.base/load-xform "FieldValues" [fv]
+  (let [[db schema table field :as field-ref] (map :id (pop (serdes.base/serdes-path fv)))
+        field-ref (if field
+                    field-ref
+                    ;; It's too short, so no schema. Shift them over and add a nil schema.
+                    [db nil schema table])]
+    (-> (serdes.base/load-xform-basics fv)
+        (assoc :field_id (serdes.util/import-field-fk field-ref)))))
+
+(defmethod serdes.base/load-find-local "FieldValues" [path]
+  ;; Delegate to finding the parent Field, then look up its corresponding FieldValues.
+  (let [field-id (serdes.base/load-find-local (pop path))]
+    (db/select-one-id FieldValues :field_id field-id)))
-- 
GitLab