Consistent instance-independent naming scheme that replaces IDs with human-readable paths. | (ns metabase-enterprise.serialization.names (:require [clojure.string :as str] [malli.core :as mc] [metabase.db :as mdb] [metabase.lib.schema.id :as lib.schema.id] [metabase.models.card :refer [Card]] [metabase.models.collection :refer [Collection]] [metabase.models.dashboard :refer [Dashboard]] [metabase.models.database :refer [Database]] [metabase.models.field :refer [Field]] [metabase.models.interface :as mi] [metabase.models.native-query-snippet :refer [NativeQuerySnippet]] [metabase.models.pulse :refer [Pulse]] [metabase.models.segment :refer [Segment]] [metabase.models.table :refer [Table]] [metabase.models.user :refer [User]] [metabase.util.log :as log] [metabase.util.malli.schema :as ms] [ring.util.codec :as codec] [toucan2.core :as t2] [toucan2.protocols :as t2.protocols])) |
(set! *warn-on-reflection* true) | |
(def ^:private root-collection-path "/collections/root") | |
Return entity name URL encoded except that spaces are retained. | (defn safe-name [entity] (some-> entity ((some-fn :email :name)) codec/url-encode (str/replace "%20" " "))) |
Inverse of | (def unescape-name codec/url-decode) |
(defmulti ^:private fully-qualified-name* mi/model) | |
Get the logical path for entity | (def ^{:arglists '([entity] [model id])} fully-qualified-name (mdb/memoize-for-application-db (fn ([entity] (fully-qualified-name* entity)) ([model id] (if (string? id) id (fully-qualified-name* (t2/select-one model :id id))))))) |
(defmethod fully-qualified-name* Database [db] (str "/databases/" (safe-name db))) | |
(defmethod fully-qualified-name* Table [table] (if (:schema table) (format "%s/schemas/%s/tables/%s" (->> table :db_id (fully-qualified-name Database)) (:schema table) (safe-name table)) (format "%s/tables/%s" (->> table :db_id (fully-qualified-name Database)) (safe-name table)))) | |
(defmethod fully-qualified-name* Field [field] (if (:fk_target_field_id field) (str (->> field :table_id (fully-qualified-name Table)) "/fks/" (safe-name field)) (str (->> field :table_id (fully-qualified-name Table)) "/fields/" (safe-name field)))) | |
(defmethod fully-qualified-name* Segment [segment] (str (->> segment :table_id (fully-qualified-name Table)) "/segments/" (safe-name segment))) | |
(defn- local-collection-name [collection] (let [ns-part (when-let [coll-ns (:namespace collection)] (str ":" (if (keyword? coll-ns) (name coll-ns) coll-ns) "/"))] (str "/collections/" ns-part (safe-name collection)))) | |
(defmethod fully-qualified-name* Collection [collection] (let [parents (some->> (str/split (:location collection) #"/") rest not-empty (map #(local-collection-name (t2/select-one Collection :id (Integer/parseInt %)))) (apply str))] (str root-collection-path parents (local-collection-name collection)))) | |
(defmethod fully-qualified-name* Dashboard [dashboard] (format "%s/dashboards/%s" (or (some->> dashboard :collection_id (fully-qualified-name Collection)) root-collection-path) (safe-name dashboard))) | |
(defmethod fully-qualified-name* Pulse [pulse] (format "%s/pulses/%s" (or (some->> pulse :collection_id (fully-qualified-name Collection)) root-collection-path) (safe-name pulse))) | |
(defmethod fully-qualified-name* Card [card] (format "%s/cards/%s" (or (some->> card :collection_id (fully-qualified-name Collection)) root-collection-path) (safe-name card))) | |
(defmethod fully-qualified-name* User [user] (str "/users/" (:email user))) | |
(defmethod fully-qualified-name* NativeQuerySnippet [snippet] (format "%s/snippets/%s" (or (some->> snippet :collection_id (fully-qualified-name Collection)) root-collection-path) (safe-name snippet))) | |
(defmethod fully-qualified-name* nil [_] nil) | |
All the references in the dumps should resolved to entities already loaded. | (def ^:private Context [:map {:closed true} [:database {:optional true} ms/PositiveInt] [:table {:optional true} ms/PositiveInt] [:schema {:optional true} [:maybe :string]] [:field {:optional true} ms/PositiveInt] [:metric {:optional true} ms/PositiveInt] [:segment {:optional true} ms/PositiveInt] [:card {:optional true} ms/PositiveInt] [:dashboard {:optional true} ms/PositiveInt] [:collection {:optional true} [:maybe ms/PositiveInt]] ; root collection [:pulse {:optional true} ms/PositiveInt] [:user {:optional true} ms/PositiveInt] [:snippet {:optional true} [:maybe ms/PositiveInt]]]) |
(defmulti ^:private path->context* (fn [_ model _ _] model)) | |
Extract entities from a logical path. | (def ^:private ^{:arglists '([context model model-attrs entity-name])} path->context path->context*) |
(defmethod path->context* "databases" [context _ _ db-name] (assoc context :database (if (= db-name "__virtual") lib.schema.id/saved-questions-virtual-database-id (t2/select-one-pk Database :name db-name)))) | |
(defmethod path->context* "schemas" [context _ _ schema] (assoc context :schema schema)) | |
(defmethod path->context* "tables" [context _ _ table-name] (assoc context :table (t2/select-one-pk Table :db_id (:database context) :schema (:schema context) :name table-name))) | |
(defmethod path->context* "fields" [context _ _ field-name] (assoc context :field (t2/select-one-pk Field :table_id (:table context) :name field-name))) | |
(defmethod path->context* "fks" [context _ _ field-name] (path->context* context "fields" nil field-name)) | |
(defmethod path->context* "segments" [context _ _ segment-name] (assoc context :segment (t2/select-one-pk Segment :table_id (:table context) :name segment-name))) | |
(defmethod path->context* "collections" [context _ model-attrs collection-name] (if (= collection-name "root") (assoc context :collection nil) (assoc context :collection (t2/select-one-pk Collection :name collection-name :namespace (:namespace model-attrs) :location (or (letfn [(collection-location [id] (t2/select-one-fn :location Collection :id id))] (some-> context :collection collection-location (str (:collection context) "/"))) "/"))))) | |
(defmethod path->context* "dashboards" [context _ _ dashboard-name] (assoc context :dashboard (t2/select-one-pk Dashboard :collection_id (:collection context) :name dashboard-name))) | |
(defmethod path->context* "pulses" [context _ _ pulse-name] (assoc context :dashboard (t2/select-one-pk Pulse :collection_id (:collection context) :name pulse-name))) | |
(defmethod path->context* "cards" [context _ _ dashboard-name] (assoc context :card (t2/select-one-pk Card :collection_id (:collection context) :name dashboard-name))) | |
(defmethod path->context* "users" [context _ _ email] (assoc context :user (t2/select-one-pk User :email email))) | |
(defmethod path->context* "snippets" [context _ _ snippet-name] (assoc context :snippet (t2/select-one-pk NativeQuerySnippet :collection_id (:collection context) :name snippet-name))) | |
(def ^:private separator-pattern #"\/") | |
Dynamic boolean var that controls whether warning messages will NOT be logged on a failed name lookup (from within
| (def ^:dynamic *suppress-log-name-lookup-exception* false) |
Returns true if the given | (defn fully-qualified-field-name? [field-name] (and (some? field-name) (str/starts-with? field-name "/databases/") (or (str/includes? field-name "/fks/") (str/includes? field-name "/fields/")))) |
Returns true if the given | (defn fully-qualified-table-name? [table-name] (and (some? table-name) (string? table-name) (str/starts-with? table-name "/databases/") (not (str/starts-with? table-name "card__")))) |
Returns true if the given | (defn fully-qualified-card-name? [card-name] (and (some? card-name) (string? card-name) (str/starts-with? card-name "/collections/root/") (str/includes? card-name "/cards/"))) |
WARNING: THIS MUST APPEAR AFTER ALL path->context* IMPLEMENTATIONS | (def ^:private all-entities (-> path->context* methods keys set)) |
This is more complicated than it needs to be due to potential clashes between an entity name (ex: a table called
"users" and a model name (ex: "users"). Could fix in a number of ways, including special prefix of model names,
but that would require changing the format and updating all the | (defn- partition-name-components ([name-comps] (partition-name-components {::name-components [] ::current-component []} name-comps)) ([acc [c & more-comps]] (cond (nil? more-comps) (conj (::name-components acc) (conj (::current-component acc) c)) (::prev-model-name? acc) (if (= \: (first c)) (partition-name-components (update acc ::current-component conj c) more-comps) (partition-name-components (-> (assoc acc ::prev-model-name? false) (update ::current-component conj c)) more-comps)) (contains? all-entities c) (partition-name-components (cond-> (assoc acc ::prev-model-name? true ::current-component [c]) (not-empty (::current-component acc)) (update ::name-components conj (::current-component acc))) more-comps)))) |
Parse a logical path into a context map. | (defn fully-qualified-name->context [fully-qualified-name] (when fully-qualified-name (let [components (->> (str/split fully-qualified-name separator-pattern) rest ; we start with a / partition-name-components (map (fn [[model-name & entity-parts]] (cond-> {::model-name model-name ::entity-name (last entity-parts)} (and (= "collections" model-name) (> (count entity-parts) 1)) (assoc :namespace (->> entity-parts first ; ns is first/only item after "collections" rest ; strip the starting : (apply str))))))) context (loop [acc-context {} [{::keys [model-name entity-name] :as model-map} & more] components] (let [model-attrs (dissoc model-map ::model-name ::entity-name) new-context (path->context acc-context model-name model-attrs (unescape-name entity-name))] (if (empty? more) new-context (recur new-context more))))] (if (and (not (mc/validate [:maybe Context] context)) (not *suppress-log-name-lookup-exception*)) (log/warn (ex-info (format "Can't resolve %s in fully qualified name %s" (str/join ", " (map name (keys context))) fully-qualified-name) {:fully-qualified-name fully-qualified-name :resolve-name-failed? true :context context})) context)))) |
Return a string representation of entity suitable for logs | (defn name-for-logging ([entity] (name-for-logging (t2.protocols/model entity) entity)) ([model {:keys [name id]}] (cond (and name id) (format "%s \"%s\" (ID %s)" model name id) name (format "%s \"%s\"" model name) id (format "%s %s" model id) :else model))) |