Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Snowflake/Redshift filtering on date for timestamp with time zone columns #41864

Merged
merged 12 commits into from
May 1, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,8 @@
(mt/test-driver :presto-jdbc
(testing "Make sure date params work correctly when report timezones are set (#10487)"
(mt/with-temporary-setting-values [report-timezone "Asia/Hong_Kong"]
;; the `read-column-thunk` for `Types/TIMESTAMP` always returns an `OffsetDateTime`, not a `LocalDateTime`, as
;; the original Presto version of this test expected; therefore, convert the `ZonedDateTime` corresponding to
;; midnight on this date (at the report TZ) to `OffsetDateTime` for comparison's sake
(is (= [[(-> (t/zoned-date-time 2014 8 2 0 0 0 0 (t/zone-id "Asia/Hong_Kong"))
t/offset-date-time
(t/with-offset-same-instant (t/zone-offset 0)))
(t/local-date 2014 8 2)]]
(is (= [[(t/local-date "2014-08-02")
(t/local-date "2014-08-02")]]
(mt/rows
(qp/process-query
{:database (mt/id)
Expand Down
39 changes: 35 additions & 4 deletions modules/drivers/redshift/src/metabase/driver/redshift.clj
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@
[metabase.query-processor.util.relative-datetime :as qp.relative-datetime]
[metabase.upload :as upload]
[metabase.util :as u]
[metabase.util.date-2 :as u.date]
[metabase.util.honey-sql-2 :as h2x]
[metabase.util.log :as log])
(:import
(com.amazon.redshift.util RedshiftInterval)
(java.sql Connection PreparedStatement ResultSet ResultSetMetaData Types)
(java.time OffsetTime)))
(java.sql Connection PreparedStatement ResultSet ResultSetMetaData Types)))

(set! *warn-on-reflection* true)

Expand Down Expand Up @@ -315,6 +315,35 @@
[driver [_ amount unit]]
(qp.relative-datetime/maybe-cacheable-relative-datetime-honeysql driver unit amount))

(defmethod sql.qp/->honeysql [:redshift java.time.LocalDate]
[_driver t]
(-> [:raw (format "date '%s'" (u.date/format t))]
(h2x/with-database-type-info "date")))

(defmethod sql.qp/->honeysql [:redshift java.time.LocalTime]
[_driver t]
(-> [:raw (format "time '%s'" (u.date/format "HH:mm:ss.SSS" t))]
(h2x/with-database-type-info "time")))

(defmethod sql.qp/->honeysql [:redshift java.time.OffsetTime]
[_driver t]
(-> [:raw (format "time with time zone '%s'" (u.date/format "HH:mm:ss.SSS xxx" t))]
(h2x/with-database-type-info "timetz")))

(defmethod sql.qp/->honeysql [:redshift java.time.LocalDateTime]
[_driver t]
(-> [:raw (format "timestamp '%s'" (u.date/format "yyyy-MM-dd HH:mm:ss.SSS" t))]
(h2x/with-database-type-info "timestamp")))

(defmethod sql.qp/->honeysql [:redshift java.time.OffsetDateTime]
[_driver t]
(-> [:raw (format "timestamp with time zone '%s'" (u.date/format "yyyy-MM-dd HH:mm:ss.SSS xxx" t))]
(h2x/with-database-type-info "timestamptz")))

(defmethod sql.qp/->honeysql [:redshift java.time.ZonedDateTime]
[driver t]
(sql.qp/->honeysql driver (t/offset-date-time t)))

(defmethod sql.qp/datetime-diff [:redshift :year]
[driver _unit x y]
(h2x// (sql.qp/datetime-diff driver :month x y) 12))
Expand Down Expand Up @@ -395,10 +424,12 @@
[::sql-jdbc.legacy/use-legacy-classes-for-read-and-set Types/TIME]
[:postgres Types/TIME])

;;; I don't think this should actually ever get called because we should be compiling an `OffsetTime` as a `timetz`
;;; literal
(prefer-method
sql-jdbc.execute/set-parameter
[::sql-jdbc.legacy/use-legacy-classes-for-read-and-set OffsetTime]
[:postgres OffsetTime])
[::sql-jdbc.legacy/use-legacy-classes-for-read-and-set java.time.OffsetTime]
[:postgres java.time.OffsetTime])

(defn- field->parameter-value
"Map fields used in parameters to parameter `:value`s."
Expand Down
169 changes: 96 additions & 73 deletions modules/drivers/snowflake/src/metabase/driver/snowflake.clj
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
[metabase.driver.sql-jdbc.sync.describe-table :as sql-jdbc.describe-table]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.driver.sql.util :as sql.u]
[metabase.driver.sql.util.unprepare :as unprepare]
[metabase.driver.sync :as driver.s]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema.temporal-bucketing :as lib.schema.temporal-bucketing]
[metabase.models.secret :as secret]
[metabase.public-settings :as public-settings]
[metabase.query-processor.error-type :as qp.error-type]
Expand All @@ -42,7 +42,7 @@
(:import
(java.io File)
(java.sql Connection DatabaseMetaData ResultSet Types)
(java.time OffsetDateTime OffsetTime ZonedDateTime)
(java.time LocalDate LocalDateTime LocalTime OffsetDateTime OffsetTime ZonedDateTime)
(net.snowflake.client.jdbc SnowflakeSQLException)))

(set! *warn-on-reflection* true)
Expand Down Expand Up @@ -197,52 +197,83 @@
:DATETIME :type/DateTime
:TIME :type/Time
:TIMESTAMP :type/DateTime
;; This is a weird one. A timestamp with local time zone, stored without time zone but treated as being in the
;; Session time zone for filtering purposes etc.
:TIMESTAMPLTZ :type/DateTime
;; timestamp with no time zone
:TIMESTAMPNTZ :type/DateTime
:TIMESTAMPTZ :type/DateTimeWithTZ
;; timestamp with time zone normalized to UTC, similar to Postgres
:TIMESTAMPTZ :type/DateTimeWithLocalTZ
:VARIANT :type/*
;; Maybe also type *
:OBJECT :type/Dictionary
:ARRAY :type/*} base-type))

(defmethod sql.qp/unix-timestamp->honeysql [:snowflake :seconds] [_ _ expr] [:to_timestamp expr])
(defmethod sql.qp/unix-timestamp->honeysql [:snowflake :milliseconds] [_ _ expr] [:to_timestamp expr 3])
(defmethod sql.qp/unix-timestamp->honeysql [:snowflake :microseconds] [_ _ expr] [:to_timestamp expr 6])
(defmethod sql.qp/unix-timestamp->honeysql [:snowflake :seconds] [_ _ expr] [:to_timestamp_tz expr])
(defmethod sql.qp/unix-timestamp->honeysql [:snowflake :milliseconds] [_ _ expr] [:to_timestamp_tz expr 3])
(defmethod sql.qp/unix-timestamp->honeysql [:snowflake :microseconds] [_ _ expr] [:to_timestamp_tz expr 6])

(defmethod sql.qp/add-interval-honeysql-form :snowflake
[_ hsql-form amount unit]
[:dateadd
[:raw (name unit)]
[:raw (int amount)]
(h2x/->timestamp hsql-form)])
[_driver hsql-form amount unit]
;; return type is always the same as expr type, unless expr is a DATE and you're adding something not in a DATE e.g.
;; `:seconds`, in which case it returns `timestamp_ntz`. See
;; https://docs.snowflake.com/en/sql-reference/functions/dateadd
(let [db-type (h2x/database-type hsql-form)
return-type (if (and (= db-type "date")
(not (contains? lib.schema.temporal-bucketing/date-bucketing-units unit)))
"timestamp_ntz"
db-type)]
(-> [:dateadd
[:raw (name unit)]
[:inline (int amount)]
hsql-form]
(h2x/with-database-type-info return-type))))

(defn- in-report-timezone
"Convert timestamps with time zones (`timestamp_tz`) to timestamps that should be interpreted in the session
timezone (`timestamp_ltz`) so various datetime extraction and truncation operations work as expected."
[expr]
(let [report-timezone (qp.timezone/report-timezone-id-if-supported)]
(if (and report-timezone
(= (h2x/database-type expr) "timestamptz"))
[:to_timestamp_ltz expr]
expr)))

(defn- extract
[unit expr]
(-> [:date_part unit (h2x/->timestamp expr)]
(-> [:date_part (h2x/literal unit) (in-report-timezone expr)]
(h2x/with-database-type-info "integer")))

(defn- date-trunc
[unit expr]
(let [acceptable-types (case unit
(:millisecond :second :minute :hour) #{"time" "timestamp"}
(:day :week :month :quarter :year) #{"date" "timestamp"})
expr (h2x/cast-unless-type-in "timestamp" acceptable-types expr)]
(-> [:date_trunc unit expr]
(:millisecond :second :minute :hour) #{"time" "timestampltz" "timestampntz" "timestamptz"}
(:day :week :month :quarter :year) #{"date" "timestampltz" "timestampntz" "timestamptz"})
expr (h2x/cast-unless-type-in "timestampntz" acceptable-types expr)]
(-> [:date_trunc (h2x/literal unit) (in-report-timezone expr)]
(h2x/with-database-type-info (h2x/database-type expr)))))

(defmethod sql.qp/date [:snowflake :default] [_ _ expr] expr)
(defmethod sql.qp/date [:snowflake :minute] [_ _ expr] (date-trunc :minute expr))
(defmethod sql.qp/date [:snowflake :minute-of-hour] [_ _ expr] (extract :minute expr))
(defmethod sql.qp/date [:snowflake :hour] [_ _ expr] (date-trunc :hour expr))
(defmethod sql.qp/date [:snowflake :hour-of-day] [_ _ expr] (extract :hour expr))
(defmethod sql.qp/date [:snowflake :day] [_ _ expr] (date-trunc :day expr))
(defmethod sql.qp/date [:snowflake :day-of-month] [_ _ expr] (extract :day expr))
(defmethod sql.qp/date [:snowflake :day-of-year] [_ _ expr] (extract :dayofyear expr))
(defmethod sql.qp/date [:snowflake :month] [_ _ expr] (date-trunc :month expr))
(defmethod sql.qp/date [:snowflake :month-of-year] [_ _ expr] (extract :month expr))
(defmethod sql.qp/date [:snowflake :quarter] [_ _ expr] (date-trunc :quarter expr))
(defmethod sql.qp/date [:snowflake :quarter-of-year] [_ _ expr] (extract :quarter expr))
(defmethod sql.qp/date [:snowflake :year] [_ _ expr] (date-trunc :year expr))
(defmethod sql.qp/date [:snowflake :year-of-era] [_ _ expr] (extract :year expr))

(defmethod sql.qp/date [:snowflake :day]
[_driver _unit expr]
(if (= (h2x/database-type expr) "date")
expr
(date-trunc :day expr))
(-> [:to_date (date-trunc :day expr)]
(h2x/with-database-type-info "date")))

;; these don't need to be adjusted for start of week, since we're Setting the WEEK_START connection parameter
(defmethod sql.qp/date [:snowflake :week]
Expand All @@ -259,7 +290,10 @@
;; we don't support it at the moment because the implementation in (defmethod date [:sql :week-of-year-us])
;; relies on the ability to dynamicall change `start-of-week` setting, but with snowflake we set the
;; start-of-week in connection session instead of manipulate in MBQL
(throw (ex-info (tru "sqlite doesn't support extract us week")
;;
;; TODO -- what about the `weekofyear()` or `week()` functions in Snowflake? Would that do what we want?
;; https://docs.snowflake.com/en/sql-reference/functions/year
(throw (ex-info (tru "Snowflake doesn''t support extract us week")
{:driver driver
:form expr
:type qp.error-type/invalid-query})))
Expand Down Expand Up @@ -383,6 +417,8 @@
(cond-> identifier
qualify? qualify-identifier)))

;;; TODO -- I don't think these actually ever get qualified since the parent method returns things wrapped
;;; in [[h2x/with-database-type-info]] thus nothing will ever be an identifier.
(defmethod sql.qp/->honeysql [:snowflake :field]
[driver [_ _ {::add/keys [source-table]} :as field-clause]]
(let [parent-method (get-method sql.qp/->honeysql [:sql :field])
Expand Down Expand Up @@ -416,6 +452,35 @@
[driver [_ amount unit]]
(qp.relative-datetime/maybe-cacheable-relative-datetime-honeysql driver unit amount))

(defmethod sql.qp/->honeysql [:snowflake LocalDate]
[_driver t]
(-> [:raw (format "'%s'::date" (u.date/format t))]
(h2x/with-database-type-info "date")))

(defmethod sql.qp/->honeysql [:snowflake LocalTime]
[_driver t]
(-> [:raw (format "'%s'::time" (u.date/format "HH:mm:ss.SSS" t))]
(h2x/with-database-type-info "time")))

;;; Snowflake doesn't have `timetz`, so just convert to an equivalent local time.
(defmethod sql.qp/->honeysql [:snowflake OffsetTime]
[driver t]
(sql.qp/->honeysql driver (t/local-time (t/with-offset-same-instant t (t/zone-offset 0)))))

(defmethod sql.qp/->honeysql [:snowflake LocalDateTime]
[_driver t]
(-> [:raw (format "'%s'::timestamp_ntz" (u.date/format "yyyy-MM-dd HH:mm:ss.SSS" t))]
(h2x/with-database-type-info "timestampntz")))

(defmethod sql.qp/->honeysql [:snowflake OffsetDateTime]
[_driver t]
(-> [:raw (format "'%s'::timestamp_tz" (u.date/format "yyyy-MM-dd HH:mm:ss.SSS xx" t))]
(h2x/with-database-type-info "timestamptz")))

(defmethod sql.qp/->honeysql [:snowflake ZonedDateTime]
[driver t]
(sql.qp/->honeysql driver (t/offset-date-time t)))

(defmethod driver/table-rows-seq :snowflake
[driver database table]
(sql-jdbc/query driver database {:select [:*]
Expand Down Expand Up @@ -579,65 +644,23 @@
(m/dissoc-in [:details :regionid]))
database))

(defmethod unprepare/unprepare-value [:snowflake OffsetDateTime]
[_ t]
(format "'%s %s %s'::timestamp_tz" (t/local-date t) (t/local-time t) (t/zone-offset t)))

(defmethod unprepare/unprepare-value [:snowflake ZonedDateTime]
[driver t]
(unprepare/unprepare-value driver (t/offset-date-time t)))

(defmethod unprepare/unprepare-value [:snowflake OffsetTime]
[driver t]
(unprepare/unprepare-value driver (t/local-time (t/with-offset-same-instant t (t/zone-offset 0)))))
Comment on lines -582 to -592
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer needed since we don't parameterize these anymore.


;; Like Vertica, Snowflake doesn't seem to be able to return a LocalTime/OffsetTime like everyone else, but it can
;; return a String that we can parse

;;; If you try to read a Snowflake `timestamptz` as a String with `.getString` it always comes back in
;;; `America/Los_Angeles` for some reason I cannot figure out. Let's just read them out as UTC, which is what they're
;;; stored as internally anyway, and let the format-rows middleware adjust the timezone as needed
(defmethod sql-jdbc.execute/read-column-thunk [:snowflake Types/TIMESTAMP_WITH_TIMEZONE]
[_ ^ResultSet rs _ ^Integer i]
(fn []
(when-let [s (.getString rs i)]
(let [t (u.date/parse s)]
(log/tracef "(.getString rs %d) [TIMESTAMP_WITH_TIMEZONE] -> %s -> %s" i (pr-str s) (pr-str t))
t))))

(defmethod sql-jdbc.execute/read-column-thunk [:snowflake Types/TIME]
[_ ^ResultSet rs _ ^Integer i]
(fn []
(when-let [s (.getString rs i)]
(let [t (u.date/parse s)]
(log/tracef "(.getString rs %d) [TIME] -> %s -> %s" i (pr-str s) (pr-str t))
t))))

(defmethod sql-jdbc.execute/read-column-thunk [:snowflake Types/TIME_WITH_TIMEZONE]
[_ ^ResultSet rs _ ^Integer i]
(fn []
(when-let [s (.getString rs i)]
(let [t (u.date/parse s)]
(log/tracef "(.getString rs %d) [TIME_WITH_TIMEZONE] -> %s -> %s" i (pr-str s) (pr-str t))
t))))

;; TODO ­ would it make more sense to use functions like `timestamp_tz_from_parts` directly instead of JDBC parameters?

;; Snowflake seems to ignore the calendar parameter of `.setTime` and `.setTimestamp` and instead uses the session
;; timezone; normalize temporal values to UTC so we end up with the right values
(defmethod sql-jdbc.execute/set-parameter [::use-legacy-classes-for-read-and-set java.time.OffsetTime]
[driver ps i t]
(sql-jdbc.execute/set-parameter driver ps i (t/sql-time (t/with-offset-same-instant t (t/zone-offset 0)))))

(defmethod sql-jdbc.execute/set-parameter [::use-legacy-classes-for-read-and-set java.time.OffsetDateTime]
[driver ps i t]
(sql-jdbc.execute/set-parameter driver ps i (t/sql-timestamp (t/with-offset-same-instant t (t/zone-offset 0)))))

(defmethod sql-jdbc.execute/set-parameter [:snowflake java.time.ZonedDateTime]
[driver ps i t]
(sql-jdbc.execute/set-parameter driver ps i (t/sql-timestamp (t/with-zone-same-instant t (t/zone-id "UTC")))))
Comment on lines -621 to -635
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer needed since we're generating SQL literals instead

[_driver ^ResultSet rs _rsmeta ^Integer i]
;; if we don't explicitly specify the Calendar then it looks like it defaults to the system timezone, we don't really
;; want that now do we.
(let [utc-calendar (java.util.Calendar/getInstance (java.util.TimeZone/getTimeZone "UTC"))]
(fn []
(some-> (.getTimestamp rs i utc-calendar)
t/instant
(t/offset-date-time (t/zone-offset 0))))))

;;; --------------------------------------------------- Query remarks ---------------------------------------------------

; Snowflake strips comments prepended to the SQL statement (default remark injection behavior). We should append the
; remark instead.
;; Snowflake strips comments prepended to the SQL statement (default remark injection behavior). We should append the
;; remark instead.
(defmethod sql-jdbc.execute/inject-remark :snowflake
[_ sql remark]
(str sql "\n\n-- " remark))
Expand Down