From 620767943c385e82bf234239a72e48df30a5e0b7 Mon Sep 17 00:00:00 2001 From: Cam Saul <1455846+camsaul@users.noreply.github.com> Date: Fri, 10 Jun 2022 13:31:45 -0700 Subject: [PATCH] [Actions] Execute custom actions endpoint (#23130) * Execute custom actions endpoint [WIP] [ci skip] * Wow! It's all working. * Test fixes :wrench: * Code cleanup * More test fixes. :wrench: * 2 final test fixes * Remove unused namespace --- shared/src/metabase/mbql/normalize.cljc | 5 +- src/metabase/api/actions.clj | 4 +- src/metabase/api/emitter.clj | 58 +++++++- src/metabase/driver.clj | 10 +- src/metabase/driver/h2.clj | 3 +- src/metabase/driver/postgres.clj | 9 +- src/metabase/driver/sql_jdbc/execute.clj | 50 ++++--- src/metabase/models/action.clj | 36 ++--- src/metabase/models/emitter.clj | 63 +++++---- src/metabase/query_processor.clj | 2 +- src/metabase/query_processor/writeback.clj | 121 +++++++++++++++++ src/metabase/util.clj | 1 + test/metabase/api/card_test.clj | 12 +- test/metabase/api/dashboard_test.clj | 12 +- test/metabase/api/emitter_test.clj | 70 ++++++++++ test/metabase/api/login_history_test.clj | 127 +++++++++--------- test/metabase/models/user_test.clj | 6 +- test/metabase/server/middleware/auth_test.clj | 27 ++-- .../server/middleware/session_test.clj | 109 ++++++++------- test/metabase/test/redefs.clj | 42 ++++-- test/metabase/test/util.clj | 9 +- 21 files changed, 545 insertions(+), 231 deletions(-) create mode 100644 src/metabase/query_processor/writeback.clj create mode 100644 test/metabase/api/emitter_test.clj diff --git a/shared/src/metabase/mbql/normalize.cljc b/shared/src/metabase/mbql/normalize.cljc index 154d1bf6bca..74c0fcebaef 100644 --- a/shared/src/metabase/mbql/normalize.cljc +++ b/shared/src/metabase/mbql/normalize.cljc @@ -251,8 +251,11 @@ (assoc :name tag-name))]))) template-tags)) -(defn- normalize-query-parameter [{:keys [type target], :as param}] +(defn normalize-query-parameter + "Normalize a parameter in the query `:parameters` list." + [{:keys [type target id], :as param}] (cond-> param + id (update :id mbql.u/qualified-name) ;; some things that get ran thru here, like dashcard param targets, do not have :type type (update :type maybe-normalize-token) target (update :target #(normalize-tokens % :ignore-path)))) diff --git a/src/metabase/api/actions.clj b/src/metabase/api/actions.clj index e9e0bd57c17..06186c7babf 100644 --- a/src/metabase/api/actions.clj +++ b/src/metabase/api/actions.clj @@ -1,4 +1,5 @@ (ns metabase.api.actions + ;; TODO -- should probably rename this to `/api/action` for consistency since other API endpoints aren't plural "`/api/actions/` endpoints." (:require [cheshire.core :as json] [compojure.core :as compojure :refer [POST]] @@ -46,7 +47,6 @@ {database (s/maybe s/Int)} (when database (do-check-actions-enabled database nil)) - (api/check-superuser) (let [cards+actions (db/query {:select [:card.* [:db.settings :db_settings] [:a.id :a_id] @@ -103,4 +103,4 @@ (fn [driver] (actions/row-action! (keyword action) driver query))))) -(api/define-routes actions/+check-actions-enabled) +(api/define-routes actions/+check-actions-enabled api/+check-superuser) diff --git a/src/metabase/api/emitter.clj b/src/metabase/api/emitter.clj index e31d17223a1..107f12669f4 100644 --- a/src/metabase/api/emitter.clj +++ b/src/metabase/api/emitter.clj @@ -1,10 +1,18 @@ (ns metabase.api.emitter - (:require [compojure.core :refer [DELETE POST PUT]] - [metabase.actions :as actions] - [metabase.api.common :as api] - [metabase.models :refer [CardEmitter DashboardEmitter Emitter]] - [metabase.util.i18n :refer [tru]] - [toucan.db :as db])) + (:require + [compojure.core :refer [DELETE POST PUT]] + [metabase.actions :as actions] + [metabase.api.common :as api] + [metabase.mbql.schema :as mbql.s] + [metabase.models + :refer [CardEmitter DashboardEmitter Emitter EmitterAction]] + [metabase.query-processor.writeback :as qp.writeback] + [metabase.util :as u] + [metabase.util.i18n :refer [tru]] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db] + [toucan.hydrate :refer [hydrate]])) (api/defendpoint POST "/" "Endpoint to create an emitter." @@ -31,4 +39,40 @@ (db/delete! Emitter :id emitter-id) api/generic-204-no-content) -(api/define-routes actions/+check-actions-enabled) +(defn- emitter + "Fetch the Emitter with `emitter-id` and extra info from FK-related tables for custom Emitter execution purposes." + [emitter-id] + (when-let [emitter (->> (db/query {:select [:*] + :from [[Emitter :emitter]] + :left-join [[EmitterAction :emitter_action] + [:= :emitter_action.emitter_id :emitter.id] + ;; TODO -- not 100% sure we need CardEmitter and DashboardEmitter, we'd + ;; only need that if we wanted to hydrate the Card or Dashboard they + ;; came from. + [CardEmitter :card_emitter] + [:= :card_emitter.emitter_id :emitter.id] + [DashboardEmitter :dashboard_emitter] + [:= :dashboard_emitter.emitter_id :emitter.id]] + :where [:= :id emitter-id]}) + (db/do-post-select Emitter) + first)] + (hydrate emitter [:action :card]))) + +(def ^:private CustomActionParametersMap + (-> {s/Keyword {:type (apply s/enum (map u/qualified-name (keys mbql.s/parameter-types))) ; type will come in as a string. + :value s/Any + s/Keyword s/Any}} + (su/with-api-error-message "map of parameter name or ID -> map of parameter `:value` and `:type` of the value"))) + +(api/defendpoint POST "/:id/execute" + "Execute a custom emitter." + [id :as {{:keys [parameters]} :body}] + {parameters (s/maybe CustomActionParametersMap)} + (let [emitter (api/check-404 (emitter id))] + (or (get-in emitter [:action :card]) + (throw (ex-info (tru "No Query Action found for Emitter {0}. Only Query Actions are supported at this point in time." + id) + {:status-code 400, :emitter emitter}))) + (qp.writeback/execute-query-emitter! emitter parameters))) + +(api/define-routes actions/+check-actions-enabled api/+check-superuser) diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 86cbbeeaadd..d044b08e63a 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -438,7 +438,8 @@ ;; Does the driver support experimental "writeback" actions like "delete this row" or "insert a new row" from 44+? :actions - ;; Does the driver support custom writeback actions using `is_write` Saved Questions + ;; Does the driver support custom writeback actions using `is_write` Saved Questions. Drivers that support this must + ;; implement [[execute-write-query!]] :actions/custom}) (defmulti supports? @@ -712,3 +713,10 @@ (defmethod superseded-by :default [_] nil) + +(defmulti execute-write-query! + "Execute a writeback query (from an `is_write` Card) e.g. one powering a custom + `QueryAction` (see [[metabase.models.action]]). Drivers that support `:actions/custom` must implement this method." + {:added "0.44.0", :arglists '([driver query])} + dispatch-on-initialized-driver + :hierarchy #'hierarchy) diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj index a8812ec9ab0..1d8bb1debb0 100644 --- a/src/metabase/driver/h2.clj +++ b/src/metabase/driver/h2.clj @@ -30,7 +30,8 @@ (doseq [[feature supported?] {:full-join false :regex false :percentile-aggregations false - :actions true}] + :actions true + :actions/custom true}] (defmethod driver/database-supports? [:h2 feature] [_driver _feature _database] supported?)) diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index d039a50e9e3..068bd62a8bf 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -59,10 +59,11 @@ [_driver _feat db] (-> db :options :persist-models-enabled)) -(defmethod driver/database-supports? [:postgres :actions] - [driver _feat _db] - ;; only supported for Postgres for right now. Not supported for child drivers like Redshift or whatever. - (= driver :postgres)) +(doseq [feature [:actions :actions/custom]] + (defmethod driver/database-supports? [:postgres feature] + [driver _feat _db] + ;; only supported for Postgres for right now. Not supported for child drivers like Redshift or whatever. + (= driver :postgres))) (defn- ->timestamp [honeysql-form] (hx/cast-unless-type-in "timestamp" #{"timestamp" "timestamptz" "date"} honeysql-form)) diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj index 774e7ebb2d3..8e479560872 100644 --- a/src/metabase/driver/sql_jdbc/execute.clj +++ b/src/metabase/driver/sql_jdbc/execute.clj @@ -5,6 +5,7 @@ for JDBC drivers that do not support `java.time` classes can be found in `metabase.driver.sql-jdbc.execute.legacy-impl`. " (:require [clojure.core.async :as a] + [clojure.java.jdbc :as jdbc] [clojure.string :as str] [clojure.tools.logging :as log] [java-time :as t] @@ -329,29 +330,27 @@ (.close stmt) (throw e))))) -(defn- prepared-statement* - ^PreparedStatement [driver conn sql params canceled-chan] - ;; if canceled-chan gets a message, cancel the PreparedStatement - (let [^PreparedStatement stmt (prepared-statement driver conn sql params)] +(defn- wire-up-canceled-chan-to-cancel-Statement! + "If `canceled-chan` gets a message, cancel the Statement `stmt`." + [^Statement stmt canceled-chan] + (when canceled-chan (a/go (when (a/<! canceled-chan) - (log/debug (trs "Query canceled, calling PreparedStatement.cancel()")) + (log/debug (trs "Query canceled, calling Statement.cancel()")) (u/ignore-exceptions - (.cancel stmt)))) - stmt)) + (.cancel stmt)))))) + +(defn- prepared-statement* + ^PreparedStatement [driver conn sql params canceled-chan] + (doto (prepared-statement driver conn sql params) + (wire-up-canceled-chan-to-cancel-Statement! canceled-chan))) (defn- use-statement? [driver params] (and (statement-supported? driver) (empty? params))) (defn- statement* ^Statement [driver conn canceled-chan] - ;; if canceled-chan gets a message, cancel the Statement - (let [^Statement stmt (statement driver conn)] - (a/go - (when (a/<! canceled-chan) - (log/debug (trs "Query canceled, calling Statement.cancel()")) - (u/ignore-exceptions - (.cancel stmt)))) - stmt)) + (doto (statement driver conn) + (wire-up-canceled-chan-to-cancel-Statement! canceled-chan))) (defn- statement-or-prepared-statement ^Statement [driver conn sql params canceled-chan] (if (use-statement? driver params) @@ -507,6 +506,27 @@ (respond results-metadata (reducible-rows driver rs rsmeta (qp.context/canceled-chan context))))))) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Actions Stuff | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(defmethod driver/execute-write-query! :sql-jdbc + [driver {{sql :query, :keys [params]} :native}] + {:pre [(string? sql)]} + (try + (let [{:keys [details]} (qp.store/database) + jdbc-spec (sql-jdbc.conn/connection-details->spec driver details)] + ;; TODO -- should this be done in a transaction? Should we set the isolation level? + (with-open [conn (jdbc/get-connection jdbc-spec) + stmt (statement-or-prepared-statement driver conn sql params nil)] + {:rows-affected (if (instance? PreparedStatement stmt) + (.executeUpdate ^PreparedStatement stmt) + (.executeUpdate stmt sql))})) + (catch Throwable e + (throw (ex-info (tru "Error executing write query: {0}" (ex-message e)) + {:sql sql, :params params, :type qp.error-type/invalid-query} + e))))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Convenience Imports from Old Impl | ;;; +----------------------------------------------------------------------------------------------------------------+ diff --git a/src/metabase/models/action.clj b/src/metabase/models/action.clj index d4723f3581c..ca014c5e38d 100644 --- a/src/metabase/models/action.clj +++ b/src/metabase/models/action.clj @@ -10,11 +10,10 @@ (u/strict-extend (class Action) models/IModel (merge models/IModelDefaults - {:types (constantly {:type :keyword}) - :properties (constantly {:timestamped? true})})) + {:types (constantly {:type :keyword}) + :properties (constantly {:timestamped? true})})) -(u/strict-extend - (class QueryAction) +(u/strict-extend (class QueryAction) models/IModel (merge models/IModelDefaults ;; This is ok as long as we're 1:1 @@ -24,15 +23,20 @@ "Hydrates Action from Emitter" {:batched-hydrate :action} [emitters] - (let [actions (->> {:select [:emitter_action.emitter_id - :query_action.card_id - :action.type] - :from [[Action :action]] - ;; Will need to change if we stop being 1:1 - :join [:emitter_action [:= :emitter_action.action_id :action.id] - [QueryAction :query_action] [:= :query_action.action_id :action.id]] - :where [:in :emitter_action.emitter_id (map :id emitters)]} - (db/query) - (db/do-post-select Action) - (m/index-by :id))] - (map #(assoc % :action (get actions (:action_id %))) emitters))) + ;; emitters apparently might actually be `[nil]` (not 100% sure why) so just make sure we're not doing anything dumb + ;; if this is the case. + (let [emitter-ids (filter some? (map :id emitters)) + actions (when (seq emitter-ids) + (->> {:select [:emitter_action.emitter_id + :query_action.card_id + :action.type] + :from [[Action :action]] + ;; Will need to change if we stop being 1:1 + :join [:emitter_action [:= :emitter_action.action_id :action.id] + [QueryAction :query_action] [:= :query_action.action_id :action.id]] + :where [:in :emitter_action.emitter_id emitter-ids]} + db/query + (db/do-post-select Action) + (m/index-by :emitter_id)))] + (for [{emitter-id :id, :as emitter} emitters] + (some-> emitter (assoc :action (get actions emitter-id)))))) diff --git a/src/metabase/models/emitter.clj b/src/metabase/models/emitter.clj index 617dcc33fd5..49166e1e42e 100644 --- a/src/metabase/models/emitter.clj +++ b/src/metabase/models/emitter.clj @@ -1,23 +1,37 @@ (ns metabase.models.emitter - (:require [medley.core :as m] - [metabase.models.card :refer [Card]] - [metabase.models.dashboard :refer [Dashboard]] - [metabase.util :as u] - [toucan.db :as db] - [toucan.models :as models])) + (:require + [medley.core :as m] + [metabase.mbql.normalize :as mbql.normalize] + [metabase.models.card :refer [Card]] + [metabase.models.dashboard :refer [Dashboard]] + [metabase.models.interface :as mi] + [metabase.util :as u] + [toucan.db :as db] + [toucan.models :as models])) (models/defmodel CardEmitter :card_emitter) (models/defmodel DashboardEmitter :dashboard_emitter) (models/defmodel Emitter :emitter) (models/defmodel EmitterAction :emitter_action) +(defn- normalize-parameter-mappings + [parameter-mappings] + (into {} + (map (fn [[param-id target]] + [param-id (:target (mbql.normalize/normalize-query-parameter {:target target}))])) + parameter-mappings)) + +(models/add-type! ::parameter-mappings + :in (comp mi/json-in normalize-parameter-mappings) + :out (comp (mi/catch-normalization-exceptions normalize-parameter-mappings) mi/json-out-with-keywordization)) + (u/strict-extend - (class Emitter) - models/IModel - (merge models/IModelDefaults - {:types (constantly {:parameter_mappings :json - :options :json}) - :properties (constantly {:timestamped? true})})) + (class Emitter) + models/IModel + (merge models/IModelDefaults + {:types (constantly {:parameter_mappings ::parameter-mappings + :options :json}) + :properties (constantly {:timestamped? true})})) (u/strict-extend (class EmitterAction) @@ -27,14 +41,15 @@ {:primary-key (constantly :emitter_id)})) (defn- pre-insert - [emitter] + [{action-id :action_id, :as emitter}] + {:pre [(integer? action-id)]} (let [base-emitter (db/insert! Emitter (select-keys emitter [:parameter_mappings :options]))] (db/insert! EmitterAction (-> emitter (select-keys [:action_id]) (assoc :emitter_id (:id base-emitter)))) (-> emitter (select-keys [:dashboard_id :card_id]) - (assoc :emitter_id (:id base-emitter))))) + (assoc :emitter_id (u/the-id base-emitter))))) (defn- pre-update [emitter] @@ -51,23 +66,21 @@ (db/delete! Emitter :id (:emitter_id emitter)) emitter) -(u/strict-extend - (class DashboardEmitter) - models/IModel +(def ^:private Emitter-subtype-IModel-impl + "[[models/IModel]] impl for `DashboardEmitter` and `CardEmitter`" (merge models/IModelDefaults - {:primary-key (constantly :emitter_id) ;; This is ok as long as we're 1:1 + {:primary-key (constantly :emitter_id) ; This is ok as long as we're 1:1 :pre-delete pre-delete :pre-update pre-update :pre-insert pre-insert})) -(u/strict-extend - (class CardEmitter) +(u/strict-extend (class DashboardEmitter) models/IModel - (merge models/IModelDefaults - {:primary-key (constantly :emitter_id) ;; This is ok as long as we're 1:1 - :pre-delete pre-delete - :pre-update pre-update - :pre-insert pre-insert})) + Emitter-subtype-IModel-impl) + +(u/strict-extend (class CardEmitter) + models/IModel + Emitter-subtype-IModel-impl) (defn emitters "Hydrate emitters onto a list of dashboards or cards." diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 816aae383d1..b3be3505785 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -178,7 +178,7 @@ rff post-processing-middleware)) -(def ^:private around-middleware +(def around-middleware "Middleware that goes AROUND *all* the other middleware (even for pre-processing only or compilation only). Has the form diff --git a/src/metabase/query_processor/writeback.clj b/src/metabase/query_processor/writeback.clj new file mode 100644 index 00000000000..bc8e43ec440 --- /dev/null +++ b/src/metabase/query_processor/writeback.clj @@ -0,0 +1,121 @@ +(ns metabase.query-processor.writeback + "Code for executing writeback queries." + (:require + [clojure.tools.logging :as log] + [metabase.api.actions :as api.actions] + [metabase.driver :as driver] + [metabase.query-processor :as qp] + [metabase.query-processor.error-type :as qp.error-type] + [metabase.query-processor.middleware.parameters :as parameters] + [metabase.query-processor.store :as qp.store] + [metabase.util :as u] + [metabase.util.i18n :refer [tru]] + [metabase.util.schema :as su] + [schema.core :as s])) + +(defn- map-parameters + "Take the `parameters` map passed in to an endpoint like `POST /api/emitter/:id/execute` and map it to the parameters + in the underlying `Action` so they can be attached to the query that gets passed to [[qp/execute-write-query!]]. + + Incoming API request `:parameters` should look like + + {:parameters {\"my_id\" {:type :number/= + :value 10}}} + + And `parameter_mappings` should look like + + {:my_id [:template-tag \"id\"]} + + We need to convert these to a list like + + [{:target [:template-tag \"id\"] + :value 10}] + + before passing to the QP code." + [parameters parameter-mappings] + (mapv (fn [[param-id param-value-info]] + (let [target (or (get parameter-mappings param-id) + (throw (ex-info (tru "No parameter mapping found for parameter {0}. Found: {1}" + (pr-str param-id) + (pr-str (set (keys parameter-mappings)))) + {:status-code 400 + :type qp.error-type/invalid-parameter + :parameters parameters + :mappings parameter-mappings})))] + (merge {:id param-id + :target target} + param-value-info))) + parameters)) + +(defn- apply-middleware [qp middleware-fns] + (reduce + (fn [qp middleware] + (if middleware + (middleware qp) + qp)) + qp + middleware-fns)) + +(defn- writeback-qp [] + ;; `rff` and `context` are not currently used by the writeback QP stuff, so these parameters can be ignored; we pass + ;; in `nil` for these below. + (letfn [(qp* [{database-id :database, :as query} _rff _context] + (let [query (parameters/substitute-parameters query)] + ;; make sure that the Database supports the `:actions/custom` feature + (when-not (driver/database-supports? driver/*driver* :actions/custom (qp.store/database)) + (throw (ex-info (tru "{0} database {1} does not support executing custom actions." + (name driver/*driver*) database-id) + {:status-code 400 + :type qp.error-type/invalid-query}))) + ;; ok, now execute the query. + (log/debugf "Executing query\n\n%s" (u/pprint-to-str query)) + (driver/execute-write-query! driver/*driver* query)))] + (apply-middleware qp* qp/around-middleware))) + +(defn execute-write-query! + "Execute an writeback query from an `is_write` SavedQuestion." + [{query-type :type, :as query}] + ;; make sure this is a native query. + (when-not (= query-type :native) + (throw (ex-info (tru "Only native queries can be executed as write queries.") + {:type qp.error-type/invalid-query, :status-code 400, :query query}))) + ((writeback-qp) query nil nil)) + +(def ^:private HydratedQueryEmitter + {:id su/IntGreaterThanZero + :action {:card {:dataset_query su/Map + :is_write s/Bool + s/Keyword s/Any} + s/Keyword s/Any} + s/Keyword s/Any}) + +(s/defn execute-query-emitter! + "Execute a `QueryEmitter` with parameters as passed in to the `POST /api/emitter/:id/execute` + endpoint (see [[map-parameters]] for a description of their shape). `emitter` should already be hydrated with its `:action` + and the Action's `:card`." + [{{:keys [card]} :action, emitter-id :id, :as emitter} :- HydratedQueryEmitter + parameters] + (when-not (:is_write card) + (throw (ex-info (tru "Cannot execute emitter {0}: Card {1} is not marked as `is_write`" + emitter-id + (:id card)) + {:status-code 400, :emitter emitter}))) + (log/tracef "Executing emitter\n\n%s" (u/pprint-to-str emitter)) + (try + (log/tracef "Mapping parameters\n\n%s\nwith mappings\n\n%s" + (u/pprint-to-str parameters) + (u/pprint-to-str (:parameter_mappings emitter))) + (let [parameters (map-parameters parameters (:parameter_mappings emitter)) + {database-id :database, :as query} (assoc (:dataset_query card) :parameters parameters)] + (log/debugf "Query (before preprocessing):\n\n%s" (u/pprint-to-str query)) + ;; make sure actions are enabled and supported for this Database. (We'll check the `:actions/custom` feature flag + ;; later once the DB is fetched in and the QP store -- see above) + (api.actions/do-check-actions-enabled + database-id + (fn [_driver] + (execute-write-query! query)))) + (catch Throwable e + (throw (ex-info (tru "Error executing QueryEmitter: {0}" (ex-message e)) + {:emitter emitter + :parameters parameters} + e))))) diff --git a/src/metabase/util.clj b/src/metabase/util.clj index 49594387dc8..2d25424d9f0 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -188,6 +188,7 @@ (.isReachable host-addr host-up-timeout)) (catch Throwable _ false))) +;; TODO -- maybe renaming this to `adoto` or `doto<>` or something would be a little clearer. (defmacro prog1 "Execute `first-form`, then any other expressions in `body`, presumably for side-effects; return the result of `first-form`. diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 504dcd6fb45..53e3e7fc6e7 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -732,16 +732,16 @@ (testing "GET /api/card/:id" (testing "Fetch card with an emitter" (mt/with-temp* [Card [read-card {:name "Test Read Card"}] - Card [write-card {:is_write true :name "Test Write Card"}] - CardEmitter [emitter {:action_id (db/select-field :action_id QueryAction :card_id (u/the-id write-card)) - :card_id (u/the-id read-card)}]] + Card [write-card {:is_write true :name "Test Write Card"}]] + (db/insert! CardEmitter {:action_id (u/the-id (db/select-one-field :action_id QueryAction :card_id (u/the-id write-card))) + :card_id (u/the-id read-card)}) (testing "admin sees emitters" (is (partial= - {:emitters [{:action {:type "row" :card {:name "Test Write Card"}}}]} - (mt/user-http-request :crowberto :get 200 (format "card/%d" (u/the-id read-card)))))) + {:emitters [{:action {:type "row" :card {:name "Test Write Card"}}}]} + (mt/user-http-request :crowberto :get 200 (format "card/%d" (u/the-id read-card)))))) (testing "non-admin does not see emitters" (is (nil? - (:emitters (mt/user-http-request :rasta :get 200 (format "card/%d" (u/the-id read-card))))))))))) + (:emitters (mt/user-http-request :rasta :get 200 (format "card/%d" (u/the-id read-card))))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | UPDATING A CARD | diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index b29a2aa6bcf..eb5dd0c70af 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -344,16 +344,16 @@ (testing "GET /api/dashboard/:id" (testing "Fetch dashboard with an emitter" (mt/with-temp* [Dashboard [dashboard {:name "Test Dashboard"}] - Card [write-card {:is_write true :name "Test Write Card"}] - DashboardEmitter [emitter {:action_id (db/select-field :action_id QueryAction :card_id (u/the-id write-card)) - :dashboard_id (u/the-id dashboard)}]] + Card [write-card {:is_write true :name "Test Write Card"}]] + (db/insert! DashboardEmitter {:action_id (u/the-id (db/select-one-field :action_id QueryAction :card_id (u/the-id write-card))) + :dashboard_id (u/the-id dashboard)}) (testing "admin sees emitters" (is (partial= - {:emitters [{:action {:type "row" :card {:name "Test Write Card"}}}]} - (dashboard-response (mt/user-http-request :crowberto :get 200 (format "dashboard/%d" (u/the-id dashboard))))))) + {:emitters [{:action {:type "row" :card {:name "Test Write Card"}}}]} + (dashboard-response (mt/user-http-request :crowberto :get 200 (format "dashboard/%d" (u/the-id dashboard))))))) (testing "non-admin does not see emitters" (is (nil? - (:emitters (dashboard-response (mt/user-http-request :rasta :get 200 (format "dashboard/%d" (u/the-id dashboard)))))))))))) + (:emitters (dashboard-response (mt/user-http-request :rasta :get 200 (format "dashboard/%d" (u/the-id dashboard)))))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PUT /api/dashboard/:id | diff --git a/test/metabase/api/emitter_test.clj b/test/metabase/api/emitter_test.clj new file mode 100644 index 00000000000..f899c2edfca --- /dev/null +++ b/test/metabase/api/emitter_test.clj @@ -0,0 +1,70 @@ +(ns metabase.api.emitter-test + (:require + [clojure.test :refer :all] + [metabase.actions.test-util :as actions.test-util] + [metabase.models + :refer [Action Card CardEmitter Database Emitter EmitterAction QueryAction]] + [metabase.test :as mt] + [toucan.db :as db])) + +(defn- do-with-query-action [f] + (mt/with-temp* [Card [{card-id :id} {:database_id (mt/id) + :dataset_query {:database (mt/id) + :type :native + :native {:query (str "UPDATE categories\n" + "SET name = 'Bird Shop'\n" + "WHERE id = {{id}}") + :template-tags {"id" {:name "id" + :display-name "ID" + :type :number + :required true}}}} + :is_write true}] + Action [{action-id :id} {:type :row}]] + ;; this is tied to the Card and Action above and will get cascade deleted. We can't use `with-temp*` for them + ;; because it doesn't seem to work with tables with compound PKs + (db/insert! QueryAction {:action_id action-id + :card_id card-id}) + (f {:query-action-card-id card-id + :action-id action-id}))) + +(defn- do-with-card-emitter [{:keys [action-id], :as context} f] + (mt/with-temp* [Card [{emitter-card-id :id}] + Emitter [{emitter-id :id} {:parameter_mappings {"my_id" [:variable [:template-tag "id"]]}}]] + (testing "Sanity check: emitter-id should be non-nil" + (is (integer? emitter-id))) + (testing "Sanity check: make sure parameter mappings were defined the way we'd expect" + (is (= {:my_id [:variable [:template-tag "id"]]} + (db/select-one-field :parameter_mappings Emitter :id emitter-id)))) + ;; these are tied to the Card and Emitter above and will get cascade deleted. We can't use `with-temp*` for them + ;; because it doesn't seem to work with tables with compound PKs + (db/insert! EmitterAction {:emitter_id emitter-id + :action_id action-id}) + (db/insert! CardEmitter {:card_id emitter-card-id + :action_id action-id}) + (f (assoc context + :emitter-id emitter-id + :emitter-card-id emitter-card-id)))) + +(defn- do-with-actions-setup [thunk] + (actions.test-util/with-actions-test-data + (mt/with-temporary-setting-values [experimental-enable-actions true] + (mt/with-temp-vals-in-db Database (mt/id) {:settings {:database-enable-actions true}} + (thunk))))) + +(deftest execute-custom-action-test + (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom) + (do-with-actions-setup + (fn [] + (do-with-query-action + (fn [context] + (do-with-card-emitter + context + (fn [{:keys [emitter-id], :as _context}] + (testing "Should be able to execute an emitter" + (is (= {:rows-affected 1} + (mt/user-http-request :crowberto :post 200 (format "emitter/%d/execute" emitter-id) + {:parameters {"my_id" {:type :number/= + :value 1}}})))) + (is (= [1 "Bird Shop"] + (mt/first-row + (mt/run-mbql-query categories {:filter [:= $id 1]})))))))))))) diff --git a/test/metabase/api/login_history_test.clj b/test/metabase/api/login_history_test.clj index 0eb800abe74..5ce9444f318 100644 --- a/test/metabase/api/login_history_test.clj +++ b/test/metabase/api/login_history_test.clj @@ -3,7 +3,8 @@ [metabase.models :refer [LoginHistory Session User]] [metabase.test :as mt] [metabase.util :as u] - [schema.core :as s])) + [schema.core :as s] + [toucan.db :as db])) ;; don't run these tests when running driver tests (i.e., `DRIVERS` is set) because they tend to flake (use-fixtures :each (fn [thunk] @@ -19,65 +20,65 @@ (deftest login-history-test (testing "GET /api/login-history/current" (let [session-id (str (java.util.UUID/randomUUID))] - (mt/with-temp* [User [user] - Session [session {:id session-id, :user_id (u/the-id user)}] - LoginHistory [_ {:timestamp #t "2021-03-18T19:52:41.808482Z" - :user_id (u/the-id user) - :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" - :device_description windows-user-agent - :ip_address "185.233.100.23" - :session_id session-id}] - LoginHistory [_ {:timestamp #t "2021-03-18T20:04:24.727300Z" - :user_id (u/the-id user) - :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" - :device_description "Apache-HttpClient/4.5.10 (Java/14.0.1)" - :ip_address "127.0.0.1"}] - LoginHistory [_ {:timestamp #t "2021-03-18T20:55:50.955232Z" - :user_id (u/the-id user) - :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" - :device_description ios-user-agent - :ip_address "0:0:0:0:0:0:0:1"}] - LoginHistory [_ {:timestamp #t "2021-03-18T19:52:20.172351Z" - :user_id (u/the-id user) - :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" - :device_description windows-user-agent - :ip_address "52.206.149.9"}] - ;; this one shouldn't show up because it's from a different User - LoginHistory [_ {:timestamp #t "2021-03-17T19:00Z" - :user_id (mt/user->id :rasta) - :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" - :device_description windows-user-agent - :ip_address "52.206.149.9"}]] - (is (schema= [(s/one - {:timestamp (s/eq "2021-03-18T20:55:50.955232Z") - :device_description (s/eq "Mobile Browser (Mobile Safari/iOS)") - :ip_address (s/eq "0:0:0:0:0:0:0:1") - :active (s/eq false) - :location (s/eq "Unknown location") - :timezone (s/eq nil)} - "localhost ipv6") - (s/one - {:timestamp (s/eq "2021-03-18T20:04:24.7273Z") - :device_description (s/eq "Library (Apache-HttpClient/JVM (Java))") - :ip_address (s/eq "127.0.0.1") - :active (s/eq false) - :location (s/eq "Unknown location") - :timezone (s/eq nil)} - "localhost ipv4") - (s/one - {:timestamp (s/eq "2021-03-18T20:52:41.808482+01:00") - :device_description (s/eq "Browser (Chrome/Windows)") - :ip_address (s/eq "185.233.100.23") - :active (s/eq true) - :location #"France" - :timezone (s/eq "CET")} - "France") - (s/one - {:timestamp (s/eq "2021-03-18T15:52:20.172351-04:00") - :device_description (s/eq "Browser (Chrome/Windows)") - :ip_address (s/eq "52.206.149.9") - :active (s/eq false) - :location (s/eq "Ashburn, Virginia, United States") - :timezone (s/eq "ET")} - "Virginia")] - (mt/client session-id :get 200 "login-history/current"))))))) + (mt/with-temp User [user] + (db/insert! Session {:id session-id, :user_id (u/the-id user)}) + (mt/with-temp* [LoginHistory [_ {:timestamp #t "2021-03-18T19:52:41.808482Z" + :user_id (u/the-id user) + :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" + :device_description windows-user-agent + :ip_address "185.233.100.23" + :session_id session-id}] + LoginHistory [_ {:timestamp #t "2021-03-18T20:04:24.727300Z" + :user_id (u/the-id user) + :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" + :device_description "Apache-HttpClient/4.5.10 (Java/14.0.1)" + :ip_address "127.0.0.1"}] + LoginHistory [_ {:timestamp #t "2021-03-18T20:55:50.955232Z" + :user_id (u/the-id user) + :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" + :device_description ios-user-agent + :ip_address "0:0:0:0:0:0:0:1"}] + LoginHistory [_ {:timestamp #t "2021-03-18T19:52:20.172351Z" + :user_id (u/the-id user) + :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" + :device_description windows-user-agent + :ip_address "52.206.149.9"}] + ;; this one shouldn't show up because it's from a different User + LoginHistory [_ {:timestamp #t "2021-03-17T19:00Z" + :user_id (mt/user->id :rasta) + :device_id "e9b49ec7-bc64-4a83-9b1a-ecd3ae26ba9d" + :device_description windows-user-agent + :ip_address "52.206.149.9"}]] + (is (schema= [(s/one + {:timestamp (s/eq "2021-03-18T20:55:50.955232Z") + :device_description (s/eq "Mobile Browser (Mobile Safari/iOS)") + :ip_address (s/eq "0:0:0:0:0:0:0:1") + :active (s/eq false) + :location (s/eq "Unknown location") + :timezone (s/eq nil)} + "localhost ipv6") + (s/one + {:timestamp (s/eq "2021-03-18T20:04:24.7273Z") + :device_description (s/eq "Library (Apache-HttpClient/JVM (Java))") + :ip_address (s/eq "127.0.0.1") + :active (s/eq false) + :location (s/eq "Unknown location") + :timezone (s/eq nil)} + "localhost ipv4") + (s/one + {:timestamp (s/eq "2021-03-18T20:52:41.808482+01:00") + :device_description (s/eq "Browser (Chrome/Windows)") + :ip_address (s/eq "185.233.100.23") + :active (s/eq true) + :location #"France" + :timezone (s/eq "CET")} + "France") + (s/one + {:timestamp (s/eq "2021-03-18T15:52:20.172351-04:00") + :device_description (s/eq "Browser (Chrome/Windows)") + :ip_address (s/eq "52.206.149.9") + :active (s/eq false) + :location (s/eq "Ashburn, Virginia, United States") + :timezone (s/eq "ET")} + "Virginia")] + (mt/client session-id :get 200 "login-history/current")))))))) diff --git a/test/metabase/models/user_test.clj b/test/metabase/models/user_test.clj index 6bf5efd2344..437e498d68d 100644 --- a/test/metabase/models/user_test.clj +++ b/test/metabase/models/user_test.clj @@ -397,9 +397,9 @@ (db/select-one-field :reset_token User :id user-id))))) (testing "should clear out all existing Sessions" - (mt/with-temp* [User [{user-id :id}] - Session [_ {:id (str (java.util.UUID/randomUUID)), :user_id user-id}] - Session [_ {:id (str (java.util.UUID/randomUUID)), :user_id user-id}]] + (mt/with-temp* [User [{user-id :id}]] + (dotimes [_ 2] + (db/insert! Session {:id (str (java.util.UUID/randomUUID)), :user_id user-id})) (letfn [(session-count [] (db/count Session :user_id user-id))] (is (= 2 (session-count))) diff --git a/test/metabase/server/middleware/auth_test.clj b/test/metabase/server/middleware/auth_test.clj index 3340a6c46ba..978e913c8d9 100644 --- a/test/metabase/server/middleware/auth_test.clj +++ b/test/metabase/server/middleware/auth_test.clj @@ -9,8 +9,7 @@ [metabase.test.data.users :as test.users] [metabase.test.fixtures :as fixtures] [ring.mock.request :as ring.mock] - [toucan.db :as db] - [toucan.util.test :as tt]) + [toucan.db :as db]) (:import java.util.UUID)) (use-fixtures :once (fixtures/initialize :db :test-users :web-server)) @@ -38,11 +37,13 @@ (deftest wrap-current-user-info-test (testing "Valid requests should add `metabase-user-id` to requests with valid session info" (let [session-id (random-session-id)] - (tt/with-temp Session [_ {:id session-id - :user_id (test.users/user->id :rasta)}] + (try + (db/insert! Session {:id session-id + :user_id (test.users/user->id :rasta)}) (is (= (test.users/user->id :rasta) (-> (auth-enforced-handler (request-with-session-id session-id)) - :metabase-user-id)))))) + :metabase-user-id))) + (finally (db/delete! Session :id session-id))))) (testing "Invalid requests should return unauthed response" (testing "when no session ID is sent with request" @@ -54,23 +55,27 @@ ;; create a new session (specifically created some time in the past so it's EXPIRED) should fail due to session ;; expiration (let [session-id (random-session-id)] - (tt/with-temp Session [_ {:id session-id - :user_id (test.users/user->id :rasta)}] + (try + (db/insert! Session {:id session-id + :user_id (test.users/user->id :rasta)}) (db/update-where! Session {:id session-id} :created_at (t/instant 0)) (is (= mw.util/response-unauthentic - (auth-enforced-handler (request-with-session-id session-id))))))) + (auth-enforced-handler (request-with-session-id session-id)))) + (finally (db/delete! Session :id session-id))))) (testing "when a Session tied to an inactive User is sent with the request" ;; create a new session (specifically created some time in the past so it's EXPIRED) ;; should fail due to inactive user ;; NOTE that :trashbird is our INACTIVE test user (let [session-id (random-session-id)] - (tt/with-temp Session [_ {:id session-id - :user_id (test.users/user->id :trashbird)}] + (try + (db/insert! Session {:id session-id + :user_id (test.users/user->id :trashbird)}) (is (= mw.util/response-unauthentic (auth-enforced-handler - (request-with-session-id session-id))))))))) + (request-with-session-id session-id)))) + (finally (db/delete! Session :id session-id))))))) ;;; ------------------------------------------ TEST wrap-api-key middleware ------------------------------------------ diff --git a/test/metabase/server/middleware/session_test.clj b/test/metabase/server/middleware/session_test.clj index 8b3e9334868..eb02f04201a 100644 --- a/test/metabase/server/middleware/session_test.clj +++ b/test/metabase/server/middleware/session_test.clj @@ -210,99 +210,98 @@ ;; for some reason Toucan seems to be busted with models with non-integer IDs and `with-temp` doesn't seem to work ;; the way we'd expect :/ (try - (mt/with-temp Session [session {:id (str test-uuid), :user_id (mt/user->id :lucky)}] - (is (= {:metabase-user-id (mt/user->id :lucky), :is-superuser? false, :is-group-manager? false, :user-locale nil} - (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) + (db/insert! Session {:id (str test-uuid), :user_id (mt/user->id :lucky)}) + (is (= {:metabase-user-id (mt/user->id :lucky), :is-superuser? false, :is-group-manager? false, :user-locale nil} + (#'mw.session/current-user-info-for-session (str test-uuid) nil))) (finally (db/delete! Session :id (str test-uuid))))) (testing "superusers should come back as `:is-superuser?`" (try - (mt/with-temp Session [session {:id (str test-uuid), :user_id (mt/user->id :crowberto)}] - (is (= {:metabase-user-id (mt/user->id :crowberto), :is-superuser? true, :is-group-manager? false, :user-locale nil} - (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) + (db/insert! Session {:id (str test-uuid), :user_id (mt/user->id :crowberto)}) + (is (= {:metabase-user-id (mt/user->id :crowberto), :is-superuser? true, :is-group-manager? false, :user-locale nil} + (#'mw.session/current-user-info-for-session (str test-uuid) nil))) (finally (db/delete! Session :id (str test-uuid))))) (testing "If user is a group manager of at least one group, `:is-group-manager?` " (try - (mt/with-user-in-groups - [group-1 {:name "New Group 1"} - group-2 {:name "New Group 2"} - user [group-1 group-2]] - (db/update-where! PermissionsGroupMembership {:user_id (:id user), :group_id (:id group-2)} - :is_group_manager true) - (mt/with-temp Session [_session {:id (str test-uuid) - :user_id (:id user)}] - (testing "is `false` if advanced-permisison is disabled" - (premium-features-test/with-premium-features #{} - (is (= false - (:is-group-manager? (#'mw.session/current-user-info-for-session (str test-uuid) nil)))))) - - (testing "is `true` if advanced-permisison is enabled" - ;; a trick to run this test in OSS because even if advanced-permisison is enabled but EE ns is not evailable - ;; `enable-advanced-permissions?` will still return false - (with-redefs [premium-features/enable-advanced-permissions? (fn [& _args] true)] - (is (= true - (:is-group-manager? (#'mw.session/current-user-info-for-session (str test-uuid) nil)))))))) - (finally - (db/delete! Session :id (str test-uuid))))) + (mt/with-user-in-groups [group-1 {:name "New Group 1"} + group-2 {:name "New Group 2"} + user [group-1 group-2]] + (db/update-where! PermissionsGroupMembership {:user_id (:id user), :group_id (:id group-2)} + :is_group_manager true) + (db/insert! Session {:id (str test-uuid) + :user_id (:id user)}) + (testing "is `false` if advanced-permisison is disabled" + (premium-features-test/with-premium-features #{} + (is (= false + (:is-group-manager? (#'mw.session/current-user-info-for-session (str test-uuid) nil)))))) + + (testing "is `true` if advanced-permisison is enabled" + ;; a trick to run this test in OSS because even if advanced-permisison is enabled but EE ns is not evailable + ;; `enable-advanced-permissions?` will still return false + (with-redefs [premium-features/enable-advanced-permissions? (fn [& _args] true)] + (is (= true + (:is-group-manager? (#'mw.session/current-user-info-for-session (str test-uuid) nil))))))) + (finally + (db/delete! Session :id (str test-uuid))))) (testing "full-app-embed sessions shouldn't come back if we don't explicitly specifiy the anti-csrf token" (try - (mt/with-temp Session [session {:id (str test-uuid) - :user_id (mt/user->id :lucky) - :anti_csrf_token test-anti-csrf-token}] - (is (= nil - (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) + (db/insert! Session {:id (str test-uuid) + :user_id (mt/user->id :lucky) + :anti_csrf_token test-anti-csrf-token}) + (is (= nil + (#'mw.session/current-user-info-for-session (str test-uuid) nil))) (finally (db/delete! Session :id (str test-uuid)))) (testing "...but if we do specifiy the token, they should come back" (try - (mt/with-temp Session [session {:id (str test-uuid) - :user_id (mt/user->id :lucky) - :anti_csrf_token test-anti-csrf-token}] - (is (= {:metabase-user-id (mt/user->id :lucky), :is-superuser? false, :is-group-manager? false, :user-locale nil} - (#'mw.session/current-user-info-for-session (str test-uuid) test-anti-csrf-token)))) + (db/insert! Session {:id (str test-uuid) + :user_id (mt/user->id :lucky) + :anti_csrf_token test-anti-csrf-token}) + (is (= {:metabase-user-id (mt/user->id :lucky), :is-superuser? false, :is-group-manager? false, :user-locale nil} + (#'mw.session/current-user-info-for-session (str test-uuid) test-anti-csrf-token))) (finally (db/delete! Session :id (str test-uuid)))) (testing "(unless the token is wrong)" (try - (mt/with-temp Session [session {:id (str test-uuid) - :user_id (mt/user->id :lucky) - :anti_csrf_token test-anti-csrf-token}] - (is (= nil - (#'mw.session/current-user-info-for-session (str test-uuid) (str/join (reverse test-anti-csrf-token)))))) + (db/insert! Session {:id (str test-uuid) + :user_id (mt/user->id :lucky) + :anti_csrf_token test-anti-csrf-token}) + (is (= nil + (#'mw.session/current-user-info-for-session (str test-uuid) (str/join (reverse test-anti-csrf-token))))) (finally (db/delete! Session :id (str test-uuid))))))) (testing "if we specify an anti-csrf token we shouldn't get back a session without that token" (try - (mt/with-temp Session [session {:id (str test-uuid) - :user_id (mt/user->id :lucky)}] - (is (= nil - (#'mw.session/current-user-info-for-session (str test-uuid) test-anti-csrf-token)))) + (db/insert! Session {:id (str test-uuid) + :user_id (mt/user->id :lucky)}) + (is (= nil + (#'mw.session/current-user-info-for-session (str test-uuid) test-anti-csrf-token))) (finally (db/delete! Session :id (str test-uuid))))) (testing "shouldn't fetch expired sessions" (try - (mt/with-temp Session [session {:id (str test-uuid) - :user_id (mt/user->id :lucky)}] - ;; use low-level `execute!` because updating is normally disallowed for Sessions - (db/execute! {:update Session, :set {:created_at (java.sql.Date. 0)}, :where [:= :id (str test-uuid)]}) - (is (= nil - (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) + (db/insert! Session {:id (str test-uuid) + :user_id (mt/user->id :lucky)}) + ;; use low-level `execute!` because updating is normally disallowed for Sessions + (db/execute! {:update Session, :set {:created_at (java.sql.Date. 0)}, :where [:= :id (str test-uuid)]}) + (is (= nil + (#'mw.session/current-user-info-for-session (str test-uuid) nil))) (finally (db/delete! Session :id (str test-uuid))))) (testing "shouldn't fetch sessions for inactive users" (try - (mt/with-temp Session [session {:id (str test-uuid), :user_id (mt/user->id :trashbird)}] - (is (= nil - (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) + (db/insert! Session {:id (str test-uuid), :user_id (mt/user->id :trashbird)}) + (is (= nil + (#'mw.session/current-user-info-for-session (str test-uuid) nil))) (finally (db/delete! Session :id (str test-uuid)))))) diff --git a/test/metabase/test/redefs.clj b/test/metabase/test/redefs.clj index 0ffbfb63a30..ab7cf78b30e 100644 --- a/test/metabase/test/redefs.clj +++ b/test/metabase/test/redefs.clj @@ -21,18 +21,38 @@ (test-runner.parallel/assert-test-is-not-parallel "with-temp") ;; catch any Exceptions thrown when creating the object and rethrow them with some extra context to make them a ;; little easier to debug. - (let [temp-object (try - (db/insert! model (merge (tt/with-temp-defaults model) - attributes)) - (catch Throwable e - (throw (ex-info (str "with-temp error: " (ex-message e)) - {:model (name model), :attributes attributes} - e)))) - primary-key (models/primary-key model)] + (let [attributes-with-defaults (merge (tt/with-temp-defaults model) + attributes)] (try - (f temp-object) - (finally - (db/delete! model primary-key (primary-key temp-object)))))) + (let [temp-object (let [object (db/insert! model attributes-with-defaults)] + (assert (record? object) + (str (format "db/insert! for %s did not return a valid row. Got: %s." + (name model) + (pr-str object)) + \newline + "(Tip: db/insert! doesn't seem to work with H2 with tables with compound PKs.)")) + object) + primary-key-column (models/primary-key model) + primary-key-value (get temp-object primary-key-column)] + (assert primary-key-value (format "No value for primary key %s for row %s" + (pr-str primary-key-column) + (pr-str temp-object))) + (try + (f temp-object) + (finally + (db/delete! model primary-key-column primary-key-value)))) + (catch Throwable e + ;; only wrap `e` if it's not another `with-temp` error. This way we don't get "false positives" if something + ;; fails at the beginning of a big `with-temp*` form for all the other models + (throw (if (::with-temp-error? (ex-data e)) + e + (ex-info (format "with-temp error for %s: %s" (name model) (ex-message e)) + {:model (name model) + :attributes attributes + :attributes-with-defaults attributes-with-defaults + :primary-key-column (models/primary-key model) + ::with-temp-error? true} + e))))))) (alter-var-root #'tt/do-with-temp (constantly do-with-temp)) diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index d8c743ea6da..59e4c6f500c 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -697,7 +697,8 @@ (test-runner.parallel/assert-test-is-not-parallel "with-model-cleanup") (initialize/initialize-if-needed! :db) (let [model->old-max-id (into {} (for [model models] - [model (:max-id (db/select-one [model [:%max.id :max-id]]))]))] + [model (:max-id (db/select-one [model [(keyword (str "%max." (name (models/primary-key model)))) + :max-id]]))]))] (try (testing (str "\n" (pr-str (cons 'with-model-cleanup (map name models))) "\n") (f)) @@ -706,7 +707,7 @@ ;; might not have an old max ID if this is the first time the macro is used in this test run. :let [old-max-id (or (get model->old-max-id model) 0) - max-id-condition [:> :id old-max-id] + max-id-condition [:> (models/primary-key model) old-max-id] additional-conditions (with-model-cleanup-additional-conditions model)]] (db/execute! {:delete-from model @@ -723,7 +724,9 @@ (with-model-cleanup [Card] (create-card-via-api!) - (is (= ...)))" + (is (= ...))) + + Only works for models that have a numeric primary key e.g. `:id`." [models & body] `(do-with-model-cleanup ~models (fn [] ~@body))) -- GitLab