Middleware that formats the results of a query. Currently, the only thing this does is convert datetime types to ISO-8601 strings in the appropriate timezone.

(ns metabase.query-processor.middleware.format-rows
  (:require
   [java-time.api :as t]
   [metabase.query-processor.timezone :as qp.timezone]
   [metabase.util.date-2 :as u.date]
   [metabase.util.log :as log]
   [metabase.util.performance :as perf]
   [potemkin.types :as p.types])
  (:import
   (java.time Instant LocalDate LocalDateTime LocalTime OffsetDateTime OffsetTime ZonedDateTime ZoneId)))

Protocol for determining how QP results of various classes are serialized. Drivers can add implementations to support custom driver types as needed.

(p.types/defprotocol+ ^:private FormatValue
  (format-value [v ^ZoneId timezone-id]
    "Serialize a value in the QP results. You can add impementations for driver-specific types as needed."))
(extend-protocol FormatValue
  nil
  (format-value [_ _]
    nil)

  Object
  (format-value [v _]
    v)

  LocalTime
  (format-value [t timezone-id]
    (t/format :iso-offset-time (u.date/with-time-zone-same-instant t timezone-id)))

  OffsetTime
  (format-value [t timezone-id]
    (t/format :iso-offset-time (u.date/with-time-zone-same-instant t timezone-id)))

  LocalDate
  (format-value [t timezone-id]
    (t/format :iso-offset-date-time (u.date/with-time-zone-same-instant t timezone-id)))

  LocalDateTime
  (format-value [t timezone-id]
    (t/format :iso-offset-date-time (u.date/with-time-zone-same-instant t timezone-id)))

  ;; convert to a ZonedDateTime
  Instant
  (format-value [t timezone-id]
    (format-value (t/zoned-date-time t (t/zone-id "UTC")) timezone-id))

  OffsetDateTime
  (format-value [t, ^ZoneId timezone-id]
    (t/format :iso-offset-date-time (u.date/with-time-zone-same-instant t timezone-id)))

  ZonedDateTime
  (format-value [t timezone-id]
    (t/format :iso-offset-date-time (u.date/with-time-zone-same-instant t timezone-id))))
(defn- format-rows-xform [rf metadata]
  {:pre [(fn? rf)]}
  (log/debugf "Formatting rows with results timezone ID %s" (qp.timezone/results-timezone-id))
  (let [timezone-id  (t/zone-id (qp.timezone/results-timezone-id))
        ;; a column will have `converted_timezone` metadata if it is the result of `convert-timezone` expression
        ;; in that case, we'll format the results with the target timezone.
        ;; Otherwise format it with results-timezone
        cols-zone-id (perf/mapv #(t/zone-id (get % :converted_timezone timezone-id)) (:cols metadata))]
    (fn
      ([]
       (rf))
      ([result]
       (rf result))
      ([result row]
       (rf result (perf/mapv format-value row cols-zone-id))))))

Format individual query result values as needed. Ex: format temporal values as ISO-8601 strings w/ timezone offset.

(defn format-rows
  [{{:keys [format-rows?] :or {format-rows? true}} :middleware, :as _query} rff]
  (fn format-rows-rff* [metadata]
    ;; always assoc `:format-rows?` into the metadata so that
    ;; the `qp.si/streaming-results-writer` implmementations can apply/not-apply formatting based on the key's value
    (let [metadata (assoc metadata :format-rows? format-rows?)]
      (if format-rows?
        (format-rows-xform (rff metadata) metadata)
        (rff metadata)))))