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

Avoid unneeded CAST() statements (#15115)

* Optimize SQL Server GROUP BY and ORDER BY with :year, :month, or :day bucketing

* Avoid unneeded CAST() statements

* More tests :wrench:

* Revert unneeded changes

* Test fixes :wrench:

* Don't count MySQL and Postgres driver namespaces for test coverage

* Address feedback from @dpsutton; test fixes

* Test fixes :wrench:
parent 3aa732b6
Branches
Tags
No related merge requests found
Showing
with 526 additions and 235 deletions
......@@ -257,7 +257,7 @@
bigquery-type
(do
(log/tracef "Coercing %s (temporal type = %s) to %s" (binding [*print-meta* true] (pr-str x)) (pr-str (temporal-type x)) bigquery-type)
(with-temporal-type (hx/cast bigquery-type (sql.qp/->honeysql :bigquery x)) target-type))
(with-temporal-type (hsql/call :cast (sql.qp/->honeysql :bigquery x) (hsql/raw (name bigquery-type))) target-type))
:else
x))))
......
......@@ -8,6 +8,7 @@
[metabase.driver.bigquery :as bigquery]
[metabase.driver.bigquery.query-processor :as bigquery.qp]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.mbql.util :as mbql.u]
[metabase.models :refer [Database Field Table]]
[metabase.query-processor :as qp]
[metabase.query-processor-test :as qp.test]
......@@ -103,9 +104,11 @@
rows))))
(testing "let's make sure we're generating correct HoneySQL + SQL for aggregations"
(is (= {:select [[(hx/identifier :field "v3_test_data.venues" "price")
(is (= {:select [[(hx/with-database-type-info (hx/identifier :field "v3_test_data.venues" "price") "integer")
(hx/identifier :field-alias "price")]
[(hsql/call :avg (hx/identifier :field "v3_test_data.venues" "category_id"))
[(hsql/call :avg (hx/with-database-type-info
(hx/identifier :field "v3_test_data.venues" "category_id")
"integer"))
(hx/identifier :field-alias "avg")]]
:from [(hx/identifier :table "v3_test_data.venues")]
:group-by [(hx/identifier :field-alias "price")]
......@@ -272,13 +275,13 @@
(let [unix-ts (sql.qp/unix-timestamp->honeysql :bigquery :seconds :some_field)]
{:value unix-ts
:type :timestamp
:as {:date (hx/cast :date unix-ts)
:datetime (hx/cast :datetime unix-ts)}})
:as {:date (hsql/call :cast unix-ts (hsql/raw "date"))
:datetime (hsql/call :cast unix-ts (hsql/raw "datetime"))}})
(let [unix-ts (sql.qp/unix-timestamp->honeysql :bigquery :milliseconds :some_field)]
{:value unix-ts
:type :timestamp
:as {:date (hx/cast :date unix-ts)
:datetime (hx/cast :datetime unix-ts)}})])
:as {:date (hsql/call :cast unix-ts (hsql/raw "date"))
:datetime (hsql/call :cast unix-ts (hsql/raw "datetime"))}})])
(deftest temporal-type-test
(testing "Make sure we can detect temporal types correctly"
......@@ -290,9 +293,9 @@
(deftest reconcile-temporal-types-test
(mt/with-everything-store
(tt/with-temp* [Field [date-field {:name "date", :base_type :type/Date}]
Field [datetime-field {:name "datetime", :base_type :type/DateTime}]
Field [timestamp-field {:name "timestamp", :base_type :type/DateTimeWithLocalTZ}]]
(tt/with-temp* [Field [date-field {:name "date", :base_type :type/Date, :database_type "date"}]
Field [datetime-field {:name "datetime", :base_type :type/DateTime, :database_type "datetime"}]
Field [timestamp-field {:name "timestamp", :base_type :type/DateTimeWithLocalTZ, :database_type "timestamp"}]]
;; bind `*table-alias*` so the BigQuery QP doesn't try to look up the current dataset name when converting
;; `hx/identifier`s to SQL
(binding [sql.qp/*table-alias* "ABC"
......@@ -330,7 +333,9 @@
(testing (format "\nValue = %s %s" (:type value) (pr-str filter-value))
(let [filter-clause (into [(:mbql clause) field]
(repeat (dec (:args clause)) filter-value))
expected-identifier (hx/identifier :field "ABC" (name temporal-type))
field-literal? (mbql.u/match-one field [:field (_ :guard string?) _])
expected-identifier (cond-> (hx/identifier :field "ABC" (name temporal-type))
(not field-literal?) (hx/with-database-type-info (name temporal-type)))
expected-value (get-in value [:as temporal-type] (:value value))
expected-clause (build-honeysql-clause-head clause
expected-identifier
......@@ -348,9 +353,9 @@
(doseq [[temporal-type field] fields
:let [identifier (hx/identifier :field "ABC" (name temporal-type))
expected-identifier (case temporal-type
:date identifier
:datetime (hx/cast :timestamp identifier)
:timestamp identifier)]]
:date (hx/with-database-type-info identifier "date")
:datetime (hsql/call :cast identifier (hsql/raw "timestamp"))
:timestamp (hx/with-database-type-info identifier "timestamp"))]]
(testing (format "\ntemporal-type = %s" temporal-type)
(is (= [:= (hsql/call :extract :dayofweek expected-identifier) 1]
(sql.qp/->honeysql :bigquery [:= [:field (:id field) {:temporal-unit :day-of-week}] 1])))))))))))
......@@ -365,25 +370,26 @@
:timestamp [:year "CAST(datetime_trunc(datetime_add(current_datetime(), INTERVAL -1 year), year) AS timestamp)"]}]
(testing t
(let [reconciled-clause (#'bigquery.qp/->temporal-type t [:relative-datetime -1 unit])]
(is (= t
(#'bigquery.qp/temporal-type reconciled-clause))
"Should have correct type metadata after reconciliation")
(is (= [(str "WHERE " expected-sql)]
(sql.qp/format-honeysql :bigquery
{:where (sql.qp/->honeysql :bigquery reconciled-clause)}))
"Should get converted to the correct SQL")))))
(testing "Should have correct type metadata after reconciliation"
(is (= t
(#'bigquery.qp/temporal-type reconciled-clause))))
(testing "Should get converted to the correct SQL"
(is (= [(str "WHERE " expected-sql)]
(sql.qp/format-honeysql :bigquery
{:where (sql.qp/->honeysql :bigquery reconciled-clause)}))))))))
(testing "relative-datetime clauses inside filter clauses"
(doseq [[expected-type t] {:date #t "2020-01-31"
:datetime #t "2020-01-31T20:43:00.000"
:timestamp #t "2020-01-31T20:43:00.000-08:00"}]
(testing expected-type
(let [[_ _ relative-datetime] (sql.qp/->honeysql :bigquery
[:=
t
[:relative-datetime -1 :year]])]
(is (= expected-type
(#'bigquery.qp/temporal-type relative-datetime))))))))
(let [[_ _ relative-datetime :as clause] (sql.qp/->honeysql :bigquery
[:=
t
[:relative-datetime -1 :year]])]
(testing (format "\nclause = %s" (pr-str clause))
(is (= expected-type
(#'bigquery.qp/temporal-type relative-datetime)))))))))
(deftest between-test
(testing "Make sure :between clauses reconcile the temporal types of their args"
......
......@@ -12,9 +12,7 @@
[metabase.models.table :refer [Table]]
[metabase.query-processor :as qp]
[metabase.query-processor-test :as qp.test]
[metabase.query-processor.test-util :as qp.test-util]
[metabase.test :as mt]
[metabase.test.data :as data]
[metabase.test.data.oracle :as oracle.tx]
[metabase.test.data.sql :as sql.tx]
[metabase.test.data.sql.ddl :as ddl]
......@@ -138,7 +136,7 @@
(deftest return-clobs-as-text-test
(mt/test-driver :oracle
(testing "Make sure Oracle CLOBs are returned as text (#9026)"
(let [details (:details (data/db))
(let [details (:details (mt/db))
spec (sql-jdbc.conn/connection-details->spec :oracle details)
execute! (fn [format-string & args]
(jdbc/execute! spec (apply format format-string args)))
......@@ -147,21 +145,21 @@
(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 [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 (mt/id)}]
Field [id-field {:table_id (u/the-id table), :name "id", :base_type "type/Integer"}]
Field [_ {:table_id (u/the-id table), :name "message", :base_type "type/Text"}]]
(is (= [[1M "Hello"]
[2M nil]]
(qp.test/rows
(qp/process-query
{:database (data/id)
{:database (mt/id)
:type :query
:query {:source-table (u/get-id table)
:order-by [[:asc [:field-id (u/get-id id-field)]]]}}))))))))))
:query {:source-table (u/the-id table)
:order-by [[:asc [:field (u/the-id id-field) nil]]]}}))))))))))
(deftest handle-slashes-test
(mt/test-driver :oracle
(let [details (:details (data/db))
(let [details (:details (mt/db))
spec (sql-jdbc.conn/connection-details->spec :oracle details)
execute! (fn [format-string & args]
(jdbc/execute! spec (apply format format-string args)))
......@@ -180,48 +178,56 @@
;; don't sit around scratching our heads wondering why the queries themselves aren't working
(deftest honeysql-test
(mt/test-driver :oracle
(is (= {: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 [:<= (hsql/raw "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 [:=
&test_data_categories__via__cat.categories.name
[:value "BBQ" {:base_type :type/Text, :semantic_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
&test_data_categories__via__cat.categories.id]
:fk-field-id (data/id :venues :category_id)
:fields :none}]}))))
"Correct HoneySQL form should be generated")))
(testing "Correct HoneySQL form should be generated"
(mt/with-everything-store
(is (= (letfn [(id
([field-name database-type]
(id oracle.tx/session-schema "test_data_venues" field-name database-type))
([table-name field-name database-type]
(id nil table-name field-name database-type))
([schema-name table-name field-name database-type]
(-> (hx/identifier :field schema-name table-name field-name)
(hx/with-database-type-info database-type))))]
{:select [:*]
:from [{:select
[[(id "id" "number")
(hx/identifier :field-alias "id")]
[(id "name" "varchar2")
(hx/identifier :field-alias "name")]
[(id "category_id" "number")
(hx/identifier :field-alias "category_id")]
[(id "latitude" "binary_float")
(hx/identifier :field-alias "latitude")]
[(id "longitude" "binary_float")
(hx/identifier :field-alias "longitude")]
[(id "price" "number")
(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")]
[:=
(id "category_id" "number")
(id "test_data_categories__via__cat" "id" "number")]]
:where [:=
(id "test_data_categories__via__cat" "name" "varchar2")
"BBQ"]
:order-by [[(id "id" "number") :asc]]}]
:where [:<= (hsql/raw "rownum") 100]})
(#'sql.qp/mbql->honeysql
:oracle
(mt/mbql-query venues
{:source-table $$venues
:order-by [[:asc $id]]
:filter [:=
&test_data_categories__via__cat.categories.name
[:value "BBQ" {:base_type :type/Text, :semantic_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
&test_data_categories__via__cat.categories.id]
:fk-field-id (mt/id :venues :category_id)
:fields :none}]}))))))))
......@@ -120,7 +120,7 @@
(defmethod sql.qp/date [:sqlserver :minute]
[_ _ expr]
(hx/cast :smalldatetime expr))
(hx/maybe-cast :smalldatetime expr))
(defmethod sql.qp/date [:sqlserver :minute-of-hour]
[_ _ expr]
......
......@@ -405,7 +405,12 @@
[;; don't instrument logging forms, since they won't get executed as part of tests anyway
;; log calls expand to these
clojure.tools.logging/logf
clojure.tools.logging/logp]}}]
clojure.tools.logging/logp]
;; don't instrument Postgres/MySQL driver namespaces, because we don't current run tests for them
;; as part of recording test coverage, which means they can give us false positives.
:ns-exclude-regex
[#"metabase\.driver\.mysql"
#"metabase\.driver\.postgres"]}}]
;; build the uberjar with `lein uberjar`
:uberjar
......
......@@ -183,26 +183,37 @@
[driver [_ arg]]
(hsql/call :char_length (sql.qp/->honeysql driver arg)))
;; Since MySQL doesn't have date_trunc() we fake it by formatting a date to an appropriate string and then converting
;; back to a date. See http://dev.mysql.com/doc/refman/5.6/en/date-and-time-functions.html#function_date-format for an
;; explanation of format specifiers
;; this will generate a SQL statement casting the TIME to a DATETIME so date_format doesn't fail:
;; date_format(CAST(mytime AS DATETIME), '%Y-%m-%d %H') AS mytime
(defn- trunc-with-format [format-str expr]
(str-to-date format-str (date-format format-str (hx/cast :DATETIME expr))))
(str-to-date format-str (date-format format-str (hx/->datetime expr))))
(defn- ->date [expr]
(if (hx/is-of-type? expr "date")
expr
(-> (hsql/call :date expr)
(hx/with-database-type-info "date"))))
(defn make-date
"Create and return a date based on a year and a number of days value."
[year-expr number-of-days]
(-> (hsql/call :makedate year-expr number-of-days)
(hx/with-database-type-info "date")))
(defmethod sql.qp/date [:mysql :default] [_ _ expr] expr)
(defmethod sql.qp/date [:mysql :minute] [_ _ expr] (trunc-with-format "%Y-%m-%d %H:%i" expr))
(defmethod sql.qp/date [:mysql :minute-of-hour] [_ _ expr] (hx/minute expr))
(defmethod sql.qp/date [:mysql :hour] [_ _ expr] (trunc-with-format "%Y-%m-%d %H" expr))
(defmethod sql.qp/date [:mysql :hour-of-day] [_ _ expr] (hx/hour expr))
(defmethod sql.qp/date [:mysql :day] [_ _ expr] (hsql/call :date expr))
(defmethod sql.qp/date [:mysql :day] [_ _ expr] (->date expr))
(defmethod sql.qp/date [:mysql :day-of-month] [_ _ expr] (hsql/call :dayofmonth expr))
(defmethod sql.qp/date [:mysql :day-of-year] [_ _ expr] (hsql/call :dayofyear expr))
(defmethod sql.qp/date [:mysql :month-of-year] [_ _ expr] (hx/month expr))
(defmethod sql.qp/date [:mysql :quarter-of-year] [_ _ expr] (hx/quarter expr))
(defmethod sql.qp/date [:mysql :year] [_ _ expr] (hsql/call :makedate (hx/year expr) 1))
(defmethod sql.qp/date [:mysql :year] [_ _ expr] (make-date (hx/year expr) 1))
(defmethod sql.qp/date [:mysql :day-of-week]
[_ _ expr]
......
......@@ -25,7 +25,7 @@
[pretty.core :refer [PrettyPrintable]]
[schema.core :as s])
(:import metabase.models.field.FieldInstance
metabase.util.honeysql_extensions.Identifier))
[metabase.util.honeysql_extensions Identifier TypedHoneySQLForm]))
;; TODO - yet another `*query*` dynamic var. We should really consolidate them all so we only need a single one.
(def ^:dynamic ^:private *query*
......@@ -250,6 +250,10 @@
[:type/Text (:isa? :type/TemporalString)] (cast-temporal-string driver (:semantic_type field) field-identifier)
:else field-identifier))
(defmethod ->honeysql [:sql TypedHoneySQLForm]
[driver typed-form]
(->honeysql driver (hx/unwrap-typed-honeysql-form typed-form)))
;; default implmentation is a no-op; other drivers can override it as needed
(defmethod ->honeysql [:sql Identifier]
[_ identifier]
......@@ -282,15 +286,16 @@
field-alias)))
(defmethod ->honeysql [:sql (class Field)]
[driver {field-name :name, table-id :table_id, :as field}]
[driver {field-name :name, table-id :table_id, database-type :database_type, :as field}]
;; `indentifer` will automatically unnest nested calls to `identifier`
(->> (if *table-alias*
[*table-alias* (unambiguous-field-alias driver [:field (:id field) nil])]
(let [{schema :schema, table-name :name} (qp.store/table table-id)]
[schema table-name field-name]))
(apply hx/identifier :field)
(->honeysql driver)
(cast-field-if-needed driver field)))
(as-> (if *table-alias*
[*table-alias* (unambiguous-field-alias driver [:field (:id field) nil])]
(let [{schema :schema, table-name :name} (qp.store/table table-id)]
[schema table-name field-name])) expr
(apply hx/identifier :field expr)
(->honeysql driver expr)
(cast-field-if-needed driver field expr)
(hx/with-database-type-info expr database-type)))
(defn compile-field-with-join-aliases
"Compile `field-clause` to HoneySQL using the `:join-alias` from the `:field` clause options."
......
......@@ -5,6 +5,7 @@
[honeysql.core :as hsql]
[honeysql.format :as hformat]
[metabase.util :as u]
[metabase.util.schema :as su]
[potemkin.types :as p.types]
[pretty.core :as pretty :refer [PrettyPrintable]]
[schema.core :as s])
......@@ -140,19 +141,112 @@
(defn inc "Add 1 to `x`." [x] (+ x 1))
(defn dec "Subtract 1 from `x`." [x] (- x 1))
(p.types/defprotocol+ TypedHoneySQL
"Protocol for a HoneySQL form that has type information such as `::database-type`. See #15115 for background."
(type-info [honeysql-form]
"Return type information associated with `honeysql-form`, if any (i.e., if it is a `TypedHoneySQLForm`); otherwise
returns `nil`.")
(with-type-info [honeysql-form new-type-info]
"Add type information to a `honeysql-form`. Wraps `honeysql-form` and returns a `TypedHoneySQLForm`.")
(unwrap-typed-honeysql-form [honeysql-form]
"If `honeysql-form` is a `TypedHoneySQLForm`, unwrap it and return the original form without type information.
Otherwise, returns form as-is."))
;; a wrapped for any HoneySQL form that records additional type information in an `info` map.
(p.types/defrecord+ TypedHoneySQLForm [form info]
PrettyPrintable
(pretty [_]
`(with-type-info ~form ~info))
(defn cast
"Generate a statement like `cast(x AS c)`."
[c x]
(hsql/call :cast x (hsql/raw (name c))))
(defn quoted-cast
"Generate a statement like `cast(x AS \"c\")`.
Like `cast` but quotes the type C. This is useful for cases where we deal with user-defined types or other types
that may have a space in the name, for example Postgres enum types."
[c x]
(hsql/call :cast x (keyword c)))
ToSql
(to-sql [_]
(hformat/to-sql form)))
(alter-meta! #'->TypedHoneySQLForm assoc :private true)
(alter-meta! #'map->TypedHoneySQLForm assoc :private true)
(def ^:private NormalizedTypeInfo
{(s/optional-key ::database-type) (s/constrained
su/NonBlankString
(fn [s]
(= s (str/lower-case s)))
"lowercased string")})
(s/defn ^:private normalize-type-info :- NormalizedTypeInfo
"Normalize the values in the `type-info` for a `TypedHoneySQLForm` for easy comparisons (e.g., normalize
`::database-type` to a lower-case string)."
[type-info]
(cond-> type-info
(::database-type type-info) (update ::database-type (comp str/lower-case name))))
(extend-protocol TypedHoneySQL
Object
(type-info [_]
nil)
(with-type-info [this new-info]
(TypedHoneySQLForm. this (normalize-type-info new-info)))
(unwrap-typed-honeysql-form [this]
this)
nil
(type-info [_]
nil)
(with-type-info [_ new-info]
(TypedHoneySQLForm. nil (normalize-type-info new-info)))
(unwrap-typed-honeysql-form [_]
nil)
TypedHoneySQLForm
(type-info [this]
(:info this))
(with-type-info [this new-info]
(assoc this :info (normalize-type-info new-info)))
(unwrap-typed-honeysql-form [this]
(:form this)))
(defn is-of-type?
"Is `honeysql-form` a typed form with `database-type`?
(is-of-type? expr \"datetime\") ; -> true"
[honeysql-form database-type]
(= (::database-type (type-info honeysql-form))
(some-> database-type name str/lower-case)))
(s/defn with-database-type-info
"Convenience for adding only database type information to a `honeysql-form`. Wraps `honeysql-form` and returns a
`TypedHoneySQLForm`. Passing `nil` as `database-type` will remove any existing type info.
(with-database-type-info :field \"text\")
;; -> #TypedHoneySQLForm{:form :field, :info {::hx/database-type \"text\"}}"
{:style/indent [:form]}
[honeysql-form database-type :- (s/maybe su/KeywordOrString)]
(if (some? database-type)
(with-type-info honeysql-form {::database-type database-type})
(unwrap-typed-honeysql-form honeysql-form)))
(s/defn cast :- TypedHoneySQLForm
"Generate a statement like `cast(expr AS sql-type)`. Returns a typed HoneySQL form."
[sql-type expr]
(-> (hsql/call :cast expr (hsql/raw (name sql-type)))
(with-type-info {::database-type sql-type})))
(s/defn quoted-cast :- TypedHoneySQLForm
"Generate a statement like `cast(expr AS \"sql-type\")`.
Like `cast` but quotes `sql-type`. This is useful for cases where we deal with user-defined types or other types
that may have a space in the name, for example Postgres enum types.
Returns a typed HoneySQL form."
[sql-type expr]
(-> (hsql/call :cast expr (keyword sql-type))
(with-type-info {::database-type sql-type})))
(s/defn maybe-cast :- TypedHoneySQLForm
"Cast `expr` to `sql-type`, unless `expr` is typed and already of that type. Returns a typed HoneySQL form."
[sql-type expr]
(if (is-of-type? expr sql-type)
expr
(cast sql-type expr)))
(defn format
"SQL `format` function."
......@@ -164,13 +258,13 @@
[x decimal-places]
(hsql/call :round x decimal-places))
(defn ->date "CAST `x` to a `date`." [x] (cast :date x))
(defn ->datetime "CAST `x` to a `datetime`." [x] (cast :datetime x))
(defn ->timestamp "CAST `x` to a `timestamp`." [x] (cast :timestamp x))
(defn ->timestamp-with-time-zone "CAST `x` to a `timestamp with time zone`." [x] (cast "timestamp with time zone" x))
(defn ->integer "CAST `x` to a `integer`." [x] (cast :integer x))
(defn ->time "CAST `x` to a `time` datatype" [x] (cast :time x))
(defn ->boolean "CAST `x` to a `boolean` datatype" [x] (cast :boolean x))
(defn ->date "CAST `x` to a `date`." [x] (maybe-cast :date x))
(defn ->datetime "CAST `x` to a `datetime`." [x] (maybe-cast :datetime x))
(defn ->timestamp "CAST `x` to a `timestamp`." [x] (maybe-cast :timestamp x))
(defn ->timestamp-with-time-zone "CAST `x` to a `timestamp with time zone`." [x] (maybe-cast "timestamp with time zone" x))
(defn ->integer "CAST `x` to a `integer`." [x] (maybe-cast :integer x))
(defn ->time "CAST `x` to a `time` datatype" [x] (maybe-cast :time x))
(defn ->boolean "CAST `x` to a `boolean` datatype" [x] (maybe-cast :boolean x))
;;; Random SQL fns. Not all DBs support all these!
(def ^{:arglists '([& exprs])} floor "SQL `floor` function." (partial hsql/call :floor))
......
(ns metabase.driver.h2-test
(:require [clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[clojure.test :refer :all]
[honeysql.core :as hsql]
[metabase.db.spec :as db.spec]
......@@ -69,11 +70,17 @@
(deftest add-interval-honeysql-form-test
(testing "Should convert fractional seconds to milliseconds"
(is (= (hsql/call :dateadd (hx/literal "millisecond") (hsql/call :cast 100500.0 (hsql/raw "long")) :%now)
(is (= (hsql/call :dateadd
(hx/literal "millisecond")
(hx/with-database-type-info (hsql/call :cast 100500.0 (hsql/raw "long")) "long")
:%now)
(sql.qp/add-interval-honeysql-form :h2 :%now 100.5 :second))))
(testing "Non-fractional seconds should remain seconds, but be cast to longs"
(is (= (hsql/call :dateadd (hx/literal "second") (hsql/call :cast 100.0 (hsql/raw "long")) :%now)
(is (= (hsql/call :dateadd
(hx/literal "second")
(hx/with-database-type-info (hsql/call :cast 100.0 (hsql/raw "long")) "long")
:%now)
(sql.qp/add-interval-honeysql-form :h2 :%now 100.0 :second)))))
(deftest clob-test
......@@ -122,3 +129,21 @@
:parameters [{:type :date/all-options
:target [:dimension [:template-tag "date"]]
:value "past30years"}]}))))))
(defn- pretty-sql [s]
(-> s
(str/replace #"\"" "")
(str/replace #"PUBLIC\." "")))
(deftest do-not-cast-to-date-if-column-is-already-a-date-test
(mt/test-driver :h2
(testing "Don't wrap Field in date() if it's already a DATE (#11502)"
(mt/dataset attempted-murders
(let [query (mt/mbql-query attempts
{:aggregation [[:count]]
:breakout [!day.date]})]
(is (= (str "SELECT ATTEMPTS.DATE AS DATE, count(*) AS count "
"FROM ATTEMPTS "
"GROUP BY ATTEMPTS.DATE "
"ORDER BY ATTEMPTS.DATE ASC")
(some-> (qp/query->native query) :query pretty-sql))))))))
......@@ -2,8 +2,11 @@
(:require [clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[clojure.test :refer :all]
[honeysql.core :as hsql]
[java-time :as t]
[metabase.db.metadata-queries :as metadata-queries]
[metabase.driver :as driver]
[metabase.driver.mysql :as mysql]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.models.database :refer [Database]]
[metabase.models.field :refer [Field]]
......@@ -14,11 +17,11 @@
[metabase.test :as mt]
[metabase.test.data.interface :as tx]
[metabase.util :as u]
[metabase.util.date-2 :as u.date]
[metabase.util.honeysql-extensions :as hx]
[toucan.db :as db]
[toucan.hydrate :refer [hydrate]]
[toucan.util.test :as tt])
(:import [java.time ZonedDateTime ZoneId]
java.time.format.DateTimeFormatter))
[toucan.util.test :as tt]))
(deftest all-zero-dates-test
(mt/test-driver :mysql
......@@ -283,33 +286,46 @@
(map table-fingerprint))))))))))))
(deftest group-on-time-column-test
(let [driver :mysql]
(mt/test-driver driver
(let [db-name "time_test"
spec (sql-jdbc.conn/connection-details->spec :mysql (tx/dbdef->connection-details driver :server nil))]
(doseq [stmt ["DROP DATABASE IF EXISTS time_test;"
"CREATE DATABASE time_test;"]]
(jdbc/execute! spec [stmt]))
(let [details (tx/dbdef->connection-details driver :db {:database-name db-name})
spec (sql-jdbc.conn/connection-details->spec driver details)]
(doseq [stmt ["DROP TABLE IF EXISTS time_table;"
"CREATE TABLE time_table (id serial, mytime time);"
"INSERT INTO time_table (mytime) VALUES ('00:00'), ('00:00'), ('23:01'), ('23:01'), ('18:43');"]]
(jdbc/execute! spec [stmt]))
(mt/with-temp Database [db {:engine driver :details details}]
(sync/sync-database! db)
(mt/with-db db
(testing "can group on TIME columns"
(let [now (ZonedDateTime/now (ZoneId/of "UTC"))
now-date-str (.format now (DateTimeFormatter/ISO_LOCAL_DATE))
add-date-fn (fn [t] [(str now-date-str "T" t)])]
(is (= (map add-date-fn ["00:00:00Z" "18:43:00Z" "23:01:00Z"])
(mt/rows
(mt/run-mbql-query time_table
{:breakout [[:datetime-field (mt/id :time_table :mytime) :minute]]
:order-by [[:asc [:datetime-field (mt/id :time_table :mytime) :minute]]]}))))
(is (= (map add-date-fn ["23:00:00Z" "18:00:00Z" "00:00:00Z"])
(mt/rows
(mt/run-mbql-query time_table
{:breakout [[:datetime-field (mt/id :time_table :mytime) :hour]]
:order-by [[:desc [:datetime-field (mt/id :time_table :mytime) :hour]]]})))))))))))))
(mt/test-driver :mysql
(testing "can group on TIME columns (#12846)"
(mt/dataset attempted-murders
(let [now-date-str (u.date/format (t/local-date (t/zone-id "UTC")))
add-date-fn (fn [t] [(str now-date-str "T" t)])]
(testing "by minute"
(is (= (map add-date-fn ["00:14:00Z" "00:23:00Z" "00:35:00Z"])
(mt/rows
(mt/run-mbql-query attempts
{:breakout [!minute.time]
:order-by [[:asc !minute.time]]
:limit 3})))))
(testing "by hour"
(is (= (map add-date-fn ["23:00:00Z" "20:00:00Z" "19:00:00Z"])
(mt/rows
(mt/run-mbql-query attempts
{:breakout [!hour.time]
:order-by [[:desc !hour.time]]
:limit 3}))))))))))
(defn- pretty-sql [s]
(str/replace s #"`" ""))
(deftest do-not-cast-to-date-if-column-is-already-a-date-test
(testing "Don't wrap Field in date() if it's already a DATE (#11502)"
(mt/test-driver :mysql
(mt/dataset attempted-murders
(let [query (mt/mbql-query attempts
{:aggregation [[:count]]
:breakout [!day.date]})]
(is (= (str "SELECT attempts.date AS date, count(*) AS count "
"FROM attempts "
"GROUP BY attempts.date "
"ORDER BY attempts.date ASC")
(some-> (qp/query->native query) :query pretty-sql))))))
(testing "trunc-with-format should not cast a field if it is already a DATETIME"
(is (= ["SELECT str_to_date(date_format(CAST(field AS datetime), '%Y'), '%Y')"]
(hsql/format {:select [(#'mysql/trunc-with-format "%Y" :field)]})))
(is (= ["SELECT str_to_date(date_format(field, '%Y'), '%Y')"]
(hsql/format {:select [(#'mysql/trunc-with-format
"%Y"
(hx/with-database-type-info :field "datetime"))]}))))))
(ns metabase.driver.postgres-test
"Tests for features/capabilities specific to PostgreSQL driver, such as support for Postgres UUID or enum types."
(:require [clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[clojure.test :refer :all]
[honeysql.core :as hsql]
[metabase.driver :as driver]
......@@ -16,7 +17,8 @@
[metabase.sync.sync-metadata :as sync-metadata]
[metabase.test :as mt]
[metabase.util :as u]
[toucan.db :as db]))
[toucan.db :as db]
[metabase.util.honeysql-extensions :as hx]))
(defn- drop-if-exists-and-create-db!
"Drop a Postgres database named `db-name` if it already exists; then create a new empty one with that name."
......@@ -402,7 +404,7 @@
(deftest enums-test
(mt/test-driver :postgres
(testing "check that values for enum types get wrapped in appropriate CAST() fn calls in `->honeysql`"
(is (= (hsql/call :cast "toucan" (keyword "bird type"))
(is (= (hx/with-database-type-info (hsql/call :cast "toucan" (keyword "bird type")) "bird type")
(sql.qp/->honeysql :postgres [:value "toucan" {:database_type "bird type", :base_type :type/PostgresEnum}]))))
(do-with-enums-db
......@@ -593,3 +595,21 @@
(mt/native-query)
(qp/process-query)
(mt/rows))))))))))
(defn- pretty-sql [s]
(-> s
(str/replace #"\"" "")
(str/replace #"public\." "")))
(deftest do-not-cast-to-date-if-column-is-already-a-date-test
(testing "Don't wrap Field in date() if it's already a DATE (#11502)"
(mt/test-driver :postgres
(mt/dataset attempted-murders
(let [query (mt/mbql-query attempts
{:aggregation [[:count]]
:breakout [!day.date]})]
(is (= (str "SELECT attempts.date AS date, count(*) AS count "
"FROM attempts "
"GROUP BY attempts.date "
"ORDER BY attempts.date ASC")
(some-> (qp/query->native query) :query pretty-sql))))))))
......@@ -476,7 +476,7 @@
(apply assoc {:database (mt/id), :type :native} kvs)))
(deftest e2e-basic-test
(datasets/test-drivers (sql-parameters-engines)
(mt/test-drivers (sql-parameters-engines)
(is (= [29]
(mt/first-row
(mt/format-rows-by [int]
......@@ -491,7 +491,7 @@
:value "2015-04-01~2015-05-01"}])))))))
(deftest e2e-no-parameter-test
(datasets/test-drivers (sql-parameters-engines)
(mt/test-drivers (sql-parameters-engines)
(testing "no parameter — should give us a query with \"WHERE 1 = 1\""
(is (= [1000]
(mt/first-row
......@@ -505,7 +505,7 @@
:parameters []))))))))
(deftest e2e-relative-dates-test
(datasets/test-drivers (sql-parameters-engines)
(mt/test-drivers (sql-parameters-engines)
(testing (str "test that relative dates work correctly. It should be enough to try just one type of relative date "
"here, since handling them gets delegated to the functions in `metabase.query-processor.parameters`, "
"which is fully-tested :D")
......@@ -524,7 +524,7 @@
:value "thismonth"}]))))))))
(deftest e2e-combine-multiple-filters-test
(datasets/test-drivers (sql-parameters-engines)
(mt/test-drivers (sql-parameters-engines)
(testing "test that multiple filters applied to the same variable combine into `AND` clauses (#3539)"
(is (= [4]
(mt/first-row
......@@ -544,7 +544,7 @@
:value "2015-07-01"}]))))))))
(deftest e2e-parse-native-dates-test
(datasets/test-drivers (disj (sql-parameters-engines) :sqlite)
(mt/test-drivers (disj (sql-parameters-engines) :sqlite)
(is (= [(cond
(= driver/*driver* :presto)
"2018-04-18"
......
......@@ -52,40 +52,46 @@
(deftest generate-honeysql-for-join-test
(testing "Test that the correct HoneySQL gets generated for a query with a join, and that the correct identifiers are used"
(mt/with-everything-store
(is (= {: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")]]
(is (= {:select [[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "ID") "bigint")
(id :field-alias "ID")]
[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "NAME") "varchar")
(id :field-alias "NAME")]
[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "CATEGORY_ID") "integer")
(id :field-alias "CATEGORY_ID")]
[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "LATITUDE") "double")
(id :field-alias "LATITUDE")]
[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "LONGITUDE") "double")
(id :field-alias "LONGITUDE")]
[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "PRICE") "integer")
(id :field-alias "PRICE")]]
:from [(id :table "PUBLIC" "VENUES")]
:where [:=
(bound-alias "c" (id :field "c" "NAME"))
(hx/with-database-type-info (bound-alias "c" (id :field "c" "NAME")) "varchar")
"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]]
(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "CATEGORY_ID") "integer")
(hx/with-database-type-info (bound-alias "c" (id :field "c" "ID")) "bigint")]]
:order-by [[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "ID") "bigint") :asc]]
:limit 100}
(#'sql.qp/mbql->honeysql
::id-swap
(mt/mbql-query venues
{:source-table $$venues
:order-by [[:asc $id]]
:filter [:=
&c.categories.name
[:value "BBQ" {:base_type :type/Text, :semantic_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
&c.categories.id]
:fk-field-id (mt/id :venues :category_id)
:fields :none}]})))))))
{:source-table $$venues
:order-by [[:asc $id]]
:filter [:=
&c.categories.name
[:value "BBQ" {:base_type :type/Text, :semantic_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
&c.categories.id]
:fk-field-id (mt/id :venues :category_id)
:fields :none}]})))))))
(deftest correct-identifiers-test
(testing "This HAIRY query tests that the correct identifiers and aliases are used with both a nested query and JOIN in play."
......@@ -94,67 +100,79 @@
;; be qualifying aliases with aliases things still work the right way.
(mt/with-everything-store
(driver/with-driver :h2
(is (= {:select [[(bound-alias "v" (id :field "v" "NAME")) (bound-alias "source" (id :field-alias "v__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")]]
(is (= {:select [[(hx/with-database-type-info (bound-alias "v" (id :field "v" "NAME")) "varchar")
(bound-alias "source" (id :field-alias "v__NAME"))]
[:%count.*
(bound-alias "source" (id :field-alias "count"))]]
:from [[{:select [[(hx/with-database-type-info (id :field "PUBLIC" "CHECKINS" "ID") "bigint")
(id :field-alias "ID")]
[(hx/with-database-type-info (id :field "PUBLIC" "CHECKINS" "DATE") "date")
(id :field-alias "DATE")]
[(hx/with-database-type-info (id :field "PUBLIC" "CHECKINS" "USER_ID") "integer")
(id :field-alias "USER_ID")]
[(hx/with-database-type-info (id :field "PUBLIC" "CHECKINS" "VENUE_ID") "integer")
(id :field-alias "VENUE_ID")]]
:from [(id :table "PUBLIC" "CHECKINS")]
:where [:>
(id :field "PUBLIC" "CHECKINS" "DATE")
(hx/with-database-type-info (id :field "PUBLIC" "CHECKINS" "DATE") "date")
#t "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"))]
(hx/with-database-type-info (bound-alias "source" (id :field "source" "VENUE_ID")) "integer")
(hx/with-database-type-info (bound-alias "v" (id :field "v" "ID")) "bigint")]]
:group-by [(hx/with-database-type-info (bound-alias "v" (id :field "v" "NAME")) "varchar")]
:where [:and
[:like (bound-alias "v" (id :field "v" "NAME")) "F%"]
[:like
(hx/with-database-type-info (bound-alias "v" (id :field "v" "NAME")) "varchar")
"F%"]
[:> (bound-alias "source" (id :field "source" "user_id")) 0]],
:order-by [[(bound-alias "v" (id :field "v" "NAME")) :asc]]}
:order-by [[(hx/with-database-type-info (bound-alias "v" (id :field "v" "NAME")) "varchar")
:asc]]}
(#'sql.qp/mbql->honeysql
::id-swap
(mt/mbql-query checkins
{:source-query {:source-table $$checkins
:fields [$id [:field %date {:temporal-unit :default}] $user_id $venue_id]
:filter [:>
$date
[:absolute-datetime #t "2015-01-01T00:00:00.000000000-00:00" :default]],},
:aggregation [[:count]]
:order-by [[:asc &v.venues.name]]
:breakout [&v.venues.name]
:filter [:and
[:starts-with
&v.venues.name
[:value "F" {:base_type :type/Text, :semantic_type :type/Name, :database_type "VARCHAR"}]]
[:> [:field "user_id" {:base-type :type/Integer}] 0]]
:joins [{:source-table $$venues
:alias "v"
:strategy :left-join
:condition [:=
$venue_id
&v.venues.id]
:fk-field-id (mt/id :checkins :venue_id)
:fields :none}]}))))))))
{:source-query {:source-table $$checkins
:fields [$id [:field %date {:temporal-unit :default}] $user_id $venue_id]
:filter [:>
$date
[:absolute-datetime #t "2015-01-01T00:00:00.000000000-00:00" :default]]}
:aggregation [[:count]]
:order-by [[:asc &v.venues.name]]
:breakout [&v.venues.name]
:filter [:and
[:starts-with
&v.venues.name
[:value "F" {:base_type :type/Text
:semantic_type :type/Name
:database_type "VARCHAR"}]]
[:> [:field "user_id" {:base-type :type/Integer}] 0]]
:joins [{:source-table $$venues
:alias "v"
:strategy :left-join
:condition [:=
$venue_id
&v.venues.id]
:fk-field-id (mt/id :checkins :venue_id)
:fields :none}]}))))))))
(deftest handle-named-aggregations-test
(testing "Check that named aggregations are handled correctly"
(mt/with-everything-store
(driver/with-driver :h2
(is (= {:select [[(id :field "PUBLIC" "VENUES" "PRICE") (id :field-alias "PRICE")]
[(hsql/call :avg (id :field "PUBLIC" "VENUES" "CATEGORY_ID")) (id :field-alias "avg_2")]]
(is (= {:select [[(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "PRICE") "integer")
(id :field-alias "PRICE")]
[(hsql/call :avg (hx/with-database-type-info (id :field "PUBLIC" "VENUES" "CATEGORY_ID") "integer"))
(id :field-alias "avg_2")]]
:from [(id :table "PUBLIC" "VENUES")]
:group-by [(id :field "PUBLIC" "VENUES" "PRICE")]
:group-by [(hx/with-database-type-info (id :field "PUBLIC" "VENUES" "PRICE") "integer")]
:order-by [[(id :field-alias "avg_2") :asc]]}
(#'sql.qp/mbql->honeysql
::id-swap
(mt/mbql-query venues
{:aggregation [[:aggregation-options [:avg $category_id] {:name "avg_2"}]]
:breakout [$price]
:order-by [[:asc [:aggregation 0]]]}))))))))
{:aggregation [[:aggregation-options [:avg $category_id] {:name "avg_2"}]]
:breakout [$price]
:order-by [[:asc [:aggregation 0]]]}))))))))
(deftest handle-source-query-params-test
(testing "params from source queries should get passed in to the top-level. Semicolons should be removed"
......@@ -174,14 +192,14 @@
(is (= [[(sql.qp/->SQLSourceQuery "SELECT * FROM VENUES;" [])
(hx/identifier :table-alias "card")]
[:=
(hx/identifier :field "PUBLIC" "CHECKINS" "VENUE_ID")
(hx/with-database-type-info (hx/identifier :field "PUBLIC" "CHECKINS" "VENUE_ID") "integer")
(hx/identifier :field "id")]]
(sql.qp/join->honeysql :h2
(mt/$ids checkins
{:source-query {:native "SELECT * FROM VENUES;", :params []}
:alias "card"
:strategy :left-join
:condition [:= $venue_id &card.*id/Integer]}))))))))
(mt/$ids checkins
{:source-query {:native "SELECT * FROM VENUES;", :params []}
:alias "card"
:strategy :left-join
:condition [:= $venue_id &card.*id/Integer]}))))))))
(deftest compile-honeysql-test
(testing "make sure the generated HoneySQL will compile to the correct SQL"
......@@ -199,10 +217,11 @@
(driver/with-driver :h2
(with-redefs [driver/db-start-of-week (constantly :monday)
setting/get-keyword (constantly :sunday)]
(is (= (hsql/call :dateadd (hx/literal "day")
(hsql/call :cast -1 #sql/raw "long")
(is (= (hsql/call :dateadd
(hx/literal "day")
(hx/with-database-type-info (hsql/call :cast -1 #sql/raw "long") "long")
(hsql/call :week (hsql/call :dateadd (hx/literal "day")
(hsql/call :cast 1 #sql/raw "long")
(hx/with-database-type-info (hsql/call :cast 1 #sql/raw "long") "long")
:created_at)))
(sql.qp/adjust-start-of-week :h2 (partial hsql/call :week) :created_at))))
(testing "Do we skip the adjustment if offset = 0"
......
......@@ -1085,14 +1085,14 @@
(deftest compile-time-interval-test
(testing "Make sure time-intervals work the way they're supposed to."
(testing "[:time-interval $date -4 :month] should give us something like Oct 01 2020 - Feb 01 2021 if today is Feb 17 2021"
(is (= (str "SELECT CAST(CHECKINS.DATE AS date) AS DATE "
(is (= (str "SELECT CHECKINS.DATE AS DATE "
"FROM CHECKINS "
"WHERE ("
"CHECKINS.DATE >= parsedatetime(formatdatetime(dateadd('month', CAST(-4 AS long), now()), 'yyyyMM'), 'yyyyMM')"
" AND "
"CHECKINS.DATE < parsedatetime(formatdatetime(now(), 'yyyyMM'), 'yyyyMM')) "
"GROUP BY CAST(CHECKINS.DATE AS date) "
"ORDER BY CAST(CHECKINS.DATE AS date) ASC "
"GROUP BY CHECKINS.DATE "
"ORDER BY CHECKINS.DATE ASC "
"LIMIT 1048576")
(sql.qp.test/pretty-sql
(:query
......
......@@ -109,3 +109,87 @@
"division operation. The double itself should get converted to a numeric literal")
(is (= ["SELECT 0.1 AS one_tenth"]
(hsql/format {:select [[(/ 1 10) :one_tenth]]})))))
(deftest maybe-cast-test
(testing "maybe-cast should only cast things that need to be cast"
(letfn [(->sql [expr]
(hsql/format {:select [expr]}))
(maybe-cast [expr]
(->sql (hx/maybe-cast "text" expr)))]
(is (= ["SELECT CAST(field AS text)"]
(maybe-cast :field)))
(testing "cast should return a typed form"
(is (= ["SELECT CAST(field AS text)"]
(maybe-cast (hx/cast "text" :field)))))
(testing "should not cast something that's already typed"
(let [typed-expr (hx/with-type-info :field {::hx/database-type "text"})]
(is (= ["SELECT field"]
(maybe-cast typed-expr)))
(testing "should work with different string/keyword and case combos"
(is (= typed-expr
(hx/maybe-cast :text typed-expr)
(hx/maybe-cast "TEXT" typed-expr)
(hx/maybe-cast :TEXT typed-expr)))))
(testing "multiple calls to maybe-cast should only cast at most once"
(is (= (hx/maybe-cast "text" :field)
(hx/maybe-cast "text" (hx/maybe-cast "text" :field))))
(is (= ["SELECT CAST(field AS text)"]
(maybe-cast (hx/maybe-cast "text" :field)))))))))
(def ^:private typed-form (hx/with-type-info :field {::hx/database-type "text"}))
(deftest TypedHoneySQLForm-test
(testing "should generate readable output"
(is (= (pr-str `(hx/with-type-info :field {::hx/database-type "text"}))
(pr-str typed-form)))))
(deftest type-info-test
(testing "should let you get info"
(is (= {::hx/database-type "text"}
(hx/type-info typed-form)))
(is (= nil
(hx/type-info :field)
(hx/type-info nil)))))
(deftest with-type-info-test
(testing "should let you update info"
(is (= (hx/with-type-info :field {::hx/database-type "date"})
(hx/with-type-info typed-form {::hx/database-type "date"})))
(testing "should normalize :database-type"
(is (= (hx/with-type-info :field {::hx/database-type "date"})
(hx/with-type-info typed-form {::hx/database-type "date"}))))))
(deftest with-database-type-info-test
(testing "should be the same as calling `with-type-info` with `::hx/database-type`"
(is (= (hx/with-type-info :field {::hx/database-type "date"})
(hx/with-database-type-info :field "date"))))
(testing "Passing `nil` should"
(testing "return untyped clause as-is"
(is (= :field
(hx/with-database-type-info :field nil))))
(testing "unwrap a typed clause"
(is (= :field
(hx/with-database-type-info (hx/with-database-type-info :field "date") nil))))))
(deftest is-of-type?-test
(mt/are+ [expr tyype expected] (= expected (hx/is-of-type? expr tyype))
typed-form "text" true
typed-form "TEXT" true
typed-form :text true
typed-form :TEXT true
typed-form :te/xt false
typed-form "date" false
typed-form nil false
nil "date" false
:%current_date "date" false
;; I guess this behavior makes sense? I guess untyped = "is of type nil"
nil nil true
:%current_date nil true))
(deftest unwrap-typed-honeysql-form-test
(testing "should be able to unwrap"
(is (= :field
(hx/unwrap-typed-honeysql-form typed-form)
(hx/unwrap-typed-honeysql-form :field)))
(is (= nil
(hx/unwrap-typed-honeysql-form nil)))))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment