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

:not humanizer #1138

Merged
merged 1 commit into from
Dec 8, 2024
Merged
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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -817,7 +817,50 @@ Or if you already have a malli validation exception (e.g. in a catch form):

## Custom error messages

Error messages can be customized with `:error/message` and `:error/fn` properties:
Error messages can be customized with `:error/message` and `:error/fn` properties.

If `:error/message` is of a predictable structure, it will automatically support custom `[:not schema]` failures for the following locales:
- `:en` if message starts with `should` or `should not` then they will be swapped automatically. Otherwise, message is ignored.
```clojure
;; e.g.,
(me/humanize
(m/explain
[:not
[:fn {:error/message {:en "should be a multiple of 3"}}
#(= 0 (mod % 3))]]
3))
; => ["should not be a multiple of 3"]
```

The first argument to `:error/fn` is a map with keys:
- `:schema`, the schema to explain
- `:value` (optional), the value to explain
- `:negated` (optional), a function returning the explanation of `(m/explain [:not schema] value)`.
If provided, then we are explaining the failure of negating this schema via `(m/explain [:not schema] value)`.
Note in this scenario, `(m/validate schema value)` is true.
If returning a string,
the resulting error message will be negated by the `:error/fn` caller in the same way as `:error/message`.
Returning `(negated string)` disables this behavior and `string` is used as the negated error message.
```clojure
;; automatic negation
(me/humanize
(m/explain
[:not [:fn {:error/fn {:en (fn [_ _] "should not be a multiple of 3")}}
#(not= 0 (mod % 3))]]
1))
; => ["should be a multiple of 3"]

;; manual negation
(me/humanize
(m/explain [:not [:fn {:error/fn {:en (fn [{:keys [negated]} _]
(if negated
(negated "should not avoid being a multiple of 3")
"should not be a multiple of 3"))}}
#(not= 0 (mod % 3))]] 1))
; => ["should not avoid being a multiple of 3"]
```

Here are some basic examples of `:error/message` and `:error/fn`:

