(ns metabase.models.action (:require [medley.core :as m] [metabase.models.card :refer [Card]] [metabase.models.interface :as mi] [metabase.models.query :as query] [metabase.models.serialization :as serdes] [metabase.search.core :as search] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.json :as json] [methodical.core :as methodical] [toucan2.core :as t2])) | |
-------------------------------------------- Entity & Life Cycle ---------------------------------------------- | |
(methodical/defmethod t2/table-name :model/Action [_model] :action) (methodical/defmethod t2/table-name :model/QueryAction [_model] :query_action) (methodical/defmethod t2/table-name :model/HTTPAction [_model] :http_action) (methodical/defmethod t2/table-name :model/ImplicitAction [_model] :implicit_action) | |
Action model 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 Actions symbol in our codebase. QueryAction model HTTPAction model ImplicitAction model | (def Action :model/Action) (def QueryAction :model/QueryAction) (def HTTPAction :model/HTTPAction) (def ImplicitAction :model/ImplicitAction) |
(def ^:private action-sub-models [:model/QueryAction :model/HTTPAction :model/ImplicitAction]) | |
(doto :model/Action (derive :metabase/model) ;;; You can read/write an Action if you can read/write its model (Card) (derive ::mi/read-policy.full-perms-for-perms-set) (derive ::mi/write-policy.full-perms-for-perms-set) (derive :hook/entity-id) (derive :hook/timestamped?)) | |
(doseq [model action-sub-models] (derive model :metabase/model)) | |
(derive :model/QueryAction :hook/search-index) | |
(methodical/defmethod t2/primary-keys :model/QueryAction [_model] [:action_id]) (methodical/defmethod t2/primary-keys :model/HTTPAction [_model] [:action_id]) (methodical/defmethod t2/primary-keys :model/ImplicitAction [_model] [:action_id]) | |
(def ^:private transform-action-visualization-settings {:in mi/json-in :out (comp (fn [viz-settings] ;; the keys of :fields should be strings, not keywords (m/update-existing viz-settings :fields update-keys name)) mi/json-out-with-keywordization)}) | |
(t2/deftransforms :model/Action {:type mi/transform-keyword :parameter_mappings mi/transform-parameters-list :parameters mi/transform-card-parameters-list :visualization_settings transform-action-visualization-settings}) | |
(t2/deftransforms :model/QueryAction ;; shouldn't this be mi/transform-metabase-query? {:dataset_query mi/transform-json}) | |
(def ^:private transform-json-with-nested-parameters {:in (comp mi/json-in (fn [template] (u/update-if-exists template :parameters mi/normalize-parameters-list))) :out (comp (fn [template] (u/update-if-exists template :parameters (mi/catch-normalization-exceptions mi/normalize-parameters-list))) mi/json-out-with-keywordization)}) | |
(t2/deftransforms :model/HTTPAction {:template transform-json-with-nested-parameters}) | |
(methodical/defmethod t2/batched-hydrate [:model/Action :model] [_model k actions] (mi/instances-with-hydrated-data actions k #(t2/select-pk->fn identity :model/Card :id [:in (map :model_id actions)]) :model_id)) | |
(defn- check-model-is-not-a-saved-question [model-id] (when-not (= (t2/select-one-fn :type [Card :type] :id model-id) :model) (throw (ex-info (tru "Actions must be made with models, not cards.") {:status-code 400})))) | |
(t2/define-before-insert :model/Action [{model-id :model_id, :as action}] (u/prog1 action (check-model-is-not-a-saved-question model-id))) | |
(t2/define-before-update :model/Action [{archived? :archived, id :id, model-id :model_id, :as changes}] (u/prog1 changes (if archived? (t2/delete! :model/DashboardCard :action_id id) (check-model-is-not-a-saved-question model-id)))) | |
(defmethod mi/perms-objects-set :model/Action [instance read-or-write] (mi/perms-objects-set (t2/select-one Card :id (:model_id instance)) read-or-write)) | |
The columns that are common to all Action types. | (def action-columns [:archived :created_at :creator_id :description :entity_id :made_public_by_id :model_id :name :parameter_mappings :parameters :public_uuid :type :updated_at :visualization_settings]) |
Returns the model from an action type.
| (defn type->model [action-type] (case action-type :http :model/HTTPAction :implicit :model/ImplicitAction :query :model/QueryAction)) |
------------------------------------------------ CRUD fns ----------------------------------------------------- | |
Inserts an Action and related type table. Returns the action id. | (defn insert! [action-data] (t2/with-transaction [_conn] (let [action (first (t2/insert-returning-instances! Action (select-keys action-data action-columns))) model (type->model (:type action))] (t2/query-one {:insert-into (t2/table-name model) :values [(-> (apply dissoc action-data action-columns) (assoc :action_id (:id action)) (cond-> (= (:type action) :implicit) (dissoc :database_id) (= (:type action) :http) (update :template json/encode) (= (:type action) :query) (update :dataset_query json/encode)))]}) (:id action)))) |
Updates an Action and the related type table. Deletes the old type table row if the type has changed. | (defn update! [{:keys [id] :as action} existing-action] (when-let [action-row (not-empty (select-keys action action-columns))] (t2/update! Action id action-row)) (when-let [type-row (not-empty (cond-> (apply dissoc action :id action-columns) (= (or (:type action) (:type existing-action)) :implicit) (dissoc :database_id)))] (let [type-row (assoc type-row :action_id id) existing-model (type->model (:type existing-action))] (if (and (:type action) (not= (:type action) (:type existing-action))) (let [new-model (type->model (:type action))] (t2/delete! existing-model :action_id id) (t2/insert! new-model (assoc type-row :action_id id))) (t2/update! existing-model id type-row))))) |
(defn- normalize-query-actions [actions] (when (seq actions) (let [query-actions (t2/select QueryAction :action_id [:in (map :id actions)]) action-id->query-actions (m/index-by :action_id query-actions)] (for [action actions] (merge action (-> action :id action-id->query-actions (dissoc :action_id))))))) | |
(defn- normalize-http-actions [actions] (when (seq actions) (let [http-actions (t2/select HTTPAction :action_id [:in (map :id actions)]) http-actions-by-action-id (m/index-by :action_id http-actions)] (map (fn [action] (let [http-action (get http-actions-by-action-id (:id action))] (-> action (merge {:disabled false} (select-keys http-action [:template :response_handle :error_handle]) (select-keys (:template http-action) [:parameters :parameter_mappings]))))) actions)))) | |
(defn- normalize-implicit-actions [actions] (when (seq actions) (let [implicit-actions (t2/select ImplicitAction :action_id [:in (map :id actions)]) implicit-actions-by-action-id (m/index-by :action_id implicit-actions)] (map (fn [action] (let [implicit-action (get implicit-actions-by-action-id (:id action))] (merge action (select-keys implicit-action [:kind])))) actions)))) | |
Select Actions and fill in sub type information. Don't use this if you need implicit parameters
for implicit actions, use [[select-action]] instead.
| (defn- select-actions-without-implicit-params [& options] (let [{:keys [query http implicit]} (group-by :type (apply t2/select Action options)) query-actions (normalize-query-actions query) http-actions (normalize-http-actions http) implicit-actions (normalize-implicit-actions implicit)] (sort-by :updated_at (concat query-actions http-actions implicit-actions)))) |
Makes sure that if | (defn unique-field-slugs? [fields] (empty? (m/filter-vals #(not= % 1) (frequencies (map (comp u/slugify :name) fields))))) |
Returns a map of card-id -> implicit-parameters for the given models | (defn- implicit-action-parameters [cards] (let [card-by-table-id (into {} (for [card cards :let [{:keys [table-id]} (query/query->database-and-table-ids (:dataset_query card))] :when table-id] [table-id card])) tables (when-let [table-ids (seq (keys card-by-table-id))] (t2/hydrate (t2/select 'Table :id [:in table-ids]) :fields))] (into {} (for [table tables :let [fields (:fields table)] ;; Skip tables for have conflicting slugified columns i.e. table has "name" and "NAME" columns. :when (unique-field-slugs? fields) :let [card (get card-by-table-id (:id table)) id->metadata (m/index-by :id (:result_metadata card)) parameters (->> fields ;; get display_name from metadata (keep (fn [field] (when-let [metadata (id->metadata (:id field))] (assoc field :display_name (:display_name metadata))))) ;; remove exploded json fields and any structured field (remove (some-fn ;; exploded json fields can't be recombined in sql yet :nfc_path ;; their parents, a json field, nor things like cidr, macaddr, xml, etc (comp #(isa? % :type/Structured) :effective_type) ;; or things which we don't recognize (comp #{:type/*} :effective_type))) (map (fn [field] {:id (u/slugify (:name field)) :display-name (:display_name field) :target [:variable [:template-tag (u/slugify (:name field))]] :type (:base_type field) :required (:database_required field) :is-auto-increment (:database_is_auto_increment field) ::field-id (:id field) ::pk? (isa? (:semantic_type field) :type/PK)})))]] [(:id card) parameters])))) |
Find actions with given options and generate implicit parameters for execution. Also adds the Pass in known-models to save a second Card lookup. | (defn select-actions [known-models & options] (let [actions (apply select-actions-without-implicit-params options) implicit-action-model-ids (set (map :model_id (filter #(= :implicit (:type %)) actions))) implicit-action-models (if known-models (->> known-models (filter #(contains? implicit-action-model-ids (:id %))) distinct) (when (seq implicit-action-model-ids) (t2/select 'Card :id [:in implicit-action-model-ids]))) model-id->db-id (into {} (for [card implicit-action-models] [(:id card) (:database_id card)])) model-id->implicit-parameters (when (seq implicit-action-models) (implicit-action-parameters implicit-action-models))] (for [action actions] (if (= (:type action) :implicit) (let [model-id (:model_id action) saved-params (m/index-by :id (:parameters action)) action-kind (:kind action) implicit-params (cond->> (get model-id->implicit-parameters model-id) :always (map (fn [param] (merge param (get saved-params (:id param))))) (= "row/delete" action-kind) (filter ::pk?) (= "row/create" action-kind) (remove #(or (:is-auto-increment %) ;; non-required PKs like column with default is uuid_generate_v4() (and (::pk? %) (not (:required %))))) (contains? #{"row/update" "row/delete"} action-kind) (map (fn [param] (cond-> param (::pk? param) (assoc :required true)))) :always (map #(dissoc % ::pk? ::field-id)))] (cond-> (assoc action :database_id (model-id->db-id (:model_id action))) (seq implicit-params) (-> (assoc :parameters implicit-params) (update-in [:visualization_settings :fields] (fn [fields] (let [param-ids (map :id implicit-params) fields (->> (or fields {}) ;; remove entries that don't match params (in case of deleted columns) (m/filter-keys (set param-ids)))] ;; add default entries for params that don't have an entry (reduce (fn [acc param-id] (if (contains? acc param-id) acc (assoc acc param-id {:id param-id, :hidden false}))) fields param-ids))))))) action)))) |
Selects an Action and fills in the subtype data and implicit parameters.
| (defn select-action [& options] ;; TODO -- it's dumb that we're calling `t2/select` rather than `t2/select-one` above, limiting like this should never ;; be done server-side. I don't have time to fix this right now. -- Cam (first (apply select-actions nil options))) |
Adds a boolean field | (defn- map-assoc-database-enable-actions [actions] (let [action-ids (map :id actions) get-database-enable-actions (fn [{:keys [settings]}] (boolean (some-> settings ((get-in (t2/transforms :model/Database) [:settings :out])) :database-enable-actions))) id->database-enable-actions (into {} (map (juxt :id get-database-enable-actions)) (t2/query {:select [:action.id :db.settings] :from :action :join [[:report_card :card] [:= :card.id :action.model_id] [:metabase_database :db] [:= :db.id :card.database_id]] :where [:in :action.id action-ids]}))] (map (fn [action] (assoc action :database_enabled_actions (get id->database-enable-actions (:id action)))) actions))) |
(mi/define-batched-hydration-method dashcard-action :dashcard/action "Hydrates actions from DashboardCards. Adds a boolean field `:database-enabled-actions` to each action according to the `database-enable-actions` setting for the action's database." [dashcards] (let [actions-by-id (when-let [action-ids (seq (keep :action_id dashcards))] (->> (select-actions nil :id [:in action-ids]) map-assoc-database-enable-actions (m/index-by :id)))] (for [dashcard dashcards] (m/assoc-some dashcard :action (get actions-by-id (:action_id dashcard)))))) | |
Get the action associated with a dashcard if exists, return | (defn dashcard->action [dashcard-or-dashcard-id] (some->> (t2/select-one-fn :action_id :model/DashboardCard :id (u/the-id dashcard-or-dashcard-id)) (select-action :id))) |
------------------------------------------------ Serialization --------------------------------------------------- | |
(defmethod serdes/hash-fields :model/Action [_action] [:name (serdes/hydrated-hash :model) :created_at]) | |
(defmethod serdes/generate-path "QueryAction" [_ _] nil) (defmethod serdes/make-spec "QueryAction" [_model-name _opts] {:copy [] :transform {:action_id (serdes/parent-ref) :database_id (serdes/fk :model/Database :name) :dataset_query {:export serdes/export-mbql :import serdes/import-mbql}}}) | |
(defmethod serdes/generate-path "HTTPAction" [_ _] nil) (defmethod serdes/make-spec "HTTPAction" [_model-name _opts] {:copy [:error_handle :response_handle :template] :transform {:action_id (serdes/parent-ref)}}) | |
(defmethod serdes/generate-path "ImplicitAction" [_ _] nil) (defmethod serdes/make-spec "ImplicitAction" [_model-name _opts] {:copy [:kind] :transform {:action_id (serdes/parent-ref)}}) | |
(defmethod serdes/make-spec "Action" [_model-name opts] {:copy [:archived :description :entity_id :name :public_uuid] :skip [] :transform {:created_at (serdes/date) :type (serdes/kw) :creator_id (serdes/fk :model/User) :made_public_by_id (serdes/fk :model/User) :model_id (serdes/fk :model/Card) :query (serdes/nested :model/QueryAction :action_id opts) :http (serdes/nested :model/HTTPAction :action_id opts) :implicit (serdes/nested :model/ImplicitAction :action_id opts) :parameters {:export serdes/export-parameters :import serdes/import-parameters} :parameter_mappings {:export serdes/export-parameter-mappings :import serdes/import-parameter-mappings} :visualization_settings {:export serdes/export-visualization-settings :import serdes/import-visualization-settings}}}) | |
(defmethod serdes/dependencies "Action" [action] (set (concat ;; other stuff is implicitly referenced through a Card [[{:model "Card" :id (:model_id action)}]] ;; this method is called on ingested data before transformation, and so here it always will be a string (when (= (:type action) "query") (let [{:keys [database_id dataset_query]} (first (:query action))] (concat [[{:model "Database" :id database_id}]] (serdes/mbql-deps dataset_query))))))) | |
(defmethod serdes/storage-path "Action" [action _ctx] (let [{:keys [id label]} (-> action serdes/path last)] ["actions" (serdes/storage-leaf-file-name id label)])) | |
------------------------------------------------- Search ---------------------------------------------------------- | |
(search/define-spec "action" {:model :model/Action :attrs {:archived true :collection-id :model.collection_id :creator-id true :database-id :query_action.database_id :native-query :query_action.dataset_query ;; workaround for actions not having revisions (yet) :last-edited-at :updated_at :created-at true :updated-at true} :search-terms [:name :description] :render-terms {:model-id :model.id :model-name :model.name} :where [:= :collection.namespace nil] :joins {:model [:model/Card [:= :model.id :this.model_id]] :query_action [:model/QueryAction [:= :query_action.action_id :this.id]] :collection [:model/Collection [:= :collection.id :model.collection_id]]}}) | |