(ns metabase.models.revision (:require [clojure.data :as data] [metabase.config :as config] [metabase.models.interface :as mi] [metabase.models.revision.diff :refer [diff-strings*]] [metabase.util :as u] [metabase.util.i18n :refer [deferred-tru tru]] [metabase.util.json :as json] [metabase.util.malli :as mu] [methodical.core :as methodical] [toucan2.core :as t2] [toucan2.model :as t2.model])) | |
Check if | (defn toucan-model? [model] (isa? model :metabase/model)) |
Maximum number of revisions to keep for each individual object. After this limit is surpassed, the oldest revisions will be deleted. | (def ^:const max-revisions 15) |
Prepare an instance for serialization in a Revision. | (defmulti serialize-instance {:arglists '([model id instance])} mi/dispatch-on-model) |
no default implementation for [[serialize-instance]]; models need to implement this themselves. | |
Return an object to the state recorded by | (defmulti revert-to-revision! {:arglists '([model id user-id serialized-instance])} mi/dispatch-on-model) |
(defmethod revert-to-revision! :default [model id _user-id serialized-instance] (let [valid-columns (keys (t2/select-one (t2/table-name model) :id id)) ;; Only include fields that we know are on the model in the current version of Metabase! Otherwise we'll get ;; an error if a field in an earlier version has since been dropped, but is still present in the revision. ;; This is best effort — other kinds of schema changes could still break the ability to revert successfully. revert-instance (select-keys serialized-instance valid-columns)] (t2/update! model id revert-instance))) | |
Return a map describing the difference between | (defmulti diff-map {:arglists '([model object-1 object-2])} mi/dispatch-on-model) |
(defmethod diff-map :default [_model o1 o2] (when o1 (let [[before after] (data/diff o1 o2)] {:before before :after after}))) | |
Return a seq of string describing the difference between Each string in the seq should be i18n-ed. | (defmulti diff-strings {:arglists '([model object-1 object-2])} mi/dispatch-on-model) |
(defmethod diff-strings :default [model o1 o2] (diff-strings* (name model) o1 o2)) | |
----------------------------------------------- Entity & Lifecycle ----------------------------------------------- | |
Used to be the toucan1 model name defined using [[toucan.models/defmodel]], now it's a reference to the toucan2 model name. We'll keep this till we replace all the symbols in our codebase. | (def Revision :model/Revision) |
(methodical/defmethod t2/table-name :model/Revision [_model] :revision) | |
(doto :model/Revision (derive :metabase/model) (derive :hook/search-index)) | |
(t2/deftransforms :model/Revision {:object mi/transform-json}) | |
(t2/define-before-insert :model/Revision [{:keys [model model_id] :as revision}] ;; obtain a lock on the existing revisions for this entity to prevent concurrent inserts of new revisions (t2/query {:select [:id] :from [:revision] :where [:and [:= :model model] [:= :model_id model_id]] :for :update}) (assoc revision :timestamp (or (:timestamp revision) :%now) :metabase_version config/mb-version-string :most_recent true)) | |
(t2/define-before-update :model/Revision [_revision] (fn [& _] (throw (Exception. (tru "You cannot update a Revision!"))))) | |
(t2/define-after-select :model/Revision ;; Call the appropriate `post-select` methods (including the type functions) on the `:object` this Revision recorded. ;; This is important for things like Card revisions, where the `:dataset_query` property needs to be normalized when ;; coming out of the DB. [{:keys [model] :as revision}] ;; in some cases (such as tests) we have 'fake' models that cannot be resolved normally; don't fail entirely in ;; those cases (let [model (u/ignore-exceptions (t2.model/resolve-model (symbol model)))] (cond-> revision model (update :object (partial mi/do-after-select model))))) | |
Delete old revisions of | (defn- delete-old-revisions! [model id] (when-let [old-revisions (seq (drop max-revisions (t2/select-fn-vec :id :model/Revision :model (name model) :model_id id {:order-by [[:timestamp :desc] [:id :desc]]})))] (t2/delete! :model/Revision :id [:in old-revisions]))) |
(t2/define-after-insert :model/Revision [revision] (u/prog1 revision (let [{:keys [id model model_id]} revision] ;; Note 1: Update the last `most_recent revision` to false (not including the current revision) ;; Note 2: We don't allow updating revision but this is a special case, so we by pass the check by ;; updating directly with the table name (t2/update! (t2/table-name :model/Revision) {:model model :model_id model_id :most_recent true :id [:not= id]} {:most_recent false}) (delete-old-revisions! model model_id)))) | |
Functions | |
(defn- revision-changes [model prev-revision revision] (cond (:is_creation revision) [(deferred-tru "created this")] (:is_reversion revision) [(deferred-tru "reverted to an earlier version")] ;; We only keep [[revision/max-revisions]] number of revision per entity. ;; prev-revision can be nil when we generate description for oldest revision (nil? prev-revision) [(deferred-tru "modified this")] :else (diff-strings model (:object prev-revision) (:object revision)))) | |
(defn- revision-description-info [model prev-revision revision] (let [changes (revision-changes model prev-revision revision)] {:description (if (seq changes) (u/build-sentence changes) ;; HACK: before #30285 we record revision even when there is nothing changed, ;; so there are cases when revision can comeback as `nil`. ;; This is a safe guard for us to not display "Crowberto null" as ;; description on UI (deferred-tru "created a revision with no change.")) ;; this is used on FE :has_multiple_changes (> (count changes) 1)})) | |
Add enriched revision data such as | (defn add-revision-details [model revision prev-revision] (-> revision (assoc :diff (diff-map model (:object prev-revision) (:object revision))) (merge (revision-description-info model prev-revision revision)) ;; add revision user details (t2/hydrate :user) (update :user select-keys [:id :first_name :last_name :common_name]) ;; Filter out irrelevant info (dissoc :model :model_id :user_id :object))) |
Get the revisions for | (mu/defn revisions [model :- [:fn toucan-model?] id :- pos-int?] (let [model-name (name model)] (t2/select Revision :model model-name :model_id id {:order-by [[:id :desc]]}))) |
Fetch | (mu/defn revisions+details [model :- [:fn toucan-model?] id :- pos-int?] (when-let [revisions (revisions model id)] (loop [acc [], [r1 r2 & more] revisions] (if-not r2 (conj acc (add-revision-details model r1 nil)) (recur (conj acc (add-revision-details model r1 r2)) (conj more r2)))))) |
Record a new Revision for | (mu/defn push-revision! [{:keys [id entity user-id object is-creation? message] :or {is-creation? false}} :- [:map {:closed true} [:id pos-int?] [:object :map] [:entity [:fn toucan-model?]] [:user-id pos-int?] [:is-creation? {:optional true} [:maybe :boolean]] [:message {:optional true} [:maybe :string]]]] (let [entity-name (name entity) serialized-object (serialize-instance entity id (dissoc object :message)) last-object (t2/select-one-fn :object Revision :model entity-name :model_id id {:order-by [[:id :desc]]})] ;; make sure we still have a map after calling out serialization function (assert (map? serialized-object)) ;; the last-object could have nested object, e.g: Dashboard can have multiple Card in it, ;; even though we call `post-select` on the `object`, the nested object might not be transformed correctly ;; E.g: Cards inside Dashboard will not be transformed ;; so to be safe, we'll just compare them as string (when-not (= (json/encode serialized-object) (json/encode last-object)) (t2/insert! Revision :model entity-name :model_id id :user_id user-id :object serialized-object :is_creation is-creation? :is_reversion false :message message) object))) |
Revert | (mu/defn revert! [info :- [:map {:closed true} [:id pos-int?] [:user-id pos-int?] [:revision-id pos-int?] [:entity [:fn toucan-model?]]]] (let [{:keys [id user-id revision-id entity]} info model-name (name entity) serialized-instance (t2/select-one-fn :object Revision :model model-name :model_id id :id revision-id)] (t2/with-transaction [_conn] ;; Do the reversion of the object (revert-to-revision! entity id user-id serialized-instance) ;; Push a new revision to record this change (let [last-revision (t2/select-one Revision :model model-name, :model_id id, {:order-by [[:id :desc]]}) new-revision (first (t2/insert-returning-instances! Revision :model model-name :model_id id :user_id user-id :object serialized-instance :is_creation false :is_reversion true))] (add-revision-details entity new-revision last-revision))))) |