Second Date

0.0.1-SNAPSHOT


Helpful 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 ID into ıd, in the Turkish locale. This function always uses the en-US locale.

(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 id into İD, in the Turkish locale. This function always uses the en-US locale.

(defn upper-case-en
  ^String [s]
  (when s
    (.toUpperCase (str s) Locale/US)))

Protocol for anything that can be coerced to a java.util.Locale.

(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 nil for invalid strings -- you can use this to check whether a String is valid.

(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 date-2.

Utility function to get the static members of a class. Returns map of lisp-case keyword names of members -> value.

(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 zone-id at January 1 of the current year (since that is the best we can do in this situation).

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 metabase.util.date that consistently uses java.time instead of a mix of java.util.Date, java.sql.*, and Joda-Time.

(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 ZonedDateTime).

(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 java.time class, such as LocalDate or OffsetDateTime. With a second arg, literals that do not explicitly specify a timezone are interpreted as being in timezone-id.

(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 t, by default as an ISO-8601 date/time/datetime string. By default t is formatted in a way that's appropriate for its type, e.g. a LocalDate is formatted as year-month-day. You can optionally pass formatter to format a different way. formatter can be:

  1. A keyword name of a predefined formatter. Eval

    (keys java-time.format/predefined-formatters)

    for a list of predefined formatters.

    1. An instance of java.time.format.DateTimeFormatter. You can use utils in second-date.parse.builder to help create one of these formatters.

    2. A format String e.g. YYYY-MM-dd

(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 t, as an RFC3339 datetime string.

(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 t as a SQL-style literal string (for most SQL databases). This is the same as ISO-8601 but uses a space rather than of a T to separate the date and time components.

(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 t in a human-friendly way for locale.

(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 t by adding (or subtracting) a number of units. Returned value will be of same class as t.

(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 :millisecond-of-second (or :fraction-of-second?) . Not sure where we'd use these, but we should have them for consistency

(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 WeekFields, which is used for localized day-of-week, week-of-month, and week-of-year.

(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 :minute-of-hour from a temporal value t.

(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 :fraction-of-second in the future.)

(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 TemporalAdjuster named by k.

;; 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 LocalDate to :day or anything smaller we can go ahead and return it as is

(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 java-time/truncate-to copy of the actual underlying method since extend-protocol mutates the var

(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 t to the beginning of unit, e.g. :hour or :day. Not all truncation units are supported on all subclasses of Temporal — for example, you can't truncate a LocalTime to :month, for obvious reasons.

options are passed to [[adjuster]]/[[-adjuster]] and can include things like :first-day-of-week or :first-week-of-year.

(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 t. (These two operations are collectively known as 'date bucketing' in Metabase code and MBQL, e.g. for date/time columns in MBQL :breakout (SQL GROUP BY)).

You can combine this function with group-by to do some date/time bucketing in Clojure-land:

(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 unit span of time containing t. e.g.

(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 :first-day-of-week are passed to [[truncate]] if it is needed.

(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 unit would be =, <, <=, >, or >= to the value of an instant t bucketed by unit. (comparison-type is one of :=, :<, :<=, :>, or :>=.) By default, the start of the resulting range is inclusive, and the end exclusive; this can be tweaked by passing options.

;; 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 x and y.

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 d1 is shorter than d2, zero if they are equal, or positive if d1 is longer than d2.

(date/compare-period-durations "P1Y" "P11M") ; -> 1 (i.e., 1 year is longer than 11 months)

You can combine this with period-duration to compare the duration between two temporal values against another duration:

(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 d1 is longer than period/duration d2.

(defn greater-than-period-duration?
  [d1 d2]
  (pos? (compare-period-durations d1 d2)))

Return a temporal value representing now of the same class as t, e.g. for comparison purposes.

(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 t happened before some period/duration ago, compared to now. Prefer this over using t/before? to compare times to now because it is incredibly fussy about the classes of arguments it is passed.

;; did t happen more than 2 months ago? (older-than? t (t/months 2))

(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 java.time temporal class.

(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 java.time object.

(defn parse
  [^String s]
  (parse-with-formatter formatter s))
 

Utility functions for programmatically building a DateTimeFormatter. Easier to understand than chaining a hundred Java calls and trying to keep the structure straight.

The basic idea here is you pass a number of sections to formatter to build a DateTimeFormatter — see second-date.parse for examples. Most of these sections are simple wrappers around corresponding DateTimeFormatterBuilder -- see https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatterBuilder.html for documentation.

(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 sections optional.

(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 sections.

(defn strict
  [& sections]
  (with-option-section :strictness :strict sections))

Use lenient parsing for wrapped sections.

(defn lenient
  [& sections]
  (with-option-section :strictness :lenient sections))

Make wrapped sections case-sensitive.

(defn case-sensitive
  [& sections]
  (with-option-section :case-sensitivity :case-sensitive sections))

Make wrapped sections case-insensitive.

(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 :hour-of-day or :minute-of-hour. Refer to second-date.common/temporal-field for all possible temporal fields names.

(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 :minute-of-hour.

(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. -08:00.

(defn zone-offset
  []
  (lenient
   (fn [^DateTimeFormatterBuilder builder]
     (.appendOffsetId builder))))

An a section for a timezone ID wrapped in square brackets, e.g. [America/Los_Angeles].

(defn zone-id
  []
  (strict
   (case-sensitive
    (optional "[")
    (fn [^DateTimeFormatterBuilder builder]
      (.appendZoneRegionId builder))
    (optional "]"))))

Return a new DateTimeFormatter from sections. See examples in second-date.parse for more details.

(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 body with the system time zone temporarily changed to the time zone named by timezone-id.

(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 ZonedDateTime instances directly (converting them to a mock clock automatically).

(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 locale-tag, then invokes f and reverts the locale change

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