diff --git a/README.md b/README.md index 1db306442..cd9d685ab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/malli/error.cljc b/src/malli/error.cljc index 6cc2a4313..3bd165b6b 100644 --- a/src/malli/error.cljc +++ b/src/malli/error.cljc @@ -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]} _] diff --git a/test/malli/error_test.cljc b/test/malli/error_test.cljc index bcd55480e..64f41dfc4 100644 --- a/test/malli/error_test.cljc +++ b/test/malli/error_test.cljc @@ -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))))))