From 8c2c556ad3ebf9025a9f185832303ade4ae82e50 Mon Sep 17 00:00:00 2001 From: Braden Shepherdson <braden@metabase.com> Date: Tue, 16 Apr 2024 13:16:24 -0400 Subject: [PATCH] Use cached Malli validators/explainers; make `:fn` schemas cacheable (#39947) This improves our Malli performance substantially! Many of our code paths were calling vanilla `malli.core/validate` or `malli.core/explain`; these redo the (possibly expensive) compilation of the schema into a validator or explainer for every call. We should use the caching versions in `metabase.util.malli.registry` everywhere, and our runtime performance will be much improved. However when I started using these cached versions, I found that the memory use was growing out of control. Eventually I tracked this down to `:fn` schemas. Functions are only comparable by pointer equality, so they make poor cache keys. `(fn ...)` or `comp` calls in a schema on a `mu/defn` function **get re-created for every call of the `mu/defn`'d function**! That's a big time sink recompiling the schemas if we're *not* caching, and a huge memory sink if we *are* caching! This PR pulls every such schema I could find out into a `def` so it uses the same closure and is cacheable. I'd like to automate that in some part of the Malli pipeline, or maybe a linter rule, but I haven't found a good way to do it yet. Part of #39946. --- .../metabase_enterprise/sandbox/api/table.clj | 5 +- src/metabase/api/database.clj | 2 +- src/metabase/api/native_query_snippet.clj | 2 +- src/metabase/domain_entities/core.clj | 4 +- .../driver/common/parameters/dates.clj | 4 +- src/metabase/driver/sql_jdbc/execute.clj | 19 ++++--- src/metabase/email.clj | 2 +- src/metabase/integrations/google.clj | 4 +- src/metabase/lib/convert.cljc | 6 +-- src/metabase/lib/metadata/calculation.cljc | 13 +++-- src/metabase/lib/query.cljc | 6 +-- src/metabase/lib/remove_replace.cljc | 6 ++- src/metabase/lib/schema/common.cljc | 9 ++-- .../lib/schema/expression/arithmetic.cljc | 3 +- .../lib/schema/expression/temporal.cljc | 3 +- src/metabase/lib/schema/literal.cljc | 3 +- src/metabase/lib/schema/mbql_clause.cljc | 5 +- src/metabase/models/collection.clj | 16 +++--- src/metabase/models/dispatch.clj | 13 ----- src/metabase/models/interface.clj | 1 - src/metabase/models/pulse.clj | 26 +++++----- src/metabase/pulse.clj | 3 +- src/metabase/query_processor/execute.clj | 15 +++--- .../query_processor/middleware/annotate.clj | 13 +++-- .../middleware/normalize_query.clj | 19 ++++--- .../sync_metadata/sync_table_privileges.clj | 4 +- src/metabase/transforms/core.clj | 3 +- src/metabase/util/date_2/parse.clj | 8 ++- src/metabase/util/malli/schema.clj | 49 ++++++++++--------- test/metabase/test/data.clj | 21 ++++---- 30 files changed, 152 insertions(+), 135 deletions(-) diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj b/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj index a4ecb26ea56..85d1e604c51 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj @@ -7,14 +7,13 @@ [metabase.lib.util.match :as lib.util.match] [metabase.models.card :refer [Card]] [metabase.models.data-permissions :as data-perms] - [metabase.models.interface :as mi] [metabase.models.table :as table :refer [Table]] [metabase.util :as u] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) -(mu/defn ^:private find-gtap-question :- [:maybe (mi/InstanceOf Card)] +(mu/defn ^:private find-gtap-question :- [:maybe (ms/InstanceOf Card)] "Find the associated GTAP question (if there is one) for the given `table-or-table-id` and `user-or-user-id`. Returns nil if no question was found." [table-or-table-id user-or-user-id] @@ -30,7 +29,7 @@ (mu/defn only-sandboxed-perms? :- :boolean "Returns true if the user has sandboxed permissions. If a sandbox policy exists, it overrides existing permission on the table." - [table :- (mi/InstanceOf Table)] + [table :- (ms/InstanceOf Table)] (contains? (->> (sandbox.api.util/enforced-sandboxes-for api/*current-user-id*) (map :table_id) set) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 46e5efc2459..3201a2b39e7 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -313,7 +313,7 @@ ;;; --------------------------------------------- GET /api/database/:id ---------------------------------------------- -(mu/defn ^:private expanded-schedules [db :- (mi/InstanceOf Database)] +(mu/defn ^:private expanded-schedules [db :- (ms/InstanceOf Database)] {:metadata_sync (u.cron/cron-string->schedule-map (:metadata_sync_schedule db)) :cache_field_values (some-> (:cache_field_values_schedule db) u.cron/cron-string->schedule-map)}) diff --git a/src/metabase/api/native_query_snippet.clj b/src/metabase/api/native_query_snippet.clj index b39fbb70353..b4ae3f15558 100644 --- a/src/metabase/api/native_query_snippet.clj +++ b/src/metabase/api/native_query_snippet.clj @@ -16,7 +16,7 @@ (set! *warn-on-reflection* true) -(mu/defn ^:private hydrated-native-query-snippet :- [:maybe (mi/InstanceOf NativeQuerySnippet)] +(mu/defn ^:private hydrated-native-query-snippet :- [:maybe (ms/InstanceOf NativeQuerySnippet)] [id :- ms/PositiveInt] (-> (api/read-check (t2/select-one NativeQuerySnippet :id id)) (t2/hydrate :creator))) diff --git a/src/metabase/domain_entities/core.clj b/src/metabase/domain_entities/core.clj index b1bd6a30421..29a74ef4a03 100644 --- a/src/metabase/domain_entities/core.clj +++ b/src/metabase/domain_entities/core.clj @@ -6,10 +6,10 @@ [metabase.legacy-mbql.util :as mbql.u] [metabase.lib.util.match :as lib.util.match] [metabase.models.card :refer [Card]] - [metabase.models.interface :as mi] [metabase.models.table :as table :refer [Table]] [metabase.util :as u] [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) (def ^:private ^{:arglists '([field])} field-type @@ -28,7 +28,7 @@ (def SourceEntity "A source for a card. Can be either a table or another card." - [:or (mi/InstanceOf Table) (mi/InstanceOf Card)]) + [:or (ms/InstanceOf Table) (ms/InstanceOf Card)]) (def Bindings "Top-level lexical context mapping source names to their corresponding entity and constituent dimensions. See also diff --git a/src/metabase/driver/common/parameters/dates.clj b/src/metabase/driver/common/parameters/dates.clj index 5a858eef388..0c9c886926f 100644 --- a/src/metabase/driver/common/parameters/dates.clj +++ b/src/metabase/driver/common/parameters/dates.clj @@ -387,8 +387,8 @@ (def ^:private TemporalRange [:map - [:start {:optional true} [:fn #(instance? Temporal %)]] - [:end {:optional true} [:fn #(instance? Temporal %)]] + [:start {:optional true} (lib.schema.common/instance-of-class Temporal)] + [:end {:optional true} (lib.schema.common/instance-of-class Temporal)] [:unit TemporalUnit]]) (mu/defn ^:private adjust-inclusive-range-if-needed :- [:maybe TemporalRange] diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj index 73b52074f48..10d02abe8e6 100644 --- a/src/metabase/driver/sql_jdbc/execute.clj +++ b/src/metabase/driver/sql_jdbc/execute.clj @@ -251,6 +251,16 @@ (seq more) (recur more))))) +(def ^:private DbOrIdOrSpec + [:and + [:or :int :map] + [:fn + ;; can't wrap a java.sql.Connection here because we're not + ;; responsible for its lifecycle and that means you can't use + ;; `with-open` on the Connection you'd get from the DataSource + {:error/message "Cannot be a JDBC spec wrapping a java.sql.Connection"} + (complement :connection)]]) + (mu/defn do-with-resolved-connection-data-source :- (lib.schema.common/instance-of-class DataSource) "Part of the default implementation for [[do-with-connection-with-options]]: get an appropriate `java.sql.DataSource` for `db-or-id-or-spec`. Not for use with a JDBC spec wrapping a `java.sql.Connection` (a spec with the key @@ -258,14 +268,7 @@ Connections provided by this DataSource." {:added "0.47.0", :arglists '(^javax.sql.DataSource [driver db-or-id-or-spec options])} [driver :- :keyword - db-or-id-or-spec :- [:and - [:or :int :map] - [:fn - ;; can't wrap a java.sql.Connection here because we're not - ;; responsible for its lifecycle and that means you can't use - ;; `with-open` on the Connection you'd get from the DataSource - {:error/message "Cannot be a JDBC spec wrapping a java.sql.Connection"} - (complement :connection)]] + db-or-id-or-spec :- DbOrIdOrSpec {:keys [^String session-timezone], :as _options} :- ConnectionOptions] (if-not (u/id db-or-id-or-spec) ;; not a Database or Database ID... this is a raw `clojure.java.jdbc` spec, use that diff --git a/src/metabase/email.clj b/src/metabase/email.clj index 244170bb0cf..1d0d33384f8 100644 --- a/src/metabase/email.clj +++ b/src/metabase/email.clj @@ -184,7 +184,7 @@ `:metabase.email/error`, which will either be `nil` (indicating no error) or an instance of [[java.lang.Throwable]] with the error." [:map {:closed true} - [::error [:maybe [:fn #(instance? Throwable %)]]]]) + [::error [:maybe (ms/InstanceOfClass Throwable)]]]) (defn send-message! "Send an email to one or more `:recipients`. `:recipients` is a sequence of email addresses; `:message-type` must be diff --git a/src/metabase/integrations/google.clj b/src/metabase/integrations/google.clj index 75b76019d11..2b4accb8595 100644 --- a/src/metabase/integrations/google.clj +++ b/src/metabase/integrations/google.clj @@ -6,7 +6,6 @@ [metabase.api.common :as api] [metabase.config :as config] [metabase.integrations.google.interface :as google.i] - [metabase.models.interface :as mi] [metabase.models.setting :as setting :refer [defsetting]] [metabase.models.setting.multi-setting :refer [define-multi-setting-impl]] @@ -16,6 +15,7 @@ [metabase.util.i18n :refer [deferred-tru tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) ;; Load EE implementation if available @@ -124,7 +124,7 @@ :last_name last-name})) (assoc user :first_name first-name :last_name last-name)) -(mu/defn ^:private google-auth-fetch-or-create-user! :- (mi/InstanceOf User) +(mu/defn ^:private google-auth-fetch-or-create-user! :- (ms/InstanceOf User) [first-name last-name email] (let [existing-user (t2/select-one [User :id :email :last_login :first_name :last_name] :%lower.email (u/lower-case-en email))] (if existing-user diff --git a/src/metabase/lib/convert.cljc b/src/metabase/lib/convert.cljc index 4195dc2d7b1..6c7ceea52da 100644 --- a/src/metabase/lib/convert.cljc +++ b/src/metabase/lib/convert.cljc @@ -3,7 +3,6 @@ [clojure.data :as data] [clojure.set :as set] [clojure.string :as str] - [malli.core :as mc] [malli.error :as me] [medley.core :as m] [metabase.legacy-mbql.normalize :as mbql.normalize] @@ -17,6 +16,7 @@ [metabase.lib.util :as lib.util] [metabase.util :as u] [metabase.util.malli :as mu] + [metabase.util.malli.registry :as mr] #?@(:clj ([metabase.util.log :as log]))) #?@(:cljs [(:require-macros [metabase.lib.convert :refer [with-aggregation-list]])])) @@ -52,7 +52,7 @@ (binding [lib.schema.expression/*suppress-expression-type-check?* true] (loop [almost-stage almost-stage removals []] - (if-let [[error-type error-location] (->> (mc/explain ::lib.schema/stage.mbql almost-stage) + (if-let [[error-type error-location] (->> (mr/explain ::lib.schema/stage.mbql almost-stage) :errors (filter (comp stage-keys first :in)) (map (juxt :type :in)) @@ -61,7 +61,7 @@ error-desc (pr-str (or error-type ;; if `error-type` is missing, which seems to happen sometimes, ;; fall back to humanizing the entire error. - (me/humanize (mc/explain ::lib.schema/stage.mbql almost-stage))))] + (me/humanize (mr/explain ::lib.schema/stage.mbql almost-stage))))] #?(:cljs (js/console.warn "Clean: Removing bad clause due to error!" error-location error-desc (u/pprint-to-str (first (data/diff almost-stage new-stage)))) :clj (log/warnf "Clean: Removing bad clause in %s due to error %s:\n%s" diff --git a/src/metabase/lib/metadata/calculation.cljc b/src/metabase/lib/metadata/calculation.cljc index a06980c5c8d..c3ea6e8f872 100644 --- a/src/metabase/lib/metadata/calculation.cljc +++ b/src/metabase/lib/metadata/calculation.cljc @@ -235,11 +235,14 @@ {:query query, :stage-number stage-number, :x x} e))))) -(mu/defn metadata :- [:map [:lib/type [:and - :keyword - [:fn - {:error/message ":lib/type should be a :metadata/ keyword"} - #(= (namespace %) "metadata")]]]] +(def ^:private MetadataMap + [:map [:lib/type [:and + :keyword + [:fn + {:error/message ":lib/type should be a :metadata/ keyword"} + #(= (namespace %) "metadata")]]]]) + +(mu/defn metadata :- MetadataMap "Calculate an appropriate `:metadata/*` object for something. What this looks like depends on what we're calculating metadata for. If it's a reference or expression of some sort, this should return a single `:metadata/column` map (i.e., something satisfying the `::lib.schema.metadata/column` schema." diff --git a/src/metabase/lib/query.cljc b/src/metabase/lib/query.cljc index f5588169b69..6f58d687131 100644 --- a/src/metabase/lib/query.cljc +++ b/src/metabase/lib/query.cljc @@ -1,7 +1,6 @@ (ns metabase.lib.query (:refer-clojure :exclude [remove]) (:require - [malli.core :as mc] [medley.core :as m] [metabase.legacy-mbql.normalize :as mbql.normalize] [metabase.lib.convert :as lib.convert] @@ -19,7 +18,8 @@ [metabase.lib.util.match :as lib.util.match] [metabase.shared.util.i18n :as i18n] [metabase.util :as u] - [metabase.util.malli :as mu])) + [metabase.util.malli :as mu] + [metabase.util.malli.registry :as mr])) (defmethod lib.metadata.calculation/metadata-method :mbql/query [_query _stage-number _query] @@ -64,7 +64,7 @@ "Returns whether the query is runnable. Manually validate schema for cljs." [query :- ::lib.schema/query] (and (binding [lib.schema.expression/*suppress-expression-type-check?* true] - (mc/validate ::lib.schema/query query)) + (mr/validate ::lib.schema/query query)) (boolean (can-run-method query)))) (mu/defn can-save :- :boolean diff --git a/src/metabase/lib/remove_replace.cljc b/src/metabase/lib/remove_replace.cljc index 374224d61b0..eb1fdb0844c 100644 --- a/src/metabase/lib/remove_replace.cljc +++ b/src/metabase/lib/remove_replace.cljc @@ -12,10 +12,12 @@ [metabase.lib.metadata.calculation :as lib.metadata.calculation] [metabase.lib.options :as lib.options] [metabase.lib.ref :as lib.ref] + [metabase.lib.schema :as lib.schema] [metabase.lib.util :as lib.util] [metabase.lib.util.match :as lib.util.match] [metabase.util :as u] - [metabase.util.malli :as mu])) + [metabase.util.malli :as mu] + [metabase.util.malli.registry :as mr])) (defn- stage-paths [query stage-number] @@ -339,7 +341,7 @@ replacement :- :metabase.lib.schema.expression/expression] (mu/disable-enforcement (loop [query (tweak-expression unmodified-query stage-number target replacement)] - (let [explanation (mc/explain :metabase.lib.schema/query query) + (let [explanation (mr/explain ::lib.schema/query query) error-paths (->> (:errors explanation) (keep #(on-stage-path query %)) distinct)] diff --git a/src/metabase/lib/schema/common.cljc b/src/metabase/lib/schema/common.cljc index 3ee80512a06..6f588a25791 100644 --- a/src/metabase/lib/schema/common.cljc +++ b/src/metabase/lib/schema/common.cljc @@ -155,9 +155,7 @@ [:options {:optional true} ::options]]) #?(:clj - (defn instance-of-class - "Convenience for defining a Malli schema for an instance of a particular Class." - [& classes] + (defn- instance-of-class* [& classes] [:fn {:error/message (str "instance of " (str/join " or " (map #(.getName ^Class %) classes)))} @@ -165,3 +163,8 @@ (some (fn [klass] (instance? klass x)) classes))])) + +#?(:clj + (def ^{:arglists '([& classes])} instance-of-class + "Convenience for defining a Malli schema for an instance of a particular Class." + (memoize instance-of-class*))) diff --git a/src/metabase/lib/schema/expression/arithmetic.cljc b/src/metabase/lib/schema/expression/arithmetic.cljc index 6c4fc42a531..bc7557f9d3e 100644 --- a/src/metabase/lib/schema/expression/arithmetic.cljc +++ b/src/metabase/lib/schema/expression/arithmetic.cljc @@ -1,7 +1,6 @@ (ns metabase.lib.schema.expression.arithmetic "Arithmetic expressions like `:+`." (:require - [malli.core :as mc] [medley.core :as m] [metabase.lib.hierarchy :as lib.hierarchy] [metabase.lib.schema.common :as common] @@ -17,7 +16,7 @@ (isa? expr-type :type/Time) ::temporal-bucketing/unit.time.interval (isa? expr-type :type/DateTime) ::temporal-bucketing/unit.date-time.interval)] (if unit-schema - (mc/validate unit-schema unit) + (mr/validate unit-schema unit) true))) (mr/def ::args.numbers diff --git a/src/metabase/lib/schema/expression/temporal.cljc b/src/metabase/lib/schema/expression/temporal.cljc index b3c9f84aea7..b9b44d7448a 100644 --- a/src/metabase/lib/schema/expression/temporal.cljc +++ b/src/metabase/lib/schema/expression/temporal.cljc @@ -1,7 +1,6 @@ (ns metabase.lib.schema.expression.temporal (:require [clojure.set :as set] - [malli.core :as mc] [metabase.lib.hierarchy :as lib.hierarchy] [metabase.lib.schema.common :as common] [metabase.lib.schema.expression :as expression] @@ -152,7 +151,7 @@ (when (= value :current) (cond (= unit :default) :type/DateTime - (mc/validate ::temporal-bucketing/unit.date unit) :type/Date + (mr/validate ::temporal-bucketing/unit.date unit) :type/Date :else :type/DateTime)) ;; handle year-month and year string regexes, which are not allowed as date literals unless wrapped in ;; `:absolute-datetime`. diff --git a/src/metabase/lib/schema/literal.cljc b/src/metabase/lib/schema/literal.cljc index f4590245a8f..6912442b0de 100644 --- a/src/metabase/lib/schema/literal.cljc +++ b/src/metabase/lib/schema/literal.cljc @@ -2,7 +2,6 @@ "Malli schemas for string, temporal, number, and boolean literals." (:require #?@(:clj ([metabase.lib.schema.literal.jvm])) - [malli.core :as mc] [metabase.lib.schema.common :as common] [metabase.lib.schema.expression :as expression] [metabase.lib.schema.mbql-clause :as mbql-clause] @@ -81,7 +80,7 @@ (defmethod expression/type-of-method :dispatch-type/string [s] - (condp mc/validate s + (condp mr/validate s ::string.datetime #{:type/Text :type/DateTime} ::string.date #{:type/Text :type/Date} ::string.time #{:type/Text :type/Time} diff --git a/src/metabase/lib/schema/mbql_clause.cljc b/src/metabase/lib/schema/mbql_clause.cljc index 42211013142..3495ef281aa 100644 --- a/src/metabase/lib/schema/mbql_clause.cljc +++ b/src/metabase/lib/schema/mbql_clause.cljc @@ -18,6 +18,9 @@ [tag] (keyword "mbql.clause" (name tag))) +(def ^:private invalid-clause-schema + [:fn {:error/message "not a known MBQL clause"} (constantly false)]) + (defn- clause-schema "Build the schema for `::clause`, a `:multi` schema that maps MBQL clause tag -> the schema in [[clause-schema-registry]]." @@ -28,7 +31,7 @@ (if-let [tag (common/mbql-clause-tag value)] (str "Invalid " tag " clause: " (pr-str value)) "not an MBQL clause"))} - [::mc/default [:fn {:error/message "not a known MBQL clause"} (constantly false)]]] + [::mc/default invalid-clause-schema]] (map (fn [tag] [tag [:ref (tag->registered-schema-name tag)]])) @tag-registry)) diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj index 4a06ea65368..15a6ef0c84e 100644 --- a/src/metabase/models/collection.clj +++ b/src/metabase/models/collection.clj @@ -375,7 +375,7 @@ ;;; | Nested Collections: Ancestors, Childrens, Child Collections | ;;; +----------------------------------------------------------------------------------------------------------------+ -(mu/defn ^:private ancestors* :- [:maybe [:sequential (mi/InstanceOf Collection)]] +(mu/defn ^:private ancestors* :- [:maybe [:sequential (ms/InstanceOf Collection)]] [{:keys [location]}] (when-let [ancestor-ids (seq (location-path->ids location))] (t2/select [Collection :name :id :personal_owner_id] @@ -389,7 +389,7 @@ [collection] (ancestors* collection)) -(mu/defn ^:private effective-ancestors* :- [:sequential [:or RootCollection (mi/InstanceOf Collection)]] +(mu/defn ^:private effective-ancestors* :- [:sequential [:or RootCollection (ms/InstanceOf Collection)]] [collection :- CollectionWithLocationAndIDOrRoot] (if (collection.root/is-root-collection? collection) [] @@ -441,7 +441,7 @@ (def ^:private Children [:schema {:registry {::children [:and - (mi/InstanceOf Collection) + (ms/InstanceOf Collection) [:map [:children [:set [:ref ::children]]]]]}} [:ref ::children]]) @@ -548,7 +548,7 @@ :from [[:collection :col]] :where (apply effective-children-where-clause collection additional-honeysql-where-clauses)}) -(mu/defn ^:private effective-children* :- [:set (mi/InstanceOf Collection)] +(mu/defn ^:private effective-children* :- [:set (ms/InstanceOf Collection)] [collection :- CollectionWithLocationAndIDOrRoot & additional-honeysql-where-clauses] (set (t2/select [Collection :id :name :description] {:where (apply effective-children-where-clause collection additional-honeysql-where-clauses)}))) @@ -822,7 +822,7 @@ would immediately become invisible to all save admins, because no Group would have perms for it. This is obviously a bad experience -- we do not want a User to move a Collection that they have read/write perms for (by definition) to somewhere else and lose all access for it." - [collection :- (mi/InstanceOf Collection) new-location :- LocationPath] + [collection :- (ms/InstanceOf Collection) new-location :- LocationPath] (copy-collection-permissions! (parent {:location new-location}) (cons collection (descendants collection)))) (mu/defn ^:private revoke-perms-when-moving-into-personal-collection! @@ -831,7 +831,7 @@ need to be deleted, so other users cannot access this newly-Personal Collection. This needs to be done recursively for all descendants as well." - [collection :- (mi/InstanceOf Collection)] + [collection :- (ms/InstanceOf Collection)] (t2/query-one {:delete-from :permissions :where [:in :object (for [collection (cons collection (descendants collection)) path-fn [perms/collection-read-path @@ -1107,14 +1107,14 @@ :name collection-name :slug (u/slugify collection-name))))) -(mu/defn user->existing-personal-collection :- [:maybe (mi/InstanceOf Collection)] +(mu/defn user->existing-personal-collection :- [:maybe (ms/InstanceOf Collection)] "For a `user-or-id`, return their personal Collection, if it already exists. Use [[metabase.models.collection/user->personal-collection]] to fetch their personal Collection *and* create it if needed." [user-or-id] (t2/select-one Collection :personal_owner_id (u/the-id user-or-id))) -(mu/defn user->personal-collection :- (mi/InstanceOf Collection) +(mu/defn user->personal-collection :- (ms/InstanceOf Collection) "Return the Personal Collection for `user-or-id`, if it already exists; if not, create it and return it." [user-or-id] (or (user->existing-personal-collection user-or-id) diff --git a/src/metabase/models/dispatch.clj b/src/metabase/models/dispatch.clj index 020ad5b89c9..0716c9e2cdd 100644 --- a/src/metabase/models/dispatch.clj +++ b/src/metabase/models/dispatch.clj @@ -15,16 +15,3 @@ "True if `x` is a Toucan instance, but not a Toucan model." [x] (t2/instance? x)) - -(defn InstanceOf - "Helper for creating a Malli schema to check whether something is an instance of `model`. Use this instead of of using - the `<Model>Instance` or calling [[type]] or [[class]] on a model yourself, since that won't work once we switch to - Toucan 2. - - (mu/defn my-fn :- (mi/InstanceOf User) - [] - ...)" - [model] - [:fn - {:error/message (format "instance of a %s" (name model))} - (partial instance-of? model)]) diff --git a/src/metabase/models/interface.clj b/src/metabase/models/interface.clj index 2a4a6554436..4808d42a271 100644 --- a/src/metabase/models/interface.clj +++ b/src/metabase/models/interface.clj @@ -43,7 +43,6 @@ (p/import-vars [models.dispatch toucan-instance? - InstanceOf instance-of? model instance]) diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj index de167bbc194..ddac37283e3 100644 --- a/src/metabase/models/pulse.clj +++ b/src/metabase/models/pulse.clj @@ -257,29 +257,29 @@ ;;; ---------------------------------------- Notification Fetching Helper Fns ---------------------------------------- -(mu/defn hydrate-notification :- (mi/InstanceOf Pulse) +(mu/defn hydrate-notification :- (ms/InstanceOf Pulse) "Hydrate Pulse or Alert with the Fields needed for sending it." - [notification :- (mi/InstanceOf Pulse)] + [notification :- (ms/InstanceOf Pulse)] (-> notification (t2/hydrate :creator :cards [:channels :recipients]) (m/dissoc-in [:details :emails]))) -(mu/defn ^:private hydrate-notifications :- [:sequential (mi/InstanceOf Pulse)] +(mu/defn ^:private hydrate-notifications :- [:sequential (ms/InstanceOf Pulse)] "Batched-hydrate multiple Pulses or Alerts." - [notifications :- [:sequential (mi/InstanceOf Pulse)]] + [notifications :- [:sequential (ms/InstanceOf Pulse)]] (as-> notifications <> (t2/hydrate <> :creator :cards [:channels :recipients]) (map #(m/dissoc-in % [:details :emails]) <>))) -(mu/defn ^:private notification->pulse :- (mi/InstanceOf Pulse) +(mu/defn ^:private notification->pulse :- (ms/InstanceOf Pulse) "Take a generic `Notification`, and put it in the standard Pulse format the frontend expects. This really just consists of removing associated `Alert` columns." - [notification :- (mi/InstanceOf Pulse)] + [notification :- (ms/InstanceOf Pulse)] (dissoc notification :alert_condition :alert_above_goal :alert_first_only)) ;; TODO - do we really need this function? Why can't we just use `t2/select` and `hydrate` like we do for everything ;; else? (#40016) -(mu/defn retrieve-pulse :- [:maybe (mi/InstanceOf Pulse)] +(mu/defn retrieve-pulse :- [:maybe (ms/InstanceOf Pulse)] "Fetch a single *Pulse*, and hydrate it with a set of 'standard' hydrations; remove Alert columns, since this is a *Pulse* and they will all be unset." [pulse-or-id] @@ -287,7 +287,7 @@ hydrate-notification notification->pulse)) -(mu/defn retrieve-notification :- [:maybe (mi/InstanceOf Pulse)] +(mu/defn retrieve-notification :- [:maybe (ms/InstanceOf Pulse)] "Fetch an Alert or Pulse, and do the 'standard' hydrations, adding `:channels` with `:recipients`, `:creator`, and `:cards`." [notification-or-id & additional-conditions] @@ -295,15 +295,15 @@ (some-> (apply t2/select-one Pulse :id (u/the-id notification-or-id), additional-conditions) hydrate-notification)) -(mu/defn ^:private notification->alert :- (mi/InstanceOf Pulse) +(mu/defn ^:private notification->alert :- (ms/InstanceOf Pulse) "Take a generic `Notification` and put it in the standard `Alert` format the frontend expects. This really just consists of collapsing `:cards` into a `:card` key with whatever the first Card is." - [notification :- (mi/InstanceOf Pulse)] + [notification :- (ms/InstanceOf Pulse)] (-> notification (assoc :card (first (:cards notification))) (dissoc :cards))) -(mu/defn retrieve-alert :- [:maybe (mi/InstanceOf Pulse)] +(mu/defn retrieve-alert :- [:maybe (ms/InstanceOf Pulse)] "Fetch a single Alert by its `id` value, do the standard hydrations, and put it in the standard `Alert` format." [alert-or-id] (some-> (t2/select-one Pulse, :id (u/the-id alert-or-id), :alert_condition [:not= nil]) @@ -313,7 +313,7 @@ (defn- query-as [model query] (t2/select model query)) -(mu/defn retrieve-alerts :- [:sequential (mi/InstanceOf Pulse)] +(mu/defn retrieve-alerts :- [:sequential (ms/InstanceOf Pulse)] "Fetch all Alerts." ([] (retrieve-alerts nil)) @@ -342,7 +342,7 @@ :when (:card alert)] alert)))) -(mu/defn retrieve-pulses :- [:sequential (mi/InstanceOf Pulse)] +(mu/defn retrieve-pulses :- [:sequential (ms/InstanceOf Pulse)] "Fetch all `Pulses`. When `user-id` is included, only fetches `Pulses` for which the provided user is the creator or a recipient." [{:keys [archived? dashboard-id user-id] diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index 995180423cb..43ff4778239 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -27,6 +27,7 @@ [metabase.util.i18n :refer [trs tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] [metabase.util.retry :as retry] [metabase.util.ui-logic :as ui-logic] [metabase.util.urls :as urls] @@ -220,7 +221,7 @@ (mu/defn defaulted-timezone :- :string "Returns the timezone ID for the given `card`. Either the report timezone (if applicable) or the JVM timezone." - [card :- (mi/InstanceOf :model/Card)] + [card :- (ms/InstanceOf :model/Card)] (or (some->> card database-id (t2/select-one Database :id) qp.timezone/results-timezone-id) (qp.timezone/system-timezone-id))) diff --git a/src/metabase/query_processor/execute.clj b/src/metabase/query_processor/execute.clj index 94cd44e79e8..0211301e781 100644 --- a/src/metabase/query_processor/execute.clj +++ b/src/metabase/query_processor/execute.clj @@ -60,15 +60,18 @@ (log/infof "%s changed, rebuilding %s" varr `execute*) (rebuild-execute-fn!)))) +(def ^:private CompiledQuery + [:and + [:map + [:database ::lib.schema.id/database]] + [:fn + {:error/message "Query must be compiled -- should have either :native or :qp/compiled."} + (some-fn :native :qp/compiled)]]) + ;;; TODO -- consider whether this should return an `IReduceInit` that we can reduce as a separate step. (mu/defn execute :- some? "Execute a compiled query, then reduce the results." - [compiled-query :- [:and - [:map - [:database ::lib.schema.id/database]] - [:fn - {:error/message "Query must be compiled -- should have either :native or :qp/compiled."} - (some-fn :native :qp/compiled)]] + [compiled-query :- CompiledQuery rff :- ::qp.schema/rff] (qp.setup/with-qp-setup [compiled-query compiled-query] (execute* compiled-query rff))) diff --git a/src/metabase/query_processor/middleware/annotate.clj b/src/metabase/query_processor/middleware/annotate.clj index 0eaaaa6c753..5a259076dda 100644 --- a/src/metabase/query_processor/middleware/annotate.clj +++ b/src/metabase/query_processor/middleware/annotate.clj @@ -366,16 +366,19 @@ (update-keys u/->snake_case_en) (dissoc :lib/type))))) +(def ^:private LegacyInnerQuery + [:and + :map + [:fn + {:error/message "legacy inner-query with :source-table or :source-query"} + (some-fn :source-table :source-query)]]) + (mu/defn aggregation-name :- ::lib.schema.common/non-blank-string "Return an appropriate aggregation name/alias *used inside a query* for an `:aggregation` subclause (an aggregation or expression). Takes an options map as schema won't support passing keypairs directly as a varargs. These names are also used directly in queries, e.g. in the equivalent of a SQL `AS` clause." - [inner-query :- [:and - :map - [:fn - {:error/message "legacy inner-query with :source-table or :source-query"} - (some-fn :source-table :source-query)]] + [inner-query :- LegacyInnerQuery ag-clause] (lib/column-name (mlv2-query inner-query) (lib.convert/->pMBQL ag-clause))) diff --git a/src/metabase/query_processor/middleware/normalize_query.clj b/src/metabase/query_processor/middleware/normalize_query.clj index 7fc64aeaa3e..ca72ffdc479 100644 --- a/src/metabase/query_processor/middleware/normalize_query.clj +++ b/src/metabase/query_processor/middleware/normalize_query.clj @@ -20,14 +20,17 @@ ;; a well-formed pMBQL query, we don't need that and it actually ends up breaking some stuff (lib/query metadata-provider (dissoc query :lib.convert/converted?)))) -(mu/defn normalize-preprocessing-middleware :- [:and - [:map - [:database ::lib.schema.id/database] - [:lib/type {:optional true} [:= :mbql/query]] - [:type {:optional true} [:= :internal]]] - [:fn - {:error/message "valid pMBQL query or :internal audit query"} - (some-fn :lib/type :type)]] +(def ^:private NormalizedQuery + [:and + [:map + [:database ::lib.schema.id/database] + [:lib/type {:optional true} [:= :mbql/query]] + [:type {:optional true} [:= :internal]]] + [:fn + {:error/message "valid pMBQL query or :internal audit query"} + (some-fn :lib/type :type)]]) + +(mu/defn normalize-preprocessing-middleware :- NormalizedQuery "Preprocessing middleware. Normalize a query, meaning do things like convert keys and MBQL clause tags to kebab-case keywords. Convert query to pMBQL if needed." [query :- [:map [:database ::lib.schema.id/database]]] diff --git a/src/metabase/sync/sync_metadata/sync_table_privileges.clj b/src/metabase/sync/sync_metadata/sync_table_privileges.clj index fe1a833e91a..8df930fda35 100644 --- a/src/metabase/sync/sync_metadata/sync_table_privileges.clj +++ b/src/metabase/sync/sync_metadata/sync_table_privileges.clj @@ -2,8 +2,8 @@ (:require [metabase.driver :as driver] [metabase.driver.util :as driver.u] - [metabase.models.interface :as mi] [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) (set! *warn-on-reflection* true) @@ -12,7 +12,7 @@ "Sync the `table_privileges` table with the privileges in the database. This is a cache of the data returned from `driver/table-privileges`, but it's stored in the database for performance." - [database :- (mi/InstanceOf :model/Database)] + [database :- (ms/InstanceOf :model/Database)] (let [driver (driver.u/database->driver database)] (when (and (not= :redshift driver) ;; redshift does support table-privileges, but we don't want to sync it now because table privileges are diff --git a/src/metabase/transforms/core.clj b/src/metabase/transforms/core.clj index bbc52f18f50..daa59707d50 100644 --- a/src/metabase/transforms/core.clj +++ b/src/metabase/transforms/core.clj @@ -19,6 +19,7 @@ [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) (mu/defn ^:private add-bindings :- Bindings @@ -137,7 +138,7 @@ :dimensions (infer-resulting-dimensions local-bindings step query)}))) (def ^:private Tableset - [:sequential (mi/InstanceOf Table)]) + [:sequential (ms/InstanceOf Table)]) (mu/defn ^:private find-tables-with-domain-entity :- Tableset [tableset :- Tableset diff --git a/src/metabase/util/date_2/parse.clj b/src/metabase/util/date_2/parse.clj index 80706d1e00b..c6d63639702 100644 --- a/src/metabase/util/date_2/parse.clj +++ b/src/metabase/util/date_2/parse.clj @@ -42,8 +42,12 @@ :when (.isSupported temporal-accessor field)] [k (.getLong temporal-accessor field)]))) -(mu/defn parse-with-formatter :- [:maybe [:fn {:error/message "Instance of java.time.temporal.Temporal"} - (partial instance? Temporal)]] +(def ^:private InstanceOfTemporal + [:fn + {:error/message "Instance of a java.time.temporal.Temporal"} + (partial instance? Temporal)]) + +(mu/defn parse-with-formatter :- [:maybe InstanceOfTemporal] "Parse a String with a DateTimeFormatter, returning an appropriate instance of an `java.time` temporal class." [formattr s :- [:maybe :string]] diff --git a/src/metabase/util/malli/schema.clj b/src/metabase/util/malli/schema.clj index 7172f2da9eb..b4a5147d194 100644 --- a/src/metabase/util/malli/schema.clj +++ b/src/metabase/util/malli/schema.clj @@ -21,38 +21,41 @@ ;;; -------------------------------------------------- Utils -------------------------------------------------- ;;; TODO -- consider renaming this to `InstanceOfModel` to differentiate it from [[InstanceOfClass]] -(defn InstanceOf +(def ^{:arglists '([model])} InstanceOf "Helper for creating a schema to check whether something is an instance of `model`. (ms/defn my-fn [user :- (ms/InstanceOf User)] ...)" - [model] - (mu/with-api-error-message - [:fn - {:error/message (format "value must be an instance of %s" (name model))} - #(models.dispatch/instance-of? model %)] - (deferred-tru "value must be an instance of {0}" (name model)))) + (memoize + (fn [model] + (mu/with-api-error-message + [:fn + {:error/message (format "value must be an instance of %s" (name model))} + #(models.dispatch/instance-of? model %)] + (deferred-tru "value must be an instance of {0}" (name model)))))) -(defn InstanceOfClass +(def ^{:arglists '([^Class klass])} InstanceOfClass "Helper for creating schemas to check whether something is an instance of a given class." - [^Class klass] - [:fn - {:error/message (format "Instance of a %s" (.getCanonicalName klass))} - (partial instance? klass)]) + (memoize + (fn [^Class klass] + [:fn + {:error/message (format "Instance of a %s" (.getCanonicalName klass))} + (partial instance? klass)]))) -(defn maps-with-unique-key +(def ^{:arglists '([maps-schema k])} maps-with-unique-key "Given a schema of a sequence of maps, returns a schema that does an additional unique check on key `k`." - [maps-schema k] - (mu/with-api-error-message - [:and - [:fn (fn [maps] - (= (count maps) - (-> (map #(get % k) maps) - distinct - count)))] - maps-schema] - (deferred-tru "value must be seq of maps in which {0}s are unique" (name k)))) + (memoize + (fn [maps-schema k] + (mu/with-api-error-message + [:and + [:fn (fn [maps] + (= (count maps) + (-> (map #(get % k) maps) + distinct + count)))] + maps-schema] + (deferred-tru "value must be seq of maps in which {0}s are unique" (name k)))))) ;;; -------------------------------------------------- Schemas -------------------------------------------------- diff --git a/test/metabase/test/data.clj b/test/metabase/test/data.clj index 881c245873f..84e33948709 100644 --- a/test/metabase/test/data.clj +++ b/test/metabase/test/data.clj @@ -187,19 +187,22 @@ [table-name & [query]] `(run-mbql-query* (mbql-query ~table-name ~(or query {})))) +(def ^:private FormattableName + [:or + :keyword + :string + :symbol + [:fn + {:error/message (str "Cannot format `nil` name -- did you use a `$field` without specifying its Table? " + "(Change the form to `$table.field`, or specify a top-level default Table to " + "`$ids` or `mbql-query`.)")} + (constantly false)]]) + (mu/defn format-name :- :string "Format a SQL schema, table, or field identifier in the correct way for the current database by calling the current driver's implementation of [[ddl.i/format-name]]. (Most databases use the default implementation of `identity`; H2 uses [[clojure.string/upper-case]].) This function DOES NOT quote the identifier." - [a-name :- [:or - :keyword - :string - :symbol - [:fn - {:error/message (str "Cannot format `nil` name -- did you use a `$field` without specifying its Table? " - "(Change the form to `$table.field`, or specify a top-level default Table to " - "`$ids` or `mbql-query`.)")} - (constantly false)]]] + [a-name :- FormattableName] (ddl.i/format-name (tx/driver) (name a-name))) (defn id -- GitLab