Skip to content
Snippets Groups Projects
Unverified Commit 11884ebe authored by Cam Saul's avatar Cam Saul Committed by GitHub
Browse files

Merge pull request #9999 from metabase/new-joins-syntax

New joins syntax
parents 26d55530 f394b55b
No related merge requests found
Showing
with 659 additions and 325 deletions
...@@ -58,14 +58,10 @@ ...@@ -58,14 +58,10 @@
(s/defn ^:private dataset-name-for-current-query :- BigQueryIdentifierString (s/defn ^:private dataset-name-for-current-query :- BigQueryIdentifierString
"Fetch the dataset name for the database associated with this query, needed because BigQuery requires you to qualify "Fetch the dataset name for the database associated with this query, needed because BigQuery requires you to qualify
identifiers with it. This is primarily called automatically for the `to-sql` implementation of the identifiers with it. This is primarily called automatically for the `to-sql` implementation of the
`BigQueryIdentifier` record type; see its definition for more details. `BigQueryIdentifier` record type; see its definition for more details."
This looks for the value inside the SQL QP's `*query*` dynamic var; since this won't be bound for non-MBQL queries,
you will want to avoid this function for SQL queries."
[] []
(or (some-> sql.qp/*query* :dataset-id) (when (qp.store/initialized?)
(when (qp.store/initialized?) (some-> (qp.store/database) :details :dataset-id)))
(some-> (qp.store/database) :details :dataset-id))))
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
...@@ -412,25 +408,17 @@ ...@@ -412,25 +408,17 @@
((partial apply h/merge-select) (for [field-clause breakout-field-clauses ((partial apply h/merge-select) (for [field-clause breakout-field-clauses
:when (not (contains? (set fields-field-clauses) field-clause))] :when (not (contains? (set fields-field-clauses) field-clause))]
(sql.qp/as driver field-clause))))) (sql.qp/as driver field-clause)))))
(defn- ag-ref->alias [[_ index]]
(let [{{aggregations :aggregation} :query} sql.qp/*query* ;; as with breakouts BigQuery requires that you use the Field aliases in order by clauses, so override the methods for
[ag-type :as ag] (nth aggregations index)] ;; compiling `:asc` and `:desc` and alias the Fields if applicable
(mbql.u/match-one ag (defn- alias-order-by-field [driver [direction field-clause]]
[:distinct _] :count (let [field-clause (if (mbql.u/is-clause? :aggregation field-clause)
[:expression operator & _] operator field-clause
[:named _ ag-name] (keyword ag-name) (sql.qp/field-clause->alias driver field-clause))]
[ag-type & _] ag-type))) ((get-method sql.qp/->honeysql [:sql direction]) driver [direction field-clause])))
(defmethod sql.qp/apply-top-level-clause [:bigquery :order-by] (defmethod sql.qp/->honeysql [:bigquery :asc] [driver clause] (alias-order-by-field driver clause))
[driver _ honeysql-form {subclauses :order-by, :as query}] (defmethod sql.qp/->honeysql [:bigquery :desc] [driver clause] (alias-order-by-field driver clause))
(loop [honeysql-form honeysql-form, [[direction field-clause] & more] subclauses]
(let [honeysql-form (h/merge-order-by honeysql-form [(if (mbql.u/is-clause? :aggregation field-clause)
(ag-ref->alias field-clause)
(sql.qp/field-clause->alias driver field-clause))
direction])]
(if (seq more)
(recur honeysql-form more)
honeysql-form))))
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
......
...@@ -9,16 +9,19 @@ ...@@ -9,16 +9,19 @@
[util :as u]] [util :as u]]
[metabase.db.metadata-queries :as metadata-queries] [metabase.db.metadata-queries :as metadata-queries]
[metabase.driver.bigquery :as bigquery] [metabase.driver.bigquery :as bigquery]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.mbql.util :as mbql.u] [metabase.mbql.util :as mbql.u]
[metabase.models [metabase.models
[database :refer [Database]] [database :refer [Database]]
[field :refer [Field]] [field :refer [Field]]
[table :refer [Table]]] [table :refer [Table]]]
[metabase.query-processor.test-util :as qp.test-util]
[metabase.test [metabase.test
[data :as data] [data :as data]
[util :as tu]] [util :as tu]]
[metabase.test.data.datasets :refer [expect-with-driver]] [metabase.test.data.datasets :refer [expect-with-driver]]
[metabase.test.util.timezone :as tu.tz] [metabase.test.util.timezone :as tu.tz]
[metabase.util.honeysql-extensions :as hx]
[toucan.util.test :as tt])) [toucan.util.test :as tt]))
;; Test native queries ;; Test native queries
...@@ -238,3 +241,30 @@ ...@@ -238,3 +241,30 @@
:limit 1} :limit 1}
:info {:executed-by 1000 :info {:executed-by 1000
:query-hash (byte-array [1 2 3 4])}})) :query-hash (byte-array [1 2 3 4])}}))
;; let's make sure we're generating correct HoneySQL + SQL for aggregations
(expect-with-driver :bigquery
{:select [[(hx/identifier :field "test_data.venues" "price") (hx/identifier :field-alias "price")]
[(hsql/call :avg (hx/identifier :field "test_data.venues" "category_id")) (hx/identifier :field-alias "avg")]]
:from [(hx/identifier :table "test_data.venues")]
:group-by [(hx/identifier :field-alias "price")]
:order-by [[(hx/identifier :field-alias "avg") :asc]]}
(qp.test-util/with-everything-store
(#'sql.qp/mbql->honeysql
:bigquery
(data/mbql-query venues
{:aggregation [[:avg $category_id]]
:breakout [$price]
:order-by [[:asc [:aggregation 0]]]}))))
(expect-with-driver :bigquery
{:query (str "SELECT `test_data.venues`.`price` AS `price`,"
" avg(`test_data.venues`.`category_id`) AS `avg` "
"FROM `test_data.venues` "
"GROUP BY `price` "
"ORDER BY `avg` ASC, `price` ASC")
:table-name "venues"
:mbql? true}
(qp/query->native
(data/mbql-query venues
{:aggregation [[:avg $category_id]], :breakout [$price], :order-by [[:asc [:aggregation 0]]]})))
...@@ -3,28 +3,25 @@ ...@@ -3,28 +3,25 @@
[set :as set] [set :as set]
[string :as str]] [string :as str]]
[clojure.java.jdbc :as jdbc] [clojure.java.jdbc :as jdbc]
[clojure.tools.logging :as log]
[honeysql.core :as hsql] [honeysql.core :as hsql]
[metabase [metabase
[config :as config] [config :as config]
[driver :as driver] [driver :as driver]]
[util :as u]]
[metabase.driver.common :as driver.common] [metabase.driver.common :as driver.common]
[metabase.driver.sql
[query-processor :as sql.qp]
[util :as sql.u]]
[metabase.driver.sql-jdbc [metabase.driver.sql-jdbc
[connection :as sql-jdbc.conn] [connection :as sql-jdbc.conn]
[execute :as sql-jdbc.execute] [execute :as sql-jdbc.execute]
[sync :as sql-jdbc.sync]] [sync :as sql-jdbc.sync]]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.driver.sql.util.unprepare :as unprepare] [metabase.driver.sql.util.unprepare :as unprepare]
[metabase.util [metabase.util
[date :as du] [date :as du]
[honeysql-extensions :as hx] [honeysql-extensions :as hx]
[i18n :refer [trs]] [ssh :as ssh]])
[ssh :as ssh]]
[schema.core :as s])
(:import [java.sql ResultSet Types] (:import [java.sql ResultSet Types]
java.util.Date java.util.Date))
metabase.util.honeysql_extensions.Identifier))
(driver/register! :oracle, :parent :sql-jdbc) (driver/register! :oracle, :parent :sql-jdbc)
...@@ -160,76 +157,6 @@ ...@@ -160,76 +157,6 @@
(defmethod sql.qp/unix-timestamp->timestamp [:oracle :milliseconds] [driver _ field-or-value] (defmethod sql.qp/unix-timestamp->timestamp [:oracle :milliseconds] [driver _ field-or-value]
(sql.qp/unix-timestamp->timestamp driver :seconds (hx// field-or-value (hsql/raw 1000)))) (sql.qp/unix-timestamp->timestamp driver :seconds (hx// field-or-value (hsql/raw 1000))))
(s/defn ^:private increment-identifier-string :- s/Str
[last-component :- s/Str]
(if-let [[_ existing-suffix] (re-find #"^.*_(\d+$)" last-component)]
;; if last-component already has an alias like col_2 then increment it to col_3
(let [new-suffix (str (inc (Integer/parseInt existing-suffix)))]
(str/replace last-component (re-pattern (str existing-suffix \$)) new-suffix))
;; otherwise just stick a _2 on the end so it's col_2
(str last-component "_2")))
(s/defn ^:private increment-identifier
"Add an appropriate suffix to a keyword `identifier` to make it distinct from previous usages of the same identifier,
e.g.
(increment-identifier :my_col) ; -> :my_col_2
(increment-identifier :my_col_2) ; -> :my_col_3"
[identifier :- Identifier]
(update
identifier
:components
(fn [components]
(conj
(vec (butlast components))
(increment-identifier-string (u/keyword->qualified-name (last components)))))))
(defn- alias-everything
"Make sure all the columns in `select-clause` are alias forms, e.g. `[:table.col :col]` instead of `:table.col`.
(This faciliates our deduplication logic.)"
[select-clause]
(for [col select-clause]
(cond
;; if something's already an alias form like [:table.col :col] it's g2g
(sequential? col)
col
;; otherwise we *should* be dealing with an Identifier. If so, take the last component of the Identifier and use
;; that as the alias.
;;
;; TODO - could this be done using `->honeysql` or `field->alias` instead?
(instance? Identifier col)
[col (hx/identifier :field-alias (last (:components col)))]
:else
(do
(log/error (trs "Don't know how to alias {0}, expected an Identifer." col))
[col col]))))
(defn- deduplicate-identifiers
"Make sure every column in `select-clause` has a unique alias. This is done because Oracle can't figure out how to use
a query that produces duplicate columns in a subselect."
[select-clause]
(if (= select-clause [:*])
;; if we're doing `SELECT *` there's no way we can deduplicate anything so we're SOL, return as-is
select-clause
;; otherwise we can actually deduplicate things
(loop [already-seen #{}, acc [], [[col alias] & more] (alias-everything select-clause)]
(cond
;; if not more cols are left to deduplicate, we're done
(not col)
acc
;; otherwise if we've already used this alias, replace it with one like `identifier_2` and try agan
(contains? already-seen alias)
(recur already-seen acc (cons [col (increment-identifier alias)]
more))
;; otherwise if we haven't seen it record it as seen and move on to the next column
:else
(recur (conj already-seen alias) (conj acc [col alias]) more)))))
;; Oracle doesn't support `LIMIT n` syntax. Instead we have to use `WHERE ROWNUM <= n` (`NEXT n ROWS ONLY` isn't ;; Oracle doesn't support `LIMIT n` syntax. Instead we have to use `WHERE ROWNUM <= n` (`NEXT n ROWS ONLY` isn't
;; supported on Oracle versions older than 12). This has to wrap the actual query, e.g. ;; supported on Oracle versions older than 12). This has to wrap the actual query, e.g.
;; ;;
...@@ -267,7 +194,7 @@ ...@@ -267,7 +194,7 @@
;; back to including a `SELECT *` just to make sure a valid query is produced ;; back to including a `SELECT *` just to make sure a valid query is produced
:from [(-> (merge {:select [:*]} :from [(-> (merge {:select [:*]}
honeysql-query) honeysql-query)
(update :select deduplicate-identifiers))] (update :select sql.u/select-clause-deduplicate-aliases))]
:where [:<= (hsql/raw "rownum") value]}) :where [:<= (hsql/raw "rownum") value]})
(defmethod sql.qp/apply-top-level-clause [:oracle :page] (defmethod sql.qp/apply-top-level-clause [:oracle :page]
......
...@@ -2,16 +2,18 @@ ...@@ -2,16 +2,18 @@
"Tests for specific behavior of the Oracle driver." "Tests for specific behavior of the Oracle driver."
(:require [clojure.java.jdbc :as jdbc] (:require [clojure.java.jdbc :as jdbc]
[expectations :refer [expect]] [expectations :refer [expect]]
[honeysql.core :as hsql]
[metabase [metabase
[driver :as driver] [driver :as driver]
[query-processor :as qp] [query-processor :as qp]
[query-processor-test :as qp.test] [query-processor-test :as qp.test]
[util :as u]] [util :as u]]
[metabase.driver.oracle :as oracle]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.models [metabase.models
[field :refer [Field]] [field :refer [Field]]
[table :refer [Table]]] [table :refer [Table]]]
[metabase.query-processor.test-util :as qp.test-util]
[metabase.test [metabase.test
[data :as data] [data :as data]
[util :as tu]] [util :as tu]]
...@@ -69,36 +71,6 @@ ...@@ -69,36 +71,6 @@
:service-name "MyCoolService" :service-name "MyCoolService"
:sid "ORCL"})) :sid "ORCL"}))
;; `deduplicate-identifiers` should use the last component of an identifier as the alias if it does not already have
;; one
(expect
[[(hx/identifier :field "A" "B" "C" "D") (hx/identifier :field-alias "D")]
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]
(#'oracle/deduplicate-identifiers
[(hx/identifier :field "A" "B" "C" "D")
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]))
;; `deduplicate-identifiers` should append numeric suffixes to duplicate aliases
(expect
[[(hx/identifier :field "A" "B" "C" "D") (hx/identifier :field-alias "D")]
[(hx/identifier :field "E" "D") (hx/identifier :field-alias "D_2")]
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]
(#'oracle/deduplicate-identifiers
[(hx/identifier :field "A" "B" "C" "D")
(hx/identifier :field "E" "D")
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]))
;; `deduplicate-identifiers` should handle aliases that are already suffixed gracefully
(expect
[[(hx/identifier :field "A" "B" "C" "D") (hx/identifier :field-alias "D")]
[(hx/identifier :field "E" "D") (hx/identifier :field-alias "D_2")]
[(hx/identifier :field "F") (hx/identifier :field-alias "D_3")]]
(#'oracle/deduplicate-identifiers
[(hx/identifier :field "A" "B" "C" "D")
(hx/identifier :field "E" "D")
[(hx/identifier :field "F") (hx/identifier :field-alias "D_2")]]))
(expect (expect
com.jcraft.jsch.JSchException com.jcraft.jsch.JSchException
(let [engine :oracle (let [engine :oracle
...@@ -157,3 +129,46 @@ ...@@ -157,3 +129,46 @@
{:database (data/id) {:database (data/id)
:type :query :type :query
:query {:source-table (u/get-id table)}})))))) :query {:source-table (u/get-id table)}}))))))
;; let's make sure we're actually attempting to generate the correctl HoneySQL for joins and source queries so we
;; don't sit around scratching our heads wondering why the queries themselves aren't working
(expect-with-driver :oracle
{:select [:*]
:from [{:select
[[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "id") (hx/identifier :field-alias "id")]
[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "name") (hx/identifier :field-alias "name")]
[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "category_id") (hx/identifier :field-alias "category_id")]
[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "latitude") (hx/identifier :field-alias "latitude")]
[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "longitude") (hx/identifier :field-alias "longitude")]
[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "price") (hx/identifier :field-alias "price")]]
:from [(hx/identifier :table oracle.tx/session-schema "test_data_venues")]
:left-join [[(hx/identifier :table oracle.tx/session-schema "test_data_categories") (hx/identifier :table-alias "test_data_categories__via__cat")]
[:=
(hx/identifier :field oracle.tx/session-schema "test_data_venues" "category_id")
(hx/identifier :field "test_data_categories__via__cat" "id")]]
:where [:=
(hx/identifier :field "test_data_categories__via__cat" "name")
"BBQ"]
:order-by [[(hx/identifier :field oracle.tx/session-schema "test_data_venues" "id") :asc]]}]
:where [:<= {:s "rownum"} 100]}
(qp.test-util/with-everything-store
(#'sql.qp/mbql->honeysql
:oracle
(data/mbql-query venues
{:source-table $$venues
:order-by [[:asc $id]]
:filter [:=
[:joined-field "test_data_categories__via__cat" $categories.name]
[:value "BBQ" {:base_type :type/Text, :special_type :type/Name, :database_type "VARCHAR"}]]
:fields [$id $name $category_id $latitude $longitude $price]
:limit 100
:joins [{:source-table $$categories
:alias "test_data_categories__via__cat",
:strategy :left-join
:condition [:=
$category_id
[:joined-field "test_data_categories__via__cat" $categories.id]]
:fk-field-id (data/id :venues :category_id)
:fields :none}]}))))
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
;; PUBLIC.CHECKINS.USER_ID | CAM_195.test_data_checkins.user_id ;; PUBLIC.CHECKINS.USER_ID | CAM_195.test_data_checkins.user_id
;; PUBLIC.INCIDENTS.TIMESTAMP | CAM_195.sad_toucan_incidents.timestamp ;; PUBLIC.INCIDENTS.TIMESTAMP | CAM_195.sad_toucan_incidents.timestamp
(defonce ^:private session-schema-number (rand-int 200)) (defonce ^:private session-schema-number (rand-int 200))
(defonce ^:private session-schema (str "CAM_" session-schema-number)) (defonce session-schema (str "CAM_" session-schema-number))
(defonce ^:private session-password (apply str (repeatedly 16 #(rand-nth (map char (range (int \a) (inc (int \z)))))))) (defonce ^:private session-password (apply str (repeatedly 16 #(rand-nth (map char (range (int \a) (inc (int \z))))))))
;; Session password is only used when creating session user, not anywhere else ;; Session password is only used when creating session user, not anywhere else
......
...@@ -24,12 +24,13 @@ ...@@ -24,12 +24,13 @@
[i18n :refer [tru]] [i18n :refer [tru]]
[schema :as su]] [schema :as su]]
[schema.core :as s]) [schema.core :as s])
(:import honeysql.format.ToSql (:import metabase.util.honeysql_extensions.Identifier))
metabase.util.honeysql_extensions.Identifier))
;; TODO - yet another `*query*` dynamic var. We should really consolidate them all so we only need a single one. ;; TODO - yet another `*query*` dynamic var. We should really consolidate them all so we only need a single one.
(def ^:dynamic *query* (def ^:dynamic *query*
"The outer query currently being processed." "The outer query currently being processed.
(This is only used to power `[:aggregation <index>]` and expression references, because they need to be able to find
the corresponding clauses outside of where they're being processed.)"
nil) nil)
(def ^:dynamic *nested-query-level* (def ^:dynamic *nested-query-level*
...@@ -263,26 +264,29 @@ ...@@ -263,26 +264,29 @@
[driver [_ pred]] [driver [_ pred]]
(hsql/call :/ (->honeysql driver [:count-where pred]) :%count.*)) (hsql/call :/ (->honeysql driver [:count-where pred]) :%count.*))
;; actual handling of the name is done in the top-level clause handler for aggregations
(defmethod ->honeysql [:sql :named] [driver [_ ag ag-name]] (defmethod ->honeysql [:sql :named] [driver [_ ag ag-name]]
(->honeysql driver ag)) (->honeysql driver ag))
;; aggregation REFERENCE e.g. the ["aggregation" 0] fields we allow in order-by ;; aggregation REFERENCE e.g. the ["aggregation" 0] fields we allow in order-by
(defmethod ->honeysql [:sql :aggregation] (defmethod ->honeysql [:sql :aggregation]
[driver [_ index]] [driver [_ index]]
(let [aggregation (mbql.u/aggregation-at-index *query* index *nested-query-level*)] (mbql.u/match-one (mbql.u/aggregation-at-index *query* index *nested-query-level*)
(cond [:named _ ag-name]
;; For some arcane reason we name the results of a distinct aggregation "count", (->honeysql driver (hx/identifier :field-alias ag-name))
;; everything else is named the same as the aggregation
(mbql.u/is-clause? :distinct aggregation)
:count
(mbql.u/is-clause? #{:+ :- :* :/} aggregation) ;; For some arcane reason we name the results of a distinct aggregation "count", everything else is named the
(->honeysql driver aggregation) ;; same as the aggregation
:distinct
(->honeysql driver (hx/identifier :field-alias :count))
;; for everything else just use the name of the aggregation as an identifer, e.g. `:sum` #{:+ :- :* :/}
;; TODO - this obviously doesn't work right for multiple aggregations of the same type (->honeysql driver &match)
:else
(first aggregation)))) ;; for everything else just use the name of the aggregation as an identifer, e.g. `:sum`
;; TODO - this obviously doesn't work right for multiple aggregations of the same type
[ag-type & _]
(->honeysql driver (hx/identifier :field-alias ag-type))))
(defmethod ->honeysql [:sql :absolute-datetime] (defmethod ->honeysql [:sql :absolute-datetime]
[driver [_ timestamp unit]] [driver [_ timestamp unit]]
...@@ -303,7 +307,7 @@ ...@@ -303,7 +307,7 @@
;;; | Field Aliases (AS Forms) | ;;; | Field Aliases (AS Forms) |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
(s/defn field-clause->alias :- (s/maybe ToSql) (s/defn field-clause->alias
"Generate HoneySQL for an approriate alias (e.g., for use with SQL `AN`) for a Field clause of any type, or `nil` if "Generate HoneySQL for an approriate alias (e.g., for use with SQL `AN`) for a Field clause of any type, or `nil` if
the Field should not be aliased (e.g. if `field->alias` returns `nil`)." the Field should not be aliased (e.g. if `field->alias` returns `nil`)."
[driver, field-clause :- mbql.s/Field] [driver, field-clause :- mbql.s/Field]
...@@ -350,16 +354,12 @@ ...@@ -350,16 +354,12 @@
(defmethod apply-top-level-clause [:sql :aggregation] (defmethod apply-top-level-clause [:sql :aggregation]
[driver _ honeysql-form {aggregations :aggregation}] [driver _ honeysql-form {aggregations :aggregation}]
(loop [form honeysql-form, [ag & more] aggregations] (let [honeysql-ags (for [ag aggregations]
(let [form (h/merge-select [(->honeysql driver ag)
form (->honeysql driver (hx/identifier
[(->honeysql driver ag) :field-alias
(->honeysql driver (hx/identifier (driver/format-custom-field-name driver (annotate/aggregation-name ag))))])]
:field-alias (reduce h/merge-select honeysql-form honeysql-ags)))
(driver/format-custom-field-name driver (annotate/aggregation-name ag))))])]
(if-not (seq more)
form
(recur form more)))))
;;; ----------------------------------------------- breakout & fields ------------------------------------------------ ;;; ----------------------------------------------- breakout & fields ------------------------------------------------
...@@ -446,42 +446,59 @@ ...@@ -446,42 +446,59 @@
(declare build-honeysql-form) (declare build-honeysql-form)
(defn- make-honeysql-join-clauses (defmulti join->honeysql
"Returns a seq of honeysql join clauses, joining to `table-or-query-expr`. `jt-or-jq` can be either a `JoinTable` or "Compile a single MBQL `join` to HoneySQL."
a `JoinQuery`" {:arglists '([driver join]), :style/indent 1}
[driver table-or-query-expr {:keys [join-alias fk-field-id pk-field-id]}] driver/dispatch-on-initialized-driver
(let [source-field (qp.store/field fk-field-id) :hierarchy #'driver/hierarchy)
pk-field (qp.store/field pk-field-id)]
[[table-or-query-expr (->honeysql driver (hx/identifier :table-alias join-alias))] (defmulti join-source
[:= "Generate HoneySQL for a table or query to be joined."
(->honeysql driver source-field) {:arglists '([driver join]), :style/indent 1}
(binding [*table-alias* join-alias] driver/dispatch-on-initialized-driver
(->honeysql driver pk-field))]])) :hierarchy #'driver/hierarchy)
(s/defn ^:private join-info->honeysql (defmethod join-source :sql
[driver , {:keys [query table-id], :as info} :- mbql.s/JoinInfo] [driver {:keys [source-table source-query]}]
(if query (if source-query
(make-honeysql-join-clauses driver (build-honeysql-form driver query) info) (build-honeysql-form driver source-query)
(let [table (qp.store/table table-id) (binding [*table-alias* nil]
table-identifier (binding [*table-alias* nil] (->honeysql driver (qp.store/table source-table)))))
(->honeysql driver table))]
(make-honeysql-join-clauses driver table-identifier info)))) (s/defmethod join->honeysql :sql
[driver, {:keys [condition alias], :as join} :- mbql.s/Join]
(defmethod apply-top-level-clause [:sql :join-tables] [[(join-source driver join) (->honeysql driver (hx/identifier :table-alias alias))]
[driver _ honeysql-form {:keys [join-tables]}] (->honeysql driver condition)])
(reduce (partial apply h/merge-left-join) honeysql-form (map (partial join-info->honeysql driver) join-tables)))
(def ^:private join-strategy->merge-fn
{:left-join h/merge-left-join
:right-join h/merge-right-join
:inner-join h/merge-join
:full-join h/merge-full-join})
(defmethod apply-top-level-clause [:sql :joins]
[driver _ honeysql-form {:keys [joins]}]
(reduce
(fn [honeysql-form {:keys [strategy], :as join}]
(apply (join-strategy->merge-fn strategy) honeysql-form (join->honeysql driver join)))
honeysql-form
joins))
;;; ---------------------------------------------------- order-by ---------------------------------------------------- ;;; ---------------------------------------------------- order-by ----------------------------------------------------
(defmethod ->honeysql [:sql :asc]
[driver [direction field]]
[(->honeysql driver field) direction])
(defmethod ->honeysql [:sql :desc]
[driver [direction field]]
[(->honeysql driver field) direction])
(defmethod apply-top-level-clause [:sql :order-by] (defmethod apply-top-level-clause [:sql :order-by]
[driver _ honeysql-form {subclauses :order-by breakout-fields :breakout}] [driver _ honeysql-form {subclauses :order-by}]
(let [[{:keys [special-type] :as first-breakout-field}] breakout-fields] (reduce h/merge-order-by honeysql-form (map (partial ->honeysql driver)
(loop [honeysql-form honeysql-form, [[direction field] & more] subclauses] subclauses)))
(let [honeysql-form (h/merge-order-by honeysql-form [(->honeysql driver field) direction])]
(if (seq more)
(recur honeysql-form more)
honeysql-form)))))
;;; -------------------------------------------------- limit & page -------------------------------------------------- ;;; -------------------------------------------------- limit & page --------------------------------------------------
...@@ -514,29 +531,39 @@ ...@@ -514,29 +531,39 @@
(def ^:private top-level-clause-application-order (def ^:private top-level-clause-application-order
"Order to apply top-level clauses in. This is important because we build things like the `SELECT` clause progressively "Order to apply top-level clauses in. This is important because we build things like the `SELECT` clause progressively
and MBQL requires us to return results with `:breakout` columns before `:aggregation`, etc." and MBQL requires us to return results with `:breakout` columns before `:aggregation`, etc.
[:source-table :breakout :aggregation :fields :filter :join-tables :order-by :page :limit])
Map of clause -> index, e.g.
{:source-table 0, :breakout 1, ...}"
(into {} (map-indexed
#(vector %2 %1)
[:source-table :breakout :aggregation :fields :filter :joins :order-by :page :limit])))
(defn- query->keys-in-application-order (defn- query->keys-in-application-order
"Return the keys present in an MBQL `inner-query` in the order they should be processed." "Return the keys present in an MBQL `inner-query` in the order they should be processed."
[inner-query] [inner-query]
;; sort first by any known top-level clauses according to the `top-level-application-clause-order` defined above, ;; sort first by any known top-level clauses according to the `top-level-application-clause-order` defined above,
;; then sort any unknown clauses by name. ;; then sort any unknown clauses by name.
(let [known-clause->index (into {} (map-indexed (fn [i clause] [clause i]) top-level-clause-application-order))] (sort-by (fn [clause] [(get top-level-clause-application-order clause Integer/MAX_VALUE) clause])
;; We'll do this using a [<known-applicaton-order-index> <clause-name-keyword>] tuple (keys inner-query)))
(sort-by (fn [clause] [(known-clause->index clause Integer/MAX_VALUE) clause]) (keys inner-query))))
(defn- add-default-select
"Add `SELECT *` to `honeysql-form` if no `:select` clause is present."
[{:keys [select], :as honeysql-form}]
(cond-> honeysql-form
(empty? select) (assoc :select [:*])))
(defn- apply-top-level-clauses (defn- apply-top-level-clauses
"Loop through all the `clause->handler` entries; if the query contains a given clause, apply the handler fn. Doesn't "`apply-top-level-clause` for all of the top-level clauses in `inner-query`, progressively building a HoneySQL form.
handle `:source-query`; since that must be handled in a special way, that is handled separately." Clauses are applied according to the order in `top-level-clause-application-order`."
[driver honeysql-form inner-query] [driver honeysql-form inner-query]
(loop [honeysql-form honeysql-form, [k & more] (query->keys-in-application-order inner-query)] (-> (reduce
(let [honeysql-form (apply-top-level-clause driver k honeysql-form inner-query)] (fn [honeysql-form k]
(if (seq more) (apply-top-level-clause driver k honeysql-form inner-query))
(recur honeysql-form more) honeysql-form
;; ok, we're done; if no `:select` clause was specified (for whatever reason) put a default (`SELECT *`) one (query->keys-in-application-order inner-query))
;; in add-default-select))
(update honeysql-form :select #(if (seq %) % [:*]))))))
;;; -------------------------------------------- Handling source queries --------------------------------------------- ;;; -------------------------------------------- Handling source queries ---------------------------------------------
...@@ -587,8 +614,8 @@ ...@@ -587,8 +614,8 @@
(s/defn build-honeysql-form (s/defn build-honeysql-form
"Build the HoneySQL form we will compile to SQL and execute." "Build the HoneySQL form we will compile to SQL and execute."
[driverr, {inner-query :query} :- su/Map] [driver, {inner-query :query} :- su/Map]
(u/prog1 (apply-clauses driverr {} inner-query) (u/prog1 (apply-clauses driver {} inner-query)
(when-not i/*disable-qp-logging* (when-not i/*disable-qp-logging*
(log/debug (tru "HoneySQL Form:") (u/emoji "🍯") "\n" (u/pprint-to-str 'cyan <>))))) (log/debug (tru "HoneySQL Form:") (u/emoji "🍯") "\n" (u/pprint-to-str 'cyan <>)))))
...@@ -613,11 +640,14 @@ ...@@ -613,11 +640,14 @@
(throw e)))] (throw e)))]
(into [sql] args))) (into [sql] args)))
(defn- mbql->honeysql [driver outer-query]
(binding [*query* outer-query]
(build-honeysql-form driver outer-query)))
(defn mbql->native (defn mbql->native
"Transpile MBQL query into a native SQL statement." "Transpile MBQL query into a native SQL statement."
[driver {inner-query :query, database :database, :as outer-query}] [driver {inner-query :query, database :database, :as outer-query}]
(binding [*query* outer-query] (let [honeysql-form (mbql->honeysql driver outer-query)
(let [honeysql-form (build-honeysql-form driver outer-query) [sql & args] (honeysql-form->sql+args driver honeysql-form)]
[sql & args] (honeysql-form->sql+args driver honeysql-form)] {:query sql
{:query sql :params args}))
:params args})))
(ns metabase.driver.sql.util (ns metabase.driver.sql.util
"Utility functions for writing SQL drivers." "Utility functions for writing SQL drivers."
(:require [honeysql.core :as hsql] (:require [clojure.string :as str]
[clojure.tools.logging :as log]
[honeysql.core :as hsql]
[metabase.driver.sql.query-processor :as sql.qp] [metabase.driver.sql.query-processor :as sql.qp]
[metabase.util.honeysql-extensions :as hx] [metabase.util :as u]
[schema.core :as s])) [metabase.util
[honeysql-extensions :as hx]
[i18n :refer [trs]]]
[schema.core :as s])
(:import metabase.util.honeysql_extensions.Identifier))
(s/defn quote-name (s/defn quote-name
"Quote unqualified string or keyword identifier(s) by passing them to `hx/identifier`, then calling HoneySQL `format` "Quote unqualified string or keyword identifier(s) by passing them to `hx/identifier`, then calling HoneySQL `format`
...@@ -21,3 +27,76 @@ ...@@ -21,3 +27,76 @@
(hsql/format (sql.qp/->honeysql driver (apply hx/identifier identifier-type components)) (hsql/format (sql.qp/->honeysql driver (apply hx/identifier identifier-type components))
:quoting (sql.qp/quote-style driver) :quoting (sql.qp/quote-style driver)
:allow-dashed-names? true))) :allow-dashed-names? true)))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Deduplicate Field Aliases |
;;; +----------------------------------------------------------------------------------------------------------------+
(s/defn ^:private increment-identifier-string :- s/Str
[last-component :- s/Str]
(if-let [[_ existing-suffix] (re-find #"^.*_(\d+$)" last-component)]
;; if last-component already has an alias like col_2 then increment it to col_3
(let [new-suffix (str (inc (Integer/parseInt existing-suffix)))]
(str/replace last-component (re-pattern (str existing-suffix \$)) new-suffix))
;; otherwise just stick a _2 on the end so it's col_2
(str last-component "_2")))
(s/defn ^:private increment-identifier
"Add an appropriate suffix to a keyword `identifier` to make it distinct from previous usages of the same identifier,
e.g.
(increment-identifier :my_col) ; -> :my_col_2
(increment-identifier :my_col_2) ; -> :my_col_3"
[identifier :- Identifier]
(update
identifier
:components
(fn [components]
(conj
(vec (butlast components))
(increment-identifier-string (u/keyword->qualified-name (last components)))))))
(defn select-clause-alias-everything
"Make sure all the columns in `select-clause` are alias forms, e.g. `[:table.col :col]` instead of `:table.col`.
(This faciliates our deduplication logic.)"
[select-clause]
(for [col select-clause]
(cond
;; if something's already an alias form like [:table.col :col] it's g2g
(sequential? col)
col
;; otherwise we *should* be dealing with an Identifier. If so, take the last component of the Identifier and use
;; that as the alias.
;;
;; TODO - could this be done using `->honeysql` or `field->alias` instead?
(instance? Identifier col)
[col (hx/identifier :field-alias (last (:components col)))]
:else
(do
(log/error (trs "Don't know how to alias {0}, expected an Identifer." col))
[col col]))))
(defn select-clause-deduplicate-aliases
"Make sure every column in `select-clause` has a unique alias. This is useful for databases like Oracle that can't
figure out how to use a query that produces duplicate columns in a subselect."
[select-clause]
(if (= select-clause [:*])
;; if we're doing `SELECT *` there's no way we can deduplicate anything so we're SOL, return as-is
select-clause
;; otherwise we can actually deduplicate things
(loop [already-seen #{}, acc [], [[col alias] & more] (select-clause-alias-everything select-clause)]
(cond
;; if not more cols are left to deduplicate, we're done
(not col)
acc
;; otherwise if we've already used this alias, replace it with one like `identifier_2` and try agan
(contains? already-seen alias)
(recur already-seen acc (cons [col (increment-identifier alias)]
more))
;; otherwise if we haven't seen it record it as seen and move on to the next column
:else
(recur (conj already-seen alias) (conj acc [col alias]) more)))))
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
[core :as core] [core :as core]
[set :as set]] [set :as set]]
[metabase.mbql.schema.helpers :refer [defclause is-clause? one-of]] [metabase.mbql.schema.helpers :refer [defclause is-clause? one-of]]
[metabase.util :as u]
[metabase.util [metabase.util
[date :as du] [date :as du]
[schema :as su]] [schema :as su]]
...@@ -526,38 +527,81 @@ ...@@ -526,38 +527,81 @@
(set/rename-keys NativeQuery {:query :native}) (set/rename-keys NativeQuery {:query :native})
(s/recursive #'MBQLQuery))) (s/recursive #'MBQLQuery)))
(def JoinTableInfo (def JoinStrategy
"Schema for information about a JOIN (or equivalent) that should be performed, and how to do it.. This is added "Strategy that should be used to perform the equivalent of a SQL `JOIN` against another table or a nested query.
automatically by `resolve-joined-tables` middleware for `fk->` forms that are encountered." These correspond 1:1 to features of the same name in driver features lists; e.g. you should check that the current
{ ;; The alias we should use for the table driver supports `:full-join` before generating a Join clause using that strategy."
:join-alias su/NonBlankString (s/enum :left-join :right-join :inner-join :full-join))
;; ID of the Table to JOIN against. Table will be present in the QP store
:table-id su/IntGreaterThanZero (def Join
;; ID of the Field of the Query's SOURCE TABLE to use for the JOIN "Perform the equivalent of a SQL `JOIN` with another Table or nested `:source-query`. JOINs are either explicitly
;; TODO - can `fk-field-id` and `pk-field-id` possibly be NAMES of FIELD LITERALS?? specified in the incoming query, or implicitly generated when one uses a `:fk->` clause.
:fk-field-id su/IntGreaterThanZero In the top-level query, you can reference Fields from the joined table or nested query by the `:fk->` clause for
;; ID of the Field on the Table we will JOIN (i.e., Table with `table-id`) to use for the JOIN implicit joins; for explicit joins, you *must* specify `:alias` yourself; you can then reference Fields by using a
:pk-field-id su/IntGreaterThanZero}) `:joined-field` clause, e.g.
[:joined-field \"my_join_alias\" [:field-id 1]] ; for joins against other Tabless
(def JoinQueryInfo [:joined-field \"my_join_alias\" [:field-literal \"my_field\"]] ; for joins against nested queries"
"Schema for information about about a JOIN (or equivalent) that should be performed using a recursive MBQL or native (->
query." { ;; The condition on which to JOIN. Can be anything that is a valid `:filter` clause. For automatically-generated
;; Similar to a `JoinTable` but instead of referencing a table, it references a query expression ;; JOINs this is always
(assoc JoinTableInfo ;;
:query (s/recursive #'Query))) ;; [:= <source-table-fk-field> [:joined-field <join-table-alias> <dest-table-pk-field>]]
;;
(def JoinInfo :condition Filter
"Schema for information about a JOIN (or equivalent) that needs to be performed, either `JoinTableInfo` or ;;
`JoinQueryInfo`." ;; *What* to JOIN. Self-joins can be done by using the same `:source-table` as in the query where this is specified.
(s/if :query ;; YOU MUST SUPPLY EITHER `:source-table` OR `:source-query`, BUT NOT BOTH!
JoinQueryInfo (s/optional-key :source-table) su/IntGreaterThanZero
JoinTableInfo)) (s/optional-key :source-query) (s/recursive #'Query)
;;
(def ^:private JoinInfos ;; Defaults to `:left-join`; used for all automatically-generated JOINs
;;
;; Driver implementations: this is guaranteed to be present after pre-processing.
(s/optional-key :strategy) JoinStrategy
;;
;; The Fields to include in the results *if* a top-level `:fields` clause *is not* specified. This can be either
;; `:none`, `:all`, or a sequence of Field clauses.
;;
;; * `:none`: no Fields from the joined table or nested query are included (unless indirectly included by
;; breakouts or other clauses). This is the default, and what is used for automatically-generated joins.
;;
;; * `:all`: will include all of the Fields from the joined table or query
;;
;; * a sequence of Field clauses: include only the Fields specified. Valid clauses are the same as the top-level
;; `:fields` clause. This should be non-empty and all elements should be distinct. The normalizer will
;; automatically remove duplicate fields for you, and replace empty clauses with `:none`.
;;
;; Driver implementations: you can ignore this clause. Relevant fields will be added to top-level `:fields` clause
;; with appropriate aliases.
(s/optional-key :fields) (s/cond-pre
(s/enum :all :none)
(su/distinct (su/non-empty [Field])))
;;
;; The name used to alias the joined table or query. This is usually generated automatically and generally looks
;; like `table__via__field`. You can specify this yourself if you need to reference a joined field in a
;; `:joined-field` clause.
;;
;; Driver implementations: This is guaranteed to be present after pre-processing.
(s/optional-key :alias) su/NonBlankString
;;
;; Used internally, only for annotation purposes in post-processing. When a join is implicitly generated via an
;; `:fk->` clause, the ID of the foreign key field in the source Table will be recorded here. This information is
;; used to add `fk_field_id` information to the `:cols` in the query results; I believe this is used to facilitate
;; drill-thru? :shrug:
;;
;; Don't set this information yourself. It will have no effect.
(s/optional-key :fk-field-id) (s/maybe su/IntGreaterThanZero)}
(s/constrained
(fn [{:keys [source-table source-query]}]
(u/xor source-table source-query))
"Joins can must have either a `source-table` or `source-query`, but not both.")))
(def Joins
"Schema for a valid sequence of `Join`s. Must be a non-empty sequence, and `:alias`, if specified, must be unique."
(s/constrained (s/constrained
(su/distinct [JoinInfo]) (su/non-empty [Join])
#(su/empty-or-distinct? (filter some? (map :join-alias %))) #(su/empty-or-distinct? (filter some? (map :alias %)))
"all join aliases must be distinct.")) "All join aliases must be unique."))
(def ^java.util.regex.Pattern source-table-card-id-regex (def ^java.util.regex.Pattern source-table-card-id-regex
"Pattern that matches `card__id` strings that can be used as the `:source-table` of MBQL queries." "Pattern that matches `card__id` strings that can be used as the `:source-table` of MBQL queries."
...@@ -574,7 +618,7 @@ ...@@ -574,7 +618,7 @@
(s/optional-key :source-table) SourceTable (s/optional-key :source-table) SourceTable
(s/optional-key :aggregation) (su/non-empty [Aggregation]) (s/optional-key :aggregation) (su/non-empty [Aggregation])
(s/optional-key :breakout) (su/non-empty [Field]) (s/optional-key :breakout) (su/non-empty [Field])
; TODO - expressions keys should be strings; fix this when we get a chance ;; TODO - expressions keys should be strings; fix this when we get a chance
(s/optional-key :expressions) {s/Keyword FieldOrExpressionDef} (s/optional-key :expressions) {s/Keyword FieldOrExpressionDef}
(s/optional-key :fields) (su/distinct (su/non-empty [Field])) (s/optional-key :fields) (su/distinct (su/non-empty [Field]))
(s/optional-key :filter) Filter (s/optional-key :filter) Filter
...@@ -588,7 +632,7 @@ ...@@ -588,7 +632,7 @@
:items su/IntGreaterThanZero} :items su/IntGreaterThanZero}
;; Various bits of middleware add additonal keys, such as `fields-is-implicit?`, to record bits of state or pass ;; Various bits of middleware add additonal keys, such as `fields-is-implicit?`, to record bits of state or pass
;; info to other pieces of middleware. Everyone else can ignore them. ;; info to other pieces of middleware. Everyone else can ignore them.
(s/optional-key :join-tables) JoinInfos (s/optional-key :joins) Joins
s/Keyword s/Any} s/Keyword s/Any}
(s/constrained (s/constrained
......
...@@ -59,7 +59,7 @@ ...@@ -59,7 +59,7 @@
;; TODO - this is a copy of the one in the `metabase.mbql.util` namespace. We need to reorganize things a bit so we ;; TODO - this is a copy of the one in the `metabase.mbql.util` namespace. We need to reorganize things a bit so we
;; can use the same fn and avoid circular refs ;; can use the same fn and avoid circular refs
(defn ^:deprecated is-clause? (defn is-clause?
"If `x` an MBQL clause, and an instance of clauses defined by keyword(s) `k-or-ks`? "If `x` an MBQL clause, and an instance of clauses defined by keyword(s) `k-or-ks`?
(is-clause? :count [:count 10]) ; -> true (is-clause? :count [:count 10]) ; -> true
......
...@@ -354,6 +354,7 @@ ...@@ -354,6 +354,7 @@
number to as optional arg `nesting-level` to make sure you reference aggregations at the right level of nesting." number to as optional arg `nesting-level` to make sure you reference aggregations at the right level of nesting."
([query index] ([query index]
(aggregation-at-index query index 0)) (aggregation-at-index query index 0))
([query :- mbql.s/Query, index :- su/NonNegativeInt, nesting-level :- su/NonNegativeInt] ([query :- mbql.s/Query, index :- su/NonNegativeInt, nesting-level :- su/NonNegativeInt]
(if (zero? nesting-level) (if (zero? nesting-level)
(or (nth (get-in query [:query :aggregation]) index) (or (nth (get-in query [:query :aggregation]) index)
......
...@@ -33,17 +33,17 @@ ...@@ -33,17 +33,17 @@
;; ↓ ↓ ;; ↓ ↓
;; tables->permissions-path-set source-card-read-perms ;; tables->permissions-path-set source-card-read-perms
(defn- query->source-and-join-tables (defn- query->source-and-joins
"Return a sequence of all Tables (as TableInstance maps, or IDs) referenced by `query`." "Return a sequence of all Tables (as TableInstance maps, or IDs) referenced by `query`."
[{:keys [source-table join-tables source-query native], :as query}] [{:keys [source-table joins source-query native], :as query}]
(cond (cond
;; if we come across a native query just put a placeholder (`::native`) there so we know we need to add native ;; if we come across a native query just put a placeholder (`::native`) there so we know we need to add native
;; permissions to the complete set below. ;; permissions to the complete set below.
native [::native] native [::native]
;; if we have a source-query just recur until we hit either the native source or the MBQL source ;; if we have a source-query just recur until we hit either the native source or the MBQL source
source-query (recur source-query) source-query (recur source-query)
;; for root MBQL queries just return source-table + join-tables ;; for root MBQL queries just return source-table + joins
:else (cons source-table (map :table-id join-tables)))) :else (cons source-table (map :source-table joins))))
(def ^:private PermsOptions (def ^:private PermsOptions
"Map of options to be passed to the permissions checking functions." "Map of options to be passed to the permissions checking functions."
...@@ -109,7 +109,7 @@ ...@@ -109,7 +109,7 @@
;; otherwise if there's no source card then calculate perms based on the Tables referenced in the query ;; otherwise if there's no source card then calculate perms based on the Tables referenced in the query
(let [{:keys [query database]} (cond-> query (let [{:keys [query database]} (cond-> query
(not already-preprocessed?) preprocess-query)] (not already-preprocessed?) preprocess-query)]
(tables->permissions-path-set database (query->source-and-join-tables query)))) (tables->permissions-path-set database (query->source-and-joins query))))
;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card) just return a set of permissions ;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card) just return a set of permissions
;; that means no one will ever get to see it (except for superusers who get to see everything) ;; that means no one will ever get to see it (except for superusers who get to see everything)
(catch Throwable e (catch Throwable e
......
...@@ -62,13 +62,13 @@ ...@@ -62,13 +62,13 @@
;;; | Adding :cols info for MBQL queries | ;;; | Adding :cols info for MBQL queries |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
(s/defn ^:private join-with-alias :- (s/maybe mbql.s/JoinInfo) (s/defn ^:private join-with-alias :- (s/maybe mbql.s/Join)
[{{:keys [join-tables]} :query} :- su/Map, join-alias :- su/NonBlankString] [{{:keys [joins]} :query} :- su/Map, join-alias :- su/NonBlankString]
(some (some
(fn [{alias :join-alias, :as join}] (fn [{:keys [alias], :as join}]
(when (= alias join-alias) (when (= alias join-alias)
join)) join))
join-tables)) joins))
;;; --------------------------------------------------- Field Info --------------------------------------------------- ;;; --------------------------------------------------- Field Info ---------------------------------------------------
......
...@@ -114,19 +114,22 @@ ...@@ -114,19 +114,22 @@
(resolve-one-join-info clause pk-info)))) (resolve-one-join-info clause pk-info))))
;;; ---------------------------------------- Adding :join-tables to the query ---------------------------------------- ;;; ------------------------------------------- Adding :joins to the query -------------------------------------------
(s/defn ^:private resolved-join-info->join-clause :- mbql.s/JoinInfo (s/defn ^:private resolved-join-info->join-clause :- mbql.s/Join
[{:keys [source-table alias fk-field-id pk-field-id]} :- ResolvedJoinInfo] [{:keys [source-table alias fk-field-id pk-field-id]} :- ResolvedJoinInfo]
{:table-id source-table {:source-table source-table
:join-alias alias :alias alias
:strategy :left-join
:condition [:= [:field-id fk-field-id] [:joined-field alias [:field-id pk-field-id]]]
:fk-field-id fk-field-id :fk-field-id fk-field-id
:pk-field-id pk-field-id}) :fields :none})
(s/defn ^:private add-implicit-join-clauses :- mbql.s/Query (s/defn ^:private add-implicit-join-clauses :- mbql.s/Query
[query, resolved-join-infos :- [ResolvedJoinInfo]] [query, resolved-join-infos :- [ResolvedJoinInfo]]
(let [join-clauses (map resolved-join-info->join-clause resolved-join-infos)] (let [join-clauses (map resolved-join-info->join-clause resolved-join-infos)]
(update-in query [:query :join-tables] (comp distinct concat) join-clauses))) (update-in query [:query :joins] (comp distinct concat) join-clauses)))
;;; --------------------------------------------- Replacing fk-> clauses --------------------------------------------- ;;; --------------------------------------------- Replacing fk-> clauses ---------------------------------------------
......
(ns metabase.driver.sql.query-processor-test (ns metabase.driver.sql.query-processor-test
(:require [expectations :refer [expect]] (:require [expectations :refer [expect]]
[metabase.driver.sql.query-processor :as sql.qp])) [honeysql.core :as hsql]
[metabase.driver :as driver]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.query-processor.test-util :as qp.test-util]
[metabase.test.data :as data]
[metabase.util
[honeysql-extensions :as hx]
[pretty :refer [PrettyPrintable]]])
(:import metabase.util.honeysql_extensions.Identifier))
;; make sure our logic for deciding which order to process keys in the query works as expected ;; make sure our logic for deciding which order to process keys in the query works as expected
(expect (expect
...@@ -12,3 +20,141 @@ ...@@ -12,3 +20,141 @@
:aggregation 3 :aggregation 3
:fields 4 :fields 4
:breakout 2})) :breakout 2}))
;; Let's make sure we're actually attempting to generate the correctl HoneySQL for stuff so we don't sit around
;; scratching our heads wondering why the queries themselves aren't working
;; We'll slap together a driver called `::id-swap` whose only purpose is to replace instances of `Identifier` with
;; `CustomIdentifier` when `->honeysql` is called. This way we can be sure it's being called everywhere it's used so
;; drivers have the chance to do custom things as needed. Also `::id-swap` will record the current `*table-alias*` at
;; the time `->honeysql` is called so we can make sure that's correct
(driver/register! ::id-swap, :parent :sql, :abstract? true)
(defrecord ^:private CustomIdentifier [identifier table-alias]
PrettyPrintable
(pretty [_]
(let [identifier (cons 'id (cons (:identifier-type identifier) (:components identifier)))]
(if table-alias
(list 'bound-alias table-alias identifier)
identifier))))
(defn- id [& args]
(CustomIdentifier. (apply hx/identifier args) nil))
(defn- bound-alias [table-alias identifier]
(assoc identifier :table-alias table-alias))
(defmethod sql.qp/->honeysql [::id-swap Identifier]
[driver identifier]
((get-method sql.qp/->honeysql [:sql Identifier]) driver (CustomIdentifier. identifier sql.qp/*table-alias*)))
;; This query tests that the correct HoneySQL gets generated for a query with a join, and that the correct identifiers
;; are used
(expect
{:select [[(id :field "PUBLIC" "VENUES" "ID") (id :field-alias "ID")]
[(id :field "PUBLIC" "VENUES" "NAME") (id :field-alias "NAME")]
[(id :field "PUBLIC" "VENUES" "CATEGORY_ID") (id :field-alias "CATEGORY_ID")]
[(id :field "PUBLIC" "VENUES" "LATITUDE") (id :field-alias "LATITUDE")]
[(id :field "PUBLIC" "VENUES" "LONGITUDE") (id :field-alias "LONGITUDE")]
[(id :field "PUBLIC" "VENUES" "PRICE") (id :field-alias "PRICE")]]
:from [(id :table "PUBLIC" "VENUES")]
:where [:=
(bound-alias "c" (id :field "c" "NAME"))
"BBQ"]
:left-join [[(id :table "PUBLIC" "CATEGORIES") (id :table-alias "c")]
[:=
(id :field "PUBLIC" "VENUES" "CATEGORY_ID")
(bound-alias "c" (id :field "c" "ID"))]]
:order-by [[(id :field "PUBLIC" "VENUES" "ID") :asc]]
:limit 100}
(qp.test-util/with-everything-store
(#'sql.qp/mbql->honeysql
::id-swap
(data/mbql-query venues
{:source-table $$venues
:order-by [[:asc $id]]
:filter [:=
[:joined-field "c" $categories.name]
[:value "BBQ" {:base_type :type/Text, :special_type :type/Name, :database_type "VARCHAR"}]]
:fields [$id $name $category_id $latitude $longitude $price]
:limit 100
:joins [{:source-table $$categories
:alias "c",
:strategy :left-join
:condition [:=
$category_id
[:joined-field "c" $categories.id]]
:fk-field-id (data/id :venues :category_id)
:fields :none}]}))))
;; This HAIRY query tests that the correct identifiers and aliases are used with both a nested query and JOIN in play.
;;
;; TODO `*table-alias*` stays bound to `:source` in a few places below where it probably shouldn't (for the top-level
;; SELECT `:field-alias` identifiers and the `v` `:table-alias` identifier) but since drivers shouldn't be qualifying
;; aliases with aliases things still work the right way.
(expect
{:select [[(bound-alias "v" (id :field "v" "NAME")) (bound-alias :source (id :field-alias "NAME"))]
[:%count.* (bound-alias :source (id :field-alias "count"))]]
:from [[{:select [[(id :field "PUBLIC" "CHECKINS" "ID") (id :field-alias "ID")]
[(id :field "PUBLIC" "CHECKINS" "DATE") (id :field-alias "DATE")]
[(id :field "PUBLIC" "CHECKINS" "USER_ID") (id :field-alias "USER_ID")]
[(id :field "PUBLIC" "CHECKINS" "VENUE_ID") (id :field-alias "VENUE_ID")]]
:from [(id :table "PUBLIC" "CHECKINS")]
:where [:>
(id :field "PUBLIC" "CHECKINS" "DATE")
#inst "2015-01-01T00:00:00.000-00:00"]}
(id :table-alias "source")]]
:left-join [[(id :table "PUBLIC" "VENUES") (bound-alias :source (id :table-alias "v"))]
[:=
(bound-alias :source (id :field "source" "VENUE_ID"))
(bound-alias "v" (id :field "v" "ID"))]],
:group-by [(bound-alias "v" (id :field "v" "NAME"))]
:where [:and
[:like (bound-alias "v" (id :field "v" "NAME")) "F%"]
[:> (bound-alias :source (id :field "source" "user_id")) 0]],
:order-by [[(bound-alias "v" (id :field "v" "NAME")) :asc]]}
(qp.test-util/with-everything-store
(driver/with-driver :h2
(#'sql.qp/mbql->honeysql
::id-swap
(data/mbql-query checkins
{:source-query {:source-table $$checkins
:fields [$id [:datetime-field $date :default] $user_id $venue_id]
:filter [:>
$date
[:absolute-datetime #inst "2015-01-01T00:00:00.000000000-00:00" :default]],},
:aggregation [[:count]]
:order-by [[:asc [:joined-field "v" $venues.name]]]
:breakout [[:joined-field "v" $venues.name]],
:filter [:and
[:starts-with
[:joined-field "v" $venues.name]
[:value "F" {:base_type :type/Text, :special_type :type/Name, :database_type "VARCHAR"}]]
[:> [:field-literal "user_id" :type/Integer] 0]]
:joins [{:source-table $$venues
:alias "v"
:strategy :left-join
:condition [:=
$venue_id
[:joined-field "v" $venues.id]]
:fk-field-id (data/id :checkins :venue_id)
:fields :none}]})))))
;; This query tests that named aggregations are handled correctly
(expect
{:select [[(id :field "PUBLIC" "VENUES" "PRICE") (id :field-alias "PRICE")]
[(hsql/call :avg (id :field "PUBLIC" "VENUES" "CATEGORY_ID")) (id :field-alias "my_average")]]
:from [(id :table "PUBLIC" "VENUES")]
:group-by [(id :field "PUBLIC" "VENUES" "PRICE")],
:order-by [[(id :field-alias "my_average") :asc]]}
(qp.test-util/with-everything-store
(metabase.driver/with-driver :h2
(#'sql.qp/mbql->honeysql
::id-swap
(data/mbql-query venues
{:aggregation [[:named [:avg $category_id] "my_average"]]
:breakout [$price]
:order-by [[:asc [:aggregation 0]]]})))))
(ns metabase.driver.sql.util-test
(:require [expectations :refer [expect]]
[metabase.driver.sql.util :as sql.u]
[metabase.util.honeysql-extensions :as hx]))
;; `select-clause-deduplicate-aliases` should use the last component of an identifier as the alias if it does not
;; already have one
(expect
[[(hx/identifier :field "A" "B" "C" "D") (hx/identifier :field-alias "D")]
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]
(sql.u/select-clause-deduplicate-aliases
[(hx/identifier :field "A" "B" "C" "D")
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]))
;; `select-clause-deduplicate-aliases` should append numeric suffixes to duplicate aliases
(expect
[[(hx/identifier :field "A" "B" "C" "D") (hx/identifier :field-alias "D")]
[(hx/identifier :field "E" "D") (hx/identifier :field-alias "D_2")]
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]
(sql.u/select-clause-deduplicate-aliases
[(hx/identifier :field "A" "B" "C" "D")
(hx/identifier :field "E" "D")
[(hx/identifier :field "F") (hx/identifier :field-alias "G")]]))
;; `select-clause-deduplicate-aliases` should handle aliases that are already suffixed gracefully
(expect
[[(hx/identifier :field "A" "B" "C" "D") (hx/identifier :field-alias "D")]
[(hx/identifier :field "E" "D") (hx/identifier :field-alias "D_2")]
[(hx/identifier :field "F") (hx/identifier :field-alias "D_3")]]
(sql.u/select-clause-deduplicate-aliases
[(hx/identifier :field "A" "B" "C" "D")
(hx/identifier :field "E" "D")
[(hx/identifier :field "F") (hx/identifier :field-alias "D_2")]]))
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
[test-util :as qp.test-util]] [test-util :as qp.test-util]]
[metabase.query-processor.middleware.annotate :as annotate] [metabase.query-processor.middleware.annotate :as annotate]
[metabase.test.data :as data] [metabase.test.data :as data]
[toucan.db :as db]
[toucan.util.test :as tt])) [toucan.util.test :as tt]))
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
...@@ -37,13 +38,17 @@ ...@@ -37,13 +38,17 @@
;;; | add-mbql-column-info | ;;; | add-mbql-column-info |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
(defn- info-for-field
([field-id]
(db/select-one (into [Field] (disj (set qp.store/field-columns-to-fetch) :database_type)) :id field-id))
([table-key field-key]
(info-for-field (data/id table-key field-key))))
;; make sure columns are comming back the way we'd expect ;; make sure columns are comming back the way we'd expect
(expect (expect
[(-> (Field (data/id :venues :price)) [(assoc (info-for-field :venues :price)
(dissoc :database_type) :source :fields)]
(assoc :source :fields))] (qp.test-util/with-everything-store
(qp.store/with-store
(qp.store/store-field! (Field (data/id :venues :price)))
(-> (#'annotate/add-mbql-column-info (-> (#'annotate/add-mbql-column-info
{:query {:fields [[:field-id (data/id :venues :price)]]}} {:query {:fields [[:field-id (data/id :venues :price)]]}}
{:columns [:price]}) {:columns [:price]})
...@@ -55,11 +60,9 @@ ...@@ -55,11 +60,9 @@
;; TODO - this can be removed, now that `fk->` forms are "sugar" and replaced with `:joined-field` clauses before the ;; TODO - this can be removed, now that `fk->` forms are "sugar" and replaced with `:joined-field` clauses before the
;; query ever makes it to the 'annotate' stage ;; query ever makes it to the 'annotate' stage
(expect (expect
[(-> (Field (data/id :categories :name)) [(assoc (info-for-field :categories :name)
(dissoc :database_type) :fk_field_id (data/id :venues :category_id), :source :fields)]
(assoc :fk_field_id (data/id :venues :category_id), :source :fields))] (qp.test-util/with-everything-store
(qp.store/with-store
(qp.store/store-field! (Field (data/id :categories :name)))
(-> (#'annotate/add-mbql-column-info (-> (#'annotate/add-mbql-column-info
{:query {:fields [[:fk-> {:query {:fields [[:fk->
[:field-id (data/id :venues :category_id)] [:field-id (data/id :venues :category_id)]
...@@ -70,28 +73,29 @@ ...@@ -70,28 +73,29 @@
;; we should get `:fk_field_id` and information where possible when using `:joined-field` clauses ;; we should get `:fk_field_id` and information where possible when using `:joined-field` clauses
(expect (expect
[(-> (Field (data/id :categories :name)) [(assoc (info-for-field :categories :name)
(dissoc :database_type) :fk_field_id (data/id :venues :category_id), :source :fields)]
(assoc :fk_field_id (data/id :venues :category_id), :source :fields))] (data/$ids venues
(qp.store/with-store (qp.test-util/with-everything-store
(qp.store/store-field! (Field (data/id :categories :name))) (-> (#'annotate/add-mbql-column-info
(-> (#'annotate/add-mbql-column-info {:query {:fields [[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]]
{:query {:fields [[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id (data/id :categories :name)]]] :joins [{:alias "CATEGORIES__via__CATEGORY_ID"
:join-tables [{:join-alias "CATEGORIES__via__CATEGORY_ID" :source-table $$table
:table-id (data/id :categories) :condition [:=
:fk-field-id (data/id :venues :category_id) [:field-id $category_id]
:pk-field-id (data/id :categories :id)}]}} [:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.id]]]
{:columns [:name]}) :strategy :left-join
:cols :fk-field-id $category_id}]}}
vec))) {:columns [:name]})
:cols
vec))))
;; when a `:datetime-field` form is used, we should add in info about the `:unit` ;; when a `:datetime-field` form is used, we should add in info about the `:unit`
(expect (expect
[(-> (Field (data/id :venues :price)) [(assoc (info-for-field :venues :price)
(dissoc :database_type) :unit :month
(assoc :unit :month, :source :fields))] :source :fields)]
(qp.store/with-store (qp.test-util/with-everything-store
(qp.store/store-field! (Field (data/id :venues :price)))
(-> (#'annotate/add-mbql-column-info (-> (#'annotate/add-mbql-column-info
{:query {:fields [[:datetime-field [:field-id (data/id :venues :price)] :month]]}} {:query {:fields [[:datetime-field [:field-id (data/id :venues :price)] :month]]}}
{:columns [:price]}) {:columns [:price]})
...@@ -198,9 +202,8 @@ ...@@ -198,9 +202,8 @@
:special_type :type/Number :special_type :type/Number
:name "sum" :name "sum"
:display_name "sum"} :display_name "sum"}
(qp.store/with-store (qp.test-util/with-everything-store
(data/$ids venues (data/$ids venues
(qp.store/store-field! (Field $price))
(col-info-for-aggregation-clause [:sum [:+ [:field-id $price] 1]])))) (col-info-for-aggregation-clause [:sum [:+ [:field-id $price] 1]]))))
;; if a driver is kind enough to supply us with some information about the `:cols` that come back, we should include ;; if a driver is kind enough to supply us with some information about the `:cols` that come back, we should include
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
:type :query :type :query
:query query}))) :query query})))
;; make sure `:join-tables` get added automatically for `:fk->` clauses ;; make sure `:joins` get added automatically for `:fk->` clauses
(expect (expect
{:database (data/id) {:database (data/id)
:type :query :type :query
...@@ -18,17 +18,21 @@ ...@@ -18,17 +18,21 @@
{:source-table $$table {:source-table $$table
:fields [[:field-id $name] :fields [[:field-id $name]
[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]] [:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]]
:join-tables [{:join-alias "CATEGORIES__via__CATEGORY_ID" :joins [{:source-table (data/id :categories)
:table-id (data/id :categories) :alias "CATEGORIES__via__CATEGORY_ID"
:fk-field-id $category_id :condition [:=
:pk-field-id $categories.id}]})} [:field-id $category_id]
[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.id]]]
:strategy :left-join
:fields :none
:fk-field-id $category_id}]})}
(resolve-joined-tables (resolve-joined-tables
(data/$ids venues (data/$ids venues
{:source-table $$table {:source-table $$table
:fields [[:field-id $name] :fields [[:field-id $name]
[:fk-> [:field-id $category_id] [:field-id $categories.name]]]}))) [:fk-> [:field-id $category_id] [:field-id $categories.name]]]})))
;; For FK clauses inside nested source queries, we should add the `:join-tables` info to the nested query instead of ;; For FK clauses inside nested source queries, we should add the `:joins` info to the nested query instead of
;; at the top level (#8972) ;; at the top level (#8972)
(expect (expect
{:database (data/id) {:database (data/id)
...@@ -38,10 +42,14 @@ ...@@ -38,10 +42,14 @@
{:source-table $$table {:source-table $$table
:fields [[:field-id $name] :fields [[:field-id $name]
[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]] [:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]]
:join-tables [{:join-alias "CATEGORIES__via__CATEGORY_ID" :joins [{:source-table (data/id :categories)
:table-id (data/id :categories) :alias "CATEGORIES__via__CATEGORY_ID"
:fk-field-id $category_id :condition [:=
:pk-field-id $categories.id}]})}} [:field-id $category_id]
[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.id]]]
:strategy :left-join
:fields :none
:fk-field-id $category_id}]})}}
(resolve-joined-tables (resolve-joined-tables
{:source-query {:source-query
(data/$ids venues (data/$ids venues
...@@ -59,10 +67,14 @@ ...@@ -59,10 +67,14 @@
{:source-table $$table {:source-table $$table
:fields [[:field-id $name] :fields [[:field-id $name]
[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]] [:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.name]]]
:join-tables [{:join-alias "CATEGORIES__via__CATEGORY_ID" :joins [{:source-table (data/id :categories)
:table-id (data/id :categories) :alias "CATEGORIES__via__CATEGORY_ID"
:fk-field-id $category_id :condition [:=
:pk-field-id $categories.id}]})}}} [:field-id $category_id]
[:joined-field "CATEGORIES__via__CATEGORY_ID" [:field-id $categories.id]]]
:strategy :left-join
:fields :none
:fk-field-id $category_id}]})}}}
(resolve-joined-tables (resolve-joined-tables
{:source-query {:source-query
{:source-query {:source-query
...@@ -84,10 +96,14 @@ ...@@ -84,10 +96,14 @@
:aggregation [[:count]] :aggregation [[:count]]
:breakout [[:joined-field "VENUES__via__VENUE_ID" [:field-id $venues.price]]] :breakout [[:joined-field "VENUES__via__VENUE_ID" [:field-id $venues.price]]]
:order-by [[:asc [:joined-field "VENUES__via__VENUE_ID" [:field-id $venues.price]]]] :order-by [[:asc [:joined-field "VENUES__via__VENUE_ID" [:field-id $venues.price]]]]
:join-tables [{:join-alias "VENUES__via__VENUE_ID" :joins [{:source-table (data/id :venues)
:table-id (data/id :venues) :alias "VENUES__via__VENUE_ID"
:fk-field-id $venue_id :condition [:=
:pk-field-id $venues.id}]})} [:field-id $venue_id]
[:joined-field "VENUES__via__VENUE_ID" [:field-id $venues.id]]]
:strategy :left-join
:fields :none
:fk-field-id $venue_id}]})}
(resolve-joined-tables (resolve-joined-tables
(data/$ids [checkins {:wrap-field-ids? true}] (data/$ids [checkins {:wrap-field-ids? true}]
{:source-query {:source-table $$table {:source-query {:source-table $$table
......
...@@ -2,20 +2,33 @@ ...@@ -2,20 +2,33 @@
"Utilities for writing Query Processor tests that test internal workings of the QP rather than end-to-end results, "Utilities for writing Query Processor tests that test internal workings of the QP rather than end-to-end results,
e.g. middleware tests." e.g. middleware tests."
(:require [metabase.models (:require [metabase.models
[database :refer [Database]]
[field :refer [Field]] [field :refer [Field]]
[table :refer [Table]]] [table :refer [Table]]]
[metabase.query-processor.store :as qp.store] [metabase.query-processor.store :as qp.store]
[metabase.test.data :as data]
[metabase.util.schema :as su]
[schema.core :as s]
[toucan.db :as db])) [toucan.db :as db]))
(s/defn ^:private everything-store-database []
(or (:database @@#'qp.store/*store*)
(db/select-one (into [Database] qp.store/database-columns-to-fetch), :id (data/id))))
(s/defn ^:private everything-store-table [table-id :- (s/maybe su/IntGreaterThanZero)]
(or (get-in @@#'qp.store/*store* [:tables table-id])
(db/select-one (into [Table] qp.store/table-columns-to-fetch), :id table-id)))
(s/defn ^:private everything-store-field [field-id :- (s/maybe su/IntGreaterThanZero)]
(or (get-in @@#'qp.store/*store* [:fields field-id])
(db/select-one (into [Field] qp.store/field-columns-to-fetch), :id field-id)))
(defn do-with-everything-store (defn do-with-everything-store
"Impl for `with-everything-store`." "Impl for `with-everything-store`."
[f] [f]
(with-redefs [qp.store/table (fn [table-id] (with-redefs [qp.store/database everything-store-database
(or (get-in @@#'qp.store/*store* [:tables table-id]) qp.store/table everything-store-table
(db/select-one (into [Table] qp.store/table-columns-to-fetch), :id table-id))) qp.store/field everything-store-field]
qp.store/field (fn [field-id]
(or (get-in @@#'qp.store/*store* [:fields field-id])
(db/select-one (into [Field] qp.store/field-columns-to-fetch), :id field-id)))]
(qp.store/with-store (qp.store/with-store
(f)))) (f))))
......
...@@ -344,3 +344,9 @@ ...@@ -344,3 +344,9 @@
(qp.test/rows (qp.test/rows
(data/run-mbql-query venues (data/run-mbql-query venues
{:aggregation [[:sum $id] [:sum $price]]})))) {:aggregation [[:sum $id] [:sum $price]]}))))
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
;; ! !
;; ! tests for named aggregations can be found in `expression-aggregations-test` !
;; ! !
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
...@@ -112,7 +112,7 @@ ...@@ -112,7 +112,7 @@
{:expressions {"bird-scarcity" ~formula} {:expressions {"bird-scarcity" ~formula}
:fields [[:expression "bird-scarcity"]] :fields [[:expression "bird-scarcity"]]
:filter ~filter-clause :filter ~filter-clause
:order-by [[:asc [:field-id $date]]] :order-by [[:asc [:field-id ~'$date]]]
:limit 10}) :limit 10})
rows rows
(format-rows-by [(partial u/round-to-decimals 2)])))) (format-rows-by [(partial u/round-to-decimals 2)]))))
......
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