diff --git a/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj b/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj index 78f0ca0730ec58a80609825d926f379a46c9fe4a..51440946dd9ed6e84c6ee3bff33ecfed48ec67c5 100644 --- a/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj +++ b/modules/drivers/bigquery/src/metabase/driver/bigquery/query_processor.clj @@ -9,6 +9,7 @@ [driver :as driver] [util :as u]] [metabase.driver.sql :as sql] + [metabase.driver.sql.parameters.substitution :as sql.params.substitution] [metabase.driver.sql.query-processor :as sql.qp] [metabase.driver.sql.util.unprepare :as unprepare] [metabase.mbql.util :as mbql.u] @@ -26,6 +27,7 @@ [schema.core :as s] [toucan.db :as db]) (:import [java.time LocalDate LocalDateTime LocalTime OffsetDateTime OffsetTime ZonedDateTime] + metabase.driver.common.parameters.FieldFilter metabase.util.honeysql_extensions.Identifier)) (defn- valid-bigquery-identifier? @@ -490,8 +492,19 @@ [_] :mysql) -;; TIMEZONE FIXME — Not working in all cases — see #11222 +;; convert LocalDate to an OffsetDateTime in UTC since BigQuery doesn't handle LocalDates as we'd like (defmethod sql/->prepared-substitution [:bigquery LocalDate] - [_ t] - {:sql-string "?" - :param-values [(t/offset-date-time t (t/local-time 0) (t/zone-offset 0))]}) + [driver t] + (sql/->prepared-substitution driver (t/offset-date-time t (t/local-time 0) (t/zone-offset 0)))) + +(defmethod sql.params.substitution/->replacement-snippet-info [:bigquery FieldFilter] + [driver {:keys [field], :as field-filter}] + (let [field-temporal-type (temporal-type field) + parent-method (get-method sql.params.substitution/->replacement-snippet-info [:sql FieldFilter]) + result (parent-method driver field-filter)] + (cond-> result + field-temporal-type (update :prepared-statement-args (fn [args] + (for [arg args] + (if (instance? java.time.temporal.Temporal arg) + (->temporal-type field-temporal-type arg) + arg))))))) diff --git a/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj b/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj index caeb7bf28197dfae66e1a15a0822cef4a08ee0c8..0d4715288a75b847e9460cf8e82c90212ef370f9 100644 --- a/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj +++ b/modules/drivers/bigquery/test/metabase/driver/bigquery/query_processor_test.clj @@ -7,12 +7,14 @@ [models :refer [Database Field]] [query-processor :as qp] [query-processor-test :as qp.test] + [sync :as sync] [test :as mt] [util :as u]] [metabase.driver.bigquery :as bigquery] [metabase.driver.bigquery.query-processor :as bigquery.qp] [metabase.driver.sql.query-processor :as sql.qp] [metabase.query-processor.store :as qp.store] + [metabase.test.data.bigquery :as bigquery.tx] [metabase.test.util.timezone :as tu.tz] [metabase.util.honeysql-extensions :as hx] [toucan.util.test :as tt])) @@ -362,3 +364,79 @@ [:field-literal "date" :type/Date] (t/local-date-time "2019-11-11T12:00:00") (t/local-date-time "2019-11-12T12:00:00")])))))))))) + +(deftest timezones-test + (mt/test-driver :bigquery + (testing "BigQuery does not support report-timezone, so setting it should not affect results" + (doseq [timezone ["UTC" "US/Pacific"]] + (mt/with-temporary-setting-values [report-timezone timezone] + (is (= [[37 "2015-11-19T00:00:00Z"]] + (mt/rows + (mt/run-mbql-query checkins + {:fields [$id $date] + :filter [:= $date "2015-11-19"] + :order-by [[:asc $id]]}))))))))) + +(defn- do-with-datetime-timestamp-table [f] + (driver/with-driver :bigquery + (let [table-name (name (munge (gensym "table_")))] + (mt/with-temp-copy-of-db + (try + (bigquery.tx/execute! + (format "CREATE TABLE `test_data.%s` ( ts TIMESTAMP, dt DATETIME )" table-name)) + (bigquery.tx/execute! + (format "INSERT INTO `test_data.%s` (ts, dt) VALUES (TIMESTAMP \"2020-01-01 00:00:00 UTC\", DATETIME \"2020-01-01 00:00:00\")" + table-name)) + (sync/sync-database! (mt/db)) + (f table-name) + (finally + (bigquery.tx/execute! "DROP TABLE IF EXISTS `test_data.%s`" table-name))))))) + +(deftest filter-by-datetime-timestamp-test + (mt/test-driver :bigquery + ;; there are more tests in the `bigquery.query-processor-test` namespace + (testing "Make sure we can filter against different types of BigQuery temporal columns (#11222)" + (do-with-datetime-timestamp-table + (fn [table-name] + (doseq [column [:ts :dt]] + (testing (format "Filtering against %s column" column) + (doseq [s ["2020-01-01" "2020-01-01T00:00:00"] + field [[:field-id (mt/id table-name column)] + [:datetime-field [:field-id (mt/id table-name column)] :default] + [:datetime-field [:field-id (mt/id table-name column)] :day]] + :let [filter-clause [:= field s]]] + (testing (format "\nMBQL filter clause = %s" (pr-str filter-clause)) + (is (= [["2020-01-01T00:00:00Z" "2020-01-01T00:00:00Z"]] + (mt/rows + (mt/run-mbql-query nil + {:source-table (mt/id table-name) + :filter filter-clause}))))))))))))) + +(deftest datetime-parameterized-sql-test + (testing "Make sure Field filters against temporal fields generates correctly-typed SQL (#11578)" + (mt/test-driver :bigquery + (mt/dataset attempted-murders + (doseq [field [:datetime + :date + :datetime_tz] + [value-type value] {:date/relative "past30days" + :date/range "2019-12-11~2020-01-09" + :date/single "2020-01-09" + :date/quarter-year "Q1-2020" + :date/month-year "2020-01"}] + (testing (format "\nField filter with %s Field" field) + (testing (format "\nfiltering against %s value '%s'" value-type value) + (is (= [[0]] + (mt/rows + (qp/process-query + {:database (mt/id) + :type :native + :native {:query "SELECT count(*) FROM `attempted_murders.attempts` WHERE {{d}}" + :template-tags {"d" {:name "d" + :display-name "Date" + :type :dimension + :dimension [:field-id (mt/id :attempts field)]}}} + :parameters [{:type value-type + :name "d" + :target [:dimension [:template-tag "d"]] + :value value}]}))))))))))) diff --git a/modules/drivers/bigquery/test/metabase/driver/bigquery_test.clj b/modules/drivers/bigquery/test/metabase/driver/bigquery_test.clj index 6eb1fdc1d7d8e02ea62692d3197a2101a7d752d9..9ef3b6fd1f80ddc5acc7575a2e8ae44cd924fc04 100644 --- a/modules/drivers/bigquery/test/metabase/driver/bigquery_test.clj +++ b/modules/drivers/bigquery/test/metabase/driver/bigquery_test.clj @@ -80,50 +80,3 @@ :query {:source-table (data/id view-name) :order-by [[:asc (data/id view-name :id)]]}}))) "We should be able to run queries against the view (#3414)")))) - -(deftest timezones-test - (datasets/test-driver :bigquery - (testing "BigQuery does not support report-timezone, so setting it should not affect results" - (doseq [timezone ["UTC" "US/Pacific"]] - (tu/with-temporary-setting-values [report-timezone timezone] - (is (= [[37 "2015-11-19T00:00:00Z"]] - (qp.test/rows - (data/run-mbql-query checkins - {:fields [$id $date] - :filter [:= $date "2015-11-19"] - :order-by [[:asc $id]]}))))))))) - -(defn- do-with-datetime-timestamp-table [f] - (driver/with-driver :bigquery - (let [table-name (name (munge (gensym "table_")))] - (data/with-temp-copy-of-db - (try - (bigquery.tx/execute! - (format "CREATE TABLE `test_data.%s` ( ts TIMESTAMP, dt DATETIME )" table-name)) - (bigquery.tx/execute! - (format "INSERT INTO `test_data.%s` (ts, dt) VALUES (TIMESTAMP \"2020-01-01 00:00:00 UTC\", DATETIME \"2020-01-01 00:00:00\")" - table-name)) - (sync/sync-database! (data/db)) - (f table-name) - (finally - (bigquery.tx/execute! "DROP TABLE IF EXISTS `test_data.%s`" table-name))))))) - -(deftest filter-by-datetime-timestamp-test - (datasets/test-driver :bigquery - ;; there are more tests in the `bigquery.query-processor-test` namespace - (testing "Make sure we can filter against different types of BigQuery temporal columns (#11222)" - (do-with-datetime-timestamp-table - (fn [table-name] - (doseq [column [:ts :dt]] - (testing (format "Filtering against %s column" column) - (doseq [s ["2020-01-01" "2020-01-01T00:00:00"] - field [[:field-id (data/id table-name column)] - [:datetime-field [:field-id (data/id table-name column)] :default] - [:datetime-field [:field-id (data/id table-name column)] :day]] - :let [filter-clause [:= field s]]] - (testing (format "\nMBQL filter clause = %s" (pr-str filter-clause)) - (is (= [["2020-01-01T00:00:00Z" "2020-01-01T00:00:00Z"]] - (qp.test/rows - (data/run-mbql-query nil - {:source-table (data/id table-name) - :filter filter-clause}))))))))))))) diff --git a/modules/drivers/bigquery/test/metabase/test/data/bigquery.clj b/modules/drivers/bigquery/test/metabase/test/data/bigquery.clj index 34bc9b3ab04b0a035129a4c947a021119d4c98f3..4909ebc4d7238c4bc5ac7fa1cdf0755a1f8438ce 100644 --- a/modules/drivers/bigquery/test/metabase/test/data/bigquery.clj +++ b/modules/drivers/bigquery/test/metabase/test/data/bigquery.clj @@ -20,9 +20,8 @@ (:import com.google.api.client.util.DateTime com.google.api.services.bigquery.Bigquery [com.google.api.services.bigquery.model Dataset DatasetReference QueryRequest QueryResponse - Table TableDataInsertAllRequest TableDataInsertAllRequest$Rows TableFieldSchema TableReference TableRow - TableSchema] - java.sql.Time)) + Table TableDataInsertAllRequest TableDataInsertAllRequest$Rows TableDataInsertAllResponse TableFieldSchema + TableReference TableRow TableSchema])) (sql.tx/add-test-extensions! :bigquery) @@ -93,12 +92,16 @@ response)))) (def ^:private valid-field-types - #{:BOOLEAN :FLOAT :INTEGER :RECORD :STRING :TIMESTAMP :TIME}) + #{:BOOLEAN :DATE :DATETIME :FLOAT :INTEGER :NUMERIC :RECORD :STRING :TIME :TIMESTAMP}) + +;; Fields must contain only letters, numbers, and underscores, start with a letter or underscore, and be at most 128 +;; characters long. +(def ^:private ValidFieldName #"^[A-Za-z_]\w{0,127}$") (s/defn ^:private create-table! [dataset-id :- su/NonBlankString table-id :- su/NonBlankString, - field-name->type :- {su/KeywordOrString (apply s/enum valid-field-types)}] + field-name->type :- {ValidFieldName (apply s/enum valid-field-types)}] (google/execute (.insert (.tables (bigquery)) @@ -124,59 +127,88 @@ (doto (QueryRequest.) (.setQuery (format "SELECT COUNT(*) FROM [%s.%s]" dataset-id table-id))))))))) -;; This is a dirty HACK -(defn- ^DateTime timestamp-honeysql-form->GoogleDateTime - "Convert the HoneySQL form we normally use to wrap a `Timestamp` to a Google `DateTime`." - [{[{s :literal}] :args}] - {:pre [(string? s) (seq s)]} - (DateTime. (t/to-java-date (u.date/parse (str/replace s #"'" ""))))) +(defprotocol ^:private Insertable + (^:private ->insertable [this] + "Convert a value to an appropriate Google type when inserting a new row.")) + +(extend-protocol Insertable + nil + (->insertable [_] nil) + + Object + (->insertable [this] this) + + java.time.temporal.Temporal + (->insertable [t] (str t)) + java.time.ZonedDateTime + (->insertable [t] (->insertable (t/offset-date-time t))) + + ;; normalize to UTC, since BigQuery doesn't support TIME WITH TIME ZONE + java.time.OffsetTime + (->insertable [t] (->insertable (t/local-time (t/with-offset-same-instant t (t/zone-offset 0))))) + + ;; Convert the HoneySQL form we normally use to wrap a `Timestamp` to a plain literal string + honeysql.types.SqlCall + (->insertable [{[{s :literal}] :args}] + (->insertable (u.date/parse (str/replace s #"'" ""))))) (defn- insert-data! [^String dataset-id, ^String table-id, row-maps] {:pre [(seq dataset-id) (seq table-id) (sequential? row-maps) (seq row-maps) (every? map? row-maps)]} - (google/execute - (.insertAll - (.tabledata (bigquery)) (project-id) dataset-id table-id - (doto (TableDataInsertAllRequest.) - (.setRows (for [row-map row-maps] - (let [data (TableRow.)] - (doseq [[k v] row-map - :let [v (cond - (instance? honeysql.types.SqlCall v) - (timestamp-honeysql-form->GoogleDateTime v) - :else v)]] - (.set data (name k) v)) - (doto (TableDataInsertAllRequest$Rows.) - (.setJson data)))))))) - ;; Wait up to 30 seconds for all the rows to be loaded and become available by BigQuery - (let [expected-row-count (count row-maps)] - (loop [seconds-to-wait-for-load 30] - (let [actual-row-count (table-row-count dataset-id table-id)] - (cond - (= expected-row-count actual-row-count) - :ok - - (> seconds-to-wait-for-load 0) - (do (Thread/sleep 1000) - (recur (dec seconds-to-wait-for-load))) - - :else - (throw (Exception. (format "Failed to load table data for %s.%s: expected %d rows, loaded %d" - dataset-id table-id expected-row-count actual-row-count)))))))) - - -(def ^:private base-type->bigquery-type - {:type/BigInteger :INTEGER - :type/Boolean :BOOLEAN - :type/Date :TIMESTAMP - :type/DateTime :TIMESTAMP - :type/DateTimeWithTZ :TIMESTAMP ; is this correct ??? - :type/Decimal :NUMERIC - :type/Dictionary :RECORD - :type/Float :FLOAT - :type/Integer :INTEGER - :type/Text :STRING - :type/Time :TIME}) + (let [rows + (for [row-map row-maps] + (let [data (TableRow.)] + (doseq [[k v] row-map + :let [v (->insertable v)]] + (.set data (name k) v)) + (doto (TableDataInsertAllRequest$Rows.) + (.setJson data)))) + + request + (.insertAll + (.tabledata (bigquery)) (project-id) dataset-id table-id + (doto (TableDataInsertAllRequest.) + (.setRows rows))) + + ^TableDataInsertAllResponse response (google/execute request)] + (when (seq (.getInsertErrors response)) + (println "Error inserting rows:" (u/pprint-to-str (seq (.getInsertErrors response)))) + (throw (ex-info "Error inserting rows" + {:errors (seq (.getInsertErrors response)) + :metabase.util/no-auto-retry? true + :rows row-maps}))) + ;; Wait up to 30 seconds for all the rows to be loaded and become available by BigQuery + (let [expected-row-count (count row-maps)] + (loop [seconds-to-wait-for-load 30] + (let [actual-row-count (table-row-count dataset-id table-id)] + (cond + (= expected-row-count actual-row-count) + :ok + + (> seconds-to-wait-for-load 0) + (do (Thread/sleep 1000) + (recur (dec seconds-to-wait-for-load))) + + :else + (let [error-message (format "Failed to load table data for %s.%s: expected %d rows, loaded %d" + dataset-id table-id expected-row-count actual-row-count)] + (println (u/format-color 'red error-message)) + (throw (ex-info error-message {:metabase.util/no-auto-retry? true, :response response}))))))))) + +(defn- base-type->bigquery-type [base-type] + (let [types {:type/BigInteger :INTEGER + :type/Boolean :BOOLEAN + :type/Date :DATE + :type/DateTime :DATETIME + :type/DateTimeWithTZ :TIMESTAMP + :type/Decimal :NUMERIC + :type/Dictionary :RECORD + :type/Float :FLOAT + :type/Integer :INTEGER + :type/Text :STRING + :type/Time :TIME}] + (or (get types base-type) + (some base-type->bigquery-type (parents base-type))))) (defn- fielddefs->field-name->base-type "Convert `field-definitions` to a format appropriate for passing to `create-table!`." @@ -185,28 +217,17 @@ {"id" :INTEGER} (for [{:keys [field-name base-type]} field-definitions] {field-name (or (base-type->bigquery-type base-type) - (println (u/format-color 'red "Don't know what BigQuery type to use for base type: %s" base-type)) - (throw (Exception. (format "Don't know what BigQuery type to use for base type: %s" base-type))))}))) + (let [message (format "Don't know what BigQuery type to use for base type: %s" base-type)] + (println (u/format-color 'red message)) + (throw (ex-info message {:metabase.util/no-auto-retry? true}))))}))) (defn- tabledef->prepared-rows "Convert `table-definition` to a format approprate for passing to `insert-data!`." [{:keys [field-definitions rows]}] {:pre [(every? map? field-definitions) (sequential? rows) (seq rows)]} (let [field-names (map :field-name field-definitions)] - (for [[i row] (m/indexed rows) - :let [vs (for [v row] - (u/prog1 (cond - (instance? Time v) - (u.date/format-sql (t/local-time v)) - - (instance? java.util.Date v) - ;; convert to Google version of DateTime, otherwise it doesn't work (!) - (DateTime. ^java.util.Date v) - - :else v) - ;; make sure v is non-nil - (assert (not (nil? <>)))))]] - (assoc (zipmap field-names vs) + (for [[i row] (m/indexed rows)] + (assoc (zipmap field-names row) :id (inc i))))) (defn- load-tabledef! [dataset-name {:keys [table-name field-definitions], :as tabledef}] @@ -238,7 +259,7 @@ (let [database-name (normalize-name database-name)] (when-not (contains? @existing-datasets database-name) (try - (u/auto-retry 10 + (u/auto-retry 2 ;; if the dataset failed to load successfully last time around, destroy whatever was loaded so we start ;; again from a blank slate (u/ignore-exceptions @@ -253,8 +274,8 @@ ;; if creating the dataset ultimately fails to complete, then delete it so it will hopefully work next time ;; around (catch Throwable e + (println (u/format-color 'red "Failed to load BigQuery dataset '%s'." database-name)) (u/ignore-exceptions - (println (u/format-color 'red "Failed to load BigQuery dataset '%s'." database-name)) (destroy-dataset! database-name)) (throw e)))))) diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj b/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj index a1e5e1a26aa7fb24bb03b5a7c69d297e74337d27..0461c98e78fa6bacd8b4d69bfdde7a51b9b1c9a1 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj @@ -34,7 +34,7 @@ (defn- field->name [field] (pr-str (mongo.qp/field->name field "."))) -(defn- substitute-one-field-filter-relative-date [{field :field, {param-type :type, value :value} :value}] +(defn- substitute-one-field-filter-date-range [{field :field, {param-type :type, value :value} :value}] (let [{:keys [start end]} (date-params/date-string->range value) start-condition (when start (format "{%s: {$gte: %s}}" (field->name field) (param-value->str (u.date/parse start)))) @@ -49,8 +49,8 @@ ;; sequence of those maps. (defn- substitute-one-field-filter [{field :field, {param-type :type, value :value} :value, :as field-filter}] ;; convert relative dates to approprate date range representations - (if (date-params/relative-date-param-type? param-type) - (substitute-one-field-filter-relative-date field-filter) + (if (date-params/date-range-type? param-type) + (substitute-one-field-filter-date-range field-filter) (format "{%s: %s}" (field->name field) (param-value->str value)))) (defn- substitute-field-filter [{field :field, {:keys [value]} :value, :as field-filter}] diff --git a/modules/drivers/oracle/test/metabase/driver/oracle_test.clj b/modules/drivers/oracle/test/metabase/driver/oracle_test.clj index 6acbea284b963687d05360cc468859e8acae8c4a..db4957ee865952edd3b24ffd0a32529ddc3818ce 100644 --- a/modules/drivers/oracle/test/metabase/driver/oracle_test.clj +++ b/modules/drivers/oracle/test/metabase/driver/oracle_test.clj @@ -129,14 +129,15 @@ (execute! "CREATE TABLE \"%s\".\"messages\" (\"id\" %s, \"message\" CLOB)" username pk-type) (execute! "INSERT INTO \"%s\".\"messages\" (\"id\", \"message\") VALUES (1, 'Hello')" username) (execute! "INSERT INTO \"%s\".\"messages\" (\"id\", \"message\") VALUES (2, NULL)" username) - (tt/with-temp* [Table [table {:schema username, :name "messages", :db_id (data/id)}] - Field [_ {:table_id (u/get-id table), :name "id", :base_type "type/Integer"}] - Field [_ {:table_id (u/get-id table), :name "message", :base_type "type/Text"}]] + (tt/with-temp* [Table [table {:schema username, :name "messages", :db_id (data/id)}] + Field [id-field {:table_id (u/get-id table), :name "id", :base_type "type/Integer"}] + Field [_ {:table_id (u/get-id table), :name "message", :base_type "type/Text"}]] (qp.test/rows (qp/process-query - {:database (data/id) - :type :query - :query {:source-table (u/get-id table)}})))))) + {:database (data/id) + :type :query + :query {:source-table (u/get-id table) + :order-by [[:asc [:field-id (u/get-id id-field)]]]}})))))) ;; 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 diff --git a/src/metabase/driver/common/parameters/dates.clj b/src/metabase/driver/common/parameters/dates.clj index fbf3069a679e95524462b6299daed418fe893ef7..230c1101da108cad0b2869782701e5c1d83d9a80 100644 --- a/src/metabase/driver/common/parameters/dates.clj +++ b/src/metabase/driver/common/parameters/dates.clj @@ -4,8 +4,10 @@ [medley.core :as m] [metabase.mbql.schema :as mbql.s] [metabase.models.params :as params] + [metabase.query-processor.error-type :as error-type] [metabase.util [date-2 :as u.date] + [i18n :refer [tru]] [schema :as su]] [schema.core :as s])) @@ -15,9 +17,11 @@ (or (= param-type :date) (= "date" (namespace param-type)))) -(defn relative-date-param-type? - "Is param type a relative `:date` param (e.g., not an absolute moment in time?)" +(defn date-range-type? + "Does date `param-type` represent a range of dates, rather than a single absolute date? (The value may be relative, + such as `past30days`, or absolute, such as `2020-01`.)" [param-type] + ;; TODO — (#{:date/range :date/month-year :date/quarter-year :date/relative :date/all-options} param-type)) ;; Both in MBQL and SQL parameter substitution a field value is compared to a date range, either relative or absolute. @@ -218,7 +222,11 @@ ;; Absolute date ranges don't need the time zone conversion because in SQL the date ranges are compared ;; against the db field value that is casted granularity level of a day in the db time zone (->> (execute-decoders absolute-date-string-decoders :range nil date-string) - (m/map-vals u.date/format))))) + (m/map-vals u.date/format)) + ;; if both of the decoders above fail, then the date string is invalid + (throw (ex-info (tru "Don''t know how to parse date param ''{0}'' — invalid format" date-string) + {:param date-string + :type error-type/invalid-parameter}))))) ;; 2-arg version is for legacy compatibility only; no longer needed ([date-string _] diff --git a/src/metabase/driver/common/parameters/values.clj b/src/metabase/driver/common/parameters/values.clj index 0fac0e52f19d6023fbb27e1deb7953f98e9ed9d0..e346aac594881887a4901207df18705c5aa23454 100644 --- a/src/metabase/driver/common/parameters/values.clj +++ b/src/metabase/driver/common/parameters/values.clj @@ -37,14 +37,16 @@ ;; FieldFilter as well. (def ^:private TagParam "Schema for a tag parameter declaration, passed in as part of the `:template-tags` list." - {(s/optional-key :id) su/NonBlankString ; this is used internally by the frontend - :name su/NonBlankString - :display-name su/NonBlankString - :type ParamType - (s/optional-key :dimension) [s/Any] - (s/optional-key :widget-type) s/Keyword ; type of the [default] value if `:type` itself is `dimension` - (s/optional-key :required) s/Bool - (s/optional-key :default) s/Any}) + (s/named + {(s/optional-key :id) su/NonBlankString ; this is used internally by the frontend + :name su/NonBlankString + :display-name su/NonBlankString + :type ParamType + (s/optional-key :dimension) [s/Any] + (s/optional-key :widget-type) s/Keyword ; type of the [default] value if `:type` itself is `dimension` + (s/optional-key :required) s/Bool + (s/optional-key :default) s/Any} + "valid template-tags tag")) (def ^:private ParsedParamValue "Schema for valid param value(s). Params can have one or more values." @@ -93,10 +95,10 @@ (when-let [field-filter (:dimension tag)] (i/map->FieldFilter ;; TODO - shouldn't this use the QP Store? - {:field (or (db/select-one [Field :name :parent_id :table_id :base_type] - :id (field-filter->field-id field-filter)) - (throw (Exception. (tru "Can''t find field with ID: {0}" - (field-filter->field-id field-filter))))) + {:field (let [field-id (field-filter->field-id field-filter)] + (or (db/select-one [Field :name :parent_id :table_id :base_type] :id field-id) + (throw (ex-info (tru "Can''t find field with ID: {0}" field-id) + {:field-id field-id, :type qp.error-type/invalid-parameter})))) :value (if-let [value-info-or-infos (or ;; look in the sequence of params we were passed to see if there's anything ;; that matches @@ -222,9 +224,16 @@ -> {:checkin_date #t \"2019-09-19T23:30:42.233-07:00\"}" [{tags :template-tags, params :parameters}] - (into {} (for [[k tag] tags - :let [v (value-for-tag tag params)] - :when v] - ;; TODO - if V is `nil` *on purpose* this still won't give us a query like `WHERE field = NULL`. That - ;; kind of query shouldn't be possible from the frontend anyway - {k v}))) + (try + (into {} (for [[k tag] tags + :let [v (value-for-tag tag params)] + :when v] + ;; TODO - if V is `nil` *on purpose* this still won't give us a query like `WHERE field = NULL`. That + ;; kind of query shouldn't be possible from the frontend anyway + {k v})) + (catch Throwable e + (throw (ex-info (.getMessage e) + {:type (or (:type (ex-data e)) qp.error-type/invalid-parameter) + :tags tags + :params params} + e))))) diff --git a/src/metabase/driver/sql/parameters/substitute.clj b/src/metabase/driver/sql/parameters/substitute.clj index 1168608993a9f28f8b3c97323b4257263f5ec05a..72adfc70c6ba13407211c400319d72d5756305dd 100644 --- a/src/metabase/driver/sql/parameters/substitute.clj +++ b/src/metabase/driver/sql/parameters/substitute.clj @@ -1,5 +1,6 @@ (ns metabase.driver.sql.parameters.substitute (:require [clojure.string :as str] + [metabase.driver :as driver] [metabase.driver.common.parameters :as i] [metabase.driver.sql.parameters.substitution :as substitution] [metabase.query-processor.error-type :as error-type] @@ -10,7 +11,7 @@ ;; no-value field filters inside optional clauses are ignored, and eventually emitted entirely [sql args (conj missing k)] ;; otherwise no values get replaced with `1 = 1` and other values get replaced normally - (let [{:keys [replacement-snippet prepared-statement-args]} (substitution/->replacement-snippet-info v)] + (let [{:keys [replacement-snippet prepared-statement-args]} (substitution/->replacement-snippet-info driver/*driver* v)] [(str sql replacement-snippet) (concat args prepared-statement-args) missing]))) (defn- substitute-param [param->value [sql args missing] in-optional? {:keys [k]}] @@ -25,7 +26,7 @@ [sql args (conj missing k)] :else - (let [{:keys [replacement-snippet prepared-statement-args]} (substitution/->replacement-snippet-info v)] + (let [{:keys [replacement-snippet prepared-statement-args]} (substitution/->replacement-snippet-info driver/*driver* v)] [(str sql replacement-snippet) (concat args prepared-statement-args) missing]))))) (declare substitute*) @@ -62,8 +63,16 @@ {\"bird_type\" \"Steller's Jay\"}) ;; -> [\"select * from foobars where bird_type = ?\" [\"Steller's Jay\"]]" [parsed-query param->value] - (let [[sql args missing] (substitute* param->value parsed-query false)] + (let [[sql args missing] (try + (substitute* param->value parsed-query false) + (catch Throwable e + (throw (ex-info (tru "Unable to substitute parameters") + {:type (or (:type (ex-data e)) error-type/qp) + :params param->value + :parsed-query parsed-query} + e))))] (when (seq missing) (throw (ex-info (tru "Cannot run the query: missing required parameters: {0}" (set missing)) - {:type error-type/invalid-query}))) + {:type error-type/missing-required-parameter + :missing missing}))) [(str/trim sql) args])) diff --git a/src/metabase/driver/sql/parameters/substitution.clj b/src/metabase/driver/sql/parameters/substitution.clj index 0bd670d3dce74ea380f285b5f5383bb4dd866a0e..cdc59b5cc0317aa551d270989ad0de803a6ddb7a 100644 --- a/src/metabase/driver/sql/parameters/substitution.clj +++ b/src/metabase/driver/sql/parameters/substitution.clj @@ -12,7 +12,9 @@ [metabase.driver.common.parameters :as i] [metabase.driver.common.parameters.dates :as date-params] [metabase.driver.sql.query-processor :as sql.qp] - [metabase.query-processor.timezone :as qp.timezone] + [metabase.query-processor + [error-type :as qp.error-type] + [timezone :as qp.timezone]] [metabase.util [date-2 :as u.date] [i18n :refer [tru]] @@ -24,7 +26,6 @@ java.util.UUID [metabase.driver.common.parameters CommaSeparatedNumbers Date DateRange FieldFilter MultipleValues])) - ;;; ------------------------------------ ->prepared-substitution & default impls ------------------------------------- (defmulti ->prepared-substitution @@ -96,52 +97,53 @@ "Return information about how `value` should be converted to SQL, as a map with keys `:replacement-snippet` and `:prepared-statement-args`. - (->replacement-snippet-info \"ABC\") -> {:replacement-snippet \"?\", :prepared-statement-args \"ABC\"}" - {:arglists '([value])} - class) + (->replacement-snippet-info :h2 \"ABC\") -> {:replacement-snippet \"?\", :prepared-statement-args \"ABC\"}" + {:arglists '([driver value])} + (fn [driver v] [(driver/the-initialized-driver driver) (class v)]) + :hierarchy #'driver/hierarchy) (defn- create-replacement-snippet [nil-or-obj] (let [{:keys [sql-string param-values]} (->prepared-substitution driver/*driver* nil-or-obj)] {:replacement-snippet sql-string :prepared-statement-args param-values})) -(defmethod ->replacement-snippet-info nil - [this] +(defmethod ->replacement-snippet-info [:sql nil] + [_ this] (create-replacement-snippet this)) -(defmethod ->replacement-snippet-info Object - [this] +(defmethod ->replacement-snippet-info [:sql Object] + [_ this] (create-replacement-snippet (str this))) -(defmethod ->replacement-snippet-info Number - [this] +(defmethod ->replacement-snippet-info [:sql Number] + [_ this] (create-replacement-snippet this)) -(defmethod ->replacement-snippet-info Boolean - [this] +(defmethod ->replacement-snippet-info [:sql Boolean] + [_ this] (create-replacement-snippet this)) -(defmethod ->replacement-snippet-info Keyword - [this] +(defmethod ->replacement-snippet-info [:sql Keyword] + [_ this] (if (= this i/no-value) {:replacement-snippet ""} (create-replacement-snippet this))) -(defmethod ->replacement-snippet-info SqlCall - [this] +(defmethod ->replacement-snippet-info [:sql SqlCall] + [_ this] (create-replacement-snippet this)) -(defmethod ->replacement-snippet-info UUID - [this] +(defmethod ->replacement-snippet-info [:sql UUID] + [_ this] {:replacement-snippet (format "CAST('%s' AS uuid)" (str this))}) -(defmethod ->replacement-snippet-info CommaSeparatedNumbers - [{:keys [numbers]}] +(defmethod ->replacement-snippet-info [:sql CommaSeparatedNumbers] + [_ {:keys [numbers]}] {:replacement-snippet (str/join ", " numbers)}) -(defmethod ->replacement-snippet-info MultipleValues - [{:keys [values]}] - (let [values (map ->replacement-snippet-info values)] +(defmethod ->replacement-snippet-info [:sql MultipleValues] + [driver {:keys [values]}] + (let [values (map (partial ->replacement-snippet-info driver) values)] {:replacement-snippet (str/join ", " (map :replacement-snippet values)) :prepared-statement-args (apply concat (map :prepared-statement-args values))})) @@ -149,34 +151,36 @@ (condp instance? x String (u.date/parse x (qp.timezone/report-timezone-id-if-supported)) Temporal x - (throw (Exception. (tru "Don''t know how to parse {0} {1}" (class x) x))))) + (throw (ex-info (tru "Don''t know how to parse {0} {1} as a temporal literal" (class x) (pr-str x)) + {:type qp.error-type/invalid-parameter + :parameter x})))) -(defmethod ->replacement-snippet-info Date - [{:keys [s]}] +(defmethod ->replacement-snippet-info [:sql Date] + [driver {:keys [s]}] (create-replacement-snippet (maybe-parse-temporal-literal s))) -(defn- prepared-ts-subs [operator date-str] - (let [{:keys [sql-string param-values]} (->prepared-substitution driver/*driver* (maybe-parse-temporal-literal date-str))] +(defn- prepared-ts-subs [driver operator date-str] + (let [{:keys [sql-string param-values]} (->prepared-substitution driver (maybe-parse-temporal-literal date-str))] {:replacement-snippet (str operator " " sql-string) :prepared-statement-args param-values})) -(defmethod ->replacement-snippet-info DateRange - [{:keys [start end]}] +(defmethod ->replacement-snippet-info [:sql DateRange] + [driver {:keys [start end]}] (cond (= start end) - (prepared-ts-subs \= start) + (prepared-ts-subs driver \= start) (nil? start) - (prepared-ts-subs \< end) + (prepared-ts-subs driver \< end) (nil? end) - (prepared-ts-subs \> start) + (prepared-ts-subs driver \> start) :else ;; TIMEZONE FIXME - this is WRONG WRONG WRONG because date ranges should be inclusive for start and *exclusive* ;; for end (let [[start end] (map (fn [s] - (->prepared-substitution driver/*driver* (maybe-parse-temporal-literal s))) + (->prepared-substitution driver (maybe-parse-temporal-literal s))) [start end])] {:replacement-snippet (format "BETWEEN %s AND %s" (:sql-string start) (:sql-string end)) :prepared-statement-args (concat (:param-values start) (:param-values end))}))) @@ -191,40 +195,39 @@ :prepared-statement-args (reduce concat (map :prepared-statement-args replacement-snippet-maps))}) ;; for relative dates convert the param to a `DateRange` record type and call `->replacement-snippet-info` on it -(s/defn ^:private relative-date-field-filter->replacement-snippet-info :- ParamSnippetInfo - [value] - (-> (date-params/date-string->range value (qp.timezone/results-timezone-id)) - i/map->DateRange - ->replacement-snippet-info)) +(s/defn ^:private date-range-field-filter->replacement-snippet-info :- ParamSnippetInfo + [driver value] + (->> (date-params/date-string->range value (qp.timezone/results-timezone-id)) + i/map->DateRange + (->replacement-snippet-info driver))) (s/defn ^:private field-filter->equals-clause-sql :- ParamSnippetInfo - [value] - (-> (->replacement-snippet-info value) + [driver value] + (-> (->replacement-snippet-info driver value) (update :replacement-snippet (partial str "= ")))) (s/defn ^:private field-filter-multiple-values->in-clause-sql :- ParamSnippetInfo - [values] - (-> (i/map->MultipleValues {:values values}) - ->replacement-snippet-info + [driver values] + (-> (->replacement-snippet-info driver (i/map->MultipleValues {:values values})) (update :replacement-snippet (partial format "IN (%s)")))) (s/defn ^:private field-filter->replacement-snippet-info :- ParamSnippetInfo "Return `[replacement-snippet & prepared-statement-args]` appropriate for a field filter parameter." - [{param-type :type, value :value} :- i/ParamValue] + [driver {param-type :type, value :value} :- i/ParamValue] (cond - ;; convert relative dates to approprate date range representations - (date-params/relative-date-param-type? param-type) (relative-date-field-filter->replacement-snippet-info value) + ;; convert date ranges to DateRange record types + (date-params/date-range-type? param-type) (date-range-field-filter->replacement-snippet-info driver value) ;; convert all other dates to `= <date>` - (date-params/date-type? param-type) (field-filter->equals-clause-sql (i/map->Date {:s value})) + (date-params/date-type? param-type) (field-filter->equals-clause-sql driver (i/map->Date {:s value})) ;; for sequences of multiple values we want to generate an `IN (...)` clause - (sequential? value) (field-filter-multiple-values->in-clause-sql value) + (sequential? value) (field-filter-multiple-values->in-clause-sql driver value) ;; convert everything else to `= <value>` - :else (field-filter->equals-clause-sql value))) + :else (field-filter->equals-clause-sql driver value))) (s/defn ^:private honeysql->replacement-snippet-info :- ParamSnippetInfo - "Convert `x` to a replacement snippet info map by passing it to HoneySQL's `format` function." - [x] - (let [[snippet & args] (hsql/format x, :quoting (sql.qp/quote-style driver/*driver*), :allow-dashed-names? true)] + "Convert `hsql-form` to a replacement snippet info map by passing it to HoneySQL's `format` function." + [driver hsql-form] + (let [[snippet & args] (hsql/format hsql-form, :quoting (sql.qp/quote-style driver), :allow-dashed-names? true)] {:replacement-snippet snippet :prepared-statement-args args})) @@ -232,16 +235,17 @@ "Return an approprate snippet to represent this `field` in SQL given its param type. For non-date Fields, this is just a quoted identifier; for dates, the SQL includes appropriately bucketing based on the `param-type`." - [field param-type] + [driver field param-type] (:replacement-snippet (honeysql->replacement-snippet-info - (let [identifier (sql.qp/->honeysql driver/*driver* (sql.qp/field->identifier driver/*driver* field))] + driver + (let [identifier (sql.qp/->honeysql driver (sql.qp/field->identifier driver field))] (if (date-params/date-type? param-type) - (sql.qp/date driver/*driver* :day identifier) + (sql.qp/date driver :day identifier) identifier))))) -(defmethod ->replacement-snippet-info FieldFilter - [{:keys [field value], :as field-filter}] +(defmethod ->replacement-snippet-info [:sql FieldFilter] + [driver {:keys [field value], :as field-filter}] (cond ;; otherwise if the value isn't present just put in something that will always be true, such as `1` (e.g. `WHERE 1 ;; = 1`). This is only used for field filters outside of optional clauses @@ -251,9 +255,9 @@ ;; FieldFilter, not in the sense that we have a single map with multiple values for `:value`.) (vector? value) (combine-replacement-snippet-maps (for [v value] - (->replacement-snippet-info (assoc field-filter :value v)))) + (->replacement-snippet-info driver (assoc field-filter :value v)))) ;; otherwise convert single value to SQL. ;; Convert the value to a replacement snippet info map and then tack on the field identifier to the front :else - (update (field-filter->replacement-snippet-info value) - :replacement-snippet (partial str (field->identifier field (:type value)) " ")))) + (update (field-filter->replacement-snippet-info driver value) + :replacement-snippet (partial str (field->identifier driver field (:type value)) " ")))) diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj index 0a4665f0d5cae0c8fc44a76d2c9750804ef024a9..b312081741a89e7d61a8dbc4e834a7047149ea05 100644 --- a/src/metabase/driver/util.clj +++ b/src/metabase/driver/util.clj @@ -115,8 +115,12 @@ "Return info about all currently available drivers, including their connection properties fields and supported features." [] - (into {} (for [driver (available-drivers)] + (into {} (for [driver (available-drivers) + :let [props (try + (driver/connection-properties driver) + (catch Throwable e + (log/error e (trs "Unable to determine connection properties for driver {0}" driver))))] + :when props] ;; TODO - maybe we should rename `details-fields` -> `connection-properties` on the FE as well? - [driver {:details-fields (driver/connection-properties driver) - :driver-name (driver/display-name driver) - #_:features #_(features driver)}]))) + [driver {:details-fields props + :driver-name (driver/display-name driver)}]))) diff --git a/src/metabase/query_processor/error_type.clj b/src/metabase/query_processor/error_type.clj index a37e978ca310536092fd5cf2d01d97aa16c964d5..153c4363b4ca9eb065100ebc129bc0a6d255b194 100644 --- a/src/metabase/query_processor/error_type.clj +++ b/src/metabase/query_processor/error_type.clj @@ -1,6 +1,9 @@ (ns metabase.query-processor.error-type "A hierarchy of all QP error types. Ideally all QP exceptions should be `ex-data` maps with an `:type` key whose value - is one of the types here. If you see an Exception in QP code that doesn't return an `:type`, add it!") + is one of the types here. If you see an Exception in QP code that doesn't return an `:type`, add it! + + (throw (ex-info (tru \"Don''t know how to parse {0} {1}\" (class x) x) + {:type qp.error-type/invalid-parameter}))") (def ^:private hierarchy (make-hierarchy)) @@ -56,6 +59,11 @@ :parent invalid-query :show-in-embeds? true) +(deferror invalid-parameter + "The query is parameterized, and a supplied parameter has an invalid value." + :parent invalid-query + :show-in-embeds? true) + ;;;; ### Server-Side Errors (deferror server diff --git a/src/metabase/query_processor/middleware/catch_exceptions.clj b/src/metabase/query_processor/middleware/catch_exceptions.clj index 35a6cd3c64e4b951e1b552a90d3afc62dadeccd4..90f75aa309c846b04e30da0d06f2da4c13b45730 100644 --- a/src/metabase/query_processor/middleware/catch_exceptions.clj +++ b/src/metabase/query_processor/middleware/catch_exceptions.clj @@ -81,7 +81,8 @@ (defmethod format-exception ExceptionInfo [query e] - (let [{error :error, error-type :type, :as data} (ex-data e)] + (let [{error :error, :as data} (ex-data e) + {error-type :type} (u/all-ex-data e)] (merge ((get-method format-exception Throwable) query e) (when-let [error-msg (and (= error-type :schema.core/error) diff --git a/src/metabase/util.clj b/src/metabase/util.clj index a5a84e3df46abbae3b11c0b05f54b46242e270c3..71c43f746e6c9a348d2c9352e21c1dba41ef0560 100644 --- a/src/metabase/util.clj +++ b/src/metabase/util.clj @@ -386,6 +386,24 @@ (^String [s max-length] (str/join (take max-length (slugify s))))) +(defn all-ex-data + "Like `ex-data`, but merges `ex-data` from causes. If duplicate keys exist, the keys from the highest level are + preferred. + + (def e (ex-info \"A\" {:a true, :both \"a\"} (ex-info \"B\" {:b true, :both \"A\"}))) + + (ex-data e) + ;; -> {:a true, :both \"a\"} + + (u/all-ex-data e) + ;; -> {:a true, :b true, :both \"a\"}" + [e] + (reduce + (fn [data e] + (merge (ex-data e) data)) + nil + (take-while some? (iterate #(.getCause ^Throwable %) e)))) + (defn do-with-auto-retries "Execute `f`, a function that takes no arguments, and return the results. If `f` fails with an exception, retry `f` up to `num-retries` times until it succeeds. @@ -395,14 +413,20 @@ [num-retries f] (if (<= num-retries 0) (f) - (try (f) - (catch Throwable e - (log/warn (format-color 'red "auto-retry %s: %s" f (.getMessage e))) - (do-with-auto-retries (dec num-retries) f))))) + (try + (f) + (catch Throwable e + (when (::no-auto-retry? (all-ex-data e)) + (throw e)) + (log/warn (format-color 'red "auto-retry %s: %s" f (.getMessage e))) + (do-with-auto-retries (dec num-retries) f))))) (defmacro auto-retry - "Execute `body` and return the results. - If `body` fails with an exception, retry execution up to `num-retries` times until it succeeds." + "Execute `body` and return the results. If `body` fails with an exception, retry execution up to `num-retries` times + until it succeeds. + + You can disable auto-retries for a specific ExceptionInfo by including `{:metabase.util/no-auto-retry? true}` in its + data (or the data of one of its causes.)" {:style/indent 1} [num-retries & body] `(do-with-auto-retries ~num-retries diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index 4c4bfd7d162bfddab871da31bd55d9ede29f929b..6bf500decdc5fcf1be26fffc3bdae54543c3823b 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -1,9 +1,11 @@ (ns metabase.api.public-test "Tests for `api/public/` (public links) endpoints." (:require [cheshire.core :as json] - [clojure.string :as str] + [clojure + [string :as str] + [test :refer :all]] [dk.ative.docjure.spreadsheet :as spreadsheet] - [expectations :refer :all] + [expectations :refer [expect]] [metabase [http-client :as http] [query-processor-test :as qp.test] @@ -216,15 +218,15 @@ :target [:variable [:template-tag "price"]] :value 1}])))))) -;; if you're missing a required param, the error message should get passed thru, rather than the normal generic 'Query -;; Failed' message that we show for most embedding errors -(expect - {:status "failed" - :error "You'll need to pick a value for 'Price' before this query can run." - :error_type "missing-required-parameter"} - (do-with-required-param-card - (fn [uuid] - (http/client :get 200 (str "public/card/" uuid "/query"))))) +(deftest missing-required-param-error-message-test + (testing (str "If you're missing a required param, the error message should get passed thru, rather than the normal " + "generic 'Query Failed' message that we show for most embedding errors") + (is (= {:status "failed" + :error "You'll need to pick a value for 'Price' before this query can run." + :error_type "missing-required-parameter"} + (do-with-required-param-card + (fn [uuid] + (http/client :get 200 (str "public/card/" uuid "/query")))))))) ;; make sure CSV (etc.) downloads take editable params into account (#6407) diff --git a/test/metabase/driver/sql/parameters/substitute_test.clj b/test/metabase/driver/sql/parameters/substitute_test.clj index c0b4df0615ff4a9dd8bcf77868aa229856bb43c7..57e1bbae921c4dcdb9b7a7beb8645b9e611f973b 100644 --- a/test/metabase/driver/sql/parameters/substitute_test.clj +++ b/test/metabase/driver/sql/parameters/substitute_test.clj @@ -1,6 +1,5 @@ (ns metabase.driver.sql.parameters.substitute-test (:require [clojure.test :refer :all] - [expectations :refer [expect]] [java-time :as t] [metabase [driver :as driver] @@ -25,72 +24,57 @@ (driver/with-driver :h2 (substitute/substitute parsed param->value))) -;; normal substitution -(expect - ["select * from foobars where bird_type = ?" ["Steller's Jay"]] - (substitute - ["select * from foobars where bird_type = " (param "bird_type")] - {"bird_type" "Steller's Jay"})) - -;; make sure falsey values are substituted correctly -;; `nil` should get substituted as `NULL` -(expect - ["select * from foobars where bird_type = NULL" []] - (substitute - ["select * from foobars where bird_type = " (param "bird_type")] - {"bird_type" nil})) - -;; `false` should get substituted as `false` -(expect - ["select * from foobars where bird_type = FALSE" []] - (substitute - ["select * from foobars where bird_type = " (param "bird_type")] - {"bird_type" false})) - -;; optional substitution -- param present -(expect - ;; should preserve whitespace inside optional params - ["select * from foobars where bird_type = ?" ["Steller's Jay"]] - (substitute - ["select * from foobars " (optional " where bird_type = " (param "bird_type"))] - {"bird_type" "Steller's Jay"})) - -;; optional substitution -- param not present -(expect - ["select * from foobars" nil] - (substitute - ["select * from foobars " (optional " where bird_type = " (param "bird_type"))] - {})) - -;; optional -- multiple params -- all present -(expect - ["select * from foobars where bird_type = ? AND color = ?" ["Steller's Jay" "Blue"]] - (substitute - ["select * from foobars " (optional " where bird_type = " (param "bird_type") " AND color = " (param "bird_color"))] - {"bird_type" "Steller's Jay", "bird_color" "Blue"})) - -;; optional -- multiple params -- some present -(expect - ["select * from foobars" nil] - (substitute - ["select * from foobars " (optional " where bird_type = " (param "bird_type") " AND color = " (param "bird_color"))] - {"bird_type" "Steller's Jay"})) - -;; nested optionals -- all present -(expect - ["select * from foobars where bird_type = ? AND color = ?" ["Steller's Jay" "Blue"]] - (substitute - ["select * from foobars " (optional " where bird_type = " (param "bird_type") - (optional " AND color = " (param "bird_color")))] - {"bird_type" "Steller's Jay", "bird_color" "Blue"})) - -;; nested optionals -- some present -(expect - ["select * from foobars where bird_type = ?" ["Steller's Jay"]] - (substitute - ["select * from foobars " (optional " where bird_type = " (param "bird_type") - (optional " AND color = " (param "bird_color")))] - {"bird_type" "Steller's Jay"})) +(deftest substitute-test + (testing "normal substitution" + (is (= ["select * from foobars where bird_type = ?" ["Steller's Jay"]] + (substitute + ["select * from foobars where bird_type = " (param "bird_type")] + {"bird_type" "Steller's Jay"})))) + (testing "make sure falsey values are substituted correctly" + (testing "`nil` should get substituted as `NULL`" + (is (= ["select * from foobars where bird_type = NULL" []] + (substitute + ["select * from foobars where bird_type = " (param "bird_type")] + {"bird_type" nil}))))) + (testing "`false` should get substituted as `false`" + (is (= ["select * from foobars where bird_type = FALSE" []] + (substitute + ["select * from foobars where bird_type = " (param "bird_type")] + {"bird_type" false})))) + (testing "optional substitution -- param present" + (testing "should preserve whitespace inside optional params" + (is (= ["select * from foobars where bird_type = ?" ["Steller's Jay"]] + (substitute + ["select * from foobars " (optional " where bird_type = " (param "bird_type"))] + {"bird_type" "Steller's Jay"}))))) + (testing "optional substitution -- param not present" + (is (= ["select * from foobars" nil] + (substitute + ["select * from foobars " (optional " where bird_type = " (param "bird_type"))] + {})))) + (testing "optional -- multiple params -- all present" + (is (= ["select * from foobars where bird_type = ? AND color = ?" ["Steller's Jay" "Blue"]] + (substitute + ["select * from foobars " (optional " where bird_type = " (param "bird_type") " AND color = " (param "bird_color"))] + {"bird_type" "Steller's Jay", "bird_color" "Blue"})))) + (testing "optional -- multiple params -- some present" + (is (= ["select * from foobars" nil] + (substitute + ["select * from foobars " (optional " where bird_type = " (param "bird_type") " AND color = " (param "bird_color"))] + {"bird_type" "Steller's Jay"})))) + (testing "nested optionals -- all present" + (is (= ["select * from foobars where bird_type = ? AND color = ?" ["Steller's Jay" "Blue"]] + (substitute + ["select * from foobars " (optional " where bird_type = " (param "bird_type") + (optional " AND color = " (param "bird_color")))] + {"bird_type" "Steller's Jay", "bird_color" "Blue"})))) + (testing "nested optionals -- some present" + (is (= ["select * from foobars where bird_type = ?" ["Steller's Jay"]] + (substitute + ["select * from foobars " (optional " where bird_type = " (param "bird_type") + (optional " AND color = " (param "bird_color")))] + {"bird_type" "Steller's Jay"}))))) + ;;; ------------------------------------------------- Field Filters -------------------------------------------------- @@ -103,35 +87,27 @@ :value {:type :date/single :value (t/offset-date-time "2019-09-20T19:52:00.000-07:00")}})) -;; field filter -- non-optional + present -(expect - ["select * from checkins where CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ?" - [(t/offset-date-time "2019-09-20T19:52:00.000-07:00")]] - (substitute - ["select * from checkins where " (param "date")] - {"date" (date-field-filter-value)})) - -;; field filter -- non-optional + missing -- should be replaced with 1 = 1 -(expect - ["select * from checkins where 1 = 1" []] - (substitute - ["select * from checkins where " (param "date")] - {"date" (assoc (date-field-filter-value) :value i/no-value)})) - -;; field filter -- optional + present -(expect - ["select * from checkins where CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ?" - [(t/offset-date-time "2019-09-20T19:52:00.000-07:00")]] - (substitute - ["select * from checkins " (optional "where " (param "date"))] - {"date" (date-field-filter-value)})) - -;; field filter -- optional + missing -- should be omitted entirely -(expect - ["select * from checkins" nil] - (substitute - ["select * from checkins " (optional "where " (param "date"))] - {"date" (assoc (date-field-filter-value) :value i/no-value)})) +(deftest substitute-field-filter-test + (testing "field-filters" + (testing "non-optional" + (let [query ["select * from checkins where " (param "date")]] + (testing "param is present" + (is (= ["select * from checkins where CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ?" + [(t/offset-date-time "2019-09-20T19:52:00.000-07:00")]] + (substitute query {"date" (date-field-filter-value)})))) + (testing "param is missing" + (is (= ["select * from checkins where 1 = 1" []] + (substitute query {"date" (assoc (date-field-filter-value) :value i/no-value)})) + "should be replaced with 1 = 1")))) + (testing "optional" + (let [query ["select * from checkins " (optional "where " (param "date"))]] + (testing "param is present" + (is (= ["select * from checkins where CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ?" + [#t "2019-09-20T19:52:00.000-07:00"]] + (substitute query {"date" (date-field-filter-value)})))) + (testing "param is missing — should be omitted entirely" + (is (= ["select * from checkins" nil] + (substitute query {"date" (assoc (date-field-filter-value) :value i/no-value)})))))))) ;;; ------------------------------------------ simple substitution — {{x}} ------------------------------------------ @@ -659,44 +635,42 @@ :target [:dimension [:template-tag "checkin_date"]] :value "past5days"}]})))))) -;; Make sure we can specify the type of a default value for a "Dimension" (Field Filter) by setting the -;; `:widget-type` key. Check that it works correctly with relative dates... -(expect - {:query (str "SELECT count(*) AS \"count\", \"DATE\" " - "FROM CHECKINS " - "WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) BETWEEN ? AND ? " - "GROUP BY \"DATE\"") - :params [#t "2017-10-31" - #t "2017-11-04"]} - (t/with-clock (t/mock-clock #t "2017-11-05T12:00Z" (t/zone-id "UTC")) - (expand* {:native {:query (str "SELECT count(*) AS \"count\", \"DATE\" " - "FROM CHECKINS " - "WHERE {{checkin_date}} " - "GROUP BY \"DATE\"") - :template-tags {"checkin_date" {:name "checkin_date" - :display-name "Checkin Date" - :type :dimension - :dimension [:field-id (mt/id :checkins :date)] - :default "past5days" - :widget-type :date/all-options}}}}))) - -;; Check that it works with absolute dates as well -(expect - {:query (str "SELECT count(*) AS \"count\", \"DATE\" " - "FROM CHECKINS " - "WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ? " - "GROUP BY \"DATE\"") - :params [#t "2017-11-14"]} - (expand* {:native {:query (str "SELECT count(*) AS \"count\", \"DATE\" " - "FROM CHECKINS " - "WHERE {{checkin_date}} " - "GROUP BY \"DATE\"") - :template-tags {"checkin_date" {:name "checkin_date" - :display-name "Checkin Date" - :type :dimension - :dimension [:field-id (mt/id :checkins :date)] - :default "2017-11-14" - :widget-type :date/all-options}}}})) +(deftest field-filter-defaults-test + (testing (str "Make sure we can specify the type of a default value for a \"Dimension\" (Field Filter) by setting " + "the `:widget-type` key. Check that it works correctly with relative dates...") + (is (= {:query (str "SELECT count(*) AS \"count\", \"DATE\" " + "FROM CHECKINS " + "WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) BETWEEN ? AND ? " + "GROUP BY \"DATE\"") + :params [#t "2017-10-31" + #t "2017-11-04"]} + (t/with-clock (t/mock-clock #t "2017-11-05T12:00Z" (t/zone-id "UTC")) + (expand* {:native {:query (str "SELECT count(*) AS \"count\", \"DATE\" " + "FROM CHECKINS " + "WHERE {{checkin_date}} " + "GROUP BY \"DATE\"") + :template-tags {"checkin_date" {:name "checkin_date" + :display-name "Checkin Date" + :type :dimension + :dimension [:field-id (mt/id :checkins :date)] + :default "past5days" + :widget-type :date/all-options}}}}))))) + (testing "Check that it works with absolute dates as well" + (is (= {:query (str "SELECT count(*) AS \"count\", \"DATE\" " + "FROM CHECKINS " + "WHERE CAST(\"PUBLIC\".\"CHECKINS\".\"DATE\" AS date) = ? " + "GROUP BY \"DATE\"") + :params [#t "2017-11-14"]} + (expand* {:native {:query (str "SELECT count(*) AS \"count\", \"DATE\" " + "FROM CHECKINS " + "WHERE {{checkin_date}} " + "GROUP BY \"DATE\"") + :template-tags {"checkin_date" {:name "checkin_date" + :display-name "Checkin Date" + :type :dimension + :dimension [:field-id (mt/id :checkins :date)] + :default "2017-11-14" + :widget-type :date/all-options}}}}))))) (deftest newlines-test (testing "Make sure queries with newlines are parsed correctly (#11526)" diff --git a/test/metabase/driver/sql/parameters/substitution_test.clj b/test/metabase/driver/sql/parameters/substitution_test.clj index 5bb00c2cd83ef1ae132aa50777d92191d4613fc1..8daf043bf3d0ce5be3a9680513c45e47ce293ea6 100644 --- a/test/metabase/driver/sql/parameters/substitution_test.clj +++ b/test/metabase/driver/sql/parameters/substitution_test.clj @@ -1,11 +1,11 @@ (ns metabase.driver.sql.parameters.substitution-test + "Most of the code in `metabase.driver.sql.parameters.substitution` is actually tested by + `metabase.driver.sql.parameters.substitute-test`." (:require [clojure.test :refer :all] - [metabase.driver :as driver] [metabase.driver.sql.parameters.substitution :as substitution])) (deftest honeysql->replacement-snippet-info-test - (driver/with-driver :h2 - (testing "make sure we handle quotes inside names correctly!" - (is (= {:replacement-snippet "\"test-data\".\"PUBLIC\".\"checkins\".\"date\"" - :prepared-statement-args nil} - (#'substitution/honeysql->replacement-snippet-info :test-data.PUBLIC.checkins.date)))))) + (testing "make sure we handle quotes inside names correctly!" + (is (= {:replacement-snippet "\"test-data\".\"PUBLIC\".\"checkins\".\"date\"" + :prepared-statement-args nil} + (#'substitution/honeysql->replacement-snippet-info :h2 :test-data.PUBLIC.checkins.date))))) diff --git a/test/metabase/query_processor_test/timezones_test.clj b/test/metabase/query_processor_test/timezones_test.clj index 0b44a8d4e1c2c40124b0fe1a689a566e8a596ab6..0c89cc1f476c90ff507982c80ff957577cb3a7f8 100644 --- a/test/metabase/query_processor_test/timezones_test.clj +++ b/test/metabase/query_processor_test/timezones_test.clj @@ -191,11 +191,11 @@ ;; Make sure TIME values are handled consistently (#10366) (defn- attempts [] (zipmap - [:date :time :datetime :time-ltz :time-tz :datetime-ltz :datetime-tz :datetime-tz-id] + [:date :time :datetime :time_ltz :time_tz :datetime_ltz :datetime_tz :datetime_tz_id] (qp.test/first-row (qp/process-query (data/query attempts - {:query {:fields [$date $time $datetime $time-ltz $time-tz $datetime-ltz $datetime-tz $datetime-tz-id] + {:query {:fields [$date $time $datetime $time_ltz $time_tz $datetime_ltz $datetime_tz $datetime_tz_id] :filter [:= $id 1]} :middleware {:format-rows? false}}))))) @@ -216,15 +216,15 @@ {:date (t/local-date "2019-11-01") :time (t/local-time "00:23:18.331") :datetime (t/local-date-time "2019-11-01T00:23:18.331") - :datetime-ltz (t/offset-date-time "2019-11-01T07:23:18.331Z")} + :datetime_ltz (t/offset-date-time "2019-11-01T07:23:18.331Z")} (when (supports-time-with-time-zone?) - {:time-ltz (t/offset-time "07:23:18.331Z")}) + {:time_ltz (t/offset-time "07:23:18.331Z")}) (when (supports-time-with-offset?) - {:time-tz (t/offset-time "00:23:18.331-07:00")}) + {:time_tz (t/offset-time "00:23:18.331-07:00")}) (when (supports-datetime-with-offset?) - {:datetime-tz (t/offset-date-time "2019-11-01T00:23:18.331-07:00")}) + {:datetime_tz (t/offset-date-time "2019-11-01T00:23:18.331-07:00")}) (when (supports-datetime-with-zone-id?) - {:datetime-tz-id (t/zoned-date-time "2019-11-01T00:23:18.331-07:00[America/Los_Angeles]")}))) + {:datetime_tz_id (t/zoned-date-time "2019-11-01T00:23:18.331-07:00[America/Los_Angeles]")}))) (deftest time-timezone-handling-test ;; Actual value : "2019-11-01T00:23:18.331-07:00[America/Los_Angeles]" diff --git a/test/metabase/test/data/dataset_definitions.clj b/test/metabase/test/data/dataset_definitions.clj index 6a205c63a1a88250b9aa3b0e7f1654475fc64848..cc6a7e696ef41d574075e5df541699ed2954ac89 100644 --- a/test/metabase/test/data/dataset_definitions.clj +++ b/test/metabase/test/data/dataset_definitions.clj @@ -150,15 +150,15 @@ "A dataset for testing temporal values with and without timezones. Records of number of crow counts spoted and the date/time when they spotting occured in several different column types." [["attempts" - [{:field-name "num-crows", :base-type :type/Integer} + [{:field-name "num_crows", :base-type :type/Integer} {:field-name "date", :base-type :type/Date} {:field-name "time", :base-type :type/Time} - {:field-name "time-ltz", :base-type :type/TimeWithLocalTZ} - {:field-name "time-tz", :base-type :type/TimeWithZoneOffset} + {:field-name "time_ltz", :base-type :type/TimeWithLocalTZ} + {:field-name "time_tz", :base-type :type/TimeWithZoneOffset} {:field-name "datetime", :base-type :type/DateTime} - {:field-name "datetime-ltz", :base-type :type/DateTimeWithLocalTZ} - {:field-name "datetime-tz", :base-type :type/DateTimeWithZoneOffset} - {:field-name "datetime-tz-id", :base-type :type/DateTimeWithZoneID}] + {:field-name "datetime_ltz", :base-type :type/DateTimeWithLocalTZ} + {:field-name "datetime_tz", :base-type :type/DateTimeWithZoneOffset} + {:field-name "datetime_tz_id", :base-type :type/DateTimeWithZoneID}] (for [[cnt t] [[6 #t "2019-11-01T00:23:18.331-07:00[America/Los_Angeles]"] [8 #t "2019-11-02T00:14:14.246-07:00[America/Los_Angeles]"] [6 #t "2019-11-03T23:35:17.906-08:00[America/Los_Angeles]"]