```clojure
(-> [:map
96 changes: 67 additions & 29 deletions src/malli/error.cljc
Original file line number Diff line number Diff line change
@@ -3,16 +3,43 @@
[malli.core :as m]
[malli.util :as mu]))

(declare default-errors error-message)

(defn -pr-str [v] #?(:clj (pr-str v), :cljs (str v)))

(defn -pred-min-max-error-fn [{:keys [pred message]}]
(fn [{:keys [schema value]} _]
(fn [{:keys [schema value negated]} _]
(let [{:keys [min max]} (m/properties schema)]
(cond
(not (pred value)) message
(and min (= min max)) (str "should be " min)
(and min (< value min)) (str "should be at least " min)
max (str "should be at most " max)))))
(and min ((if negated >= <) value min)) (str "should be at least " min)
max (str "should be at most " max)
negated message))))

(let [prefix (str "-en-humanize-negation-" (random-uuid))]
(defn- -en-humanize-negation [{:keys [schema negated] :as error} options]
(if negated
(negated (error-message (dissoc error :negated) options))
(let [remove-prefix #(str/replace-first % prefix "")
negated? #(str/starts-with? % prefix)]
(loop [schema schema]
(or (when-some [s (error-message (assoc error :negated #(some->> % (str prefix))) options)]
(if (negated? s)
(remove-prefix s)
(or (when (and (string? s)
(str/starts-with? s "should not "))
(str/replace-first s "should not" "should"))
(when (and (string? s)
(str/starts-with? s "should "))
(str/replace-first s "should" "should not")))))
(let [dschema (m/deref schema)]
(when-not (identical? schema dschema)
(recur dschema)))))))))

(defn- -forward-negation [?schema {:keys [negated] :as error} options]
(let [schema (m/schema ?schema options)]
(negated (error-message (-> error (dissoc :negated) (assoc :schema schema)) options))))

(def default-errors
{::unknown {:error/message {:en "unknown error"}}
@@ -64,8 +91,8 @@
'uri? {:error/message {:en "should be a uri"}}
#?@(:clj ['decimal? {:error/message {:en "should be a decimal"}}])
'inst? {:error/message {:en "should be an inst"}}
'seqable? {:error/message {:en "should be a seqable"}}
'indexed? {:error/message {:en "should be an indexed"}}
'seqable? {:error/message {:en "should be seqable"}}
'indexed? {:error/message {:en "should be indexed"}}
'map? {:error/message {:en "should be a map"}}
'vector? {:error/message {:en "should be a vector"}}
'list? {:error/message {:en "should be a list"}}
@@ -79,30 +106,33 @@
#?@(:clj ['rational? {:error/message {:en "should be a rational"}}])
'coll? {:error/message {:en "should be a coll"}}
'empty? {:error/message {:en "should be empty"}}
'associative? {:error/message {:en "should be an associative"}}
'sequential? {:error/message {:en "should be a sequential"}}
'associative? {:error/message {:en "should be associative"}}
'sequential? {:error/message {:en "should be sequential"}}
#?@(:clj ['ratio? {:error/message {:en "should be a ratio"}}])
#?@(:clj ['bytes? {:error/message {:en "should be bytes"}}])
:re {:error/message {:en "should match regex"}}
:=> {:error/message {:en "invalid function"}}
:=> {:error/message {:en "should be a valid function"}}
'ifn? {:error/message {:en "should be an ifn"}}
'fn? {:error/message {:en "should be an fn"}}
'fn? {:error/message {:en "should be a fn"}}
:enum {:error/fn {:en (fn [{:keys [schema]} _]
(str "should be "
(if (= 1 (count (m/children schema)))
(-pr-str (first (m/children schema)))
(str "either " (->> (m/children schema) butlast (map -pr-str) (str/join ", "))
" or " (-pr-str (last (m/children schema)))))))}}
:not {:error/fn {:en (fn [{:keys [schema] :as error} options]
(-en-humanize-negation (assoc error :schema (-> schema m/children first)) options))}}
:any {:error/message {:en "should be any"}}
:nil {:error/message {:en "should be nil"}}
:string {:error/fn {:en (fn [{:keys [schema value]} _]
:string {:error/fn {:en (fn [{:keys [schema value negated]} _]
(let [{:keys [min max]} (m/properties schema)]
(cond
(not (string? value)) "should be a string"
(and min (= min max)) (str "should be " min " character" (when (not= 1 min) "s"))
(and min (< (count value) min)) (str "should be at least " min " character"
(when (not= 1 min) "s"))
max (str "should be at most " max " character" (when (not= 1 max) "s")))))}}
(and min ((if negated >= <) (count value) min)) (str "should be at least " min " character"
(when (not= 1 min) "s"))
max (str "should be at most " max " character" (when (not= 1 max) "s"))
negated "should be a string")))}}
:int {:error/fn {:en (-pred-min-max-error-fn {:pred int?, :message "should be an integer"})}}
:double {:error/fn {:en (-pred-min-max-error-fn {:pred double?, :message "should be a double"})}}
:boolean {:error/message {:en "should be a boolean"}}
@@ -111,22 +141,30 @@
:qualified-keyword {:error/message {:en "should be a qualified keyword"}}
:qualified-symbol {:error/message {:en "should be a qualified symbol"}}
:uuid {:error/message {:en "should be a uuid"}}
:> {:error/fn {:en (fn [{:keys [schema value]} _]
(if (number? value)
(str "should be larger than " (first (m/children schema)))
"should be a number"))}}
:>= {:error/fn {:en (fn [{:keys [schema value]} _]
(if (number? value)
(str "should be at least " (first (m/children schema)))
"should be a number"))}}
:< {:error/fn {:en (fn [{:keys [schema value]} _]
(if (number? value)
(str "should be smaller than " (first (m/children schema)))
"should be a number"))}}
:<= {:error/fn {:en (fn [{:keys [schema value]} _]
(if (number? value)
(str "should be at most " (first (m/children schema)))
"should be a number"))}}
:> {:error/fn {:en (fn [{:keys [schema value negated] :as error} options]
(if negated
(-forward-negation [:<= (first (m/children schema))] error options)
(if (number? value)
(str "should be larger than " (first (m/children schema)))
"should be a number")))}}
:>= {:error/fn {:en (fn [{:keys [schema value negated] :as error} options]
(if negated
(-forward-negation [:< (first (m/children schema))] error options)
(if (number? value)
(str "should be at least " (first (m/children schema)))
"should be a number")))}}
:< {:error/fn {:en (fn [{:keys [schema value negated] :as error} options]
(if negated
(-forward-negation [:>= (first (m/children schema))] error options)
(if (number? value)
(str "should be smaller than " (first (m/children schema)))
"should be a number")))}}
:<= {:error/fn {:en (fn [{:keys [schema value negated] :as error} options]
(if negated
(-forward-negation [:> (first (m/children schema))] error options)
(if (number? value)
(str "should be at most " (first (m/children schema)))
"should be a number")))}}
:= {:error/fn {:en (fn [{:keys [schema]} _]
(str "should be " (-pr-str (first (m/children schema)))))}}
:not= {:error/fn {:en (fn [{:keys [schema]} _]
143 changes: 140 additions & 3 deletions test/malli/error_test.cljc
Original file line number Diff line number Diff line change
@@ -6,7 +6,8 @@
[malli.generator :as mg]
[malli.util :as mu]
#?(:clj [malli.test-macros :refer [when-env]]))
#?(:cljs (:require-macros [malli.test-macros :refer [when-env]])))
#?(:cljs (:require-macros [malli.test-macros :refer [when-env]]))
#?(:cljs (:import (goog Uri))))

(deftest error-message-test
(let [msg "should be an int"
@@ -457,11 +458,11 @@
(me/humanize))))))

(deftest function-test
(is (= ["invalid function"]
(is (= ["should be a valid function"]
(-> [:=> [:cat int? int?] int?]
(m/explain malli.core-test/single-arity {::m/function-checker mg/function-checker})
(me/humanize))))
(is (= ["invalid function"]
(is (= ["should be a valid function"]
(-> [:=> [:cat int? int?] int?]
(m/explain 123)
(me/humanize)))))
@@ -793,3 +794,139 @@
(is (= ["should be a"] (me/humanize (m/explain [:= 'a] 1))))
(is (= ["should not be \"a\""] (me/humanize (m/explain [:not= "a"] "a"))))
(is (= ["should not be a"] (me/humanize (m/explain [:not= 'a] 'a))))))

(deftest not-humanize-test
(is (= ["should not be any"] (me/humanize (m/explain [:not any?] true))))
(is (= ["should not be some"] (me/humanize (m/explain [:not some?] true))))
(is (= ["should not be a number"] (me/humanize (m/explain [:not number?] 1))))
(is (= ["should not be an integer"] (me/humanize (m/explain [:not integer?] 1))))
(is (= ["should not be an int"] (me/humanize (m/explain [:not int?] 1))))
(is (= ["should not be a positive int"] (me/humanize (m/explain [:not pos-int?] 1))))
(is (= ["should not be a negative int"] (me/humanize (m/explain [:not neg-int?] -1))))
(is (= ["should not be a non-negative int"] (me/humanize (m/explain [:not nat-int?] 1))))
(is (= ["should not be positive"] (me/humanize (m/explain [:not pos?] 1))))
(is (= ["should not be negative"] (me/humanize (m/explain [:not neg?] -1))))
(is (= ["should not be a float"] (me/humanize (m/explain [:not float?] 1.23))))
(is (= ["should not be a double"] (me/humanize (m/explain [:not double?] 1.23))))
(is (= ["should not be a boolean"] (me/humanize (m/explain [:not boolean?] true))))
(is (= ["should not be a string"] (me/humanize (m/explain [:not string?] ""))))
(is (= ["should not be an ident"] (me/humanize (m/explain [:not ident?] 'a))))
(is (= ["should not be a simple ident"] (me/humanize (m/explain [:not simple-ident?] 'a))))
(is (= ["should not be a qualified ident"] (me/humanize (m/explain [:not qualified-ident?] ::a))))
(is (= ["should not be a keyword"] (me/humanize (m/explain [:not keyword?] :a))))
(is (= ["should not be a simple keyword"] (me/humanize (m/explain [:not simple-keyword?] :a))))
(is (= ["should not be a qualified keyword"] (me/humanize (m/explain [:not qualified-keyword?] ::a))))
(is (= ["should not be a symbol"] (me/humanize (m/explain [:not symbol?] 'a))))
(is (= ["should not be a simple symbol"] (me/humanize (m/explain [:not simple-symbol?] 'a))))
(is (= ["should not be a qualified symbol"] (me/humanize (m/explain [:not qualified-symbol?] `a))))
(is (= ["should not be a uuid"] (me/humanize (m/explain [:not uuid?] (random-uuid)))))
(is (= ["should not be a uri"] (me/humanize (m/explain [:not uri?] (#?(:clj java.net.URI.
:cljs Uri.
:default (throw (ex-info "Create URI" {})))
"http://asdf.com")))))
#?(:clj (is (= ["should not be a decimal"] (me/humanize (m/explain [:not decimal?] 1M)))))
(is (= ["should not be an inst"] (me/humanize (m/explain [:not inst?] #inst "2018-04-27T18:25:37Z"))))
(is (= ["should not be seqable"] (me/humanize (m/explain [:not seqable?] nil))))
(is (= ["should not be indexed"] (me/humanize (m/explain [:not indexed?] []))))
(is (= ["should not be a map"] (me/humanize (m/explain [:not map?] {}))))
(is (= ["should not be a vector"] (me/humanize (m/explain [:not vector?] []))))
(is (= ["should not be a list"] (me/humanize (m/explain [:not list?] (list)))))
(is (= ["should not be a seq"] (me/humanize (m/explain [:not seq?] (list)))))
(is (= ["should not be a char"] (me/humanize (m/explain [:not char?] \a))))
(is (= ["should not be a set"] (me/humanize (m/explain [:not set?] #{}))))
(is (= ["should not be nil"] (me/humanize (m/explain [:not nil?] nil))))
(is (= ["should not be false"] (me/humanize (m/explain [:not false?] false))))
(is (= ["should not be true"] (me/humanize (m/explain [:not true?] true))))
(is (= ["should not be zero"] (me/humanize (m/explain [:not zero?] 0))))
#?(:clj (is (= ["should not be a rational"] (me/humanize (m/explain [:not rational?] 1/2)))))
(is (= ["should not be a coll"] (me/humanize (m/explain [:not coll?] []))))
(is (= ["should not be empty"] (me/humanize (m/explain [:not empty?] []))))
(is (= ["should not be associative"] (me/humanize (m/explain [:not associative?] []))))
(is (= ["should not be sequential"] (me/humanize (m/explain [:not sequential?] []))))
#?(:clj (is (= ["should not be a ratio"] (me/humanize (m/explain [:not ratio?] 1/2)))))
#?(:clj (is (= ["should not be bytes"] (me/humanize (m/explain [:not bytes?] (byte-array 0))))))
(is (= ["should not match regex"] (me/humanize (m/explain [:not [:re #""]] ""))))
(is (= ["should not be a valid function"] (me/humanize (m/explain [:not [:=> :cat :any]] (fn [])))))
(is (= ["should not be an ifn"] (me/humanize (m/explain [:not ifn?] (fn [])))))
(is (= ["should not be a fn"] (me/humanize (m/explain [:not fn?] (fn [])))))
(is (= ["should not be 1"] (me/humanize (m/explain [:not [:enum 1]] 1))))
(is (= ["should not be either 1, 2 or 3"] (me/humanize (m/explain [:not [:enum 1 2 3]] 1))))
(is (= ["should not be any"] (me/humanize (m/explain [:not :any] 1))))
(is (= ["should not be nil"] (me/humanize (m/explain [:not :nil] nil))))
(is (= ["should not be a string"] (me/humanize (m/explain [:not :string] "a"))))
(is (= ["should not be at least 1 character"] (me/humanize (m/explain [:not [:string {:min 1}]] "a"))))
(is (= ["should not be at most 1 character"] (me/humanize (m/explain [:not [:string {:max 1}]] "a"))))
(is (= ["should not be 1 character"] (me/humanize (m/explain [:not [:string {:min 1 :max 1}]] "a"))))
(is (= ["should not be an integer"] (me/humanize (m/explain [:not :int] 1))))
(is (= ["should not be at least 1"] (me/humanize (m/explain [:not [:int {:min 1}]] 1))))
(is (= ["should not be at most 1"] (me/humanize (m/explain [:not [:int {:max 1}]] 1))))
(is (= ["should not be 1"] (me/humanize (m/explain [:not [:int {:min 1 :max 1}]] 1))))
(is (= ["should not be a double"] (me/humanize (m/explain [:not :double] 1.5))))
(is (= ["should not be at least 1.5"] (me/humanize (m/explain [:not [:double {:min 1.5}]] 1.5))))
(is (= ["should not be at most 1.5"] (me/humanize (m/explain [:not [:double {:max 1.5}]] 1.5))))
(is (= ["should not be 1.5"] (me/humanize (m/explain [:not [:double {:min 1.5 :max 1.5}]] 1.5))))
(is (= ["should not be a boolean"] (me/humanize (m/explain [:not :boolean] true))))
(is (= ["should not be a keyword"] (me/humanize (m/explain [:not :keyword] :a))))
(is (= ["should not be a symbol"] (me/humanize (m/explain [:not :symbol] 'a))))
(is (= ["should not be a qualified keyword"] (me/humanize (m/explain [:not :qualified-keyword] ::a))))
(is (= ["should not be a qualified symbol"] (me/humanize (m/explain [:not :qualified-symbol] `a))))
(is (= ["should not be a uuid"] (me/humanize (m/explain [:not :uuid] (random-uuid)))))
(is (= ["should be at most 1"] (me/humanize (m/explain [:not [:> 1]] 2))))
(is (= ["should be smaller than 1"] (me/humanize (m/explain [:not [:>= 1]] 2))))
(is (= ["should be at least 1"] (me/humanize (m/explain [:not [:< 1]] 0))))
(is (= ["should be larger than 1"] (me/humanize (m/explain [:not [:<= 1]] 0))))
(is (= ["should not be 1"] (me/humanize (m/explain [:not [:= 1]] 1))))
(is (= ["should be 1"] (me/humanize (m/explain [:not [:not= 1]] nil)))))

(deftest nested-not-humanize-test
(testing ":="
(is (= ["should be 1"] (me/humanize (m/explain [:= 1] nil))))
(is (= ["should not be 1"] (me/humanize (m/explain [:not [:= 1]] 1))))
(is (= ["should be 1"] (me/humanize (m/explain [:not [:not [:= 1]]] nil))))
(is (= ["should not be 1"] (me/humanize (m/explain [:not [:not [:not [:= 1]]]] 1))))
(is (= ["should be 1"] (me/humanize (m/explain [:not [:not [:not [:not [:= 1]]]]] nil)))))
(testing ":>"
(is (= ["should be larger than 1"] (me/humanize (m/explain [:> 1] 0))))
(is (= ["should be at most 1"] (me/humanize (m/explain [:not [:> 1]] 2))))
(is (= ["should be larger than 1"] (me/humanize (m/explain [:not [:not [:> 1]]] 0))))
(is (= ["should be at most 1"] (me/humanize (m/explain [:not [:not [:not [:> 1]]]] 2))))
(is (= ["should be larger than 1"] (me/humanize (m/explain [:not [:not [:not [:not [:> 1]]]]] 0)))))
(testing ":>="
(is (= ["should be at least 1"] (me/humanize (m/explain [:>= 1] 0))))
(is (= ["should be smaller than 1"] (me/humanize (m/explain [:not [:>= 1]] 2))))
(is (= ["should be at least 1"] (me/humanize (m/explain [:not [:not [:>= 1]]] 0))))
(is (= ["should be smaller than 1"] (me/humanize (m/explain [:not [:not [:not [:>= 1]]]] 2))))
(is (= ["should be at least 1"] (me/humanize (m/explain [:not [:not [:not [:not [:>= 1]]]]] 0)))))
(testing ":<"
(is (= ["should be smaller than 1"] (me/humanize (m/explain [:< 1] 2))))
(is (= ["should be at least 1"] (me/humanize (m/explain [:not [:< 1]] 0))))
(is (= ["should be smaller than 1"] (me/humanize (m/explain [:not [:not [:< 1]]] 2))))
(is (= ["should be at least 1"] (me/humanize (m/explain [:not [:not [:not [:< 1]]]] 0))))
(is (= ["should be smaller than 1"] (me/humanize (m/explain [:not [:not [:not [:not [:< 1]]]]] 2)))))
(testing ":<="
(is (= ["should be at most 1"] (me/humanize (m/explain [:<= 1] 2))))
(is (= ["should be larger than 1"] (me/humanize (m/explain [:not [:<= 1]] 0))))
(is (= ["should be at most 1"] (me/humanize (m/explain [:not [:not [:<= 1]]] 2))))
(is (= ["should be larger than 1"] (me/humanize (m/explain [:not [:not [:not [:<= 1]]]] 0))))
(is (= ["should be at most 1"] (me/humanize (m/explain [:not [:not [:not [:not [:<= 1]]]]] 2))))))

(deftest custom-negating-test
(is (= ["should be a multiple of 3"]
(me/humanize (m/explain [:fn {:error/message {:en "should be a multiple of 3"}} #(= 0 (mod % 3))] 2))))
(is (= ["should not be a multiple of 3"]
(me/humanize (m/explain [:not [:fn {:error/message {:en "should be a multiple of 3"}} #(= 0 (mod % 3))]] 3))))
(is (= ["should not be a multiple of 3 negated=false"]
(me/humanize (m/explain [:fn {:error/fn {:en (fn [{:keys [negated]} _] (str "should not be a multiple of 3 negated="
(boolean negated)))}}
#(not= 0 (mod % 3))] 0))))
(is (= ["should be a multiple of 3 negating=true"]
(me/humanize (m/explain [:not [:fn {:error/fn {:en (fn [{:keys [negated]} _] (str "should not be a multiple of 3 negating="
(boolean negated)))}}
#(not= 0 (mod % 3))]] 1))))
(testing ":negated disables implicit negation"
(is (= ["should not avoid being a multiple of 3"]
(me/humanize (m/explain [:not [:fn {:error/fn {:en (fn [{:keys [negated]} _]
(if negated
(negated "should not avoid being a multiple of 3")
"should not be a multiple of 3"))}}
#(not= 0 (mod % 3))]] 1))))))