diff --git a/src/metabase/models/serialization.clj b/src/metabase/models/serialization.clj index 6a334d9907e675aa1971da21e5d83414acb1bd3a..1e151bf2568cf36e9abbf653dcb0eb9ed76a1c25 100644 --- a/src/metabase/models/serialization.clj +++ b/src/metabase/models/serialization.clj @@ -16,14 +16,12 @@ [clojure.set :as set] [clojure.string :as str] [medley.core :as m] - [metabase.db :as mdb] [metabase.legacy-mbql.normalize :as mbql.normalize] [metabase.lib.schema.id :as lib.schema.id] [metabase.lib.util.match :as lib.util.match] [metabase.models.interface :as mi] [metabase.shared.models.visualization-settings :as mb.viz] [metabase.util :as u] - [metabase.util.connection :as u.conn] [metabase.util.date-2 :as u.date] [metabase.util.log :as log] [toucan2.core :as t2] @@ -314,39 +312,14 @@ `:copy` and `:skip` are vectors of field names. `:skip` is only used in tests to check that all fields were mentioned. - - `:transform` is a map from field name to a `{:ser (fn [v] ...) :des (fn [v] ...)}` map with functions to + j serialize/deserialize data. - For behavior, see `extract-by-spec` and `xform-by-spec`." + For behavior, see `extract-one` and `xform-one`." (fn [model-name _opts] model-name)) (defmethod make-spec :default [_ _] nil) -(defn- extract-by-spec [model-name opts instance] - (try - (binding [*current* instance] - (when-let [spec (make-spec model-name opts)] - (-> (select-keys instance (:copy spec)) - ;; won't assoc if `generate-path` returned `nil` - (m/assoc-some :serdes/meta (generate-path model-name instance)) - (into (for [[k transform] (:transform spec) - :let [export-k (:as transform k) - res ((:export transform) (get instance k))] - :when (not= res ::skip)] - (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}))) - - [export-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)) - e))))) - (defmulti extract-all "Entry point for extracting all entities of a particular model: `(extract-all \"ModelName\" {opts...})` @@ -375,24 +348,38 @@ {:arglists '([model-name opts])} (fn [model-name _opts] model-name)) -(defmulti extract-one +(defn extract-one "Extracts a single entity retrieved from the database into a portable map with `:serdes/meta` attached. `(extract-one \"ModelName\" opts entity)` - The default implementation uses [[generate-path]] to build the `:serdes/meta`. It also strips off the - database's numeric primary key. - - That suffices for a few simple entities, but most entities will need to override this. - They should follow the pattern of: - Convert to a vanilla Clojure map, not a modeled Toucan 2 entity. - Drop the numeric database primary key (usually `:id`) - - Replace any foreign keys with portable values (eg. entity IDs, or a user ID with their email, etc.) - - When overriding this, [[extract-one-basics]] is probably a useful starting point. + - Drop the updated_at timestamp, if it exists. + - Replace any foreign keys with portable values (eg. entity IDs, or a user ID with their email, etc.)" + [model-name opts instance] + (try + (binding [*current* instance] + (let [spec (make-spec model-name opts)] + (assert spec (str "No serialization spec defined for model " model-name)) + (-> (select-keys instance (:copy spec)) + ;; won't assoc if `generate-path` returned `nil` + (m/assoc-some :serdes/meta (generate-path model-name instance)) + (into (for [[k transform] (:transform spec) + :let [export-k (:as transform k) + res ((:export transform) (get instance k))] + :when (not= res ::skip)] + (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}))) - Keyed by the model name of the entity, the first argument." - {:arglists '([model-name opts instance])} - (fn [model-name _opts _instance] model-name)) + [export-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)) + e))))) (defn log-and-extract-one "Extracts a single entity; will replace `extract-one` as public interface once `extract-one` overrides are gone." @@ -468,27 +455,6 @@ (cond->> (extract-query-collections (keyword "model" model-name) opts) nested? (extract-reducible-nested model-name (dissoc opts :where))))) -(defn extract-one-basics - "A helper for writing [[extract-one]] implementations. It takes care of the basics: - - Convert to a vanilla Clojure map. - - Add `:serdes/meta` by calling [[generate-path]]. - - Drop the primary key. - - Drop :updated_at; it's noisy in git and not really used anywhere. - - Returns the Clojure map." - [model-name entity] - (let [model (t2.model/resolve-model (symbol model-name)) - pk (first (t2/primary-keys model))] - (-> (into {} entity) - (m/update-existing :entity_id str/trim) - (assoc :serdes/meta (generate-path model-name entity)) - (dissoc pk :updated_at)))) - -(defmethod extract-one :default [model-name opts entity] - ;; `extract-by-spec` is called here since most of tests use `extract-one` right now - (or (extract-by-spec model-name opts entity) - (extract-one-basics model-name entity))) - (defmulti descendants "Returns set of `[model-name database-id]` pairs for all entities contained or used by this entity. e.g. the Dashboard implementation should return pairs for all DashboardCard entities it contains, etc. @@ -553,8 +519,7 @@ ;;; ;;; `load-one!` has a default implementation that works for most models: ;;; -;;; - Call `(load-xform ingested)` to transform the ingested map as needed. -;;; - Override [[load-xform]] to convert any foreign keys from portable entity IDs to the local database FKs. +;;; - Call `(xform-one ingested)` to transform the ingested map as needed. ;;; - Then call either: ;;; - `(load-update! ingested local-entity)` if the local entity exists, or ;;; - `(load-insert! ingested)` if it's new. @@ -618,68 +583,6 @@ (defmethod dependencies :default [_] []) -(defmulti load-xform - "Given the incoming vanilla map as ingested, transform it so it's suitable for sending to the database (in eg. - [[t2/insert!]]). - For example, this should convert any foreign keys back from a portable entity ID or identity hash into a numeric - database ID. This is the mirror of [[extract-one]], in spirit. (They're not strictly inverses - [[extract-one]] drops - the primary key but this need not put one back, for example.) - - By default, this just calls [[load-xform-basics]]. - If you override this, call [[load-xform-basics]] as well." - {:arglists '([ingested])} - ingested-model) - -(def ^:private fields-for-table - "Given a table name, returns a map of column_name -> column_type" - (mdb/memoize-for-application-db - (fn fields-for-table [table-name] - (u.conn/app-db-column-types (mdb/app-db) table-name)))) - -(defn- ->table-name - "Returns the table name that a particular ingested entity should finally be inserted into." - [ingested] - (->> ingested ingested-model (keyword "model") t2/table-name)) - -(defmulti ingested-model-columns - "Called by `drop-excess-keys` (which is in turn called by `load-xform-basics`) to determine the full set of keys that - should be on the map returned by `load-xform-basics`. The default implementation looks in the application DB for the - table associated with the ingested model and returns the set of keywordized columns, but for some models (e.g. - Actions) there is not a 1:1 relationship between a model and a table, so we need this multimethod to allow the - model to override when necessary." - ingested-model) - -(defmethod ingested-model-columns :default - ;; this works for most models - it just returns a set of keywordized column names from the database. - [ingested] - (->> ingested - ->table-name - fields-for-table - keys - (map (comp keyword u/lower-case-en)) - set)) - -(defn- drop-excess-keys - "Given an ingested entity, removes keys that will not 'fit' into the current schema, because the column no longer - exists. This can happen when serialization dumps generated on an earlier version of Metabase are loaded into a - later version of Metabase, when a column gets removed. (At the time of writing I am seeing this happen with - color on collections)." - [ingested] - (select-keys ingested (ingested-model-columns ingested))) - -(defn load-xform-basics - "Performs the usual steps for an incoming entity: - - removes extraneous keys (e.g. `:serdes/meta`) - - You should call this as part of any implementation of [[load-xform]]. - - This is a mirror (but not precise inverse) of [[extract-one-basics]]." - [ingested] - (drop-excess-keys ingested)) - -(defmethod load-xform :default [ingested] - (load-xform-basics ingested)) - (defmulti load-update! "Called by the default [[load-one!]] if there is a corresponding entity already in the appdb. `(load-update! \"ModelName\" ingested-and-xformed local-Toucan-entity)` @@ -727,10 +630,10 @@ `ingested` is the vanilla map from ingestion, with the `:serdes/meta` key on it. `maybe-local` is either `nil`, or the corresponding Toucan entity from the appdb. - Defaults to calling [[load-xform]] to massage the incoming map, then either [[load-update!]] if `maybe-local` + Defaults to calling [[xform-one]] to massage the incoming map, then either [[load-update!]] if `maybe-local` exists, or [[load-insert!]] if it's `nil`. - Prefer overriding [[load-xform]], and if necessary [[load-update!]] and [[load-insert!]], rather than this. + Prefer overriding [[load-update!]] and [[load-insert!]] if necessary, rather than this. Keyed on the model name. @@ -738,19 +641,19 @@ (fn [ingested _] (ingested-model ingested))) -(defn- xform-by-spec [model-name ingested] +(defn- xform-one [model-name ingested] (let [spec (make-spec model-name nil)] - (when spec - (binding [*current* ingested] - (-> (select-keys ingested (:copy spec)) - (into (for [[k transform] (:transform spec) - :when (not (::nested transform)) - :let [import-k (:as transform k) - res ((:import transform) (get ingested import-k))] - :when (and (not= res ::skip) - (or (some? res) - (contains? ingested import-k)))] - [k res]))))))) + (assert spec (str "No serialization spec defined for model " model-name)) + (binding [*current* ingested] + (-> (select-keys ingested (:copy spec)) + (into (for [[k transform] (:transform spec) + :when (not (::nested transform)) + :let [import-k (:as transform k) + res ((:import transform) (get ingested import-k))] + :when (and (not= res ::skip) + (or (some? res) + (contains? ingested import-k)))] + [k res])))))) (defn- spec-nested! [model-name ingested instance] (binding [*current* instance] @@ -763,8 +666,7 @@ "Default implementation of `load-one!`" [ingested maybe-local] (let [model-name (ingested-model ingested) - adjusted (or (xform-by-spec model-name ingested) - (load-xform ingested)) + adjusted (xform-one model-name ingested) instance (binding [mi/*deserializing?* true] (if (nil? maybe-local) (load-insert! model-name adjusted) @@ -798,7 +700,7 @@ (defn lookup-by-id "Given an ID string, this endeavours to find the matching entity, whether it's an entity ID or identity hash. - This is useful when writing [[load-xform]] to turn a foreign key from a portable form to an appdb ID. + This is useful when writing [[xform-one]] to turn a foreign key from a portable form to an appdb ID. Returns a Toucan entity or nil." [model id-str] (if (entity-id? id-str)