Skip to content
Snippets Groups Projects
Commit 16f4c256 authored by Cam Saul's avatar Cam Saul
Browse files

mbql.u/match and mbql.u/replace macros [ci drivers]

parent 3aff972e
No related branches found
No related tags found
No related merge requests found
...@@ -132,15 +132,14 @@ ...@@ -132,15 +132,14 @@
;; and re-enable them if we can get them to work ;; and re-enable them if we can get them to work
#_:unused-fn-args #_:unused-fn-args
#_:unused-locals] #_:unused-locals]
:exclude-linters [#_:constant-test ; gives us false positives with forms like (when config/is-test? ...) :exclude-linters [:deprecations]} ; Turn this off temporarily until we finish removing self-deprecated functions & macros
:deprecations]} ; Turn this off temporarily until we finish removing self-deprecated functions & macros
:docstring-checker {:include [#"^metabase"] :docstring-checker {:include [#"^metabase"]
:exclude [#"test" :exclude [#"test"
#"^metabase\.http-client$"]} #"^metabase\.http-client$"]}
:profiles {:dev {:dependencies [[expectations "2.2.0-beta2"] ; unit tests :profiles {:dev {:dependencies [[expectations "2.2.0-beta2"] ; unit tests
[ring/ring-mock "0.3.0"]] ; Library to create mock Ring requests for unit tests [ring/ring-mock "0.3.0"]] ; Library to create mock Ring requests for unit tests
:plugins [[docstring-checker "1.0.2"] ; Check that all public vars have docstrings. Run with 'lein docstring-checker' :plugins [[docstring-checker "1.0.2"] ; Check that all public vars have docstrings. Run with 'lein docstring-checker'
[jonase/eastwood "0.2.6" [jonase/eastwood "0.3.1"
:exclusions [org.clojure/clojure]] ; Linting :exclusions [org.clojure/clojure]] ; Linting
[lein-bikeshed "0.4.1"] ; Linting [lein-bikeshed "0.4.1"] ; Linting
[lein-expectations "0.0.8"] ; run unit tests with 'lein expectations' [lein-expectations "0.0.8"] ; run unit tests with 'lein expectations'
......
...@@ -285,59 +285,58 @@ ...@@ -285,59 +285,58 @@
(defn- canonicalize-aggregation-subclause (defn- canonicalize-aggregation-subclause
"Remove `:rows` type aggregation (long-since deprecated; simpliy means no aggregation) if present, and wrap "Remove `:rows` type aggregation (long-since deprecated; simpliy means no aggregation) if present, and wrap
`:field-ids` where appropriate." `:field-ids` where appropriate."
[[ag-type :as ag-subclause]] [ag-subclause]
(cond (mbql.u/replace ag-subclause
(= ag-type :rows) seq? (recur (vec &match))
[:rows & _]
nil nil
;; For named aggregations (`[:named <ag> <name>]`) we want to leave as-is an just canonicalize the ag it names ;; For named aggregations (`[:named <ag> <name>]`) we want to leave as-is an just canonicalize the ag it names
(= ag-type :named) [:named ag ag-name]
(let [[_ ag ag-name] ag-subclause] [:named (canonicalize-aggregation-subclause ag) ag-name]
[:named (canonicalize-aggregation-subclause ag) ag-name])
[(ag-type :guard #{:+ :- :* :/}) & args]
(#{:+ :- :* :/} ag-type) (apply
(vec vector
(cons ag-type
ag-type ;; if args are also ag subclauses normalize those, but things like numbers are allowed too so leave them as-is
;; if args are also ag subclauses normalize those, but things like numbers are allowed too so leave them as-is (for [arg args]
(for [arg (rest ag-subclause)] (cond-> arg
(cond-> arg (mbql-clause? arg) canonicalize-aggregation-subclause)))
(mbql-clause? arg) canonicalize-aggregation-subclause))))
;; for metric macros (e.g. [:metric <metric-id>]) do not wrap the metric in a :field-id clause ;; for metric macros (e.g. [:metric <metric-id>]) do not wrap the metric in a :field-id clause
(= :metric ag-type) [:metric _]
ag-subclause &match
;; something with an arg like [:sum [:field-id 41]] ;; something with an arg like [:sum [:field-id 41]]
(second ag-subclause) [ag-type field]
(let [[_ field] ag-subclause] [ag-type (wrap-implicit-field-id field)]))
[ag-type (wrap-implicit-field-id field)])
:else
ag-subclause))
(defn- wrap-single-aggregations (defn- wrap-single-aggregations
"Convert old MBQL 95 single-aggregations like `{:aggregation :count}` or `{:aggregation [:count]}` to MBQL 98 "Convert old MBQL 95 single-aggregations like `{:aggregation :count}` or `{:aggregation [:count]}` to MBQL 98
multiple-aggregation syntax (e.g. `{:aggregation [[:count]]}`)." multiple-aggregation syntax (e.g. `{:aggregation [[:count]]}`)."
[aggregations] [aggregations]
(cond (mbql.u/replace aggregations
seq? (recur (vec &match))
;; something like {:aggregations :count} -- MBQL 95 single aggregation ;; something like {:aggregations :count} -- MBQL 95 single aggregation
(keyword? aggregations) keyword?
[[aggregations]] [[&match]]
;; special-case: MBQL 98 multiple aggregations using unwrapped :count or :rows ;; special-case: MBQL 98 multiple aggregations using unwrapped :count or :rows
;; e.g. {:aggregations [:count [:sum 10]]} or {:aggregations [:count :count]} ;; e.g. {:aggregations [:count [:sum 10]]} or {:aggregations [:count :count]}
(and (mbql-clause? aggregations) [(_ :guard (every-pred keyword? (complement #{:named :+ :- :* :/})))
(aggregation-subclause? (second aggregations)) (_ :guard aggregation-subclause?)
(not (is-clause? #{:named :+ :- :* :/} aggregations))) & _]
(reduce concat (map wrap-single-aggregations aggregations)) (vec (reduce concat (map wrap-single-aggregations aggregations)))
;; something like {:aggregations [:sum 10]} -- MBQL 95 single aggregation ;; something like {:aggregations [:sum 10]} -- MBQL 95 single aggregation
(mbql-clause? aggregations) [(_ :guard keyword?) & _]
[(vec aggregations)] [&match]
:else _
(vec aggregations))) &match))
(defn- canonicalize-aggregations (defn- canonicalize-aggregations
"Canonicalize subclauses (see above) and make sure `:aggregation` is a sequence of clauses instead of a single "Canonicalize subclauses (see above) and make sure `:aggregation` is a sequence of clauses instead of a single
...@@ -347,10 +346,12 @@ ...@@ -347,10 +346,12 @@
(map canonicalize-aggregation-subclause) (map canonicalize-aggregation-subclause)
(filterv identity))) (filterv identity)))
(defn- canonicalize-filter [[filter-name & args, :as filter-clause]] (defn- canonicalize-filter [filter-clause]
(cond (mbql.u/replace filter-clause
;; for other `and`/`or`/`not` compound filters, recurse on the arg(s), then simplify the whole thing seq? (recur (vec &match))
(#{:and :or :not} filter-name)
;; for `and`/`or`/`not` compound filters, recurse on the arg(s), then simplify the whole thing
[(filter-name :guard #{:and :or :not}) & args]
(mbql.u/simplify-compound-filter (vec (cons (mbql.u/simplify-compound-filter (vec (cons
filter-name filter-name
;; we need to canonicalize any other mbql clauses that might show up in ;; we need to canonicalize any other mbql clauses that might show up in
...@@ -359,47 +360,51 @@ ...@@ -359,47 +360,51 @@
(map (comp canonicalize-mbql-clauses canonicalize-filter) (map (comp canonicalize-mbql-clauses canonicalize-filter)
args)))) args))))
;; string filters should get the string implict filter options added if not specified explicitly [(filter-name :guard #{:starts-with :ends-with :contains :does-not-contain}) field arg options]
(#{:starts-with :ends-with :contains :does-not-contain} filter-name) [filter-name (wrap-implicit-field-id field) arg options]
(let [[field arg options] args]
(cond-> [filter-name (wrap-implicit-field-id field) arg]
(seq options) (conj options)))
(= :inside filter-name) [(filter-name :guard #{:starts-with :ends-with :contains :does-not-contain}) field arg]
(let [[field-1 field-2 & coordinates] args] [filter-name (wrap-implicit-field-id field) arg]
(vec
(concat [:inside field-1 field-2 & coordinates]
[:inside (wrap-implicit-field-id field-1) (wrap-implicit-field-id field-2)] (vec
coordinates))) (concat
[:inside (wrap-implicit-field-id field-1) (wrap-implicit-field-id field-2)]
coordinates))
;; if you put a `:datetime-field` inside a `:time-interval` we should fix it for you
[:time-interval [:datetime-field field _] & args]
(recur (apply vector :time-interval field args))
;; all the other filter types have an implict field ID for the first arg ;; all the other filter types have an implict field ID for the first arg
;; (e.g. [:= 10 20] gets canonicalized to [:= [:field-id 10] 20] ;; (e.g. [:= 10 20] gets canonicalized to [:= [:field-id 10] 20]
(#{:= :!= :< :<= :> :>= :is-null :not-null :between :inside :time-interval} filter-name) [(filter-name :guard #{:= :!= :< :<= :> :>= :is-null :not-null :between :inside :time-interval}) field & more]
(apply vector filter-name (wrap-implicit-field-id (first args)) (rest args)) (apply vector filter-name (wrap-implicit-field-id field) more)
;; don't wrap segment IDs in `:field-id` ;; don't wrap segment IDs in `:field-id`
(= filter-name :segment) [:segment _]
filter-clause &match
_
:else
(throw (IllegalArgumentException. (str (tru "Illegal filter clause: {0}" filter-clause)))))) (throw (IllegalArgumentException. (str (tru "Illegal filter clause: {0}" filter-clause))))))
(defn- canonicalize-order-by (defn- canonicalize-order-by
"Make sure order by clauses like `[:asc 10]` get `:field-id` added where appropriate, e.g. `[:asc [:field-id 10]]`" "Make sure order by clauses like `[:asc 10]` get `:field-id` added where appropriate, e.g. `[:asc [:field-id 10]]`"
[order-by-clause] [clauses]
(vec (for [subclause order-by-clause (mbql.u/replace clauses
:let [[direction field-id] (if (#{:asc :desc :ascending :descending} (first subclause)) seq? (recur (vec &match))
;; normal [<direction> <field>] clause
subclause ;; MBQL 95 reversed [<field> <direction>] clause
;; MBQL 95 reversed [<field> <direction>] clause [field :asc] (recur [:asc field])
(reverse subclause))]] [field :desc] (recur [:desc field])
[(case direction [field :ascending] (recur [:asc field])
:asc :asc [field :descending] (recur [:desc field])
:desc :desc
;; old MBQL 95 names ;; MBQL 95 names but MBQL 98+ reversed syntax
:ascending :asc [:ascending field] (recur [:asc field])
:descending :desc) [:descending field] (recur [:desc field])
(wrap-implicit-field-id field-id)])))
[:asc field] [:asc (wrap-implicit-field-id field)]
[:desc field] [:desc (wrap-implicit-field-id field)]))
(declare canonicalize-inner-mbql-query) (declare canonicalize-inner-mbql-query)
...@@ -457,8 +462,8 @@ ...@@ -457,8 +462,8 @@
(fn [clause] (fn [clause]
(if-not (mbql-clause? clause) (if-not (mbql-clause? clause)
clause clause
(let [[clause-name & args] clause (let [[clause-name & _] clause
f (mbql-clause->canonicalization-fn clause-name)] f (mbql-clause->canonicalization-fn clause-name)]
(if f (if f
(apply f clause) (apply f clause)
clause)))) clause))))
...@@ -480,8 +485,6 @@ ...@@ -480,8 +485,6 @@
;;; | REMOVING EMPTY CLAUSES | ;;; | REMOVING EMPTY CLAUSES |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
;; TODO - can't we combine these functions into a single fn?
(defn- non-empty-value? (defn- non-empty-value?
"Is this 'value' in a query map considered non-empty (e.g., should we refrain from removing that key entirely?) e.g.: "Is this 'value' in a query map considered non-empty (e.g., should we refrain from removing that key entirely?) e.g.:
...@@ -512,8 +515,6 @@ ...@@ -512,8 +515,6 @@
(walk/postwalk (walk/postwalk
(fn [x] (fn [x]
(cond (cond
;; TODO - we can remove this part once we take out the `expand` namespace. This is here just to prevent
;; double-expansion from barfing
(record? x) (record? x)
x x
...@@ -543,6 +544,7 @@ ...@@ -543,6 +544,7 @@
(normalize-fragment [:query :filter] [\"=\" 100 200]) (normalize-fragment [:query :filter] [\"=\" 100 200])
;;-> [:= [:field-id 100] 200]" ;;-> [:= [:field-id 100] 200]"
{:style/indent 1}
[path x] [path x]
(if-not (seq path) (if-not (seq path)
(normalize x) (normalize x)
......
(ns metabase.mbql.util (ns metabase.mbql.util
"Utilitiy functions for working with MBQL queries." "Utilitiy functions for working with MBQL queries."
(:refer-clojure :exclude [replace])
(:require [clojure (:require [clojure
[string :as str] [string :as str]
[walk :as walk]] [walk :as walk]]
[metabase.mbql.schema :as mbql.s] [metabase.mbql.schema :as mbql.s]
[metabase.mbql.util.match :as mbql.match]
[metabase.util :as u] [metabase.util :as u]
[metabase.util.schema :as su] [metabase.util
[date :as du]
[i18n :refer [tru]]
[schema :as su]]
[schema.core :as s])) [schema.core :as s]))
(s/defn normalize-token :- s/Keyword (s/defn normalize-token :- s/Keyword
...@@ -36,30 +41,133 @@ ...@@ -36,30 +41,133 @@
((set k-or-ks) (first x)) ((set k-or-ks) (first x))
(= k-or-ks (first x))))) (= k-or-ks (first x)))))
(defn clause-instances
"Return a sequence of all the instances of clause(s) in `x`. Like `is-clause?`, you can either look for instances of a
single clause by passing a single keyword or for instances of multiple clauses by passing a set of keywords. Returns
`nil` if no instances were found.
;; look for :field-id clauses ;;; +----------------------------------------------------------------------------------------------------------------+
(clause-instances :field-id {:query {:filter [:= [:field-id 10] 20]}}) ;;; | Match & Replace |
;;-> [[:field-id 10]] ;;; +----------------------------------------------------------------------------------------------------------------+
(defmacro match
"Return a sequence of things that match a `pattern` or `patterns` inside `x`, presumably a query, returning `nil` if
there are no matches. Recurses through maps and sequences. `pattern` can be one of several things:
* Keyword name of an MBQL clause
* Set of keyword names of MBQL clauses. Matches any clauses with those names
* A `core.match` pattern
* A symbol naming a class.
* A symbol naming a predicate function
* `_`, which will match anything
Examples:
;; keyword pattern
(match {:fields [[:field-id 10]]} :field-id) ; -> [[:field-id 10]]
;; set of keywords
(match some-query #{:field-id :fk->}) ; -> [[:field-id 10], [:fk-> [:field-id 10] [:field-id 20]], ...]
;; `core.match` patterns:
;; match any `:field-id` clause with one arg (which should be all of them)
(match some-query [:field-id _])
(match some-query [:field-id (_ :guard #(> % 100))]) ; -> [[:field-id 200], ...]
;; symbol naming a Class
;; match anything that is an instance of that class
(match some-query java.util.Date) ; -> [[#inst \"2018-10-08\", ...]
;; symbol naming a predicate function
;; match anything that satisfies that predicate
(match some-query (every-pred integer? even?)) ; -> [2 4 6 8]
;; match anything with `_`
(match 100 `_`) ; -> 100
### Using `core.match` patterns
See [`core.match` documentation](`https://github.com/clojure/core.match/wiki/Overview`) for more details.
;; look for :+ or :- clauses Pattern-matching works almost exactly the way it does when using `core.match/match` directly, with a few
(clause-instances #{:+ :-} ...) differences:
By default, this will not include subclauses of any clauses it finds, but you can toggle this behavior with the * `mbql.util/match` returns a sequence of everything that matches, rather than the first match it finds
`include-subclauses?` option:
* patterns are automatically wrapped in vectors for you when appropriate
* things like keywords and classes are automatically converted to appropriate patterns for you
* this macro automatically recurses through sequences and maps as a final `:else` clause. If you don't want to
automatically recurse, use a catch-all pattern (such as `_`). Our macro implementation will optimize out this
`:else` clause if the last pattern is `_`
### Returing something other than the exact match with result body
By default, `match` returns whatever matches the pattern you pass in. But what if you only want to return part of
the match? You can, using `core.match` binding facilities. Bind relevant things in your pattern and pass in the
optional result body. Whatever result body returns will be returned by `match`:
;; just return the IDs of Field ID clauses
(match some-query [:field-id id] id) ; -> [1 2 3]
You can also use result body to filter results; any `nil` values will be skipped:
(match some-query [:field-id id]
(when (even? id)
id))
;; -> [2 4 6 8]
Of course, it's more efficient to let `core.match` compile an efficient matching function, so prefer using
patterns with `:guard` where possible.
You can also call `recur` inside result bodies, to use the same matching logic against a different value.
0
### `&match` and `&parents` anaphors
For more advanced matches, like finding `:field-id` clauses nested anywhere inside `:datetime-field` clauses,
`match` binds a pair of anaphors inside the result body for your convenience. `&match` is bound to the entire
match, regardless of how you may have destructured it; `&parents` is bound to a sequence of keywords naming the
parent top-level keys and clauses of the match.
(mbql.u/match {:fields [[:datetime-field [:fk-> [:field-id 1] [:field-id 2]] :day]]} :field-id
;; &parents will be [:fields :datetime-field :fk->]
(when (contains? (set &parents) :datetime-field)
&match))
;; -> [[:field-id 1] [:field-id 2]]"
{:style/indent 1}
[x & patterns-and-results]
;; Actual implementation of these macros is in `mbql.util.match`. They're in a seperate namespace because they have
;; lots of other functions and macros they use for their implementation (which means they have to be public) that we
;; would like to discourage you from using directly.
`(mbql.match/match ~x ~patterns-and-results))
(defmacro match-one
"Like `match` but returns a single match rather than a sequence of matches."
{:style/indent 1}
[x & patterns-and-results]
`(first (mbql.match/match ~x ~patterns-and-results)))
(clause-instances #{:field-id :fk->} [[:field-id 1] [:fk-> [:field-id 2] [:field-id 3]]])
;; -> [[:field-id 1]
[:fk-> [:field-id 2] [:field-id 3]]]
(clause-instances #{:field-id :fk->} [[:field-id 1] [:fk-> [:field-id 2] [:field-id 3]]], :include-subclauses? true) (defmacro replace
;; -> [[:field-id 1] "Like `match`, but replace matches in `x` with the results of result body. The same pattern options are supported,
[:fk-> [:field-id 2] [:field-id 3]] and `&parents` and `&match` anaphors are available in the same way. (`&match` is particularly useful here if you
[:field-id 2] want to use keywords or sets of keywords as patterns.)"
[:field-id 3]]" {:style/indent 1}
[x & patterns-and-results]
;; as with `match` actual impl is in `match` namespace to discourage you from using the constituent functions and
;; macros that power this macro directly
`(mbql.match/replace ~x ~patterns-and-results))
(defmacro replace-in
"Like `replace`, but only replaces things in the part of `x` in the keypath `ks` (i.e. the way to `update-in` works.)"
{:style/indent 2}
[x ks & patterns-and-results]
`(let [form# ~x
ks# ~ks]
(if-not (seq (get-in form# ks#))
form#
(update-in form# ks# #(mbql.match/replace % ~patterns-and-results)))))
(defn ^:deprecated clause-instances
"DEPRECATED: use `match` instead."
{:style/indent 1} {:style/indent 1}
[k-or-ks x & {:keys [include-subclauses?], :or {include-subclauses? false}}] [k-or-ks x & {:keys [include-subclauses?], :or {include-subclauses? false}}]
(let [instances (atom [])] (let [instances (atom [])]
...@@ -73,12 +181,8 @@ ...@@ -73,12 +181,8 @@
x) x)
(seq @instances))) (seq @instances)))
(defn replace-clauses (defn ^:deprecated replace-clauses
"Walk a query looking for clauses named by keyword or set of keywords `k-or-ks` and replace them the results of a call "DEPRECATED: use `replace` instead."
to `(f clause)`.
(replace-clauses {:filter [:= [:field-id 10] 100]} :field-id (constantly 200))
;; -> {:filter [:= 200 100]}"
{:style/indent 2} {:style/indent 2}
[query k-or-ks f] [query k-or-ks f]
(walk/postwalk (walk/postwalk
...@@ -88,12 +192,8 @@ ...@@ -88,12 +192,8 @@
clause)) clause))
query)) query))
(defn replace-clauses-in (defn ^:deprecated replace-clauses-in
"Replace clauses only in a subset of `query`, defined by `keypath`. "DEPRECATED: use `replace-in` instead!"
(replace-clauses-in {:filter [:= [:field-id 10] 100], :breakout [:field-id 100]} [:filter] :field-id
(constantly 200))
;; -> {:filter [:= 200 100], :breakout [:field-id 100]}"
{:style/indent 3} {:style/indent 3}
[query keypath k-or-ks f] [query keypath k-or-ks f]
(update-in query keypath #(replace-clauses % k-or-ks f))) (update-in query keypath #(replace-clauses % k-or-ks f)))
...@@ -105,49 +205,60 @@ ...@@ -105,49 +205,60 @@
;; TODO - I think we actually should move this stuff into a `mbql.helpers` namespace so we can use the util functions ;; TODO - I think we actually should move this stuff into a `mbql.helpers` namespace so we can use the util functions
;; above in the `schema.helpers` namespace instead of duplicating them ;; above in the `schema.helpers` namespace instead of duplicating them
(defn- combine-compound-filters-of-type [compound-type subclauses]
(mapcat #(match-one %
[(_ :guard (partial = compound-type)) & args]
args
_
[&match])
subclauses))
(s/defn simplify-compound-filter :- mbql.s/Filter (s/defn simplify-compound-filter :- (s/maybe mbql.s/Filter)
"Simplify compound `:and`, `:or`, and `:not` compound filters, combining or eliminating them where possible. This "Simplify compound `:and`, `:or`, and `:not` compound filters, combining or eliminating them where possible. This
also fixes theoretically disallowed compound filters like `:and` with only a single subclause." also fixes theoretically disallowed compound filters like `:and` with only a single subclause, and eliminates `nils`
[[filter-name & args :as filter-clause]] from the clauses."
(cond [filter-clause]
(replace filter-clause
seq? (recur (vec &match))
;; if this an an empty filter, toss it
nil nil
[] nil
[(:or :and :or)] nil
;; if the clause contains any nils, toss them
[& (args :guard (partial some nil?))]
(recur (filterv some? args))
;; for `and` or `not` compound filters with only one subclase, just unnest the subclause ;; for `and` or `not` compound filters with only one subclase, just unnest the subclause
(and (#{:and :or} filter-name) [(:or :and :or) arg] (recur arg)
(= (count args) 1))
(recur (first args))
;; for `and` and `not` compound filters with subclauses of the same type pull up any compounds of the same type ;; for `and` and `not` compound filters with subclauses of the same type pull up any compounds of the same type
;; e.g. [:and :a [:and b c]] ; -> [:and a b c] ;; e.g. [:and :a [:and b c]] ; -> [:and a b c]
(and (#{:and :or} filter-name) [:and & (args :guard (partial some (partial is-clause? :and)))]
(some (partial is-clause? filter-name) args)) (recur (apply vector :and (combine-compound-filters-of-type :and args)))
(recur
(vec (cons filter-name (mapcat (fn [subclause] [:or & (args :guard (partial some (partial is-clause? :or)))]
(if (is-clause? filter-name subclause) (recur (apply vector :or (combine-compound-filters-of-type :or args)))
(rest subclause)
[subclause]))
args))))
;; for `and` or `or` clauses with duplicate args, remove the duplicates and recur ;; for `and` or `or` clauses with duplicate args, remove the duplicates and recur
(and (#{:and :or} filter-name) [(clause :guard #{:and :or}) & (args :guard #(not (apply distinct? %)))]
(not= (count args) (count (distinct args)))) (recur (apply vector clause (distinct args)))
(recur (vec (cons filter-name (distinct args))))
;; for `not` that wraps another `not`, eliminate both ;; for `not` that wraps another `not`, eliminate both
(and (= :not filter-name) [:not [:not arg]]
(is-clause? :not (first args))) (recur arg)
(recur (second (first args)))
:else :else
filter-clause)) filter-clause))
;; TODO - we should validate the query against the Query schema and the output as well. Flip that on once the schema
;; is locked-in 100%
(s/defn combine-filter-clauses :- mbql.s/Filter (s/defn combine-filter-clauses :- mbql.s/Filter
"Combine two filter clauses into a single clause in a way that minimizes slapping a bunch of `:and`s together if "Combine two filter clauses into a single clause in a way that minimizes slapping a bunch of `:and`s together if
possible." possible."
[filter-clause & more-filter-clauses] [filter-clause & more-filter-clauses]
(simplify-compound-filter (vec (cons :and (filter identity (cons filter-clause more-filter-clauses)))))) (simplify-compound-filter (cons :and (cons filter-clause more-filter-clauses))))
(s/defn add-filter-clause :- mbql.s/Query (s/defn add-filter-clause :- mbql.s/Query
"Add an additional filter clause to an `outer-query`. If `new-clause` is `nil` this is a no-op." "Add an additional filter clause to an `outer-query`. If `new-clause` is `nil` this is a no-op."
...@@ -157,8 +268,9 @@ ...@@ -157,8 +268,9 @@
(update-in outer-query [:query :filter] combine-filter-clauses new-clause))) (update-in outer-query [:query :filter] combine-filter-clauses new-clause)))
(defn query->source-table-id (s/defn query->source-table-id :- (s/maybe su/IntGreaterThanZero)
"Return the source Table ID associated with `query`, if applicable; handles nested queries as well." "Return the source Table ID associated with `query`, if applicable; handles nested queries as well. If `query` is
`nil`, returns `nil`."
{:argslists '([outer-query])} {:argslists '([outer-query])}
[{{source-table-id :source-table, source-query :source-query} :query, query-type :type, :as query}] [{{source-table-id :source-table, source-query :source-query} :query, query-type :type, :as query}]
(cond (cond
...@@ -174,12 +286,40 @@ ...@@ -174,12 +286,40 @@
(and (nil? source-table-id) source-query) (and (nil? source-table-id) source-query)
(recur (assoc query :query source-query)) (recur (assoc query :query source-query))
;; if ID is a `card__id` form that can only mean we haven't preprocessed the query and resolved the source query.
;; This is almost certainly an accident, so throw an Exception so we can make the proper fixes
((every-pred string? (partial re-matches mbql.s/source-table-card-id-regex)) source-table-id)
(throw
(Exception.
(str
(tru "Error: query's source query has not been resolved. You probably need to `preprocess` the query first."))))
;; otherwise resolve the source Table ;; otherwise resolve the source Table
:else :else
source-table-id)) source-table-id))
(s/defn unwrap-field-clause :- (s/if (partial is-clause? :field-id)
mbql.s/field-id
mbql.s/field-literal)
"Un-wrap a `Field` clause and return the lowest-level clause it wraps, either a `:field-id` or `:field-literal`."
[[clause-name x y, :as clause] :- mbql.s/Field]
;; TODO - could use `match` to do this
(case clause-name
:field-id clause
:fk-> (recur y)
:field-literal clause
:datetime-field (recur x)
:binning-strategy (recur x)))
(defn maybe-unwrap-field-clause
"Unwrap a Field `clause`, if it's something that can be unwrapped (i.e. something that is, or wraps, a `:field-id` or
`:field-literal`). Otherwise return `clause` as-is."
[clause]
(if (is-clause? #{:field-id :fk-> :field-literal :datetime-field :binning-strategy} clause)
(unwrap-field-clause clause)
clause))
(defn field-clause->id-or-literal (s/defn field-clause->id-or-literal :- (s/cond-pre su/IntGreaterThanZero su/NonBlankString)
"Get the actual Field ID or literal name this clause is referring to. Useful for seeing if two Field clauses are "Get the actual Field ID or literal name this clause is referring to. Useful for seeing if two Field clauses are
referring to the same thing, e.g. referring to the same thing, e.g.
...@@ -188,24 +328,92 @@ ...@@ -188,24 +328,92 @@
For expressions (or any other clauses) this returns the clause as-is, so as to facilitate the primary use case of For expressions (or any other clauses) this returns the clause as-is, so as to facilitate the primary use case of
comparing Field clauses." comparing Field clauses."
[[clause-name x y, :as clause]] [clause :- mbql.s/Field]
(case clause-name (second (unwrap-field-clause clause)))
:field-id x
:fk-> (recur y)
:field-literal x
:datetime-field (recur x)
:binning-strategy (recur x)
;; for anything else, including expressions and ag clause references, just return the clause as-is
clause))
(s/defn add-order-by-clause :- mbql.s/Query (s/defn add-order-by-clause :- mbql.s/Query
"Add a new `:order-by` clause to an MBQL query. If the new order-by clause references a Field that is already being "Add a new `:order-by` clause to an MBQL query. If the new order-by clause references a Field that is already being
used in another order-by clause, this function does nothing." used in another order-by clause, this function does nothing."
[outer-query :- mbql.s/Query, order-by-clause :- mbql.s/OrderBy] [outer-query :- mbql.s/Query, [_ field, :as order-by-clause] :- mbql.s/OrderBy]
(let [existing-clauses (set (map (comp field-clause->id-or-literal second) (let [existing-fields (set (for [[_ existing-field] (-> outer-query :query :order-by)]
(-> outer-query :query :order-by)))] (maybe-unwrap-field-clause existing-field)))]
(if (existing-clauses (field-clause->id-or-literal (second order-by-clause))) (if (existing-fields (maybe-unwrap-field-clause field))
;; Field already referenced, nothing to do ;; Field already referenced, nothing to do
outer-query outer-query
;; otherwise add new clause at the end ;; otherwise add new clause at the end
(update-in outer-query [:query :order-by] (comp vec conj) order-by-clause)))) (update-in outer-query [:query :order-by] (comp vec conj) order-by-clause))))
(s/defn add-datetime-units :- mbql.s/DateTimeValue
"Return a `relative-datetime` clause with `n` units added to it."
[absolute-or-relative-datetime :- mbql.s/DateTimeValue
n :- s/Num]
(if (is-clause? :relative-datetime absolute-or-relative-datetime)
(let [[_ original-n unit] absolute-or-relative-datetime]
[:relative-datetime (+ n original-n) unit])
(let [[_ timestamp unit] absolute-or-relative-datetime]
(du/relative-date unit n timestamp))))
(defn dispatch-by-clause-name-or-class
"Dispatch function perfect for use with multimethods that dispatch off elements of an MBQL query. If `x` is an MBQL
clause, dispatches off the clause name; otherwise dispatches off `x`'s class."
[x]
(if (mbql-clause? x)
(first x)
(class x)))
(s/defn fk-clause->join-info :- (s/maybe mbql.s/JoinInfo)
"Return the matching info about the JOINed for the 'destination' Field in an `fk->` clause.
(fk-clause->join-alias [:fk-> [:field-id 1] [:field-id 2]])
;; -> \"orders__via__order_id\""
[query :- mbql.s/Query, [_ source-field-clause] :- mbql.s/fk->]
(let [source-field-id (field-clause->id-or-literal source-field-clause)]
(some (fn [{:keys [fk-field-id], :as info}]
(when (= fk-field-id source-field-id)
info))
(-> query :query :join-tables))))
(s/defn expression-with-name :- mbql.s/ExpressionDef
"Return the `Expression` referenced by a given `expression-name`."
[query :- mbql.s/Query, expression-name :- su/NonBlankString]
(or (get-in query, [:query :expressions (keyword expression-name)])
(throw (Exception. (str (tru "No expression named ''{0}''" (name expression-name)))))))
(s/defn aggregation-at-index :- mbql.s/Aggregation
"Fetch the aggregation at index. This is intended to power aggregate field references (e.g. [:aggregation 0]).
This also handles nested queries, which could be potentially ambiguous if multiple levels had aggregations. To
support nested queries, you'll need to keep tract of how many `:source-query`s deep you've traveled; pass in this
number to as optional arg `nesting-level` to make sure you reference aggregations at the right level of nesting."
([query index]
(aggregation-at-index query index 0))
([query :- mbql.s/Query, index :- su/NonNegativeInt, nesting-level :- su/NonNegativeInt]
(if (zero? nesting-level)
(or (nth (get-in query [:query :aggregation]) index)
(throw (Exception. (str (tru "No aggregation at index: {0}" index)))))
;; keep recursing deeper into the query until we get to the same level the aggregation reference was defined at
(recur {:query (get-in query [:query :source-query])} index (dec nesting-level)))))
(defn ga-id?
"Is this ID (presumably of a Metric or Segment) a GA one?"
[id]
(boolean
(when ((some-fn string? keyword?) id)
(re-find #"^ga(id)?:" (name id)))))
(defn ga-metric-or-segment?
"Is this metric or segment clause not a Metabase Metric or Segment, but rather a GA one? E.g. something like `[:metric
ga:users]`. We want to ignore those because they're not the same thing at all as MB Metrics/Segments and don't
correspond to objects in our application DB."
[[_ id]]
(ga-id? id))
(defn datetime-field?
"Does `field` have a base type or special type that derives from `:type/DateTime`?"
[field]
(or (isa? (:base_type field) :type/DateTime)
(isa? (:special_type field) :type/DateTime)))
(ns metabase.mbql.util.match
"Internal implementation of the MBQL `match` and `replace` macros. Don't use these directly."
(:refer-clojure :exclude [replace])
(:require [clojure.core.match :as match]
[clojure.walk :as walk]))
;; TODO - I'm not 100% sure we actually need to keep the `&parents` anaphor around, because nobody is actually using
;; it, which makes it dead weight
;; have to do this at runtime because we don't know if a symbol is a class or pred or whatever when we compile the macro
(defn match-with-pred-or-class
"Return a function to use for pattern matching via `core.match`'s `:guard` functionality based on the value of a
`pred-or-class` passed in as a pattern to `match` or `replace`."
[pred-or-class]
(cond
(class? pred-or-class)
(partial instance? pred-or-class)
(fn? pred-or-class)
pred-or-class
:else
;; this is dev-specific so we don't need to localize it
(throw (IllegalArgumentException. "Invalid pattern: don't know how to handle symbol."))))
(defn- generate-pattern
"Generate a single approprate pattern for use with core.match based on the `pattern` input passed into `match` or
`replace`. "
[pattern]
(cond
(keyword? pattern)
[[pattern '& '_]]
(and (set? pattern) (every? keyword? pattern))
[[`(:or ~@pattern) '& '_]]
;; special case for `_`, we'll let you match anything with that
(= pattern '_)
[pattern]
(symbol? pattern)
`[(~'_ :guard (match-with-pred-or-class ~pattern))]
:else
[pattern]))
(defn- recur-form? [form]
(and (seq? form)
(= 'recur (first form))))
(defn- rewrite-recurs
"Replace any `recur` forms with ones that include the implicit `&parents` arg."
[fn-name result-form]
(walk/postwalk
(fn [form]
(if (recur-form? form)
;; we *could* use plain `recur` here, but `core.match` cannot apply code size optimizations if a `recur` form
;; is present. Instead, just do a non-tail-call-optimized call to the pattern fn so `core.match` can generate
;; efficient code.
;;
;; (recur [:new-clause ...]) ; -> (match-123456 &parents [:new-clause ...])
`(~fn-name ~'&parents ~@(rest form))
form))
result-form))
(defn- generate-patterns-and-results
"Generate the `core.match` patterns and results given the input to our macros.
`wrap-result-forms?` will wrap the results parts of the pairs in a vector, so we do something like `(reduce concat)`
on all of the results to return a sequence of matches for `match`."
{:style/indent 1}
[fn-name patterns-and-results & {:keys [wrap-result-forms?]}]
(reduce
concat
(for [[pattern result] (partition 2 2 ['&match] patterns-and-results)]
[(generate-pattern pattern) (let [result (rewrite-recurs fn-name result)]
(if (or (not wrap-result-forms?)
(and (seq? result)
(= fn-name (first result))))
result
[result]))])))
;;; --------------------------------------------------- match-impl ---------------------------------------------------
(defn match-in-collection
"Internal impl for `match`. If `form` is a collection, call `match-fn` to recursively look for matches in it."
[match-fn clause-parents form]
{:pre [(fn? match-fn) (vector? clause-parents)]}
(cond
(map? form)
(reduce concat (for [[k v] form]
(match-fn (conj clause-parents k) v)))
(sequential? form)
(mapcat (partial match-fn (if (keyword? (first form))
(conj clause-parents (first form))
clause-parents))
form)))
(defn- skip-else-clause?
"If the last pattern passed in was `_`, we can skip generating the default `:else` clause, because it will never
match."
;; TODO - why don't we just let people pass their own `:else` clause instead?
[patterns-and-results]
(= '_ (second (reverse patterns-and-results))))
(defmacro match
"Internal impl for `match`. Generate a pattern-matching function using `core.match`, and call it with `form`."
[form patterns-and-results]
(let [match-fn-symb (gensym "match-")]
`(seq
(filter
some?
((fn ~match-fn-symb [~'&parents ~'&match]
(match/match [~'&match]
~@(generate-patterns-and-results match-fn-symb patterns-and-results, :wrap-result-forms? true)
~@(when-not (skip-else-clause? patterns-and-results)
[:else `(match-in-collection ~match-fn-symb ~'&parents ~'&match)])))
[]
~form)))))
;;; -------------------------------------------------- replace impl --------------------------------------------------
(defn replace-in-collection
"Inernal impl for `replace`. Recursively replace values in a collection using a `replace-fn`."
[replace-fn clause-parents form]
(cond
(map? form)
(into form (for [[k v] form]
[k (replace-fn (conj clause-parents k) v)]))
(sequential? form)
(mapv (partial replace-fn (if (keyword? (first form))
(conj clause-parents (first form))
clause-parents))
form)
:else form))
(defmacro replace
"Internal implementation for `replace`. Generate a pattern-matching function with `core.match`, and use it to replace
matching values in `form`."
[form patterns-and-results]
(let [replace-fn-symb (gensym "replace-")]
`((fn ~replace-fn-symb [~'&parents ~'&match]
(match/match [~'&match]
~@(generate-patterns-and-results replace-fn-symb patterns-and-results, :wrap-result-forms? false)
~@(when-not (skip-else-clause? patterns-and-results)
[:else `(replace-in-collection ~replace-fn-symb ~'&parents ~'&match)])))
[]
~form)))
...@@ -22,7 +22,49 @@ ...@@ -22,7 +22,49 @@
(#'normalize/normalize-tokens {:native {:query {:NAME "FAKE_QUERY" (#'normalize/normalize-tokens {:native {:query {:NAME "FAKE_QUERY"
:description "Theoretical fake query in a JSON-based query lang"}}})) :description "Theoretical fake query in a JSON-based query lang"}}}))
;; do aggregations get normalized? ;; METRICS shouldn't get normalized in some kind of wacky way
(expect
{:aggregation [:+ [:metric 10] 1]}
(#'normalize/normalize-tokens {:aggregation ["+" ["METRIC" 10] 1]}))
;; Nor should SEGMENTS
(expect
{:filter [:= [:+ [:segment 10] 1] 10]}
(#'normalize/normalize-tokens {:filter ["=" ["+" ["SEGMENT" 10] 1] 10]}))
;; are expression names exempt from lisp-casing/lower-casing?
(expect
{:query {:expressions {:sales_tax [:- [:field-id 10] [:field-id 20]]}}}
(#'normalize/normalize-tokens {"query" {"expressions" {:sales_tax ["-" ["field-id" 10] ["field-id" 20]]}}}))
;; expression names should always be keywords
(expect
{:query {:expressions {:sales_tax [:- [:field-id 10] [:field-id 20]]}}}
(#'normalize/normalize-tokens {"query" {"expressions" {:sales_tax ["-" ["field-id" 10] ["field-id" 20]]}}}))
;; expression references should be exempt too
(expect
{:order-by [[:desc [:expression "SALES_TAX"]]]}
(#'normalize/normalize-tokens {:order-by [[:desc [:expression "SALES_TAX"]]]}) )
;; ... but they should be converted to strings if passed in as a KW for some reason. Make sure we preserve namespace!
(expect
{:order-by [[:desc [:expression "SALES/TAX"]]]}
(#'normalize/normalize-tokens {:order-by [[:desc ["expression" :SALES/TAX]]]}))
;; field literals should be exempt too
(expect
{:order-by [[:desc [:field-literal "SALES_TAX" :type/Number]]]}
(#'normalize/normalize-tokens {:order-by [[:desc [:field-literal "SALES_TAX" :type/Number]]]}) )
;; ... but they should be converted to strings if passed in as a KW for some reason
(expect
{:order-by [[:desc [:field-literal "SALES/TAX" :type/Number]]]}
(#'normalize/normalize-tokens {:order-by [[:desc ["field_literal" :SALES/TAX "type/Number"]]]}))
;;; -------------------------------------------------- aggregation ---------------------------------------------------
(expect (expect
{:query {:aggregation :rows}} {:query {:aggregation :rows}}
(#'normalize/normalize-tokens {:query {"AGGREGATION" "ROWS"}})) (#'normalize/normalize-tokens {:query {"AGGREGATION" "ROWS"}}))
...@@ -83,45 +125,8 @@ ...@@ -83,45 +125,8 @@
{:query {:aggregation [:+ [:sum 10] [:sum 20] [:sum 30]]}} {:query {:aggregation [:+ [:sum 10] [:sum 20] [:sum 30]]}}
(#'normalize/normalize-tokens {:query {:aggregation ["+" ["sum" 10] ["SUM" 20] ["sum" 30]]}})) (#'normalize/normalize-tokens {:query {:aggregation ["+" ["sum" 10] ["SUM" 20] ["sum" 30]]}}))
;; METRICS shouldn't get normalized in some kind of wacky way
(expect
{:aggregation [:+ [:metric 10] 1]}
(#'normalize/normalize-tokens {:aggregation ["+" ["METRIC" 10] 1]}))
;; Nor should SEGMENTS
(expect
{:filter [:= [:+ [:segment 10] 1] 10]}
(#'normalize/normalize-tokens {:filter ["=" ["+" ["SEGMENT" 10] 1] 10]}))
;; are expression names exempt from lisp-casing/lower-casing?
(expect
{:query {:expressions {:sales_tax [:- [:field-id 10] [:field-id 20]]}}}
(#'normalize/normalize-tokens {"query" {"expressions" {:sales_tax ["-" ["field-id" 10] ["field-id" 20]]}}}))
;; expression names should always be keywords
(expect
{:query {:expressions {:sales_tax [:- [:field-id 10] [:field-id 20]]}}}
(#'normalize/normalize-tokens {"query" {"expressions" {:sales_tax ["-" ["field-id" 10] ["field-id" 20]]}}}))
;; expression references should be exempt too
(expect
{:order-by [[:desc [:expression "SALES_TAX"]]]}
(#'normalize/normalize-tokens {:order-by [[:desc [:expression "SALES_TAX"]]]}) )
;; ... but they should be converted to strings if passed in as a KW for some reason. Make sure we preserve namespace!
(expect
{:order-by [[:desc [:expression "SALES/TAX"]]]}
(#'normalize/normalize-tokens {:order-by [[:desc ["expression" :SALES/TAX]]]}))
;; field literals should be exempt too
(expect
{:order-by [[:desc [:field-literal "SALES_TAX" :type/Number]]]}
(#'normalize/normalize-tokens {:order-by [[:desc [:field-literal "SALES_TAX" :type/Number]]]}) )
;; ... but they should be converted to strings if passed in as a KW for some reason ;;; ---------------------------------------------------- order-by ----------------------------------------------------
(expect
{:order-by [[:desc [:field-literal "SALES/TAX" :type/Number]]]}
(#'normalize/normalize-tokens {:order-by [[:desc ["field_literal" :SALES/TAX "type/Number"]]]}))
;; does order-by get properly normalized? ;; does order-by get properly normalized?
(expect (expect
...@@ -140,6 +145,9 @@ ...@@ -140,6 +145,9 @@
{:query {:order-by [[:desc [:field-id 10]]]}} {:query {:order-by [[:desc [:field-id 10]]]}}
(#'normalize/normalize-tokens {:query {"ORDER_BY" [["DESC" ["field_id" 10]]]}})) (#'normalize/normalize-tokens {:query {"ORDER_BY" [["DESC" ["field_id" 10]]]}}))
;;; ----------------------------------------------------- filter -----------------------------------------------------
;; the unit & amount in time interval clauses should get normalized ;; the unit & amount in time interval clauses should get normalized
(expect (expect
{:query {:filter [:time-interval 10 :current :day]}} {:query {:filter [:time-interval 10 :current :day]}}
...@@ -180,6 +188,9 @@ ...@@ -180,6 +188,9 @@
{:query {:filter [:starts-with 10 "ABC" {:case-sensitive true}]}} {:query {:filter [:starts-with 10 "ABC" {:case-sensitive true}]}}
(#'normalize/normalize-tokens {:query {"FILTER" ["starts_with" 10 "ABC" {"case_sensitive" true}]}})) (#'normalize/normalize-tokens {:query {"FILTER" ["starts_with" 10 "ABC" {"case_sensitive" true}]}}))
;;; --------------------------------------------------- parameters ---------------------------------------------------
;; make sure we're not running around trying to normalize the type in native query params ;; make sure we're not running around trying to normalize the type in native query params
(expect (expect
{:type :native {:type :native
...@@ -245,6 +256,8 @@ ...@@ -245,6 +256,8 @@
:target ["dimension" ["template-tag" "names_list"]] :target ["dimension" ["template-tag" "names_list"]]
:value ["=" 10 20]}]})) :value ["=" 10 20]}]}))
;;; ------------------------------------------------- source queries -------------------------------------------------
;; Make sure token normalization works correctly on source queries ;; Make sure token normalization works correctly on source queries
(expect (expect
{:database 4 {:database 4
...@@ -274,6 +287,9 @@ ...@@ -274,6 +287,9 @@
:type :query :type :query
:query {"source_query" {"source_table" 1, "aggregation" "rows"}}})) :query {"source_query" {"source_table" 1, "aggregation" "rows"}}}))
;;; ----------------------------------------------------- other ------------------------------------------------------
;; Does the QueryExecution context get normalized? ;; Does the QueryExecution context get normalized?
(expect (expect
{:context :json-download} {:context :json-download}
...@@ -298,19 +314,36 @@ ...@@ -298,19 +314,36 @@
[:field-id 10] [:field-id 10]
(#'normalize/wrap-implicit-field-id [:field-id 10])) (#'normalize/wrap-implicit-field-id [:field-id 10]))
;; make sure `binning-strategy` wraps implicit Field IDs
(expect
{:query {:breakout [[:binning-strategy [:field-id 10] :bin-width 2000]]}}
(#'normalize/canonicalize {:query {:breakout [[:binning-strategy 10 :bin-width 2000]]}}))
;;; -------------------------------------------------- aggregation ---------------------------------------------------
;; Do aggregations get canonicalized properly? ;; Do aggregations get canonicalized properly?
;; field ID should get wrapped in field-id and ags should be converted to multiple ag syntax
(expect (expect
{:query {:aggregation [[:count [:field-id 10]]]}} {:query {:aggregation [[:count [:field-id 10]]]}}
(#'normalize/canonicalize {:query {:aggregation [:count 10]}})) (#'normalize/canonicalize {:query {:aggregation [:count 10]}}))
;; ag with no Field ID
(expect (expect
{:query {:aggregation [[:count]]}} {:query {:aggregation [[:count]]}}
(#'normalize/canonicalize {:query {:aggregation [:count]}})) (#'normalize/canonicalize {:query {:aggregation [:count]}}))
;; if already wrapped in field-id it's ok
(expect (expect
{:query {:aggregation [[:count [:field-id 1000]]]}} {:query {:aggregation [[:count [:field-id 1000]]]}}
(#'normalize/canonicalize {:query {:aggregation [:count [:field-id 1000]]}})) (#'normalize/canonicalize {:query {:aggregation [:count [:field-id 1000]]}}))
;; ags in the canonicalized format should pass thru ok
(expect
{:query {:aggregation [[:metric "ga:sessions"] [:metric "ga:1dayUsers"]]}}
(#'normalize/canonicalize
{:query {:aggregation [[:metric "ga:sessions"] [:metric "ga:1dayUsers"]]}}))
;; :rows aggregation type, being deprecated since FOREVER, should just get removed ;; :rows aggregation type, being deprecated since FOREVER, should just get removed
(expect (expect
{:query {:aggregation []}} {:query {:aggregation []}}
...@@ -389,6 +422,20 @@ ...@@ -389,6 +422,20 @@
{:query {:aggregation [[:cum-count [:field-id 10]]]}} {:query {:aggregation [[:cum-count [:field-id 10]]]}}
(#'normalize/canonicalize {:query {:aggregation [:cum-count 10]}})) (#'normalize/canonicalize {:query {:aggregation [:cum-count 10]}}))
;; should handle seqs without a problem
(expect
{:query {:aggregation [[:min [:field-id 1]] [:min [:field-id 2]]]}}
(#'normalize/canonicalize {:query {:aggregation '([:min 1] [:min 2])}}))
;; make sure canonicalization can handle aggregations with expressions where the Field normally goes
(expect
{:query {:aggregation [[:sum [:* [:field-id 4] [:field-id 1]]]]}}
(#'normalize/canonicalize
{:query {:aggregation [[:sum [:* [:field-id 4] [:field-id 1]]]]}}))
;;; ---------------------------------------------------- breakout ----------------------------------------------------
;; implicit Field IDs should get wrapped in [:field-id] in :breakout ;; implicit Field IDs should get wrapped in [:field-id] in :breakout
(expect (expect
{:query {:breakout [[:field-id 10]]}} {:query {:breakout [[:field-id 10]]}}
...@@ -398,10 +445,18 @@ ...@@ -398,10 +445,18 @@
{:query {:breakout [[:field-id 10] [:field-id 20]]}} {:query {:breakout [[:field-id 10] [:field-id 20]]}}
(#'normalize/canonicalize {:query {:breakout [10 20]}})) (#'normalize/canonicalize {:query {:breakout [10 20]}}))
;; should handle seqs
(expect
{:query {:breakout [[:field-id 10] [:field-id 20]]}}
(#'normalize/canonicalize {:query {:breakout '(10 20)}}))
(expect (expect
{:query {:breakout [[:field-id 1000]]}} {:query {:breakout [[:field-id 1000]]}}
(#'normalize/canonicalize {:query {:breakout [[:field-id 1000]]}})) (#'normalize/canonicalize {:query {:breakout [[:field-id 1000]]}}))
;;; ----------------------------------------------------- fields -----------------------------------------------------
(expect (expect
{:query {:fields [[:field-id 10]]}} {:query {:fields [[:field-id 10]]}}
(#'normalize/canonicalize {:query {:fields [10]}})) (#'normalize/canonicalize {:query {:fields [10]}}))
...@@ -415,6 +470,14 @@ ...@@ -415,6 +470,14 @@
{:query {:fields [[:field-id 1000]]}} {:query {:fields [[:field-id 1000]]}}
(#'normalize/canonicalize {:query {:fields [[:field-id 1000]]}})) (#'normalize/canonicalize {:query {:fields [[:field-id 1000]]}}))
;; should handle seqs
(expect
{:query {:fields [[:field-id 10] [:field-id 20]]}}
(#'normalize/canonicalize {:query {:fields '(10 20)}}))
;;; ----------------------------------------------------- filter -----------------------------------------------------
;; implicit Field IDs should get wrapped in [:field-id] in filters ;; implicit Field IDs should get wrapped in [:field-id] in filters
(expect (expect
{:query {:filter [:= [:field-id 10] 20]}} {:query {:filter [:= [:field-id 10] 20]}}
...@@ -534,6 +597,20 @@ ...@@ -534,6 +597,20 @@
[:segment "gaid:-11"] [:segment "gaid:-11"]
[:time-interval [:field-id 6851] -365 :day {}]]}})) [:time-interval [:field-id 6851] -365 :day {}]]}}))
;; should handle seqs
(expect
{:query {:filter [:and [:= [:field-id 100] 1] [:= [:field-id 200] 2]]}}
(#'normalize/canonicalize {:query {:filter '(:and
[:= 100 1]
[:= 200 2])}}))
;; if you put a `:datetime-field` inside a `:time-interval` we should fix it for you
(expect
{:query {:filter [:time-interval [:field-id 8] -30 :day]}}
(#'normalize/canonicalize {:query {:filter [:time-interval [:datetime-field [:field-id 8] :month] -30 :day]}}))
;;; ---------------------------------------------------- order-by ----------------------------------------------------
;; ORDER BY: MBQL 95 [field direction] should get translated to MBQL 98 [direction field] ;; ORDER BY: MBQL 95 [field direction] should get translated to MBQL 98 [direction field]
(expect (expect
{:query {:order-by [[:asc [:field-id 10]]]}} {:query {:order-by [[:asc [:field-id 10]]]}}
...@@ -578,11 +655,13 @@ ...@@ -578,11 +655,13 @@
{:query {:filter [:= [:field-id 1] 10]}} {:query {:filter [:= [:field-id 1] 10]}}
(#'normalize/canonicalize {:query {:filter [:= [:field-id [:field-id 1]] 10]}})) (#'normalize/canonicalize {:query {:filter [:= [:field-id [:field-id 1]] 10]}}))
;; make sure `binning-strategy` wraps implicit Field IDs ;; we should handle seqs no prob
(expect (expect
{:query {:breakout [[:binning-strategy [:field-id 10] :bin-width 2000]]}} {:query {:filter [:= [:field-id 1] 10]}}
(#'normalize/canonicalize {:query {:breakout [[:binning-strategy 10 :bin-width 2000]]}})) (#'normalize/canonicalize {:query {:filter '(:= 1 10)}}))
;;; ------------------------------------------------- source queries -------------------------------------------------
;; Make sure canonicalization works correctly on source queries ;; Make sure canonicalization works correctly on source queries
(expect (expect
...@@ -605,21 +684,16 @@ ...@@ -605,21 +684,16 @@
:required true :required true
:default "Widget"}}}}})) :default "Widget"}}}}}))
;; make sure we recursively canonicalize source queries
(expect (expect
{:database 4, {:database 4
:type :query, :type :query
:query {:source-query {:source-table 1, :aggregation []}}} :query {:source-query {:source-table 1, :aggregation []}}}
(#'normalize/canonicalize (#'normalize/canonicalize
{:database 4 {:database 4
:type :query :type :query
:query {:source-query {:source-table 1, :aggregation :rows}}})) :query {:source-query {:source-table 1, :aggregation :rows}}}))
;; make sure canonicalization can handle aggregations with expressions where the Field normally goes
(expect
{:query {:aggregation [[:sum [:* [:field-id 4] [:field-id 1]]]]}}
(#'normalize/canonicalize
{:query {:aggregation [[:sum [:* [:field-id 4] [:field-id 1]]]]}}))
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
;;; | REMOVE EMPTY CLAUSES | ;;; | REMOVE EMPTY CLAUSES |
......
(ns metabase.mbql.util-test (ns metabase.mbql.util-test
(:require [expectations :refer :all] (:require [expectations :refer [expect]]
[metabase.mbql.util :as mbql.u])) [metabase.mbql.util :as mbql.u]))
;; can we use `clause-instances` to find the instances of a clause? ;;; +----------------------------------------------------------------------------------------------------------------+
;;; | match |
;;; +----------------------------------------------------------------------------------------------------------------+
;; can we use `match` to find the instances of a clause?
(expect (expect
[[:field-id 10] [[:field-id 10]
[:field-id 20]] [:field-id 20]]
(mbql.u/clause-instances :field-id {:query {:filter [:= (mbql.u/match {:query {:filter [:=
[:field-id 10] [:field-id 10]
[:field-id 20]]}})) [:field-id 20]]}}
[:field-id & _]))
;; clause-instances shouldn't include subclauses of certain clauses if we don't want them ;; is `match` nice enought to automatically wrap raw keywords in appropriate patterns for us?
(expect (expect
[[:field-id 1] [[:field-id 1]
[:fk-> [:field-id 2] [:field-id 3]]] [:field-id 2]
(mbql.u/clause-instances #{:field-id :fk->} [[:field-id 1] [:fk-> [:field-id 2] [:field-id 3]]])) [:field-id 3]]
(mbql.u/match {:fields [[:field-id 1] [:fk-> [:field-id 2] [:field-id 3]]]}
:field-id))
;; ...but we should be able to ask for them ;; if we pass a set of keywords, will that generate an appropriate pattern to match multiple clauses as well?
(expect (expect
[[:field-id 1] [[:field-id 1]
[:fk-> [:field-id 2] [:field-id 3]]
[:field-id 2] [:field-id 2]
[:field-id 3]] [:field-id 3]
(mbql.u/clause-instances #{:field-id :fk->} [:datetime-field [:field-id 4]]]
[[:field-id 1] [:fk-> [:field-id 2] [:field-id 3]]] (mbql.u/match {:fields [[:field-id 1]
:include-subclauses? true)) [:fk-> [:field-id 2] [:field-id 3]]
[:datetime-field [:field-id 4]]]}
#{:field-id :datetime-field}))
;; `match` shouldn't include subclauses of matches
(expect
[[:field-id 1]
[:fk-> [:field-id 2] [:field-id 3]]]
(mbql.u/match [[:field-id 1] [:fk-> [:field-id 2] [:field-id 3]]]
[(:or :field-id :fk->) & _]))
(expect (expect
[[:field-id 10] [[:field-id 10]
[:field-id 20]] [:field-id 20]]
(mbql.u/clause-instances #{:field-id :+ :-} (mbql.u/match {:query {:filter [:=
{:query {:filter [:= [:field-id 10]
[:field-id 10] [:field-id 20]]}}
[:field-id 20]]}})) [(:or :field-id :+ :-) & _]))
;; can we use some of the cool features of pattern matching?
(def ^:private a-query
{:breakout [[:field-id 10]
[:field-id 20]
[:field-literal "Wow"]]
:fields [[:fk->
[:field-id 30]
[:field-id 40]]]})
;; can we use the optional `result` parameter to find return something other than the whole clause?
(expect
[41]
;; return just the dest IDs of Fields in a fk-> clause
(mbql.u/match a-query
[:fk-> _ [:field-id dest-id]] (inc dest-id)))
(expect
[10 20]
(mbql.u/match (:breakout a-query) [:field-id id] id))
;; match should return `nil` if there are no matches so you don't need to call `seq`
(expect
nil
(mbql.u/match {} [:datetime-field _ unit] unit))
;; if pattern is just a raw keyword `match` should be kind enough to turn it into a pattern for you
(expect
[[:field-id 1]
[:field-id 2]]
(mbql.u/match {:fields [[:field-id 1] [:datetime-field [:field-id 2] :day]]}
:field-id))
;; can we `:guard` a pattern?
(expect
[[:field-id 2]]
(let [a-field-id 2]
(mbql.u/match {:fields [[:field-id 1] [:field-id 2]]}
[:field-id (id :guard (partial = a-field-id))])))
;; ok, if for some reason we can't use `:guard` in the pattern will `match` filter out nil results?
(expect
[2]
(mbql.u/match {:fields [[:field-id 1] [:field-id 2]]}
[:field-id id]
(when (= id 2)
id)))
;; Ok, if we want to use predicates but still return the whole match, can we use the anaphoric `&match` symbol to
;; return the whole thing?
(def ^:private another-query
{:fields [[:field-id 1]
[:datetime-field [:field-id 2] :day]
[:datetime-field [:fk-> [:field-id 3] [:field-id 4]] :month]]})
(expect
[[:field-id 1]
[:field-id 2]
[:field-id 3]
[:field-id 4]]
(let [some-pred? (constantly true)]
(mbql.u/match another-query
:field-id
(when some-pred?
&match))))
;; can we use the anaphoric `&parents` symbol to examine the parents of the collection? let's see if we can match
;; `:field-id` clauses that are inside `:datetime-field` clauses, regardless of whether something else wraps them
(expect
[[:field-id 2]
[:field-id 3]
[:field-id 4]]
(mbql.u/match another-query
:field-id
(when (contains? (set &parents) :datetime-field)
&match)))
;; can we match using a CLASS?
(expect
[#inst "2018-10-08T00:00:00.000-00:00"]
(mbql.u/match [[:field-id 1]
[:field-id 2]
#inst "2018-10-08"
4000]
java.util.Date))
;; can we match using a PREDICATE?
(expect
[4000 5000]
;; find the integer args to `:=` clauses that are not inside `:field-id` clauses
(mbql.u/match {:filter [:and
[:= [:field-id 1] 4000]
[:= [:field-id 2] 5000]]}
integer?
(when (= := (last &parents))
&match)))
;; how can we use predicates not named by a symbol?
(expect
[1 4000 2 5000]
(mbql.u/match {:filter [:and
[:= [:field-id 1] 4000]
[:= [:field-id 2] 5000]]}
(&match :guard #(integer? %))))
;; can we use `recur` inside a pattern?
(expect
[[0 :month]]
(mbql.u/match {:filter [:time-interval [:field-id 1] :current :month]}
[:time-interval field :current unit] (recur [:time-interval field 0 unit])
[:time-interval _ n unit] [n unit]))
;; can we short-circut a match to prevent recursive matching?
(expect
[10]
(mbql.u/match [[:field-id 10]
[:datetime-field [:field-id 20] :day]]
[:field-id id] id
[_ [:field-id & _] & _] nil))
;; can we use a list with a :guard clause?
(expect
[10 20]
(mbql.u/match {:query {:filter [:=
[:field-id 10]
[:field-id 20]]}}
(id :guard int?) id))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | replace |
;;; +----------------------------------------------------------------------------------------------------------------+
;; can we use `replace` to replace a specific clause?
(expect
{:breakout [[:datetime-field [:field-id 10] :day]
[:datetime-field [:field-id 20] :day]
[:field-literal "Wow"]]
:fields [[:fk->
[:datetime-field [:field-id 30] :day]
[:datetime-field [:field-id 40] :day]]]}
(mbql.u/replace a-query [:field-id id]
[:datetime-field [:field-id id] :day]))
;; can we wrap the pattern in a map to restrict what gets replaced?
(expect
{:breakout [[:datetime-field [:field-id 10] :day]
[:datetime-field [:field-id 20] :day]
[:field-literal "Wow"]]
:fields [[:fk-> [:field-id 30] [:field-id 40]]]}
(mbql.u/replace-in a-query [:breakout] [:field-id id]
[:datetime-field [:field-id id] :day]))
;; can we use multiple patterns at the same time?!
(expect
{:breakout [[:field-id 10] [:field-id 20] {:name "Wow"}], :fields [30]}
(mbql.u/replace a-query
[:fk-> [:field-id field-id] _] field-id
[:field-literal field-name] {:name field-name}))
;; can we use `replace` to replace the ID of the dest Field in fk-> clauses?
(expect
{:breakout [[:field-id 10]
[:field-id 20]
[:field-literal "Wow"]]
:fields [[:fk-> [:field-id 30] [:field-id 100]]]}
(mbql.u/replace a-query [:fk-> source [:field-id 40]]
[:fk-> source [:field-id 100]]))
;; can we use `replace` to fix `fk->` clauses where both args are unwrapped IDs?
(expect
{:query {:fields [[:fk-> [:field-id 1] [:field-id 2]]
[:fk-> [:field-id 3] [:field-id 4]]]}}
(mbql.u/replace-in
{:query {:fields [[:fk-> 1 2]
[:fk-> [:field-id 3] [:field-id 4]]]}}
[:query :fields]
[:fk-> (source :guard integer?) (dest :guard integer?)]
[:fk-> [:field-id source] [:field-id dest]]))
;; does `replace` accept a raw keyword as the pattern the way `match` does?
(expect
{:fields ["WOW"
[:datetime-field "WOW" :day]
[:datetime-field [:fk-> "WOW" "WOW"] :month]]}
(mbql.u/replace another-query :field-id "WOW"))
;; does `replace` accept a set of keywords the way `match` does?
(expect
{:fields ["WOW" "WOW" "WOW"]}
(mbql.u/replace another-query #{:datetime-field :field-id} "WOW"))
;; can we use the anaphor `&match` to look at the entire match?
(expect
{:fields [[:field-id 1]
[:magical-field
[:datetime-field [:field-id 2] :day]]
[:magical-field
[:datetime-field [:fk-> [:field-id 3] [:field-id 4]] :month]]]}
(mbql.u/replace another-query :datetime-field [:magical-field &match]))
;; can we use the anaphor `&parents` to look at the parents of the match?
(expect
{:fields
[[:field-id 1]
[:datetime-field "WOW" :day]
[:datetime-field [:fk-> "WOW" "WOW"] :month]]}
;; replace field ID clauses that are inside a datetime-field clause
(mbql.u/replace another-query :field-id
(if (contains? (set &parents) :datetime-field)
"WOW"
&match)))
;; can we replace using a CLASS?
(expect
[[:field-id 1]
[:field-id 2]
[:timestamp #inst "2018-10-08T00:00:00.000-00:00"]
4000]
(mbql.u/replace [[:field-id 1]
[:field-id 2]
#inst "2018-10-08"
4000]
java.util.Date
[:timestamp &match]))
;; can we replace using a PREDICATE?
(expect
{:filter [:and [:= [:field-id nil] 4000.0] [:= [:field-id nil] 5000.0]]}
;; find the integer args to `:=` clauses that are not inside `:field-id` clauses and make them FLOATS
(mbql.u/replace {:filter [:and
[:= [:field-id 1] 4000]
[:= [:field-id 2] 5000]]}
integer?
(when (= := (last &parents))
(float &match))))
;; can we do fancy stuff like remove all the filters that use datetime fields from a query?
;;
;; (NOTE: this example doesn't take into account the fact that [:binning-strategy ...] can wrap a `:datetime-field`,
;; so it's only appropriate for drivers that don't support binning (e.g. GA). Also the driver QP will need to be
;; written to handle the nils in a filter clause appropriately.)
(expect
[:and nil [:= [:field-id 100] 20]]
(mbql.u/replace [:and
[:=
[:datetime-field [:field-literal "ga:date"] :day]
[:absolute-datetime #inst "2016-11-08T00:00:00.000-00:00" :day]]
[:= [:field-id 100] 20]]
[_ [:datetime-field & _] & _] nil))
;; can we use short-circuting patterns to do something tricky like only replace `:field-id` clauses that aren't
;; wrapped by other clauses?
(expect
[[:datetime-field [:field-id 10] :day]
[:datetime-field [:field-id 20] :month]
[:field-id 30]]
(let [id-is-datetime-field? #{10}]
(mbql.u/replace [[:field-id 10]
[:datetime-field [:field-id 20] :month]
[:field-id 30]]
;; don't replace anything that's already wrapping a `field-id`
[_ [:field-id & _] & _]
&match
[:field-id (_ :guard id-is-datetime-field?)]
[:datetime-field &match :day])))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Other Fns |
;;; +----------------------------------------------------------------------------------------------------------------+
;; can `simplify-compound-filter` fix `and` or `or` with only one arg? ;; can `simplify-compound-filter` fix `and` or `or` with only one arg?
(expect (expect
...@@ -62,6 +349,54 @@ ...@@ -62,6 +349,54 @@
[:= [:field-id 1] 2] [:= [:field-id 1] 2]
(mbql.u/simplify-compound-filter [:not [:not [:= [:field-id 1] 2]]])) (mbql.u/simplify-compound-filter [:not [:not [:= [:field-id 1] 2]]]))
;; does `simplify-compound-filter` return `nil` for empty filter clauses?
(expect
nil
(mbql.u/simplify-compound-filter nil))
(expect
nil
(mbql.u/simplify-compound-filter []))
(expect
nil
(mbql.u/simplify-compound-filter [nil nil nil]))
(expect
nil
(mbql.u/simplify-compound-filter [:and nil nil]))
(expect
nil
(mbql.u/simplify-compound-filter [:and nil [:and nil nil nil] nil]))
;; can `simplify-compound-filter` eliminate `nil` inside compound filters?
(expect
[:= [:field-id 1] 2]
(mbql.u/simplify-compound-filter [:and nil [:and nil [:= [:field-id 1] 2] nil] nil]))
(expect
[:and
[:= [:field-id 1] 2]
[:= [:field-id 3] 4]
[:= [:field-id 5] 6]
[:= [:field-id 7] 8]
[:= [:field-id 9] 10]]
(mbql.u/simplify-compound-filter [:and
nil
[:= [:field-id 1] 2]
[:and
[:= [:field-id 3] 4]]
nil
[:and
[:and
[:and
[:= [:field-id 5] 6]
nil
nil]
[:= [:field-id 7] 8]
[:= [:field-id 9] 10]]]]))
;; can we add an order-by clause to a query? ;; can we add an order-by clause to a query?
(expect (expect
{:database 1, :type :query, :query {:source-table 1, :order-by [[:asc [:field-id 10]]]}} {:database 1, :type :query, :query {:source-table 1, :order-by [[:asc [:field-id 10]]]}}
...@@ -113,3 +448,35 @@ ...@@ -113,3 +448,35 @@
:query {:source-table 1 :query {:source-table 1
:order-by [[:asc [:field-id 10]]]}} :order-by [[:asc [:field-id 10]]]}}
[:asc [:datetime-field [:field-id 10] :day]])) [:asc [:datetime-field [:field-id 10] :day]]))
;;; ---------------------------------------------- aggregation-at-index ----------------------------------------------
(def ^:private query-with-some-nesting
{:database 1
:type :query
:query {:source-query {:source-table 1
:aggregation [[:stddev [:field-id 1]]
[:min [:field-id 1]]]}
:aggregation [[:avg [:field-id 1]]
[:max [:field-id 1]]]}})
(expect
[:avg [:field-id 1]]
(mbql.u/aggregation-at-index query-with-some-nesting 0))
(expect
[:max [:field-id 1]]
(mbql.u/aggregation-at-index query-with-some-nesting 1))
(expect
[:avg [:field-id 1]]
(mbql.u/aggregation-at-index query-with-some-nesting 0 0))
(expect
[:stddev [:field-id 1]]
(mbql.u/aggregation-at-index query-with-some-nesting 0 1))
(expect
[:min [:field-id 1]]
(mbql.u/aggregation-at-index query-with-some-nesting 1 1))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment