Skip to content

Commit 5bdf403

Browse files
authored
Merge pull request #1000 from metosin/fn-guard
Fn guard
2 parents 686408c + dba3a46 commit 5bdf403

File tree

6 files changed

+232
-73
lines changed

6 files changed

+232
-73
lines changed

CHANGELOG.md

+18
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ We use [Break Versioning][breakver]. The version numbers follow a `<major>.<mino
1414

1515
Malli is in well matured [alpha](README.md#alpha).
1616

17+
## Unreleased
18+
19+
* `:=>` takes optional 3rd child, the guard schema validating vector of arguments and return value `[args ret]`. See [Function Guards](docs/function-schemas.md#function-guards) for more details.
20+
21+
```clojure
22+
;; function of arg:int -> ret:int, where arg < ret
23+
[:=>
24+
[:cat :int]
25+
:int
26+
[:fn (fn [[[arg] ret]] (< arg ret))]]
27+
```
28+
29+
* **BREAKING**: `malli.generator/function-checker` returns explanations under new keys:
30+
* `::mg/explain-input` -> `::m/explain-input`
31+
* `::mg/explain-output` -> `::m/explain-output`
32+
* new `::m/explain-guard` to return guard explanation, if any
33+
* `m/explain` for `:=>` returns also errors for args, return and guard if they exist
34+
1735
## 0.14.0 (2024-01-16)
1836

1937
* Better development-time tooling

docs/function-schemas.md

+67-5
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ Examples of function definitions:
7878
[:=> [:catn
7979
[:x :int]
8080
[:xs [:+ :int]]] :int]
81-
81+
82+
;; arg:int -> ret:int, arg > ret
83+
[:=> [:cat :int] :int [:fn (fn [[arg] ret] (> arg ret))]]
84+
8285
;; multi-arity function
8386
[:function
8487
[:=> [:cat :int] :int]
@@ -156,6 +159,65 @@ Smallest failing invocation is `(str 0 0)`, which returns `"00"`, which is not a
156159

157160
But, why `mg/function-checker` is not enabled by default? The reason is that it uses generartive testing, which is orders of magnitude slower than normal validation and requires an extra dependency to `test.check`, which would make `malli.core` much heavier. This would be expecially bad for CLJS bundle size.
158161

162+
### Function Guards
163+
164+
`:=>` accepts optional third child, a guard schema that is used to validate a vector of function arguments and return value.
165+
166+
```clojure
167+
;; function schema of arg:int -> ret:int, where arg < ret
168+
;; with generative function checking always enabled
169+
(def arg<ret
170+
(m/schema
171+
[:=>
172+
[:cat :int]
173+
:int
174+
[:fn {:error/message "argument should be less than return"}
175+
(fn [[[arg] ret]] (< arg ret))]]
176+
{::m/function-checker mg/function-checker}))
177+
178+
(m/explain arg<ret (fn [x] (inc x)))
179+
; nil
180+
181+
(m/explain arg<ret (fn [x] x))
182+
;{:schema ...
183+
; :value #object[user$eval19073$fn__19074],
184+
; :errors ({:path [],
185+
; :in [],
186+
; :schema ...,
187+
; :value #object[user$eval19073$fn__19074],
188+
; :check {:total-nodes-visited 1,
189+
; :result false,
190+
; :result-data nil,
191+
; :smallest [(0)],
192+
; :time-shrinking-ms 0,
193+
; :pass? false,
194+
; :depth 0,
195+
; :malli.core/result 0}},
196+
; {:path [2],
197+
; :in [],
198+
; :schema [:fn
199+
; #:error{:message "argument should be less than return"}
200+
; (fn [[[arg] ret]] (< arg ret))],
201+
; :value [(0) 0]})}
202+
203+
(me/humanize *1)
204+
; ["invalid function" "argument should be less than return"]
205+
```
206+
207+
Identical schema using the Schema AST syntax:
208+
209+
```clojure
210+
(m/from-ast
211+
{:type :=>
212+
:input {:type :cat
213+
:children [{:type :int}]}
214+
:output {:type :int}
215+
:guard {:type :fn
216+
:value (fn [[[arg] ret]] (< arg ret))
217+
:properties {:error/message "argument should be less than return"}}}
218+
{::m/function-checker mg/function-checker})
219+
```
220+
159221
### Generating Functions
160222

161223
We can also generate function implementations based on the function schemas. The generated functions check the function arity and arguments at runtime and return generated values.
@@ -620,8 +682,8 @@ It's main entry points is `dev/start!`, taking same options as `mi/instrument!`.
620682
(m/=> plus1 [:=> [:cat :int] [:int {:max 6}]])
621683

622684
(dev/start!)
623-
; =prints=> ..instrumented #'user/plus1
624-
; =prints=> started instrumentation
685+
; malli: instrumented 1 function var
686+
; malli: dev-mode started
625687

626688
(plus1 "6")
627689
; =throws=> :malli.core/invalid-input {:input [:cat :int], :args ["6"], :schema [:=> [:cat :int] [:int {:max 6}]]}
@@ -636,8 +698,8 @@ It's main entry points is `dev/start!`, taking same options as `mi/instrument!`.
636698
; => 7
637699

638700
(dev/stop!)
639-
; =prints=> ..unstrumented #'user/plus1
640-
; =prints=> stopped instrumentation
701+
; malli: unstrumented 1 function vars
702+
; malli: dev-mode stopped
641703
```
642704

643705
## ClojureScript support

src/malli/core.cljc

+24-16
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,13 @@
204204

205205
(defn -function-info [schema]
206206
(when (= (type schema) :=>)
207-
(let [[input output] (-children schema)
207+
(let [[input output guard] (-children schema)
208208
{:keys [min max]} (-regex-min-max input false)]
209209
(cond-> {:min min
210210
:arity (if (= min max) min :varargs)
211211
:input input
212212
:output output}
213+
guard (assoc :guard guard)
213214
max (assoc :max max)))))
214215

215216
(defn -group-by-arity! [infos]
@@ -1750,15 +1751,16 @@
17501751
^{:type ::into-schema}
17511752
(reify
17521753
AST
1753-
(-from-ast [parent {:keys [input output properties]} options]
1754-
(-into-schema parent properties [(from-ast input options) (from-ast output options)] options))
1754+
(-from-ast [parent {:keys [input output guard properties]} options]
1755+
(-into-schema parent properties (cond-> [(from-ast input options) (from-ast output options)]
1756+
guard (conj (from-ast guard))) options))
17551757
IntoSchema
17561758
(-type [_] :=>)
17571759
(-type-properties [_])
17581760
(-into-schema [parent properties children {::keys [function-checker] :as options}]
1759-
(-check-children! :=> properties children 2 2)
1760-
(let [[input output :as children] (-vmap #(schema % options) children)
1761-
form (delay (-simple-form parent properties children -form options))
1761+
(-check-children! :=> properties children 2 3)
1762+
(let [[input output guard :as children] (-vmap #(schema % options) children)
1763+
form (delay (-create-form (-type parent) properties (-vmap -form children) options))
17621764
cache (-create-cache options)
17631765
->checker (if function-checker #(function-checker % options) (constantly nil))]
17641766
(when-not (#{:cat :catn} (type input))
@@ -1768,7 +1770,7 @@
17681770
AST
17691771
(-to-ast [_ _]
17701772
(cond-> {:type :=>, :input (ast input), :output (ast output)}
1771-
properties (assoc :properties properties)))
1773+
guard (assoc :guard (ast guard)), properties (assoc :properties properties)))
17721774
Schema
17731775
(-validator [this]
17741776
(if-let [checker (->checker this)]
@@ -1780,7 +1782,12 @@
17801782
(if (not (fn? x))
17811783
(conj acc (miu/-error path in this x))
17821784
(if-let [res (checker x)]
1783-
(conj acc (assoc (miu/-error path in this x) :check res))
1785+
(let [{::keys [explain-input explain-output explain-guard]} res
1786+
res (dissoc res ::explain-input ::explain-output ::explain-guard)
1787+
{:keys [path in] :as error} (assoc (miu/-error path in this x) :check res)
1788+
-push (fn [acc i e]
1789+
(cond-> acc e (into (map #(assoc % :path (conj path i), :in in) (:errors e)))))]
1790+
(-> (conj acc error) (-push 0 explain-input) (-push 1 explain-output) (-push 2 explain-guard)))
17841791
acc)))
17851792
(let [validator (-validator this)]
17861793
(fn explain [x in acc]
@@ -2582,19 +2589,19 @@
25822589
| key | description |
25832590
| ----------|-------------|
25842591
| `:schema` | function schema
2585-
| `:scope` | optional set of scope definitions, defaults to `#{:input :output}`
2592+
| `:scope` | optional set of scope definitions, defaults to `#{:input :output :guard}`
25862593
| `:report` | optional side-effecting function of `key data -> any` to report problems, defaults to `m/-fail!`
25872594
| `:gen` | optional function of `schema -> schema -> value` to be invoked on the args to get the return value"
25882595
([props]
25892596
(-instrument props nil nil))
25902597
([props f]
25912598
(-instrument props f nil))
2592-
([{:keys [scope report gen] :or {scope #{:input :output}, report -fail!} :as props} f options]
2599+
([{:keys [scope report gen] :or {scope #{:input :output :guard}, report -fail!} :as props} f options]
25932600
(let [schema (-> props :schema (schema options))]
25942601
(case (type schema)
2595-
:=> (let [{:keys [min max input output]} (-function-info schema)
2596-
[validate-input validate-output] (-vmap validator [input output])
2597-
[wrap-input wrap-output] (-vmap (partial contains? scope) [:input :output])
2602+
:=> (let [{:keys [min max input output guard]} (-function-info schema)
2603+
[validate-input validate-output validate-guard] (-vmap validator [input output (or guard :any)])
2604+
[wrap-input wrap-output wrap-guard] (-vmap #(contains? scope %) [:input :output :guard])
25982605
f (or (if gen (gen schema) f) (-fail! ::missing-function {:props props}))]
25992606
(fn [& args]
26002607
(let [args (vec args), arity (count args)]
@@ -2604,9 +2611,10 @@
26042611
(when-not (validate-input args)
26052612
(report ::invalid-input {:input input, :args args, :schema schema})))
26062613
(let [value (apply f args)]
2607-
(when wrap-output
2608-
(when-not (validate-output value)
2609-
(report ::invalid-output {:output output, :value value, :args args, :schema schema})))
2614+
(when (and wrap-output (not (validate-output value)))
2615+
(report ::invalid-output {:output output, :value value, :args args, :schema schema}))
2616+
(when (and wrap-guard (not (validate-guard [args value])))
2617+
(report ::invalid-guard {:guard guard, :value value, :args args, :schema schema}))
26102618
value))))
26112619
:function (let [arity->info (->> (children schema)
26122620
(map (fn [s] (assoc (-function-info s) :f (-instrument (assoc props :schema s) f options))))

0 commit comments

Comments
 (0)