Second Date0.0.1-SNAPSHOTHelpful java.time utility functions for Clojure | (this space intentionally left almost blank) |
(ns second-date.common (:require [clojure.string :as str] [java-time.api :as t]) (:import (java.time ZoneId ZoneOffset) (java.time.temporal ChronoField IsoFields TemporalField WeekFields) (java.util Locale) (org.apache.commons.lang3 LocaleUtils))) | |
(set! *warn-on-reflection* true) | |
Locale-agnostic version of [[clojure.string/lower-case]]. [[clojure.string/lower-case]] uses the default locale in
conversions, turning | (defn lower-case-en ^String [s] (when s (.toLowerCase (str s) Locale/US))) |
Locale-agnostic version of [[clojure.string/upper-case]]. [[clojure.string/upper-case]] uses the default locale in
conversions, turning | (defn upper-case-en ^String [s] (when s (.toUpperCase (str s) Locale/US))) |
Protocol for anything that can be coerced to a | (defprotocol CoerceToLocale (locale ^java.util.Locale [this] "Coerce `this` to a `java.util.Locale`.")) |
Normalize a locale string to the canonical format. (normalized-locale-string "EN-US") ;-> "en_US" Returns | (defn normalized-locale-string ^String [s] {:pre [((some-fn nil? string?) s)]} (when (string? s) (when-let [[_ language country] (re-matches #"^(\w{2})(?:[-_](\w{2}))?$" s)] (let [language (lower-case-en language)] (if country (str language \_ (some-> country upper-case-en)) language))))) |
(extend-protocol CoerceToLocale nil (locale [_] nil) Locale (locale [this] this) String (locale [^String s] (some-> (normalized-locale-string s) LocaleUtils/toLocale)) ;; Support namespaced keywords like `:en/US` and `:en/UK` because we can clojure.lang.Keyword (locale [this] (locale (if-let [namespce (namespace this)] (str namespce \_ (name this)) (name this))))) | |
TODO - not sure this belongs here, it seems to be a bit more general than just | |
Utility function to get the static members of a class. Returns map of | (defn static-instances ([^Class klass] (static-instances klass klass)) ([^Class klass ^Class target-class] (into {} (for [^java.lang.reflect.Field f (.getFields klass) :when (.isAssignableFrom target-class (.getType f))] [(keyword (lower-case-en (str/replace (.getName f) #"_" "-"))) (.get f nil)])))) |
Map of lisp-style-name -> TemporalField for all the various TemporalFields we use in day-to-day parsing and other temporal operations. | (def ^TemporalField temporal-field (merge ;; honestly I have no idea why there's both IsoFields/WEEK_OF_WEEK_BASED_YEAR and (.weekOfWeekBasedYear ;; WeekFields/ISO) (into {} (for [[k v] (static-instances IsoFields TemporalField)] [(keyword "iso" (name k)) v])) (static-instances ChronoField) {:week-fields/iso-week-based-year (.weekBasedYear WeekFields/ISO) :week-fields/iso-week-of-month (.weekOfMonth WeekFields/ISO) :week-fields/iso-week-of-week-based-year (.weekOfWeekBasedYear WeekFields/ISO) :week-fields/iso-week-of-year (.weekOfYear WeekFields/ISO)} {:week-fields/week-based-year (.weekBasedYear WeekFields/SUNDAY_START) :week-fields/week-of-month (.weekOfMonth WeekFields/SUNDAY_START) :week-fields/week-of-week-based-year (.weekOfWeekBasedYear WeekFields/SUNDAY_START) :week-fields/week-of-year (.weekOfYear WeekFields/SUNDAY_START)})) |
Standard (non-DST) offset for a time zone, for cases when we don't have date information. Gets the offset for the
given We don't know what zone offset to shift this to, since the offset for a zone-id can vary depending on the date part of a temporal value (e.g. DST vs non-DST). So just adjust to the non-DST "standard" offset for the zone in question. | (defn standard-offset ^ZoneOffset [^ZoneId zone-id] (.. zone-id getRules (getStandardOffset (t/instant (t/offset-date-time (-> (t/zoned-date-time) t/year t/value) 1 1))))) |
Replacement for | (ns second-date.core (:refer-clojure :exclude [format range]) (:require [clojure.string :as str] [clojure.tools.logging :as log] [java-time.api :as t] [java-time.core :as t.core] [second-date.common :as common] [second-date.parse :as parse]) (:import (java.time DayOfWeek Duration Instant LocalDate LocalDateTime LocalTime OffsetDateTime OffsetTime Period ZonedDateTime) (java.time.format DateTimeFormatter DateTimeFormatterBuilder FormatStyle TextStyle) (java.time.temporal Temporal TemporalAdjuster WeekFields) (java.util Calendar Locale) (org.threeten.extra PeriodDuration))) |
(set! *warn-on-reflection* true) | |
Converts a temporal type without timezone info to one with zone info (i.e., a | (defn- add-zone-to-local [t timezone-id] (condp instance? t LocalDateTime (t/zoned-date-time t (t/zone-id timezone-id)) LocalDate (t/zoned-date-time t (t/local-time 0) (t/zone-id timezone-id)) ;; don't attempt to convert local times to offset times because we have no idea what the offset ;; actually should be, since we don't know the date. Since it's not an exact instant in time we're ;; not using it to make ranges in MBQL filter clauses anyway ;; ;; TIMEZONE FIXME - not sure we even want to be adding zone-id info for the timestamps above either #_LocalTime #_ (t/offset-time t (t/zone-id timezone-id)) t)) |
With one arg, parse a temporal literal into a corresponding | (defn parse ([s] (parse/parse s)) ([s default-timezone-id] (let [result (parse s)] (if-not default-timezone-id result (let [result-with-timezone (add-zone-to-local result default-timezone-id)] (when-not (= result result-with-timezone) (log/tracef "Applying default timezone %s to temporal literal without timezone '%s' -> %s" default-timezone-id s (pr-str result-with-timezone))) result-with-timezone))))) |
(defn- temporal->iso-8601-formatter [t] (condp instance? t Instant :iso-offset-date-time LocalDate :iso-local-date LocalTime :iso-local-time LocalDateTime :iso-local-date-time OffsetTime :iso-offset-time OffsetDateTime :iso-offset-date-time ZonedDateTime :iso-offset-date-time)) | |
Format temporal value
| (defn format (^String [t] (when t (format (temporal->iso-8601-formatter t) t))) (^String [formatter t] (format formatter t nil)) (^String [formatter t locale] (cond (t/instant? t) (recur formatter (t/zoned-date-time t (t/zone-id "UTC")) locale) locale (recur (.withLocale (t/formatter formatter) (common/locale locale)) t nil) :else (t/format formatter t)))) |
Format temporal value | (defn format-rfc3339 [t] (cond (instance? Instant t) (recur (t/zoned-date-time t (t/zone-id "UTC"))) ;; the rfc3339 format requires a timezone component so convert any local datetime/date to zoned (instance? LocalDateTime t) (recur (t/zoned-date-time t (t/zone-id))) (instance? LocalDate t) (recur (t/zoned-date-time t (t/local-time 0) (t/zone-id))) :else (t/format "yyyy-MM-dd'T'hh:mm:ss.SSXXX" t))) |
Format a temporal value | (defn format-sql ^String [t] ;; replace the `T` with a space. Easy! (str/replace-first (format t) #"(\d{2})T(\d{2})" "$1 $2")) |
(def ^:private ^{:arglists '(^java.time.format.DateTimeFormatter [klass])} class->human-readable-formatter {LocalDate (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) LocalTime (DateTimeFormatter/ofLocalizedTime FormatStyle/MEDIUM) LocalDateTime (let [builder (doto (DateTimeFormatterBuilder.) (.appendLocalized FormatStyle/LONG FormatStyle/MEDIUM))] (.toFormatter builder)) OffsetTime (let [builder (doto (DateTimeFormatterBuilder.) (.append (DateTimeFormatter/ofLocalizedTime FormatStyle/MEDIUM)) (.appendLiteral " (") (.appendLocalizedOffset TextStyle/FULL) (.appendLiteral ")"))] (.toFormatter builder)) OffsetDateTime (let [builder (doto (DateTimeFormatterBuilder.) (.appendLocalized FormatStyle/LONG FormatStyle/MEDIUM) (.appendLiteral " (") (.appendLocalizedOffset TextStyle/FULL) (.appendLiteral ")"))] (.toFormatter builder)) ZonedDateTime (let [builder (doto (DateTimeFormatterBuilder.) (.appendLocalized FormatStyle/LONG FormatStyle/MEDIUM) (.appendLiteral " (") (.appendZoneText TextStyle/FULL) (.appendLiteral ")"))] (.toFormatter builder))}) | |
Format a temporal value (format-human-readable #t "2021-04-02T14:42:09.524392-07:00[US/Pacific]" "es-MX") ;; -> "2 de abril de 2021 02:42:09 PM PDT" | (defn format-human-readable [t locale] (when t (if-let [formatter (some (fn [[klass formatter]] (when (instance? klass t) formatter)) class->human-readable-formatter)] (format formatter t locale) (throw (ex-info (format "Don't know how to format a %s as a human-readable date/time" (some-> t class .getCanonicalName)) {:t t}))))) |
A list of units that can be added to a temporal value. | (def add-units #{:millisecond :second :minute :hour :day :week :month :quarter :year}) |
Return a temporal value relative to temporal value (add (t/zoned-date-time "2019-11-05T15:44-08:00[US/Pacific]") :month 2) -> (t/zoned-date-time "2020-01-05T15:44-08:00[US/Pacific]") | (defn add ([unit amount] (add (t/zoned-date-time) unit amount)) ([t unit amount] (if (zero? amount) t (t/plus t (case unit :millisecond (t/millis amount) :second (t/seconds amount) :minute (t/minutes amount) :hour (t/hours amount) :day (t/days amount) :week (t/days (* amount 7)) :month (t/months amount) :quarter (t/months (* amount 3)) :year (t/years amount)))))) |
Units which return a (numerical, periodic) component of a date TIMEZONE FIXME - we should add | (def extract-units #{:second-of-minute :minute-of-hour :hour-of-day :day-of-week :day-of-month :day-of-year :week-of-year :month-of-year :quarter-of-year ;; TODO - in this namespace `:year` is something you can both extract and truncate to. In MBQL `:year` is a truncation ;; operation. Maybe we should rename this unit to clear up the potential confusion (?) :year}) |
(def ^:private ^{:arglists '(^java.time.DayOfWeek [k])} keyword->day-of-week (let [m (common/static-instances DayOfWeek)] (fn [k] (or (get m k) (throw (ex-info (format "Invalid day of week: %s" (pr-str k)) {:k k, :allowed (keys m)})))))) | |
(defprotocol CoerceToDayOfWeek (day-of-week ^java.time.DayOfWeek [this] "Coerce `this` to a `DayOfWeek`.")) | |
(extend-protocol CoerceToDayOfWeek nil (day-of-week [_this] nil) Integer (day-of-week [^Integer n] (java.time.DayOfWeek/of n)) Long (day-of-week [^Long n] (java.time.DayOfWeek/of n)) clojure.lang.Keyword (day-of-week [k] (keyword->day-of-week k)) String (day-of-week [s] (keyword->day-of-week (keyword (common/lower-case-en s)))) DayOfWeek (day-of-week [this] this)) | |
Get the first day of the week for the current locale. | (defn current-locale-first-day-of-week ^DayOfWeek [] (day-of-week (.getFirstDayOfWeek (Calendar/getInstance (Locale/getDefault))))) |
If bound, this is used as the [[default-first-day-of-week]] if it was not explicitly specified. | (def ^:dynamic ^DayOfWeek *default-first-day-of-week* nil) |
(defn- default-first-day-of-week ^DayOfWeek [] (or (some-> *default-first-day-of-week* day-of-week) (current-locale-first-day-of-week))) | |
Create a new instance of a (week-fields :monday) ; -> #object[java.time.temporal.WeekFields "WeekFields[MONDAY,1]"] | (defn- week-fields (^WeekFields [first-day-of-week] ;; TODO -- ISO weeks only consider a week to be in a year if it has 4+ days in that year... `:week-of-year` ;; extraction is liable to be off for people who expect that definition of "week of year". We should probably make ;; this a Setting. See #15039 for more information (week-fields first-day-of-week 1)) (^WeekFields [first-day-of-week ^Integer minimum-number-of-days-in-first-week] (let [first-day-of-week (if first-day-of-week (day-of-week first-day-of-week) (default-first-day-of-week))] (WeekFields/of first-day-of-week minimum-number-of-days-in-first-week)))) |
Extract a field such as (extract (t/zoned-date-time "2019-11-05T15:44-08:00[US/Pacific]") :day-of-month) ;; -> 5 Values are returned as numbers (currently, always and integers, but this may change if we add support for
| (defn extract ([unit] (extract (t/zoned-date-time) unit)) ([t unit] (extract t unit nil)) ([t unit {:keys [first-day-of-week], :as _options}] (t/as t (case unit :second-of-minute :second-of-minute :minute-of-hour :minute-of-hour :hour-of-day :hour-of-day :day-of-week (.dayOfWeek (week-fields first-day-of-week)) :day-of-month :day-of-month :day-of-year :day-of-year :week-of-year (.weekOfYear (week-fields first-day-of-week)) :month-of-year :month-of-year :quarter-of-year :quarter-of-year :year :year)))) |
Implementation for [[adjuster]]. | (defmulti ^TemporalAdjuster -adjuster {:arglists '([adjuster-type options])} (fn [adjuster-type _options] adjuster-type)) |
(defmethod -adjuster :default [adjuster-type _options] (throw (ex-info (format "No temporal adjuster named %s" adjuster-type) {:k adjuster-type}))) | |
(defmethod -adjuster :first-day-of-week [_adjuster-type {:keys [first-day-of-week], :as _options}] (reify TemporalAdjuster (adjustInto [_ t] (let [first-day-of-week (if first-day-of-week (day-of-week first-day-of-week) (default-first-day-of-week))] (t/adjust t :previous-or-same-day-of-week first-day-of-week))))) | |
(defmethod -adjuster :first-day-of-quarter [_adjuster-type _options] (reify TemporalAdjuster (adjustInto [_ t] (.with t (.atDay (t/year-quarter t) 1))))) | |
(defmethod -adjuster :first-week-of-year [_adjuster-type options] (reify TemporalAdjuster (adjustInto [_ t] (-> t (t/adjust :first-day-of-year) (t/adjust (-adjuster :first-day-of-week options)))))) | |
(defmethod -adjuster :week-of-year [_adjuster-type {:keys [week-of-year], :as options}] {:pre [(integer? week-of-year)]} (reify TemporalAdjuster (adjustInto [_ t] (-> t (t/adjust (-adjuster :first-week-of-year options)) (t/plus (t/weeks (dec week-of-year))))))) | |
Get the custom ;; adjust 2019-12-10T17:26 to the second week of the year (t/adjust #t "2019-12-10T17:26" (date/adjuster :week-of-year 2)) ;; -> #t "2019-01-06T17:26" | (defn adjuster ([adjuster-type] (-adjuster adjuster-type nil)) ([adjuster-type options] (-adjuster adjuster-type options))) |
if you attempt to truncate a | (extend-protocol t.core/Truncatable LocalDate (truncate-to [t unit] (case unit :millis t :seconds t :minutes t :hours t :days t))) |
See https://github.com/dm3/clojure.java-time/issues/95. We need to d2 the | (alter-var-root #'t/truncate-to (constantly t.core/truncate-to)) |
Valid date truncation units | (def truncate-units #{:millisecond :second :minute :hour :day :week :month :quarter :year}) |
Truncate a temporal value
| (defn truncate ([unit] (truncate (t/zoned-date-time) unit)) ([t unit] (truncate t unit nil)) ([^Temporal t unit {:keys [_first-day-of-week], :as options}] (case unit :default t :millisecond (t/truncate-to t :millis) :second (t/truncate-to t :seconds) :minute (t/truncate-to t :minutes) :hour (t/truncate-to t :hours) :day (t/truncate-to t :days) :week (-> (.with t (adjuster :first-day-of-week options)) (t/truncate-to :days)) :month (-> (t/adjust t :first-day-of-month) (t/truncate-to :days)) :quarter (-> (.with t (adjuster :first-day-of-quarter options)) (t/truncate-to :days)) :year (-> (t/adjust t :first-day-of-year) (t/truncate-to :days))))) |
Perform a truncation or extraction unit on temporal value You can combine this function with (group-by #(bucket % :quarter-of-year) (map t/local-date ["2019-01-01" "2019-01-02" "2019-01-04"])) ;; -> {1 [(t/local-date "2019-01-01") (t/local-date "2019-01-02")], 2 [(t/local-date "2019-01-04")]} | (defn bucket ([unit] (bucket (t/zoned-date-time) unit)) ([t unit] (bucket t unit nil)) ([t unit options] (cond (= unit :default) t (extract-units unit) (extract t unit options) (truncate-units unit) (truncate t unit options) :else (throw (ex-info (format "Invalid unit: %s" (pr-str unit)) {:unit unit}))))) |
Get a start (by default, inclusive) and end (by default, exclusive) pair of instants for a (range (t/zoned-date-time "2019-11-01T15:29:00Z[UTC]") :week) -> {:start (t/zoned-date-time "2019-10-27T00:00Z[UTC]") :end (t/zoned-date-time "2019-11-03T00:00Z[UTC]")} Other options like | (defn range ([unit] (range (t/zoned-date-time) unit)) ([t unit] (range t unit nil)) ([t unit {:keys [start end resolution _first-day-of-week] :or {start :inclusive end :exclusive resolution :millisecond} :as options}] (let [t (truncate t unit options)] {:start (case start :inclusive t :exclusive (add t resolution -1)) :end (case end :inclusive (add (add t unit 1) resolution -1) :exclusive (add t unit 1))}))) |
Generate an range that of instants that when bucketed by ;; Generate range off instants that have the same MONTH as Nov 18th (comparison-range (t/local-date "2019-11-18") :month := {:resolution :day}) ;; -> {:start (t/local-date "2019-11-01"), :end (t/local-date "2019-12-01")} | (defn comparison-range ([unit comparison-type] (comparison-range (t/zoned-date-time) unit comparison-type)) ([t unit comparison-type] (comparison-range t unit comparison-type nil)) ([t unit comparison-type {:keys [start end resolution _first-day-of-week] :or {start :inclusive end :exclusive resolution :millisecond} :as options}] (case comparison-type :< {:end (case end :inclusive (add (truncate t unit options) resolution -1) :exclusive (truncate t unit options))} :<= {:end (let [t (add (truncate t unit options) unit 1)] (case end :inclusive (add t resolution -1) :exclusive t))} :> {:start (let [t (add (truncate t unit options) unit 1)] (case start :inclusive t :exclusive (add t resolution -1)))} :>= {:start (let [t (truncate t unit options)] (case start :inclusive t :exclusive (add t resolution -1)))} := (range t unit options)))) |
Return the Duration between two temporal values Moving the type hints to the arg lists makes clj-kondo happy, but breaks eastwood (and maybe causes reflection warnings) at the call sites. | #_{:clj-kondo/ignore [:non-arg-vec-return-type-hint]} (defn ^PeriodDuration period-duration {:arglists '([s] [period] [duration] [period duration] [start end])} ([x] (when x (condp instance? x PeriodDuration x CharSequence (PeriodDuration/parse x) Period (PeriodDuration/of ^Period x) Duration (PeriodDuration/of ^Duration x)))) ([x y] (cond (and (instance? Period x) (instance? Duration y)) (PeriodDuration/of x y) (instance? Instant x) (period-duration (t/offset-date-time x (t/zone-offset 0)) y) (instance? Instant y) (period-duration x (t/offset-date-time y (t/zone-offset 0))) :else (PeriodDuration/between x y)))) |
With two args: Compare two periods/durations. Returns a negative value if (date/compare-period-durations "P1Y" "P11M") ; -> 1 (i.e., 1 year is longer than 11 months) You can combine this with (date/compare-period-durations (date/period-duration #t "2019-01-01" #t "2019-07-01") "P11M") ; -> -1 Note that this calculation is inexact, since it calculates relative to a fixed point in time, but should be sufficient for most if not all use cases. | (defn compare-period-durations [d1 d2] (when (and d1 d2) (let [t (t/offset-date-time "1970-01-01T00:00Z")] (compare (.addTo (period-duration d1) t) (.addTo (period-duration d2) t))))) |
True if period/duration | (defn greater-than-period-duration? [d1 d2] (pos? (compare-period-durations d1 d2))) |
Return a temporal value representing now of the same class as | (defn- now-of-same-class ^Temporal [t] (when t (condp instance? t Instant (t/instant) LocalDate (t/local-date) LocalTime (t/local-time) LocalDateTime (t/local-date-time) OffsetTime (t/offset-time) OffsetDateTime (t/offset-date-time) ZonedDateTime (t/zoned-date-time)))) |
True if temporal value ;; did | (defn older-than? [t duration] (greater-than-period-duration? (period-duration t (now-of-same-class t)) duration)) |
Protocol for converting a temporal value to an equivalent one in a given timezone. | (defprotocol WithTimeZoneSameInstant (^{:style/indent 0} with-time-zone-same-instant [t ^java.time.ZoneId zone-id] "Convert a temporal value to an equivalent one in a given timezone. For local temporal values, this simply converts it to the corresponding offset/zoned type; for offset/zoned types, this applies an appropriate timezone shift.")) |
(extend-protocol WithTimeZoneSameInstant ;; convert to a OffsetTime with no offset (UTC); the OffsetTime method impl will apply the zone shift. LocalTime (with-time-zone-same-instant [t zone-id] (t/offset-time t (common/standard-offset zone-id))) OffsetTime (with-time-zone-same-instant [t ^java.time.ZoneId zone-id] (t/with-offset-same-instant t (common/standard-offset zone-id))) LocalDate (with-time-zone-same-instant [t zone-id] (t/offset-date-time t (t/local-time 0) zone-id)) LocalDate (with-time-zone-same-instant [t zone-id] (t/offset-date-time t (t/local-time 0) zone-id)) LocalDateTime (with-time-zone-same-instant [t zone-id] (t/offset-date-time t zone-id)) ;; instants are always normalized to UTC, so don't make any changes here. If you want to format in a different zone, ;; convert to an OffsetDateTime or ZonedDateTime first. Instant (with-time-zone-same-instant [t _] t) OffsetDateTime (with-time-zone-same-instant [t ^java.time.ZoneId zone-id] ;; calculate the zone offset applicable for the date in question (if (or (= t OffsetDateTime/MAX) (= t OffsetDateTime/MIN)) t (let [rules (.getRules zone-id) offset (.getOffset rules (t/instant t))] (t/with-offset-same-instant t offset)))) ZonedDateTime (with-time-zone-same-instant [t zone-id] (t/with-zone-same-instant t zone-id))) | |
+----------------------------------------------------------------------------------------------------------------+ | Etc | +----------------------------------------------------------------------------------------------------------------+ | |
Add [[print-method]] and [[print-dup]] entries for various temporal classes so they print as string literals like #second-date/t "2024-01-03T13:38:00.000" | (defn install-print-methods! ([] (install-print-methods! 'second-date/t)) ([data-reader-symbol] ;; Mainly for REPL usage. Have various temporal types print as a `java-time` function call you can use (doseq [[klass _f-symb] {Instant 't/instant LocalDate 't/local-date LocalDateTime 't/local-date-time LocalTime 't/local-time OffsetDateTime 't/offset-date-time OffsetTime 't/offset-time ZonedDateTime 't/zoned-date-time}] (defmethod print-method klass [t writer] ((get-method print-dup klass) t writer)) (defmethod print-dup klass [t ^java.io.Writer writer] (.write writer (clojure.core/format "#%s \"%s\"" data-reader-symbol (str t))))) (defmethod print-method PeriodDuration [d writer] ((get-method print-dup PeriodDuration) d writer)) (defmethod print-dup PeriodDuration [d ^java.io.Writer writer] (.write writer (clojure.core/format "(second-date.core/period-duration %s)" (pr-str (str d))))) (defmethod print-method Period [d writer] (print-method (list 't/period (str d)) writer)) (defmethod print-method Duration [d writer] (print-method (list 't/duration (str d)) writer)))) |
(ns second-date.parse (:require [clojure.string :as str] [java-time.api :as t] [second-date.common :as common] [second-date.parse.builder :as b]) (:import (java.time LocalDateTime OffsetDateTime OffsetTime ZonedDateTime ZoneOffset) (java.time.format DateTimeFormatter) (java.time.temporal TemporalAccessor TemporalField TemporalQueries))) | |
(set! *warn-on-reflection* true) | |
(def ^:private ^{:arglists '([temporal-accessor query])} query (let [queries {:local-date (TemporalQueries/localDate) :local-time (TemporalQueries/localTime) :zone-offset (TemporalQueries/offset) :zone-id (TemporalQueries/zoneId)}] (fn [^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] common/temporal-field :when (.isSupported temporal-accessor field)] [k (.getLong temporal-accessor field)]))) |
Parse a String with a DateTimeFormatter, returning an appropriate instance of an | (defn parse-with-formatter [formattr s] {: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) (t/zone-id "UTC"))) 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 (common/standard-offset zone-id))) [:offset :time] (OffsetTime/of local-time zone-offset) [:local :time] local-time (throw (ex-info (format "Don't know how to parse %s using format %s" (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] (parse-with-formatter formatter s)) |
Utility functions for programmatically building a The basic idea here is you pass a number of | (ns second-date.parse.builder (:require [second-date.common :as common]) (:import (java.time.format DateTimeFormatter DateTimeFormatterBuilder SignStyle) (java.time.temporal TemporalField))) |
(set! *warn-on-reflection* true) | |
(defprotocol ^:private Section (^:private apply-section [this builder])) | |
(extend-protocol Section String (apply-section [s builder] (.appendLiteral ^DateTimeFormatterBuilder builder s)) clojure.lang.Fn (apply-section [f builder] (f builder)) clojure.lang.Sequential (apply-section [sections builder] (doseq [section sections] (apply-section section builder))) DateTimeFormatter (apply-section [formatter builder] (.append ^DateTimeFormatterBuilder builder formatter))) | |
Make wrapped | (defn optional [& sections] (reify Section (apply-section [_ builder] (.optionalStart ^DateTimeFormatterBuilder builder) (apply-section sections builder) (.optionalEnd ^DateTimeFormatterBuilder builder)))) |
(defn- set-option [^DateTimeFormatterBuilder builder option] (case option :strict (.parseStrict builder) :lenient (.parseLenient builder) :case-sensitive (.parseCaseSensitive builder) :case-insensitive (.parseCaseInsensitive builder))) | |
(def ^:private ^:dynamic *options* {:strictness :strict :case-sensitivity :case-sensitive}) | |
(defn- do-with-option [builder k new-value thunk] (let [old-value (get *options* k)] (if (= old-value new-value) (thunk) (binding [*options* (assoc *options* k new-value)] (set-option builder new-value) (thunk) (set-option builder old-value))))) | |
(defn- with-option-section [k v sections] (reify Section (apply-section [_ builder] (do-with-option builder k v (fn [] (apply-section sections builder)))))) | |
Use strict parsing for wrapped | (defn strict [& sections] (with-option-section :strictness :strict sections)) |
Use lenient parsing for wrapped | (defn lenient [& sections] (with-option-section :strictness :lenient sections)) |
Make wrapped | (defn case-sensitive [& sections] (with-option-section :case-sensitivity :case-sensitive sections)) |
Make wrapped | (defn case-insensitive [& sections] (with-option-section :case-sensitivity :case-insensitive sections)) |
(def ^:private ^SignStyle sign-style (common/static-instances SignStyle)) | |
(defn- temporal-field ^TemporalField [x] (let [field (if (keyword? x) (common/temporal-field x) x)] (assert (instance? TemporalField field) (format "Invalid TemporalField: %s" (pr-str field))) field)) | |
Define a section for a specific field such as | (defn value ([temporal-field-name] (fn [^DateTimeFormatterBuilder builder] (.appendValue builder (temporal-field temporal-field-name)))) ([temporal-field-name width] (fn [^DateTimeFormatterBuilder builder] (.appendValue builder (temporal-field temporal-field-name) width))) ([temporal-field-name min-val max-val sign-style-name] (fn [^DateTimeFormatterBuilder builder] (.appendValue builder (temporal-field temporal-field-name) min-val max-val (sign-style sign-style-name))))) |
Define a section that sets a default value for a field like | (defn default-value [temporal-field-name default-val] (fn [^DateTimeFormatterBuilder builder] (.parseDefaulting builder (temporal-field temporal-field-name) default-val))) |
Define a section for a fractional value, e.g. milliseconds or nanoseconds. | (defn fraction [temporal-field-name _min-val-width _max-val-width & {:keys [decimal-point?]}] (fn [^DateTimeFormatterBuilder builder] (.appendFraction builder (temporal-field temporal-field-name) 0 9 (boolean decimal-point?)))) |
Define a section for a timezone offset. e.g. | (defn zone-offset [] (lenient (fn [^DateTimeFormatterBuilder builder] (.appendOffsetId builder)))) |
An a section for a timezone ID wrapped in square brackets, e.g. | (defn zone-id [] (strict (case-sensitive (optional "[") (fn [^DateTimeFormatterBuilder builder] (.appendZoneRegionId builder)) (optional "]")))) |
Return a new (formatter (case-insensitive (value :hour-of-day 2) (optional ":" (value :minute-of-hour 2) (optional ":" (value :second-of-minute))))) -> #object[java.time.format.DateTimeFormatter "ParseCaseSensitive(false)Value(HourOfDay,2)[':'Value(MinuteOfHour,2)[':'Value(SecondOfMinute)]]"] | (defn formatter ^DateTimeFormatter [& sections] (let [builder (DateTimeFormatterBuilder.)] (apply-section sections builder) (.toFormatter builder))) |
(ns second-date.test-util (:require [clojure.test :refer :all] [java-time.api :as t]) (:import (java.util Locale TimeZone))) | |
(set! *warn-on-reflection* true) | |
Implementation for [[with-system-timezone-id]]. | (defn -do-with-system-timezone-id [^String timezone-id thunk] ;; skip all the property changes if the system timezone doesn't need to be changed. (let [original-timezone (TimeZone/getDefault) original-system-property (System/getProperty "user.timezone") new-timezone (TimeZone/getTimeZone timezone-id)] (if (and (= original-timezone new-timezone) (= original-system-property timezone-id)) (thunk) (do (when ((loaded-libs) 'mb.hawk.parallel) ((resolve 'mb.hawk.parallel/assert-test-is-not-parallel) `with-system-timezone-id)) (try (TimeZone/setDefault new-timezone) (System/setProperty "user.timezone" timezone-id) (testing (format "JVM timezone set to %s" timezone-id) (thunk)) (finally (TimeZone/setDefault original-timezone) (System/setProperty "user.timezone" original-system-property))))))) |
Execute | (defmacro with-system-timezone-id [timezone-id & body] `(-do-with-system-timezone-id ~timezone-id (^:once fn* [] ~@body))) |
Implementation for [[with-clock]]. | (defn -do-with-clock [clock thunk] (testing (format "\nsystem clock = %s" (pr-str clock)) (let [clock (cond (t/clock? clock) clock (t/zoned-date-time? clock) (t/mock-clock (t/instant clock) (t/zone-id clock)) :else (throw (Exception. (format "Invalid clock: ^%s %s" (.getName (class clock)) (pr-str clock)))))] (t/with-clock clock (thunk))))) |
Same as [[t/with-clock]], but adds [[testing]] context, and also supports using (mt/with-clock #t "2019-12-10T00:00-08:00[US/Pacific]" ...) | (defmacro with-clock [clock & body] `(-do-with-clock ~clock (fn [] ~@body))) |
Sets the default locale temporarily to | (defn -do-with-locale [locale-tag f] (when ((loaded-libs) 'mb.hawk.parallel) ((resolve 'mb.hawk.parallel/assert-test-is-not-parallel) `with-locale)) (let [current-locale (Locale/getDefault)] (try (Locale/setDefault (Locale/forLanguageTag locale-tag)) (f) (finally (Locale/setDefault current-locale))))) |
Allows a test to override the locale temporarily | (defmacro with-locale [locale-tag & body] `(-do-with-locale ~locale-tag (fn [] ~@body))) |