(ns metabase.util.date-2.parse (:require [clojure.string :as str] [java-time.api :as t] [metabase.util.date-2.common :as u.date.common] [metabase.util.date-2.parse.builder :as b] [metabase.util.i18n :refer [tru]] [metabase.util.malli :as mu]) (:import (java.time Instant LocalDateTime OffsetDateTime OffsetTime ZonedDateTime ZoneOffset) (java.time.format DateTimeFormatter DateTimeParseException) (java.time.temporal Temporal TemporalAccessor TemporalField TemporalQueries))) | |
(set! *warn-on-reflection* true) | |
(let [queries {:local-date (TemporalQueries/localDate)
:local-time (TemporalQueries/localTime)
:zone-offset (TemporalQueries/offset)
:zone-id (TemporalQueries/zoneId)}]
(defn- query [^TemporalAccessor temporal-accessor query]
(.query temporal-accessor (queries query)))) | |
(defn- normalize [s]
(-> s
;; HACK - haven't figured out how to get the parser builder to allow HHmm offsets (i.e., no colons) yet, so add
;; one in there if needed. TODO - what about HH:mm:ss offsets? Will we ever see those?
(str/replace #"([+-][0-2]\d)([0-5]\d)$" "$1:$2")
(str/replace #"([0-2]\d:[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?[+-][0-2]\d$)" "$1:00"))) | |
Returns a map of supported temporal field lisp-style name -> value, e.g. (parse-special-case (.parse (b/formatter (b/value :year 4) (b/value :iso/week-of-year 2)) "201901")) ;; -> {:year 2019, :iso-week-of-year 1} | (defn all-supported-fields
[^TemporalAccessor temporal-accessor]
(into {} (for [[k ^TemporalField field] u.date.common/temporal-field
:when (.isSupported temporal-accessor field)]
[k (.getLong temporal-accessor field)]))) |
(def ^:private InstanceOfTemporal
[:fn
{:error/message "Instance of a java.time.temporal.Temporal"}
(partial instance? Temporal)]) | |
(def ^:private utc-zone-region (t/zone-id "UTC")) | |
Fastpath for parsing ISO Instant timestamp if it matches the required length. Return nil if the length doesn't match or the parsing fails, otherwise return a ZonedDateTime instance at UTC. | (defn- try-parse-as-iso-timestamp
[^String s]
(when s
(let [len (.length s)
min-len (.length "1970-01-01T00:00:00Z")
max-len (.length "1970-01-01T00:00:00.000Z")]
(when (and (>= len min-len) (<= len max-len) (.endsWith s "Z"))
(try (let [temporal-accessor (.parse DateTimeFormatter/ISO_INSTANT s)]
(.atZone (Instant/from temporal-accessor) utc-zone-region))
(catch DateTimeParseException _)))))) |
(mu/defn parse-with-formatter :- [:maybe InstanceOfTemporal]
"Parse a String with a DateTimeFormatter, returning an appropriate instance of an `java.time` temporal class."
[formattr
s :- [:maybe :string]]
{:pre [((some-fn string? nil?) s)]}
(when-not (str/blank? s)
(let [formattr (t/formatter formattr)
s (normalize s)
temporal-accessor (.parse formattr s)
local-date (query temporal-accessor :local-date)
local-time (query temporal-accessor :local-time)
zone-offset (query temporal-accessor :zone-offset)
zone-id (or (query temporal-accessor :zone-id)
(when (= zone-offset ZoneOffset/UTC)
utc-zone-region))
literal-type [(cond
zone-id :zone
zone-offset :offset
:else :local)
(cond
(and local-date local-time) :datetime
local-date :date
local-time :time)]]
(case literal-type
[:zone :datetime] (ZonedDateTime/of local-date local-time zone-id)
[:offset :datetime] (OffsetDateTime/of local-date local-time zone-offset)
[:local :datetime] (LocalDateTime/of local-date local-time)
[:zone :date] (ZonedDateTime/of local-date (t/local-time 0) zone-id)
[:offset :date] (OffsetDateTime/of local-date (t/local-time 0) zone-offset)
[:local :date] local-date
[:zone :time] (OffsetTime/of local-time (or zone-offset (u.date.common/standard-offset zone-id)))
[:offset :time] (OffsetTime/of local-time zone-offset)
[:local :time] local-time
(throw (ex-info (tru "Don''t know how to parse {0} using format {1}" (pr-str s) (pr-str formattr))
{:s s
:formatter formattr
:supported-fields (all-supported-fields temporal-accessor)})))))) | |
(def ^:private ^DateTimeFormatter date-formatter*
(b/formatter
(b/value :year 4 10 :exceeds-pad)
(b/optional
"-"
(b/value :month-of-year 2)
(b/optional
"-"
(b/value :day-of-month 2)))
(b/default-value :month-of-year 1)
(b/default-value :day-of-month 1))) | |
(def ^:private ^DateTimeFormatter time-formatter*
(b/formatter
(b/value :hour-of-day 2)
(b/optional
":"
(b/value :minute-of-hour 2)
(b/optional
":"
(b/value :second-of-minute 2)
(b/optional
(b/fraction :nano-of-second 0 9, :decimal-point? true))))
(b/default-value :minute-of-hour 0)
(b/default-value :second-of-minute 0)
(b/default-value :nano-of-second 0))) | |
(def ^:private ^DateTimeFormatter offset-formatter*
(b/formatter
(b/optional " ")
(b/optional
(b/zone-offset))
(b/optional
(b/zone-id)))) | |
(def ^:private ^DateTimeFormatter formatter
(b/formatter
(b/case-insensitive
(b/optional
date-formatter*)
(b/optional "T")
(b/optional " ")
(b/optional
time-formatter*)
(b/optional
offset-formatter*)))) | |
Parse a string into a | (defn parse
[^String s]
(or (try-parse-as-iso-timestamp s) ;; Try the fastpath first.
(parse-with-formatter formatter s))) |