Code related to the new writeback Actions. | (ns metabase.actions (:require [clojure.spec.alpha :as s] [metabase.api.common :as api] [metabase.driver :as driver] [metabase.driver.util :as driver.u] [metabase.legacy-mbql.normalize :as mbql.normalize] [metabase.lib.metadata :as lib.metadata] [metabase.lib.schema.actions :as lib.schema.actions] [metabase.models :refer [Database]] [metabase.models.setting :as setting] [metabase.query-processor.middleware.permissions :as qp.perms] [metabase.query-processor.store :as qp.store] [metabase.util :as u] [metabase.util.i18n :as i18n] [metabase.util.malli :as mu] [toucan2.core :as t2])) |
(setting/defsetting database-enable-actions (i18n/deferred-tru "Whether to enable Actions for a specific Database.") :default false :type :boolean :visibility :public :database-local :only) | |
Normalize the | (defmulti normalize-action-arg-map {:arglists '([action arg-map]), :added "0.44.0"} (fn [action _arg-map] (keyword action))) |
(defmethod normalize-action-arg-map :default [_action arg-map] arg-map) | |
Return the appropriate spec to use to validate the arg map passed to [[perform-action!*]]. (action-arg-map-spec :row/create) => :actions.args.crud/row.create | (defmulti action-arg-map-spec {:arglists '([action]), :added "0.44.0"} keyword) |
(defmethod action-arg-map-spec :default [_action] any?) | |
Multimethod for doing an Action. The specific At the time of this writing Actions are performed with either {:table-id The former endpoint is currently used for the various DON'T CALL THIS METHOD DIRECTLY TO PERFORM ACTIONS -- use [[perform-action!]] instead which does normalization, validation, and binds Database-local values. | (defmulti perform-action!* {:arglists '([driver action database arg-map]), :added "0.44.0"} (fn [driver action _database _arg-map] [(driver/dispatch-on-initialized-driver driver) (keyword action)]) :hierarchy #'driver/hierarchy) |
Set of all known actions. | (defn- known-actions [] (into #{} (comp (filter sequential?) (map second)) (keys (methods perform-action!*)))) |
(defmethod perform-action!* :default [driver action _database _arg-map] (let [action (keyword action) known-actions (known-actions)] ;; return 404 if the action doesn't exist. (when-not (contains? known-actions action) (throw (ex-info (i18n/tru "Unknown Action {0}. Valid Actions are: {1}" action (pr-str known-actions)) {:status-code 404}))) ;; return 400 if the action does exist but is not supported by this DB (throw (ex-info (i18n/tru "Action {0} is not supported for {1} Databases." action (pr-str driver)) {:status-code 400})))) | |
A cache that lives for the duration of the top-level Action invoked by [[perform-action!]]. You can use this to store miscellaneous values such as things that need to be fetched from the application database to avoid duplicate calls in bulk actions that repeatedly call code that would only be called once by single-row Actions. Bound to an atom containing a map by [[perform-action!]]. | (def ^:dynamic *misc-value-cache* nil) |
Get a cached value from the [[misc-value-cache]] using a
[::cast-values table-id] is a good key. | (defn cached-value [unique-key value-thunk] (or (when *misc-value-cache* (get @*misc-value-cache* unique-key)) (let [value (value-thunk)] (when *misc-value-cache* (swap! *misc-value-cache* assoc unique-key value)) value))) |
Throws an appropriate error if actions are unsupported or disabled for a database, otherwise returns nil. | (defn check-actions-enabled-for-database! [{db-settings :settings db-id :id driver :engine db-name :name :as db}] (when-not (driver.u/supports? driver :actions db) (throw (ex-info (i18n/tru "{0} Database {1} does not support actions." (u/qualified-name driver) (format "%d %s" db-id (pr-str db-name))) {:status-code 400, :database-id db-id}))) (binding [setting/*database-local-values* db-settings] (when-not (database-enable-actions) (throw (ex-info (i18n/tru "Actions are not enabled.") {:status-code 400, :database-id db-id})))) nil) |
(defn- database-for-action [action-or-id] (t2/select-one Database {:select [:db.*] :from :action :join [[:report_card :card] [:= :card.id :action.model_id] [:metabase_database :db] [:= :db.id :card.database_id]] :where [:= :action.id (u/the-id action-or-id)]})) | |
Throws an appropriate error if actions are unsupported or disabled for the database of the action's model, otherwise returns nil. | (defn check-actions-enabled! [action-or-id] (check-actions-enabled-for-database! (api/check-404 (database-for-action action-or-id)))) |
Perform an | (mu/defn perform-action! [action arg-map :- [:map [:create-row {:optional true} [:maybe ::lib.schema.actions/row]] [:update-row {:optional true} [:maybe ::lib.schema.actions/row]]]] (let [action (keyword action) spec (action-arg-map-spec action) arg-map (normalize-action-arg-map action arg-map)] ; is arg-map always just a regular query? (when (s/invalid? (s/conform spec arg-map)) (throw (ex-info (format "Invalid Action arg map for %s: %s" action (s/explain-str spec arg-map)) (s/explain-data spec arg-map)))) (let [{driver :engine :as db} (api/check-404 (qp.store/with-metadata-provider (:database arg-map) (lib.metadata/database (qp.store/metadata-provider))))] (check-actions-enabled-for-database! db) (binding [*misc-value-cache* (atom {})] (qp.perms/check-query-action-permissions* arg-map) (driver/with-driver driver (perform-action!* driver action db arg-map)))))) |
Action definitions. | |
Common base spec for all Actions. All Actions at least require {:database Anything else required depends on the action type. | |
(s/def :actions.args/id (s/and integer? pos?)) | |
(s/def :actions.args.common/database :actions.args/id) | |
(s/def :actions.args/common (s/keys :req-un [:actions.args.common/database])) | |
Common base spec for all CRUD row Actions. All CRUD row Actions at least require {:database | |
(s/def :actions.args.crud.row.common.query/source-table :actions.args/id) | |
(s/def :actions.args.crud.row.common/query (s/keys :req-un [:actions.args.crud.row.common.query/source-table])) | |
(s/def :actions.args.crud.row/common (s/merge :actions.args/common (s/keys :req-un [:actions.args.crud.row.common/query]))) | |
| |
row/create requires at least {:database | |
(defmethod normalize-action-arg-map :row/create [_action query] (mbql.normalize/normalize-or-throw query)) | |
(s/def :actions.args.crud.row.create/create-row (s/map-of string? any?)) | |
(s/def :actions.args.crud/row.create (s/merge :actions.args.crud.row/common (s/keys :req-un [:actions.args.crud.row.create/create-row]))) | |
(defmethod action-arg-map-spec :row/create [_action] :actions.args.crud/row.create) | |
| |
row/update requires at least {:database | |
(defmethod normalize-action-arg-map :row/update [_action query] (mbql.normalize/normalize-or-throw query)) | |
(s/def :actions.args.crud.row.update.query/filter vector?) ; MBQL filter clause | |
(s/def :actions.args.crud.row.update/query (s/merge :actions.args.crud.row.common/query (s/keys :req-un [:actions.args.crud.row.update.query/filter]))) | |
(s/def :actions.args.crud.row.update/update-row (s/map-of string? any?)) | |
(s/def :actions.args.crud/row.update (s/merge :actions.args.crud.row/common (s/keys :req-un [:actions.args.crud.row.update/update-row :actions.args.crud.row.update/query]))) | |
(defmethod action-arg-map-spec :row/update [_action] :actions.args.crud/row.update) | |
| |
row/delete requires at least {:database | |
(defmethod normalize-action-arg-map :row/delete [_action query] (mbql.normalize/normalize-or-throw query)) | |
(s/def :actions.args.crud.row.delete.query/filter vector?) ; MBQL filter clause | |
(s/def :actions.args.crud.row.delete/query (s/merge :actions.args.crud.row.common/query (s/keys :req-un [:actions.args.crud.row.delete.query/filter]))) | |
(s/def :actions.args.crud/row.delete (s/merge :actions.args.crud.row/common (s/keys :req-un [:actions.args.crud.row.delete/query]))) | |
(defmethod action-arg-map-spec :row/delete [_action] :actions.args.crud/row.delete) | |
Bulk actions | |
All bulk Actions require at least {:database | |
(s/def :actions.args.crud.bulk.common/table-id :actions.args/id) | |
(s/def :actions.args.crud.bulk/rows (s/cat :rows (s/+ (s/map-of string? any?)))) | |
(s/def :actions.args.crud.bulk/common (s/merge :actions.args/common (s/keys :req-un [:actions.args.crud.bulk.common/table-id :actions.args.crud.bulk/rows]))) | |
The request bodies for the bulk CRUD actions are all the same. The body of a request to `POST /api/action/:action-namespace/:action-name/:table-id` is just a vector of rows but the API endpoint itself calls [[perform-action!]] with {:database and we transform this to
| |
| |
(defn- normalize-bulk-crud-action-arg-map [{:keys [database table-id], rows :arg, :as _arg-map}] {:type :query, :query {:source-table table-id} :database database, :table-id table-id, :rows (map #(update-keys % u/qualified-name) rows)}) | |
(defmethod normalize-action-arg-map :bulk/create [_action arg-map] (normalize-bulk-crud-action-arg-map arg-map)) | |
(defmethod action-arg-map-spec :bulk/create [_action] :actions.args.crud.bulk/common) | |
(defmethod normalize-action-arg-map :bulk/update [_action arg-map] (normalize-bulk-crud-action-arg-map arg-map)) | |
(defmethod action-arg-map-spec :bulk/update [_action] :actions.args.crud.bulk/common) | |
| |
Request-body should look like: ;; single pk, two rows [{"ID": 76}, {"ID": 77}] ;; multiple pks, one row [{"PK1": 1, "PK2": "john"}] | |
(defmethod normalize-action-arg-map :bulk/delete [_action arg-map] (normalize-bulk-crud-action-arg-map arg-map)) | |
(defmethod action-arg-map-spec :bulk/delete [_action] :actions.args.crud.bulk/common) | |