Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add :time/period schema to experimental.time #957

Merged
merged 1 commit into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3018,6 +3018,7 @@ The following schemas and their respective types are provided:
| Schema | Example | JVM/js-joda Type (`java.time`) |
|:-------------------------|:-----------------------------------------------------|:-------------------------------|
| `:time/duration` | PT0.01S | `Duration` |
| `:time/period` | P-1Y100D | `Period` |
| `:time/instant` | 2022-12-18T12:00:25.840823567Z | `Instant` |
| `:time/local-date` | 2020-01-01 | `LocalDate` |
| `:time/local-date-time` | 2020-01-01T12:00:00 | `LocalDateTime` |
Expand Down Expand Up @@ -3074,6 +3075,13 @@ Time schemas respect min/max predicates for their respective types:

Will be valid only for local times between 12:00 and 13:00.

For the comparison of `Period`s, units are compared to corresponding units and never between.

For example a Period of 1 year will always compare greater than a period of 13 months; that is, conceptually `(< P13M P1Y)`

If you want to add further constraints you can transform your `Period`s before being used in `min` and `max` per your use-case
or combine the schema with `:and` and `:fn` for example.

#### Transformation - `malli.experimental.time.transform`

The `malli.experimental.time.transform` namespace provides a `time-transformer` from string to the correct type.
Expand All @@ -3099,6 +3107,15 @@ Require `malli.experimental.time.generator` to add support for time schema gener

Generated data also respects min/max properties.

When generating `Period`s there is no way distinguish between `nil` values and zero for each unit, so zero units will
not constrain the generator, if you need some of the units to be zero in generated `Period`s you can always `gen/fmap` the data:

```clojure
[:time/period {:gen/fmap #(. % withMonths 0) :min (. Period of -10 0 1)}]
```
This would generate `Period`s with a minimum years unit of -10, minimum days unit of 1 and months unit always equal to zero.
Without the fmap the months unit could be any negative or positive integer.

#### JSON Schema - `malli.experimental.time.json-schema`

Require `malli.experimental.time.json-schema` to add support for json
Expand Down
73 changes: 51 additions & 22 deletions src/malli/experimental/time.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,7 @@
(:refer-clojure :exclude [<=])
(:require [malli.core :as m]
#?(:cljs ["@js-joda/core" :as js-joda]))
#?(:clj (:import (java.time Duration LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime ZoneOffset))))

(defn <= [^Comparable x ^Comparable y] (not (pos? (.compareTo x y))))

(defn -min-max-pred [_]
(fn [{:keys [min max]}]
(cond
(not (or min max)) nil
(and min max) (fn [x] (and (<= x max) (<= min x)))
min (fn [x] (<= min x))
max (fn [x] (<= x max)))))

(defn -temporal-schema [{:keys [type class type-properties]}]
(m/-simple-schema
(cond->
{:type type
:pred (fn pred [x]
#?(:clj (.isInstance ^Class class x)
:cljs (instance? class x)))
:property-pred (-min-max-pred nil)}
type-properties
(assoc :type-properties type-properties))))
#?(:clj (:import (java.time Duration Period LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime ZoneOffset))))

#?(:cljs
(do
Expand All @@ -48,6 +27,54 @@
(def TemporalQuery (.-TemporalQuery js-joda))
(def DateTimeFormatter (.-DateTimeFormatter js-joda))))

(defn <= [^Comparable x ^Comparable y] (not (pos? (.compareTo x y))))

