Skip to content

Commit

Permalink
Merge pull request #1138 from frenchy64/humanize-negation
Browse files Browse the repository at this point in the history
`:not` humanizer
  • Loading branch information
ikitommi authored Dec 8, 2024
2 parents dcfc4ba + 7a44cf6 commit f8a7308
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 33 deletions.
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 67 additions & 29 deletions src/malli/error.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down Expand Up @@ -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"}}
Expand All @@ -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"}}
Expand All @@ -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]} _]
Expand Down
Loading

0 comments on commit f8a7308

Please sign in to comment.