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))) |