(defn compare-periods
"Periods are not comparable in the java Comparable sense, instead this performs simple units-by-units comparison.
So a period of 1 year will always compare greater than a period of 13 months and similar for days and months."
[^Period p1 ^Period p2]
(let [years1 #?(:clj (.getYears p1) :cljs (.years p1))
years2 #?(:clj (.getYears p2) :cljs (.years p2))
months1 #?(:clj (.getMonths p1) :cljs (.months p1))
months2 #?(:clj (.getMonths p2) :cljs (.months p2))
days1 #?(:clj (.getDays p1) :cljs (.days p1))
days2 #?(:clj (.getDays p2) :cljs (.days p2))]
(cond
(not (= years1 years2)) (- years1 years2)
(not (= months1 months2)) (- months1 months2)
:else (- days1 days2))))

(defn -min-max-pred [_]
(fn [{:keys [min max]}]
(cond
(not (or min max)) nil
(and min max)
(if (and (instance? Period min) (instance? Period max))
(fn [^Period x]
(and
(not (pos? (compare-periods x max)))
(not (pos? (compare-periods min x)))))
(fn [x] (and (<= x max) (<= min x))))
min (fn [x]
(if (instance? Period min)
(not (pos? (compare-periods min x)))
(<= min x)))
max (fn [x]
(if (instance? Period max)
(not (pos? (compare-periods x max)))
(<= x max))))))

(defn -temporal-schema [{:keys [type class type-properties]}]
(m/-simple-schema
(cond->
{:type type
:pred (fn pred [x]
#?(:clj (.isInstance ^Class class x)
:cljs (instance? class x)))
:property-pred (-min-max-pred nil)}
type-properties
(assoc :type-properties type-properties))))

#?(:cljs
(defn createTemporalQuery [f]
(let [parent (TemporalQuery. "")
Expand All @@ -56,6 +83,7 @@
query)))

(defn -duration-schema [] (-temporal-schema {:type :time/duration :class Duration}))
(defn -period-schema [] (-temporal-schema {:type :time/period :class Period}))
(defn -instant-schema [] (-temporal-schema {:type :time/instant :class Instant}))
(defn -local-date-schema [] (-temporal-schema {:type :time/local-date :class LocalDate :type-properties {:min (. LocalDate -MIN) :max (. LocalDate -MAX)}}))
(defn -local-time-schema [] (-temporal-schema {:type :time/local-time :class LocalTime :type-properties {:min (. LocalTime -MIN) :max (. LocalTime -MAX)}}))
Expand All @@ -70,6 +98,7 @@
{:time/zone-id (-zone-id-schema)
:time/instant (-instant-schema)
:time/duration (-duration-schema)
:time/period (-period-schema)
:time/zoned-date-time (-zoned-date-time-schema)
:time/offset-date-time (-offset-date-time-schema)
:time/local-date (-local-date-schema)
Expand Down
33 changes: 31 additions & 2 deletions src/malli/experimental/time/generator.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
[malli.generator :as mg]
#?(:clj [malli.experimental.time :as time]
:cljs [malli.experimental.time :as time
:refer [Duration LocalDate LocalDateTime LocalTime Instant OffsetTime ZonedDateTime OffsetDateTime ZoneId ZoneOffset]]))
#?(:clj (:import (java.time Duration LocalDate LocalDateTime LocalTime Instant OffsetTime ZonedDateTime OffsetDateTime ZoneId ZoneOffset))))
:refer [Duration Period LocalDate LocalDateTime LocalTime Instant OffsetTime ZonedDateTime OffsetDateTime ZoneId ZoneOffset]]))
#?(:clj (:import (java.time Duration Period LocalDate LocalDateTime LocalTime Instant OffsetTime ZonedDateTime OffsetDateTime ZoneId ZoneOffset))))

#?(:clj (set! *warn-on-reflection* true))

Expand Down Expand Up @@ -125,3 +125,32 @@

