From 919d02ae027de3b8e466f455a795d3a4e65fcc82 Mon Sep 17 00:00:00 2001 From: Case Nelson <case@metabase.com> Date: Wed, 8 Feb 2023 19:29:47 -0700 Subject: [PATCH] Adding support for timezone awareness to mongo (#27937) * Adding support for timezone awareness to mongo * Remove unused ddl multimethod * Remove mongo from the sql driver type test * Fix tests and linters * Add test for filtering datetimes by date in timezone * Use test-data-with-timezones dataset as it should load in more dbs * Exclude broken drivers, vertica especially returns no rows * Address pr review, add timezone to isodate, include a full year of dates in the general timezone test * Assert against each row * Presto-jdbc year was not applying timezone --- .../mongo/src/metabase/driver/mongo.clj | 1 + .../metabase/driver/mongo/query_processor.clj | 35 ++--- .../src/metabase/driver/presto_jdbc.clj | 4 + src/metabase/driver/ddl/interface.clj | 7 - src/metabase/driver/postgres.clj | 18 --- .../query_processor_test/timezones_test.clj | 123 +++++++++++++++--- 6 files changed, 127 insertions(+), 61 deletions(-) diff --git a/modules/drivers/mongo/src/metabase/driver/mongo.clj b/modules/drivers/mongo/src/metabase/driver/mongo.clj index 408e5039f34..f7c8c139f3b 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo.clj @@ -233,6 +233,7 @@ :nested-fields :nested-queries :native-parameters + :set-timezone :standard-deviation-aggregations]] (defmethod driver/supports? [:mongo feature] [_driver _feature] true)) diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj index 2ff36336128..e2ca69bc12f 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj @@ -210,7 +210,7 @@ (defn- day-of-week [column] - (mongo-let [day_of_week (add-start-of-week-offset {$dayOfWeek column} + (mongo-let [day_of_week (add-start-of-week-offset {$dayOfWeek {:date column :timezone (qp.timezone/results-timezone-id)}} (driver.common/start-of-week-offset :mongo))] {$cond {:if {$eq [day_of_week 0]} :then 7 @@ -250,6 +250,10 @@ (days-till-start-of-first-full-week column))] {:$toInt {:$add [1 {:$ceil {:$divide [{:$subtract [doy dtsofw]} 7]}}]}})) +(defn- extract + [op column] + {op {:date column :timezone (qp.timezone/results-timezone-id)}}) + (defn- with-rvalue-temporal-bucketing [field unit] (if (= unit :default) @@ -267,15 +271,15 @@ (truncate-to-resolution column unit)))] (case unit :default column - :second-of-minute {$second column} + :second-of-minute (extract $second column) :minute (truncate :minute) - :minute-of-hour {$minute column} + :minute-of-hour (extract $minute column) :hour (truncate :hour) - :hour-of-day {$hour column} + :hour-of-day (extract $hour column) :day (truncate :day) :day-of-week (day-of-week column) - :day-of-month {$dayOfMonth column} - :day-of-year {$dayOfYear column} + :day-of-month (extract $dayOfMonth column) + :day-of-year (extract $dayOfYear column) :week (if supports-dateTrunc? (truncate :week) (truncate-to-resolution (week column) :day)) @@ -284,31 +288,32 @@ (week column))] {:$ceil {$divide [{$dayOfYear week-start} 7.0]}}) - :week-of-year-iso {:$isoWeek column} + :week-of-year-iso (extract :$isoWeek column) :week-of-year-us (week-of-year column :us) :week-of-year-instance (week-of-year column :instance) :month (truncate :month) - :month-of-year {$month column} + :month-of-year (extract $month column) ;; For quarter we'll just subtract enough days from the current date to put it in the correct month and ;; stringify it as yyyy-MM Subtracting (($dayOfYear(column) % 91) - 3) days will put you in correct month. ;; Trust me. :quarter (if supports-dateTrunc? (truncate :quarter) - (mongo-let [#_{:clj-kondo/ignore [:unused-binding]} parts {:$dateToParts {:date column}}] - {:$dateFromParts {:year :$$parts.year - :month {$subtract [:$$parts.month - {$mod [{$add [:$$parts.month 2]} - 3]}]}}})) + (mongo-let [#_{:clj-kondo/ignore [:unused-binding]} parts {:$dateToParts {:date column :timezone (qp.timezone/results-timezone-id)}}] + {:$dateFromParts {:year :$$parts.year + :month {$subtract [:$$parts.month + {$mod [{$add [:$$parts.month 2]} + 3]}]} + :timezone (qp.timezone/results-timezone-id)}})) :quarter-of-year - {:$ceil {$divide [{$month column} 3.0]}} + {:$toInt {:$ceil {$divide [(extract $month column) 3.0]}}} :year (truncate :year) :year-of-era - {$year column}))))) + (extract $year column)))))) (defmethod ->rvalue :field [[_ id-or-name {:keys [temporal-unit] ::add/keys [source-alias]}]] diff --git a/modules/drivers/presto-jdbc/src/metabase/driver/presto_jdbc.clj b/modules/drivers/presto-jdbc/src/metabase/driver/presto_jdbc.clj index cd96878423a..c859b6e7d87 100644 --- a/modules/drivers/presto-jdbc/src/metabase/driver/presto_jdbc.clj +++ b/modules/drivers/presto-jdbc/src/metabase/driver/presto_jdbc.clj @@ -151,6 +151,10 @@ [_ _ expr] (hx/call :date_trunc (hx/literal :year) (in-report-zone expr))) +(defmethod sql.qp/date [:presto-jdbc :year-of-era] + [_ _ expr] + (hx/call :year (in-report-zone expr))) + (defmethod sql.qp/unix-timestamp->honeysql [:presto-jdbc :seconds] [_ _ expr] (let [report-zone (qp.timezone/report-timezone-id-if-supported :presto-jdbc)] diff --git a/src/metabase/driver/ddl/interface.clj b/src/metabase/driver/ddl/interface.clj index a419d1da3c2..c8b57994cb9 100644 --- a/src/metabase/driver/ddl/interface.clj +++ b/src/metabase/driver/ddl/interface.clj @@ -27,13 +27,6 @@ (defmethod format-name :default [_ table-or-field-name] table-or-field-name) -(defmulti field-base-type->sql-type - "A suitable db type for a base-type per database." - {:arglists '([driver base-type])} - (fn [driver base-type] - [(driver/dispatch-on-initialized-driver driver) (keyword base-type)]) - :hierarchy #'driver/hierarchy) - (defmulti check-can-persist "Verify that the source database is acceptable to persist. Returns a tuple of a boolean and `:persist.check/valid` in the event it was successful or a keyword indicating the reason for failure. diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index a8333e240a7..a2271c629c2 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -11,7 +11,6 @@ [metabase.db.spec :as mdb.spec] [metabase.driver :as driver] [metabase.driver.common :as driver.common] - [metabase.driver.ddl.interface :as ddl.i] [metabase.driver.postgres.actions :as postgres.actions] [metabase.driver.postgres.ddl :as postgres.ddl] [metabase.driver.sql-jdbc.common :as sql-jdbc.common] @@ -550,23 +549,6 @@ (keyword "timestamp with time zone") :type/DateTimeWithTZ (keyword "timestamp without time zone") :type/DateTime}) -(doseq [[base-type db-type] {:type/BigInteger "BIGINT" - :type/Boolean "BOOL" - :type/Date "DATE" - :type/DateTime "TIMESTAMP" - :type/DateTimeWithTZ "TIMESTAMP WITH TIME ZONE" - :type/DateTimeWithLocalTZ "TIMESTAMP WITH TIME ZONE" - :type/Decimal "DECIMAL" - :type/Float "FLOAT" - :type/Integer "INTEGER" - :type/IPAddress "INET" - :type/Text "TEXT" - :type/Time "TIME" - :type/TimeWithTZ "TIME WITH TIME ZONE" - :type/UUID "UUID"}] - ;; todo: we get DB types in the metadata, let's persist these in model metadata - (defmethod ddl.i/field-base-type->sql-type [:postgres base-type] [_ _] db-type)) - (defmethod sql-jdbc.sync/database-type->base-type :postgres [_driver column] (if (contains? *enum-types* column) diff --git a/test/metabase/query_processor_test/timezones_test.clj b/test/metabase/query_processor_test/timezones_test.clj index 9a0a734b03f..f635aceabe9 100644 --- a/test/metabase/query_processor_test/timezones_test.clj +++ b/test/metabase/query_processor_test/timezones_test.clj @@ -1,6 +1,7 @@ (ns metabase.query-processor-test.timezones-test (:require [clojure.set :as set] + [clojure.string :as str] [clojure.test :refer :all] [java-time :as t] [metabase.driver :as driver] @@ -9,6 +10,7 @@ [metabase.query-processor :as qp] [metabase.test :as mt] [metabase.test.data.sql :as sql.tx] + [metabase.util.date-2 :as u.date] [metabase.util.honeysql-extensions :as hx] [toucan.db :as db])) @@ -38,7 +40,7 @@ (defn timezone-aware-column-drivers "Drivers that support the equivalent of `TIMESTAMP WITH TIME ZONE` columns." [] - (conj (set-timezone-drivers) :h2 :bigquery-cloud-sdk :sqlserver :mongo)) + (conj (set-timezone-drivers) :h2 :bigquery-cloud-sdk :sqlserver)) ;; TODO - we should also do similar tests for timezone-unaware columns (deftest result-rows-test @@ -164,26 +166,26 @@ :target ["dimension" ["template-tag" "just_a_date"]] :value "2014-08-02"}]}}) -(deftest native-params-filter-test +(deftest native-sql-params-filter-test ;; parameters always get `date` bucketing so doing something the between stuff we do below is basically just going ;; to match anything with a `2014-08-02` date - (mt/test-drivers (set-timezone-drivers) - (when (driver/supports? driver/*driver* :native-parameters) - (mt/dataset test-data-with-timezones - (mt/with-temporary-setting-values [report-timezone "America/Los_Angeles"] - (testing "Native dates should be parsed with the report timezone" - (doseq [[params-description query] (native-params-queries)] - (testing (format "Query with %s" params-description) - (is (= [[6 "Shad Ferdynand" "2014-08-02T05:30:00-07:00"] - [7 "Conchúr Tihomir" "2014-08-02T02:30:00-07:00"]] - (mt/formatted-rows [int identity identity] - (qp/process-query - (merge - {:database (mt/id) - :type :native} - query))))))))))))) - - + (mt/test-drivers (filter + #(isa? driver/hierarchy % :sql) + (set/intersection (set-timezone-drivers) + (mt/normal-drivers-with-feature :native-parameters))) + (mt/dataset test-data-with-timezones + (mt/with-temporary-setting-values [report-timezone "America/Los_Angeles"] + (testing "Native dates should be parsed with the report timezone" + (doseq [[params-description query] (native-params-queries)] + (testing (format "Query with %s" params-description) + (is (= [[6 "Shad Ferdynand" "2014-08-02T05:30:00-07:00"] + [7 "Conchúr Tihomir" "2014-08-02T02:30:00-07:00"]] + (mt/formatted-rows [int identity identity] + (qp/process-query + (merge + {:database (mt/id) + :type :native} + query)))))))))))) ;; Make sure TIME values are handled consistently (#10366) (defn- attempts [] @@ -223,13 +225,92 @@ (when (supports-datetime-with-zone-id?) {:datetime_tz_id (t/zoned-date-time "2019-11-01T00:23:18.331-07:00[America/Los_Angeles]")}))) -(deftest time-timezone-handling-test +(deftest sql-time-timezone-handling-test ;; Actual value : "2019-11-01T00:23:18.331-07:00[America/Los_Angeles]" ;; Oracle doesn't have a time type - (mt/test-drivers (set-timezone-drivers) + (mt/test-drivers (filter #(isa? driver/hierarchy % :sql) (set-timezone-drivers)) (mt/dataset attempted-murders (doseq [timezone [nil "US/Pacific" "US/Eastern" "Asia/Hong_Kong"]] (mt/with-temporary-setting-values [report-timezone timezone] (let [expected (expected-attempts) actual (select-keys (attempts) (keys expected))] (is (= expected actual)))))))) + +(mt/defdataset all-dates-leap-year + (let [start-date #t "2012-01-01T01:30:54Z"] + [["alldates" [{:field-name "dt" + :base-type :type/DateTimeWithTZ}] + (for [i (range 366)] + [(u.date/add start-date :day i)])]])) + +(deftest general-timezone-support-test + (mt/dataset all-dates-leap-year + (mt/test-drivers (set-timezone-drivers) + (let [extract-units (disj u.date/extract-units :day-of-year) + ;; :week-of-year-instance is the behavior of u.date/extract (based on public-settings start-of-week) + extract-translate {:year :year-of-era :week-of-year :week-of-year-us} + trunc-units (disj u.date/truncate-units :millisecond :second)] + (doseq [timezone ["Pacific/Honolulu" "America/Los_Angeles" "UTC" "Pacific/Auckland"] + :let [expected-rows (for [i (range 366) + :let [expected-datetime (u.date/add #t "2012-01-01T01:30:54Z" :day i) + in-tz (u.date/with-time-zone-same-instant expected-datetime timezone)]] + (concat + (for [extract-unit extract-units] + [extract-unit (u.date/extract in-tz extract-unit)]) + (for [trunc-unit trunc-units] + [trunc-unit + (-> in-tz + (u.date/truncate trunc-unit) + u.date/format-sql + (str/replace #" " "T"))]) + [[:dt_tz + (-> in-tz + u.date/format-sql + (str/replace #" " "T"))]]))]] + (mt/with-temporary-setting-values [report-timezone timezone] + (let [rows (->> (mt/run-mbql-query alldates + {:expressions (->> extract-units + (map + (fn [extract-unit] + [extract-unit [:temporal-extract + [:field (mt/id :alldates :dt) nil] + (get extract-translate extract-unit extract-unit)]])) + (into {})) + :fields (concat + (for [extract-unit extract-units] + [:expression extract-unit]) + (for [trunc-unit trunc-units] + [:field (mt/id :alldates :dt) + {:temporal-unit trunc-unit}]) + [[:field (mt/id :alldates :dt)]]) + :order-by [[:asc (mt/id :alldates :id)]]}) + (mt/rows) + (map (fn [row] + (map vector + (concat + (for [extract-unit extract-units] + extract-unit) + (for [trunc-unit trunc-units] + trunc-unit) + [:dt_tz]) + row))))] + (doseq [[expected-row row] (map vector expected-rows rows)] + (is (= expected-row row)))))))))) + +(deftest filter-datetime-by-date-in-timezone-test + (mt/test-drivers (set-timezone-drivers) + (mt/dataset test-data-with-timezones + (doseq [[timezone date-filter] [["US/Pacific" "2014-07-02"] + ["US/Eastern" "2014-07-02"] + ["UTC" "2014-07-03"] + ["Asia/Hong_Kong" "2014-07-03"]] + :let [expected (-> (u.date/with-time-zone-same-instant #t "2014-07-03T01:30:00Z" timezone) + (u.date/format-sql) + (str/replace #" " "T"))]] + (mt/with-temporary-setting-values [report-timezone timezone] + (is (= [expected] + (mt/first-row + (mt/run-mbql-query users + {:fields [$last_login] + :filter [:and [:= $id 12] + [:= $last_login date-filter]]}))))))))) -- GitLab