From 1a611340418d98feaadc0a070b118af3ae0ae208 Mon Sep 17 00:00:00 2001 From: Cam Saul <cammsaul@gmail.com> Date: Wed, 6 Jun 2018 16:03:37 -0700 Subject: [PATCH] Code cleanup etc. --- src/metabase/api/common.clj | 68 +++++++++++++------ src/metabase/api/common/internal.clj | 62 +---------------- src/metabase/api/field.clj | 4 +- src/metabase/api/permissions.clj | 8 ++- src/metabase/api/user.clj | 7 +- src/metabase/core.clj | 6 ++ src/metabase/middleware.clj | 56 ++++++++++++++- src/metabase/models/card.clj | 3 +- src/metabase/models/interface.clj | 20 ++++-- src/metabase/models/user.clj | 2 +- src/metabase/plugins.clj | 24 +++++++ src/metabase/query_processor.clj | 18 ++--- .../middleware/add_implicit_clauses.clj | 17 ++--- .../query_processor/middleware/cache.clj | 30 ++++---- .../query_processor/middleware/resolve.clj | 37 +++++----- .../middleware/source_table.clj | 27 ++++---- test/metabase/api/common_test.clj | 51 ++++++++------ test/metabase/api/dataset_test.clj | 3 +- test/metabase/models/card_test.clj | 3 +- .../middleware/permissions_test.clj | 4 +- test/metabase/test/data/users.clj | 12 +++- test/metabase/test_setup.clj | 3 +- 22 files changed, 278 insertions(+), 187 deletions(-) diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj index 6b6391fb694..f5fa5bb9c50 100644 --- a/src/metabase/api/common.clj +++ b/src/metabase/api/common.clj @@ -6,7 +6,7 @@ [clojure.java.io :as io] [clojure.string :as str] [clojure.tools.logging :as log] - [compojure.core :refer [defroutes]] + [compojure.core :as compojure] [medley.core :as m] [metabase [public-settings :as public-settings] @@ -227,6 +227,11 @@ [& body] `(api-let ~generic-403 ~@body)) +(defn throw-403 + "Throw a generic 403 (no permissions) error response." + [] + (throw (ex-info (tru "You don''t have permissions to do that.") {:status-code 403}))) + ;; #### GENERIC 500 RESPONSE HELPERS ;; For when you don't feel like writing something useful (def ^:private generic-500 @@ -251,7 +256,7 @@ ;;; --------------------------------------- DEFENDPOINT AND RELATED FUNCTIONS ---------------------------------------- ;; TODO - several of the things `defendpoint` does could and should just be done by custom Ring middleware instead -;; e.g. `catch-api-exceptions` and `auto-parse` +;; e.g. `auto-parse` (defmacro defendpoint "Define an API function. This automatically does several things: @@ -283,25 +288,50 @@ :doc (route-dox method route docstr args (m/map-vals eval arg->schema) body) :is-endpoint? true) (~method ~route ~args - (catch-api-exceptions - (auto-parse ~args - ~@validate-param-calls - (wrap-response-if-needed (do ~@body)))))))) - + (auto-parse ~args + ~@validate-param-calls + (wrap-response-if-needed (do ~@body))))))) + +(defn- namespace->api-route-fns + "Return a sequence of all API endpoint functions defined by `defendpoint` in a namespace." + [nmspace] + (for [[symb varr] (ns-publics nmspace) + :when (:is-endpoint? (meta varr))] + symb)) + +(defn- api-routes-docstring [nmspace route-fns middleware] + (str + (format "Ring routes for %s:\n%s" + (-> (ns-name nmspace) + (str/replace #"^metabase\." "") + (str/replace #"\." "/")) + (u/pprint-to-str route-fns)) + (when (seq middleware) + (str "\nMiddleware applied to all endpoints in this namespace:\n" + (u/pprint-to-str middleware))))) (defmacro define-routes - "Create a `(defroutes routes ...)` form that automatically includes all functions created with - `defendpoint` in the current namespace." - [& additional-routes] - (let [api-routes (for [[symb varr] (ns-publics *ns*) - :when (:is-endpoint? (meta varr))] - symb)] - `(defroutes ~(vary-meta 'routes assoc :doc (format "Ring routes for %s:\n%s" - (-> (ns-name *ns*) - (str/replace #"^metabase\." "") - (str/replace #"\." "/")) - (u/pprint-to-str (concat api-routes additional-routes)))) - ~@additional-routes ~@api-routes))) + "Create a `(defroutes routes ...)` form that automatically includes all functions created with `defendpoint` in the + current namespace. Optionally specify middleware that will apply to all of the endpoints in the current namespace. + + (api/define-routes api/+check-superuser) ; all API endpoints in this namespace will require superuser access" + {:style/indent 0} + [& middleware] + (let [api-route-fns (namespace->api-route-fns *ns*) + routes `(compojure/routes ~@api-route-fns)] + `(def ~(vary-meta 'routes assoc :doc (api-routes-docstring *ns* api-route-fns middleware)) + ~(if (seq middleware) + `(-> ~routes ~@middleware) + routes)))) + +(defn +check-superuser + "Wrap a Ring handler to make sure the current user is a superuser before handling any requests. + + (api/+check-superuser routes)" + [handler] + (fn [request] + (check-superuser) + (handler request))) ;;; ---------------------------------------- PERMISSIONS CHECKING HELPER FNS ----------------------------------------- diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj index 5862498c567..b3b5125437c 100644 --- a/src/metabase/api/common/internal.clj +++ b/src/metabase/api/common/internal.clj @@ -1,15 +1,13 @@ (ns metabase.api.common.internal "Internal functions used by `metabase.api.common`. These are primarily used as the internal implementation of `defendpoint`." - (:require [clojure.java.jdbc :as jdbc] - [clojure.string :as str] + (:require [clojure.string :as str] [clojure.tools.logging :as log] [medley.core :as m] [metabase.util :as u] [metabase.util.schema :as su] [puppetlabs.i18n.core :refer [trs tru]] - [schema.core :as s]) - (:import java.sql.SQLException)) + [schema.core :as s])) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | DOCSTRING GENERATION | @@ -206,62 +204,6 @@ ~@body))) -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | EXCEPTION HANDLING | -;;; +----------------------------------------------------------------------------------------------------------------+ - -;; TODO - this SHOULD all be implemented as middleware instead -(defn- api-exception-response - "Convert an exception from an API endpoint into an appropriate HTTP response." - [^Throwable e] - (let [{:keys [status-code], :as info} (ex-data e) - other-info (dissoc info :status-code) - message (.getMessage e)] - {:status (or status-code 500) - :body (cond - ;; Exceptions that include a status code *and* other info are things like Field validation exceptions. - ;; Return those as is - (and status-code - (seq other-info)) - other-info - ;; If status code was specified but other data wasn't, it's something like a 404. Return message as the - ;; body. - status-code - message - ;; Otherwise it's a 500. Return a body that includes exception & filtered stacktrace for debugging - ;; purposes - :else - (let [stacktrace (u/filtered-stacktrace e)] - (merge (assoc other-info - :message message - :type (class e) - :stacktrace stacktrace) - (when (instance? SQLException e) - {:sql-exception-chain (str/split (with-out-str (jdbc/print-sql-exception-chain e)) - #"\s*\n\s*")}))))})) - -(def ^:dynamic ^Boolean *automatically-catch-api-exceptions* - "Should API exceptions automatically be caught? By default, this is `true`, but this can be disabled when we want to - catch Exceptions and return something generic to avoid leaking information, e.g. with the `api/public` and - `api/embed` endpoints. generic exceptions" - true) - -(defn do-with-caught-api-exceptions - "Execute F with and catch any exceptions, converting them to the appropriate HTTP response." - [f] - (if-not *automatically-catch-api-exceptions* - (f) - (try (f) - (catch Throwable e - (api-exception-response e))))) - -(defmacro catch-api-exceptions - "Execute BODY, and if an exception is thrown, return the appropriate HTTP response." - {:style/indent 0} - [& body] - `(do-with-caught-api-exceptions (fn [] ~@body))) - - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PARAM VALIDATION | ;;; +----------------------------------------------------------------------------------------------------------------+ diff --git a/src/metabase/api/field.clj b/src/metabase/api/field.clj index a1ea3eec15b..c3409d53635 100644 --- a/src/metabase/api/field.clj +++ b/src/metabase/api/field.clj @@ -164,8 +164,8 @@ {:values [], :field_id (:id field)})) (api/defendpoint GET "/:id/values" - "If `Field`'s special type derives from `type/Category`, or its base type is `type/Boolean`, return all distinct - values of the field, and a map of human-readable values defined by the user." + "If a Field's value of `has_field_values` is `list`, return a list of all the distinct values of the Field, and (if + defined by a User) a map of human-readable remapped values." [id] (field->values (api/read-check Field id))) diff --git a/src/metabase/api/permissions.clj b/src/metabase/api/permissions.clj index 063c3cdbbde..ea52c7da685 100644 --- a/src/metabase/api/permissions.clj +++ b/src/metabase/api/permissions.clj @@ -22,11 +22,17 @@ (defn- ->int [id] (Integer/parseInt (name id))) +(defn- dejsonify-table-perms [table-perms] + (if-not (map? table-perms) + (keyword table-perms) + (into {} (for [[k v] table-perms] + [(keyword k) (keyword v)])))) + (defn- dejsonify-tables [tables] (if (string? tables) (keyword tables) (into {} (for [[table-id perms] tables] - {(->int table-id) (keyword perms)})))) + {(->int table-id) (dejsonify-table-perms perms)})))) (defn- dejsonify-schemas [schemas] (if (string? schemas) diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index dd9c083c884..86d4a5436f0 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -1,4 +1,5 @@ (ns metabase.api.user + "/api/user endpoints" (:require [cemerick.friend.credentials :as creds] [compojure.core :refer [DELETE GET POST PUT]] [metabase.api @@ -78,7 +79,6 @@ (check-self-or-superuser id) (api/check-404 (fetch-user :id id, :is_active true))) - (api/defendpoint PUT "/:id" "Update an existing, active `User`." [id :as {{:keys [email first_name last_name is_superuser login_attributes] :as body} :body}] @@ -95,9 +95,10 @@ (api/check-500 (db/update! User id (u/select-keys-when body - :present #{:login_attributes} + :present (when api/*is-superuser?* + #{:login_attributes}) :non-nil (set (concat [:first_name :last_name :email] - (when (:is_superuser @api/*current-user*) + (when api/*is-superuser?* [:is_superuser])))))) (fetch-user :id id)) diff --git a/src/metabase/core.clj b/src/metabase/core.clj index fb0a84479c6..dcaa9cc47ed 100644 --- a/src/metabase/core.clj +++ b/src/metabase/core.clj @@ -86,7 +86,9 @@ (def ^:private app "The primary entry point to the Ring HTTP server." + ;; ▼▼▼ POST-PROCESSING ▼▼▼ happens from TOP-TO-BOTTOM (-> #'routes/routes ; the #' is to allow tests to redefine endpoints + mb-middleware/catch-api-exceptions ; catch exceptions and return them in our expected format (mb-middleware/log-api-call jetty-stats) mb-middleware/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached @@ -104,6 +106,7 @@ wrap-cookies ; Parses cookies in the request map and assocs as :cookies wrap-session ; reads in current HTTP session and sets :session/key wrap-gzip)) ; GZIP response if client can handle it +;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP ;;; --------------------------------------------------- Lifecycle ---------------------------------------------------- @@ -131,6 +134,7 @@ (task/stop-scheduler!) (log/info (trs "Metabase Shutdown COMPLETE"))) + (defn init! "General application initialization function which should be run once at application startup." [] @@ -145,6 +149,8 @@ ;; load any plugins as needed (plugins/load-plugins!) (init-status/set-progress! 0.3) + (plugins/setup-plugins!) + (init-status/set-progress! 0.35) ;; Load up all of our Database drivers, which are used for app db work (driver/find-and-load-drivers!) diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj index 0debf25c5d1..54c7150d565 100644 --- a/src/metabase/middleware.clj +++ b/src/metabase/middleware.clj @@ -1,6 +1,8 @@ (ns metabase.middleware "Metabase-specific middleware functions & configuration." (:require [cheshire.generate :refer [add-encoder encode-nil encode-str]] + [clojure.java.jdbc :as jdbc] + [clojure.string :as str] [clojure.tools.logging :as log] [metabase [config :as config] @@ -8,7 +10,6 @@ [public-settings :as public-settings] [util :as u]] [metabase.api.common :refer [*current-user* *current-user-id* *current-user-permissions-set* *is-superuser?*]] - [metabase.api.common.internal :refer [*automatically-catch-api-exceptions*]] [metabase.core.initialization-status :as init-status] [metabase.models [session :refer [Session]] @@ -17,7 +18,8 @@ [metabase.util.date :as du] [puppetlabs.i18n.core :refer [tru]] [toucan.db :as db]) - (:import com.fasterxml.jackson.core.JsonGenerator)) + (:import com.fasterxml.jackson.core.JsonGenerator + java.sql.SQLException)) ;;; ---------------------------------------------------- UTIL FNS ---------------------------------------------------- @@ -117,6 +119,8 @@ (def ^:private current-user-fields (vec (cons User user/all-user-fields))) +(defn- find-user [user-id] + (db/select-one current-user-fields, :id user-id)) (defn bind-current-user "Middleware that binds `metabase.api.common/*current-user*`, `*current-user-id*`, `*is-superuser?*`, and @@ -131,7 +135,7 @@ (if-let [current-user-id (:metabase-user-id request)] (binding [*current-user-id* current-user-id *is-superuser?* (:is-superuser? request) - *current-user* (delay (db/select-one current-user-fields, :id current-user-id)) + *current-user* (delay (find-user current-user-id)) *current-user-permissions-set* (delay (user/permissions-set current-user-id))] (handler request)) (handler request)))) @@ -349,6 +353,12 @@ ;;; ----------------------------------------------- EXCEPTION HANDLING ----------------------------------------------- +(def ^:dynamic ^:private ^Boolean *automatically-catch-api-exceptions* + "Should API exceptions automatically be caught? By default, this is `true`, but this can be disabled when we want to + catch Exceptions and return something generic to avoid leaking information, e.g. with the `api/public` and + `api/embed` endpoints. generic exceptions" + true) + (defn genericize-exceptions "Catch any exceptions thrown in the request handler body and rethrow a generic 400 exception instead. This minimizes information available to bad actors when exceptions occur on public endpoints." @@ -370,3 +380,43 @@ (handler request)) (catch Throwable e {:status 400, :body (.getMessage e)})))) + +(defn- api-exception-response + "Convert an exception from an API endpoint into an appropriate HTTP response." + [^Throwable e] + (let [{:keys [status-code], :as info} (ex-data e) + other-info (dissoc info :status-code) + message (.getMessage e)] + {:status (or status-code 500) + :body (cond + ;; Exceptions that include a status code *and* other info are things like Field validation exceptions. + ;; Return those as is + (and status-code + (seq other-info)) + other-info + ;; If status code was specified but other data wasn't, it's something like a 404. Return message as the + ;; body. + status-code + message + ;; Otherwise it's a 500. Return a body that includes exception & filtered stacktrace for debugging + ;; purposes + :else + (let [stacktrace (u/filtered-stacktrace e)] + (merge (assoc other-info + :message message + :type (class e) + :stacktrace stacktrace) + (when (instance? SQLException e) + {:sql-exception-chain (str/split (with-out-str (jdbc/print-sql-exception-chain e)) + #"\s*\n\s*")}))))})) + +(defn catch-api-exceptions + "Middleware that catches API Exceptions and returns them in our normal-style format rather than the Jetty 500 + Stacktrace page, which is not so useful for our frontend." + [handler] + (fn [request] + (if *automatically-catch-api-exceptions* + (try (handler request) + (catch Throwable e + (api-exception-response e))) + (handler request)))) diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index befccd05a4b..dc2bbe1f138 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -16,8 +16,8 @@ [query :as query] [revision :as revision]] [metabase.models.query.permissions :as query-perms] - [metabase.util.query :as q] [metabase.query-processor.util :as qputil] + [metabase.util.query :as q] [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] @@ -33,7 +33,6 @@ [{:keys [id]}] (db/count 'DashboardCard, :card_id id)) - ;;; -------------------------------------------------- Dependencies -------------------------------------------------- (defn card-dependencies diff --git a/src/metabase/models/interface.clj b/src/metabase/models/interface.clj index db7adec1dfc..d8ebb1d2383 100644 --- a/src/metabase/models/interface.clj +++ b/src/metabase/models/interface.clj @@ -27,28 +27,38 @@ obj (json/generate-string obj))) -(defn- json-out [obj] +(defn- json-out [obj keywordize-keys?] (let [s (u/jdbc-clob->str obj)] (if (string? s) - (json/parse-string s keyword) + (json/parse-string s keywordize-keys?) obj))) +(defn- json-out-with-keywordization [obj] + (json-out obj true)) + +(defn- json-out-without-keywordization [obj] + (json-out obj false)) + (models/add-type! :json :in json-in - :out json-out) + :out json-out-with-keywordization) + +(models/add-type! :json-no-keywordization + :in json-in + :out json-out-without-keywordization) ;; json-set is just like json but calls `set` on it when coming out of the DB. Intended for storing things like a ;; permissions set (models/add-type! :json-set :in json-in - :out #(when % (set (json-out %)))) + :out #(when % (set (json-out-with-keywordization %)))) (models/add-type! :clob :in identity :out u/jdbc-clob->str) (def ^:private encrypted-json-in (comp encryption/maybe-encrypt json-in)) -(def ^:private encrypted-json-out (comp json-out encryption/maybe-decrypt)) +(def ^:private encrypted-json-out (comp json-out-with-keywordization encryption/maybe-decrypt)) ;; cache the decryption/JSON parsing because it's somewhat slow (~500µs vs ~100µs on a *fast* computer) ;; cache the decrypted JSON for one hour diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index 50e9076814c..b25deb3d125 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -118,7 +118,7 @@ :pre-update pre-update :post-select post-select :pre-delete pre-delete - :types (constantly {:login_attributes :json})})) + :types (constantly {:login_attributes :json-no-keywordization})})) ;;; --------------------------------------------------- Helper Fns --------------------------------------------------- diff --git a/src/metabase/plugins.clj b/src/metabase/plugins.clj index 3ce3d4e67f5..0ee78b8d8fd 100644 --- a/src/metabase/plugins.clj +++ b/src/metabase/plugins.clj @@ -77,3 +77,27 @@ (if (u/is-java-9-or-higher?) (show-java-9-message) (dynamically-add-jars!))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Running Plugin Setup Fns | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(defn setup-plugins! + "Look for any namespaces on the classpath with the pattern `metabase.*plugin-setup` For each matching namespace, load + it. If the namespace has a function named `-init-plugin!`, call that function with no arguments. + + This is intended as a startup hook for Metabase plugins to run any startup logic that needs to be done. This + function is normally called once in Metabase startup, after `load-plugins!` runs above (which simply adds JARs to + the classpath in the `plugins` directory.)" + [] + ;; find each namespace ending in `plugin-setup` + (doseq [ns-symb @u/metabase-namespace-symbols + :when (re-find #"plugin-setup$" (name ns-symb))] + ;; load the matching ns + (log/info (u/format-color 'magenta "Loading plugin setup namespace %s... %s" (name ns-symb) (u/emoji "🔌"))) + (require ns-symb) + ;; now look for a fn in that namespace called `-init-plugin!`. If it exists, call it + (when-let [init-fn-var (ns-resolve ns-symb '-init-plugin!)] + (log/info (u/format-color 'magenta "Running plugin init fn %s/-init-plugin!... %s" (name ns-symb) (u/emoji "🔌"))) + (init-fn-var)))) diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 6f65bcdb304..a49bb992b78 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -14,8 +14,8 @@ [add-row-count-and-status :as row-count-and-status] [add-settings :as add-settings] [annotate-and-sort :as annotate-and-sort] - [binning :as binning] [bind-effective-timezone :as bind-timezone] + [binning :as binning] [cache :as cache] [catch-exceptions :as catch-exceptions] [cumulative-aggregations :as cumulative-ags] @@ -30,9 +30,9 @@ [mbql-to-native :as mbql-to-native] [parameters :as parameters] [permissions :as perms] - [results-metadata :as results-metadata] - [resolve-driver :as resolve-driver] [resolve :as resolve] + [resolve-driver :as resolve-driver] + [results-metadata :as results-metadata] [source-table :as source-table]] [metabase.query-processor.util :as qputil] [metabase.util @@ -41,9 +41,9 @@ [schema.core :as s] [toucan.db :as db])) -;;; +-------------------------------------------------------------------------------------------------------+ -;;; | QUERY PROCESSOR | -;;; +-------------------------------------------------------------------------------------------------------+ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | QUERY PROCESSOR | +;;; +----------------------------------------------------------------------------------------------------------------+ (defn- execute-query "The pivotal stage of the `process-query` pipeline where the query is actually executed by the driver's Query @@ -151,9 +151,9 @@ ;; instead of running it -;;; +----------------------------------------------------------------------------------------------------+ -;;; | DATASET-QUERY PUBLIC API | -;;; +----------------------------------------------------------------------------------------------------+ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | DATASET-QUERY PUBLIC API | +;;; +----------------------------------------------------------------------------------------------------------------+ ;; The only difference between `process-query` and `process-query-and-save-execution!` (below) is that the ;; latter records a `QueryExecution` (inserts a new row) recording some stats about this Query run including diff --git a/src/metabase/query_processor/middleware/add_implicit_clauses.clj b/src/metabase/query_processor/middleware/add_implicit_clauses.clj index 820409c2e09..77ff8cc38a4 100644 --- a/src/metabase/query_processor/middleware/add_implicit_clauses.clj +++ b/src/metabase/query_processor/middleware/add_implicit_clauses.clj @@ -26,16 +26,17 @@ (defn- fields-for-source-table "Return the all fields for SOURCE-TABLE, for use as an implicit `:fields` clause." - [{{source-table-id :id, :as source-table} :source-table, :as inner-query}] + [inner-query] ;; Sort the implicit FIELDS so the SQL (or other native query) that gets generated (mostly) approximates the 'magic' sorting ;; we do on the results. This is done so when the outer query we generate is a `SELECT *` the order doesn't change - (for [field (sort/sort-fields inner-query (fetch-fields-for-souce-table-id source-table-id)) - :let [field (-> field - resolve/convert-db-field - (resolve/resolve-table {[nil source-table-id] source-table}))]] - (if (qputil/datetime-field? field) - (i/map->DateTimeField {:field field, :unit :default}) - field))) + (let [{source-table-id :id, :as source-table} (qputil/get-normalized inner-query :source-table)] + (for [field (sort/sort-fields inner-query (fetch-fields-for-souce-table-id source-table-id)) + :let [field (-> field + resolve/convert-db-field + (resolve/resolve-table {[nil source-table-id] source-table}))]] + (if (qputil/datetime-field? field) + (i/map->DateTimeField {:field field, :unit :default}) + field)))) (defn- should-add-implicit-fields? [{:keys [fields breakout source-table], aggregations :aggregation}] (and source-table ; if query is using another query as its source then there will be no table to add nested fields for diff --git a/src/metabase/query_processor/middleware/cache.clj b/src/metabase/query_processor/middleware/cache.clj index ce8db38cea6..eedf5b2e2bd 100644 --- a/src/metabase/query_processor/middleware/cache.clj +++ b/src/metabase/query_processor/middleware/cache.clj @@ -1,17 +1,18 @@ (ns metabase.query-processor.middleware.cache "Middleware that returns cached results for queries when applicable. - If caching is enabled (`enable-query-caching` is `true`) cached results will be returned for Cards if possible. There's - a global default TTL defined by the setting `query-caching-default-ttl`, but individual Cards can override this value - with custom TTLs with a value for `:cache_ttl`. + If caching is enabled (`enable-query-caching` is `true`) cached results will be returned for Cards if possible. + There's a global default TTL defined by the setting `query-caching-default-ttl`, but individual Cards can override + this value with custom TTLs with a value for `:cache_ttl`. - For all other queries, caching is skipped. + For all other queries, caching is skipped. - Various caching backends are defined in `metabase.query-processor.middleware.cache-backend` namespaces. - The default backend is `db`, which uses the application database; this value can be changed by setting the env var - `MB_QP_CACHE_BACKEND`. + Various caching backends are defined in `metabase.query-processor.middleware.cache-backend` namespaces. The default + backend is `db`, which uses the application database; this value can be changed by setting the env var + `MB_QP_CACHE_BACKEND`. - Refer to `metabase.query-processor.middleware.cache-backend.interface` for more details about how the cache backends themselves." + Refer to `metabase.query-processor.middleware.cache-backend.interface` for more details about how the cache + backends themselves." (:require [clojure.tools.logging :as log] [metabase [config :as config] @@ -23,11 +24,11 @@ (def ^:dynamic ^Boolean *ignore-cached-results* "Should we force the query to run, ignoring cached results even if they're available? - Setting this to `true` will run the query again and will still save the updated results." + Setting this to `true` will run the query again and will still save the updated results." false) -;;; ------------------------------------------------------------ Backend ------------------------------------------------------------ +;;; ---------------------------------------------------- Backend ----------------------------------------------------- (def ^:private backend-instance (atom nil)) @@ -49,7 +50,8 @@ (valid-backend? @varr) @varr allow-reload? (do (require backend-ns-symb :reload) (get-backend-instance-in-namespace backend-ns-symb false)) - :else (throw (Exception. (format "%s/instance doesn't satisfy IQueryProcessorCacheBackend" backend-ns-symb))))))) + :else (throw (Exception. (format "%s/instance doesn't satisfy IQueryProcessorCacheBackend" + backend-ns-symb))))))) (defn- set-backend! "Set the cache backend to the cache defined by the keyword BACKEND. @@ -66,7 +68,7 @@ -;;; ------------------------------------------------------------ Cache Operations ------------------------------------------------------------ +;;; ------------------------------------------------ Cache Operations ------------------------------------------------ (defn- cached-results [query-hash max-age-seconds] (when-not *ignore-cached-results* @@ -81,7 +83,7 @@ (i/save-results! @backend-instance query-hash results)) -;;; ------------------------------------------------------------ Middleware ------------------------------------------------------------ +;;; --------------------------------------------------- Middleware --------------------------------------------------- (defn- is-cacheable? ^Boolean [{cache-ttl :cache_ttl}] (boolean (and (public-settings/enable-query-caching) @@ -127,7 +129,7 @@ (defn maybe-return-cached-results "Middleware for caching results of a query if applicable. - In order for a query to be eligible for caching: + In order for a query to be eligible for caching: * Caching (the `enable-query-caching` Setting) must be enabled * The query must pass a `:cache_ttl` value. For Cards, this can be the value of `:cache_ttl`, diff --git a/src/metabase/query_processor/middleware/resolve.clj b/src/metabase/query_processor/middleware/resolve.clj index 7ccc329c97c..78cf8066fce 100644 --- a/src/metabase/query_processor/middleware/resolve.clj +++ b/src/metabase/query_processor/middleware/resolve.clj @@ -379,24 +379,25 @@ (defn- resolve-tables "Resolve the `Tables` in an EXPANDED-QUERY-DICT." - [{{{ source-table-id :id :as source-table} :source-table} :query, :keys [table-ids fk-field-ids], :as expanded-query-dict}] - (if-not source-table-id - ;; if we have a `source-query`, recurse and resolve tables in that - (update-in expanded-query-dict [:query :source-query] (fn [source-query] - (if (:native source-query) - source-query - (:query (resolve-tables (assoc expanded-query-dict - :query source-query)))))) - ;; otherwise we can resolve tables in the (current) top-level - (let [table-ids (conj table-ids source-table-id) - joined-tables (fk-field-ids->joined-tables source-table-id fk-field-ids) - fk-id+table-id->table (into {[nil source-table-id] source-table} - (for [{:keys [source-field table-id join-alias]} joined-tables] - {[(:field-id source-field) table-id] {:name join-alias - :id table-id}}))] - (as-> expanded-query-dict <> - (assoc-in <> [:query :join-tables] joined-tables) - (walk/postwalk #(resolve-table % fk-id+table-id->table) <>))))) + [{:keys [table-ids fk-field-ids], :as expanded-query-dict}] + (let [{source-table-id :id :as source-table} (qputil/get-in-normalized expanded-query-dict [:query :source-table])] + (if-not source-table-id + ;; if we have a `source-query`, recurse and resolve tables in that + (update-in expanded-query-dict [:query :source-query] (fn [source-query] + (if (:native source-query) + source-query + (:query (resolve-tables (assoc expanded-query-dict + :query source-query)))))) + ;; otherwise we can resolve tables in the (current) top-level + (let [table-ids (conj table-ids source-table-id) + joined-tables (fk-field-ids->joined-tables source-table-id fk-field-ids) + fk-id+table-id->table (into {[nil source-table-id] source-table} + (for [{:keys [source-field table-id join-alias]} joined-tables] + {[(:field-id source-field) table-id] {:name join-alias + :id table-id}}))] + (as-> expanded-query-dict <> + (assoc-in <> [:query :join-tables] joined-tables) + (walk/postwalk #(resolve-table % fk-id+table-id->table) <>)))))) ;;; ------------------------------------------------ PUBLIC INTERFACE ------------------------------------------------ diff --git a/src/metabase/query_processor/middleware/source_table.clj b/src/metabase/query_processor/middleware/source_table.clj index 047323ade34..56718f02cff 100644 --- a/src/metabase/query_processor/middleware/source_table.clj +++ b/src/metabase/query_processor/middleware/source_table.clj @@ -4,21 +4,22 @@ [toucan.db :as db])) (defn- resolve-source-table - [{{source-table-id :source-table} :query :as expanded-query-dict}] - (cond - (not (qputil/mbql-query? expanded-query-dict)) - expanded-query-dict + [expanded-query-dict] + (let [source-table-id (qputil/get-in-normalized expanded-query-dict [:query :source-table])] + (cond + (not (qputil/mbql-query? expanded-query-dict)) + expanded-query-dict - (nil? source-table-id) - (update-in expanded-query-dict [:query :source-query] (fn [source-query] - (if (:native source-query) - source-query - (:query (resolve-source-table (assoc expanded-query-dict :query source-query)))))) + (nil? source-table-id) + (update-in expanded-query-dict [:query :source-query] (fn [source-query] + (if (:native source-query) + source-query + (:query (resolve-source-table (assoc expanded-query-dict :query source-query)))))) - :else - (let [source-table (or (db/select-one [Table :schema :name :id], :id source-table-id) - (throw (Exception. (format "Query expansion failed: could not find source table %d." source-table-id))))] - (assoc-in expanded-query-dict [:query :source-table] source-table)))) + :else + (let [source-table (or (db/select-one [Table :schema :name :id], :id source-table-id) + (throw (Exception. (format "Query expansion failed: could not find source table %d." source-table-id))))] + (assoc-in expanded-query-dict [:query :source-table] source-table))))) (defn resolve-source-table-middleware "Middleware that will take the source-table (an integer) and hydrate diff --git a/test/metabase/api/common_test.clj b/test/metabase/api/common_test.clj index c738b4ed259..335b67793f8 100644 --- a/test/metabase/api/common_test.clj +++ b/test/metabase/api/common_test.clj @@ -1,8 +1,9 @@ (ns metabase.api.common-test (:require [clojure.core.async :as async] [expectations :refer :all] - [metabase.api.common :refer :all :as api] + [metabase.api.common :as api :refer :all] [metabase.api.common.internal :refer :all] + [metabase.middleware :as mb-middleware] [metabase.test.data :refer :all] [metabase.util.schema :as su])) @@ -13,33 +14,41 @@ {:status 404 :body "Not found."}) -(defn ^:private my-mock-api-fn [_] - (catch-api-exceptions - (check-404 @*current-user*) - {:status 200 - :body @*current-user*})) +(defn ^:private my-mock-api-fn [] + ((mb-middleware/catch-api-exceptions + (fn [_] + (check-404 @*current-user*) + {:status 200 + :body @*current-user*})) + nil)) ; check that `check-404` doesn't throw an exception if TEST is true (expect {:status 200 :body "Cam Saul"} (binding [*current-user* (atom "Cam Saul")] - (my-mock-api-fn nil))) + (my-mock-api-fn))) ; check that 404 is returned otherwise (expect four-oh-four - (my-mock-api-fn nil)) + (my-mock-api-fn)) ;;let-404 should return nil if test fails -(expect four-oh-four - (catch-api-exceptions - (let-404 [user nil] - {:user user}))) +(expect + four-oh-four + ((mb-middleware/catch-api-exceptions + (fn [_] + (let-404 [user nil] + {:user user}))) + nil)) ;; otherwise let-404 should bind as expected -(expect {:user {:name "Cam"}} - (catch-api-exceptions - (let-404 [user {:name "Cam"}] - {:user user}))) +(expect + {:user {:name "Cam"}} + ((mb-middleware/catch-api-exceptions + (fn [_] + (let-404 [user {:name "Cam"}] + {:user user}))) + nil)) (defmacro ^:private expect-expansion @@ -53,7 +62,8 @@ ;;; TESTS FOR AUTO-PARSE -;; TODO - these need to be moved to `metabase.api.common.internal-test`. But first `expect-expansion` needs to be put somewhere central +;; TODO - these need to be moved to `metabase.api.common.internal-test`. But first `expect-expansion` needs to be put +;; somewhere central ;; when auto-parse gets an args form where arg is present in *autoparse-types* ;; the appropriate let binding should be generated @@ -88,10 +98,9 @@ (expect-expansion (def GET_:id (GET ["/:id" :id "#[0-9]+"] [id] - (metabase.api.common.internal/catch-api-exceptions - (metabase.api.common.internal/auto-parse [id] - (metabase.api.common.internal/validate-param 'id id su/IntGreaterThanZero) - (metabase.api.common.internal/wrap-response-if-needed (do (select-one Card :id id))))))) + (metabase.api.common.internal/auto-parse [id] + (metabase.api.common.internal/validate-param 'id id su/IntGreaterThanZero) + (metabase.api.common.internal/wrap-response-if-needed (do (select-one Card :id id)))))) (defendpoint GET "/:id" [id] {id su/IntGreaterThanZero} (select-one Card :id id)))) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index 2063529d875..2854fd26825 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -113,7 +113,8 @@ :pulse_id nil :card_id nil :dashboard_id nil}] - ;; Error message's format can differ a bit depending on DB version and the comment we prepend to it, so check that it exists and contains the substring "Syntax error in SQL statement" + ;; Error message's format can differ a bit depending on DB version and the comment we prepend to it, so check that + ;; it exists and contains the substring "Syntax error in SQL statement" (let [check-error-message (fn [output] (update output :error (fn [error-message] (boolean (re-find #"Syntax error in SQL statement" error-message))))) diff --git a/test/metabase/models/card_test.clj b/test/metabase/models/card_test.clj index e67f016da8a..9edb80f9eae 100644 --- a/test/metabase/models/card_test.clj +++ b/test/metabase/models/card_test.clj @@ -4,8 +4,7 @@ [metabase.models [card :as card :refer :all] [dashboard :refer [Dashboard]] - [dashboard-card :refer [DashboardCard]] - [permissions :as perms]] + [dashboard-card :refer [DashboardCard]]] [metabase.test [data :as data] [util :as tu]] diff --git a/test/metabase/query_processor/middleware/permissions_test.clj b/test/metabase/query_processor/middleware/permissions_test.clj index f926a718052..be3515731f5 100644 --- a/test/metabase/query_processor/middleware/permissions_test.clj +++ b/test/metabase/query_processor/middleware/permissions_test.clj @@ -18,9 +18,7 @@ (defn- do-with-rasta "Call F with rasta as the current user." [f] - (binding [api/*current-user-id* (users/user->id :rasta) - api/*current-user-permissions-set* (atom (user/permissions-set (users/user->id :rasta)))] - (f))) + (users/call-with-api-vars :rasta f)) (defn- check-perms-for-rasta "Check permissions for QUERY with rasta as the current user." diff --git a/test/metabase/test/data/users.clj b/test/metabase/test/data/users.clj index c0839c85183..4de1ebf5b27 100644 --- a/test/metabase/test/data/users.clj +++ b/test/metabase/test/data/users.clj @@ -5,8 +5,9 @@ [config :as config] [http-client :as http] [util :as u]] + [metabase.api.common :as api] [metabase.core.initialization-status :as init-status] - [metabase.models.user :refer [User]] + [metabase.models.user :as user :refer [User]] [toucan.db :as db]) (:import clojure.lang.ExceptionInfo)) @@ -161,3 +162,12 @@ remove this. (TODO)" [] (db/delete! User :id [:not-in (map user->id [:crowberto :lucky :rasta :trashbird])])) + +(defn call-with-api-vars + "Call `f` with various `metabase.api.common` dynamic vars bound to the test User named by `user-kwd`." + [user-kwd f] + (binding [api/*current-user* (delay (User (user->id user-kwd))) + api/*current-user-id* (user->id user-kwd) + api/*is-superuser?* (db/select-one-field :is_superuser User :id (user->id user-kwd)) + api/*current-user-permissions-set* (delay (user/permissions-set (user->id user-kwd)))] + (f))) diff --git a/test/metabase/test_setup.clj b/test/metabase/test_setup.clj index cf5cd773d70..67243da80c3 100644 --- a/test/metabase/test_setup.clj +++ b/test/metabase/test_setup.clj @@ -9,6 +9,7 @@ [core :as core] [db :as mdb] [driver :as driver] + [plugins :as plugins] [util :as u]] [metabase.core.initialization-status :as init-status] [metabase.models.setting :as setting])) @@ -70,8 +71,8 @@ ;; Start Jetty in the BG so if test setup fails we have an easier time debugging it -- it's trickier to debug things ;; on a BG thread (let [start-jetty! (future (core/start-jetty!))] - (try + (plugins/setup-plugins!) (log/info (format "Setting up %s test DB and running migrations..." (name (mdb/db-type)))) (mdb/setup-db! :auto-migrate true) (setting/set! :site-name "Metabase Test") -- GitLab