(defmethod mg/-schema-generator :time/duration [schema options]
(gen/fmap #(. Duration ofNanos %) (gen/large-integer* (-min-max schema options))))

;; Years, Months, Days of periods are never nil, they just return zero, so we treat zero as nil.
(defmethod mg/-schema-generator :time/period [schema options]
(let [zero->nil (fn [v] (if (zero? v) nil v))
max-int #?(:clj Integer/MAX_VALUE :cljs (.-MAX_SAFE_INTEGER js/Number))
min-int #?(:clj Integer/MIN_VALUE :cljs (.-MIN_SAFE_INTEGER js/Number))
ceil-max (fn [v] (if (nil? v) max-int (min max-int v)))
floor-min (fn [v] (if (nil? v) min-int (max min-int v)))
{^Period mn :min ^Period mx :max ^Period gen-min :gen/min ^Period gen-max :gen/max}
(merge
(m/type-properties schema options)
(m/properties schema options))
_ (when (and mn gen-min (not (pos? (time/compare-periods gen-min min))))
(m/-fail! ::mg/invalid-property {:key :gen/min, :value gen-min, :min min}))
_ (when (and mx gen-max (not (pos? (time/compare-periods max gen-max))))
(m/-fail! ::mg/invalid-property {:key :gen/max, :value gen-min, :max min}))
mn (or mn gen-min)
mx (or mx gen-max)
min-years (when mn (zero->nil (.getYears mn))), max-years (when mx (zero->nil (.getYears mx)))
min-months (when mn (zero->nil (.getMonths mn))), max-months (when mx (zero->nil (.getMonths mx)))
min-days (when mn (zero->nil (.getDays mn))), max-days (when mx (zero->nil (.getDays mx)))]
(->>
(gen/tuple
;; Period constructor only accepts java type `int` not `long`, clamp the values
(gen/large-integer* {:min (floor-min min-years) :max (ceil-max max-years)})
(gen/large-integer* {:min (floor-min min-months) :max (ceil-max max-months)})
(gen/large-integer* {:min (floor-min min-days) :max (ceil-max max-days)}))
(gen/fmap (fn [[years months days]]
(. Period of years months days))))))
6 changes: 4 additions & 2 deletions src/malli/experimental/time/transform.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
(:require [malli.transform :as mt :refer [-safe]]
[malli.core :as m]
#?(:cljs [malli.experimental.time :as time
:refer [Duration LocalDate LocalDateTime LocalTime Instant OffsetTime ZonedDateTime OffsetDateTime ZoneId ZoneOffset
:refer [Duration Period LocalDate LocalDateTime LocalTime Instant OffsetTime ZonedDateTime OffsetDateTime ZoneId ZoneOffset
TemporalAccessor TemporalQuery DateTimeFormatter createTemporalQuery]]))
#?(:clj
(:import (java.time Duration LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime ZoneOffset)
(:import (java.time Duration Period LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime ZoneOffset)
(java.time.temporal TemporalAccessor TemporalQuery)
(java.time.format DateTimeFormatter))))

Expand Down Expand Up @@ -56,6 +56,7 @@
(reduce-kv
(fn [m k v] (assoc m k (-safe (->parser v (get queries k)))))
{:time/duration (-safe #(. Duration parse %))
:time/period (-safe #(. Period parse %))
:time/zone-offset (-safe #(. ZoneOffset of ^String %))
:time/zone-id (-safe #(. ZoneId of %))}
default-formats))
Expand All @@ -79,6 +80,7 @@
(defn time-encoders [formats]
(into
{:time/duration str
:time/period str
:time/zone-id str}
(for [k (keys formats)]
[k {:compile
Expand Down
1 change: 1 addition & 0 deletions test/malli/experimental/time/generator_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
(t/deftest generator-test
(t/testing "simple schemas"
(t/is (exercise :time/duration))
(t/is (exercise :time/period))
(t/is (exercise :time/zone-id))
(t/is (exercise :time/zone-offset))
(t/is (exercise :time/instant))
Expand Down
4 changes: 4 additions & 0 deletions test/malli/experimental/time/transform_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
(t/testing "Duration"
(t/is (validate :time/duration "PT0.01S"))
(t/is (not (validate :time/duration 10))))
(t/testing "Period"
(t/is (validate :time/period "P-1Y10D"))
(t/is (validate :time/period "P-1Y8M"))
(t/is (not (validate :time/period 10))))
(t/testing "zone id"
(t/is (validate :time/zone-id "UTC"))
(t/is (not (validate :time/zone-id "UTC'"))))
Expand Down
39 changes: 37 additions & 2 deletions test/malli/experimental/time_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
[malli.registry :as mr]
#?(:clj [malli.experimental.time :as time]
:cljs [malli.experimental.time :as time
:refer [Duration LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime]])
:refer [Duration Period LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime]])
[clojure.test :as t])
#?(:clj (:import (java.time Duration LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime))))
#?(:clj (:import (java.time Duration Period LocalDate LocalDateTime LocalTime Instant ZonedDateTime OffsetDateTime ZoneId OffsetTime))))

(t/deftest compare-dates
(t/is (time/<= (. LocalDate parse "2020-01-01")
Expand All @@ -26,6 +26,9 @@
(t/testing "Duration"
(t/is (m/validate :time/duration (. Duration ofMillis 10) {:registry r}))
(t/is (not (m/validate :time/duration 10 {:registry r}))))
(t/testing "Period"
(t/is (m/validate :time/period (. Period of 10 1 2) {:registry r}))
(t/is (not (m/validate :time/period 10 {:registry r}))))
(t/testing "zone id"
(t/is (m/validate :time/zone-id (. ZoneId of "UTC") {:registry r}))
(t/is (not (m/validate :time/zone-id "UTC" {:registry r}))))
Expand Down Expand Up @@ -60,6 +63,38 @@
(t/is (-> [:time/duration {:min (. Duration ofMillis 9) :max (. Duration ofMillis 10)}]
(m/validate (. Duration ofMillis 12) {:registry r})
not)))
(t/testing "Period"
(t/is (-> [:time/period {:min (. Period ofYears 9) :max (. Period ofYears 10)}]
(m/validate (. Period ofYears 10) {:registry r})))
(t/is (-> [:time/period {:min (. Period ofMonths 9) :max (. Period ofMonths 10)}]
(m/validate (. Period ofMonths 12) {:registry r})
not))
(t/is (-> [:time/period {:min (. Period ofMonths 9) :max (. Period ofMonths 10)}]
(m/validate (. Period ofDays 12) {:registry r})
not))
(t/is (-> [:time/period {:min (. Period ofYears 9)}]
(m/validate (. Period ofYears 9) {:registry r})))
(t/is (-> [:time/period {:min (. Period ofYears 9)}]
(m/validate (. Period ofYears 10) {:registry r})))
(t/is (-> [:time/period {:min (. Period ofYears 9)}]
(m/validate (. Period ofYears 8) {:registry r})
not))
(t/is (-> [:time/period {:min (. Period of 0 10 2)}]
(m/validate (. Period of 1 9 3) {:registry r})))
(t/is (-> [:time/period {:max (. Period ofYears 9)}]
(m/validate (. Period ofYears 9) {:registry r})))
(t/is (-> [:time/period {:max (. Period ofYears 9)}]
(m/validate (. Period ofYears 8) {:registry r})))
(t/is (-> [:time/period {:max (. Period ofYears 9)}]
(m/validate (. Period ofDays 8) {:registry r})))
(t/is (-> [:time/period {:max (. Period ofYears 1)}]
(m/validate (. Period ofMonths 23) {:registry r})))
(t/is (-> [:time/period {:max (. Period ofYears 9)}]
(m/validate (. Period ofYears 10) {:registry r})
not))
(t/is (-> [:time/period {:max (. Period of 0 10 2)}]
(m/validate (. Period of 1 9 3) {:registry r})
not)))
(t/testing "local date"
(t/is (-> [:time/local-date {:min (. LocalDate parse "2020-01-01") :max (. LocalDate parse "2020-01-03")}]
(m/validate (. LocalDate parse "2020-01-01") {:registry r})))
Expand Down