Skip to content
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,31 @@ Errors can be targeted using `:error/path` property:
;; => {:password2 ["passwords don't match"]}
```

And `:error/path` can also be a function, allowing the error path to be determined dynamically based on the data and context:

```clojure
(-> [:and [:map
[:password :string]
[:password2 :string]]
[:fn {:error/message "passwords don't match"
:error/path (fn [{:keys [data path in schema]}]
(if (contains? data :password2)
[:password2]
[:password]))}
(fn [{:keys [password password2]}]
(= password password2))]]
(m/explain {:password "secret"
:password2 "faarao"})
(me/humanize))
;; => {:password2 ["passwords don't match"]}
```

The function receives a map with the following keys:
- `:data` – the value being validated
- `:path` – the current error path
- `:in` – the input path (for advanced use)
- `:schema` – the current schema instance

By default, only direct erroneous schema properties are used:

```clojure
Expand Down
30 changes: 24 additions & 6 deletions src/malli/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -1778,10 +1778,18 @@
(fn explain [x in acc]
(try
(if-not (f x)
(conj acc (miu/-error path in this x))
(let [error-path-prop (:error/path properties)
error-path (if (fn? error-path-prop)
(error-path-prop {:data x :path path :in in :schema this})
error-path-prop)]
(conj acc (miu/-error (or error-path path) in this x)))
acc)
(catch #?(:clj Exception, :cljs js/Error) e
(conj acc (miu/-error path in this x (:type (ex-data e))))))))
(let [error-path-prop (:error/path properties)
error-path (if (fn? error-path-prop)
(error-path-prop {:data x :path path :in in :schema this})
error-path-prop)]
(conj acc (miu/-error (or error-path path) in this x (:type (ex-data e)))))))))
(-parser [this] (-simple-parser this))
(-unparser [this] (-parser this))
(-transformer [this transformer method options]
Expand Down Expand Up @@ -2238,12 +2246,22 @@
(fn explain [x in acc]
(if (not (fn? x))
(conj acc (miu/-error path in this x))
(if-let [res (checker x)]
(conj acc (assoc (miu/-error path in this x) :check res))
acc)))
(if-let [res (checker x)]
(let [error-path-prop (:error/path properties)
error-path (if (fn? error-path-prop)
(error-path-prop {:data x :path path :in in :schema this})
error-path-prop)]
(conj acc (assoc (miu/-error (or error-path path) in this x) :check res)))
acc)))
(let [validator (-validator this)]
(fn explain [x in acc]
(if-not (validator x) (conj acc (miu/-error path in this x)) acc)))))
(if-not (validator x)
(let [error-path-prop (:error/path properties)
error-path (if (fn? error-path-prop)
(error-path-prop {:data x :path path :in in :schema this})
error-path-prop)]
(conj acc (miu/-error (or error-path path) in this x)))
acc)))))
(-parser [this] (-simple-parser this))
(-unparser [this] (-parser this))
(-transformer [_ _ _ _])
Expand Down
23 changes: 18 additions & 5 deletions src/malli/error.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,11 @@
(defn- -path [{:keys [schema]}
{:keys [locale default-locale]
:or {default-locale :en}}]
(let [properties (m/properties schema)]
(or (-maybe-localized (:error/path properties) locale)
(-maybe-localized (:error/path properties) default-locale))))
(let [properties (m/properties schema)
error-path-prop (or (-maybe-localized (:error/path properties) locale)
(-maybe-localized (:error/path properties) default-locale))]
(when-not (fn? error-path-prop)
error-path-prop)))

;;
;; error values
Expand Down Expand Up @@ -283,7 +285,16 @@
([error]
(error-path error nil))
([error options]
(into (:in error) (-path error options))))
(let [properties (m/properties (:schema error))
error-path-prop (or (get-in properties [:error/path (:locale options :en)])
(get-in properties [:error/path (:default-locale options :en)])
(:error/path properties))
path-from-props (-path error options)]
(if (fn? error-path-prop)
;; If :error/path is a function, use the path from the error object (already computed in explainer)
(into (:in error) (:path error))
;; Otherwise, use the path from properties (or nil if not set)
(into (:in error) path-from-props)))))

(defn error-message
([error]
Expand Down Expand Up @@ -311,7 +322,9 @@
(let [options (assoc options :unknown false)]
(loop [path path, l nil, mp path, p (m/properties (:schema error)), m (error-message error options)]
(let [[path' m' p'] (or (let [schema (mu/get-in schema path)]
(when-let [m' (error-message {:schema schema} options)] [path m' (m/properties schema)]))
(when schema
(when-let [m' (error-message {:schema schema} options)]
[path m' (m/properties schema)])))
(let [res (and l (mu/find (mu/get-in schema path) l))]
(when (vector? res)
(let [[_ props schema] res
Expand Down
75 changes: 75 additions & 0 deletions test/malli/error_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,81 @@
:password2 "faarao"})
(me/humanize {:resolve me/-resolve-root-error})))))

(testing ":fn with dynamic :error/path"
(let [schema [:and
[:map
[:last-password string?]
[:new-passwords
[:map-of :uuid
[:map
[:password string?]]]]]
[:fn {:error/message "last password is forbidden"
:error/path (fn [{:keys [data]}]
(some->> (:new-passwords data)
(filter (fn [[_k v]] (= (:password v) (:last-password data))))
(first)
(first)
(#(conj [:new-passwords] % :password))))}
(fn [{:keys [last-password new-passwords]}]
(not (some (fn [[_k v]] (= (:password v) last-password))
new-passwords)))]]
uuid1 #uuid "123e4567-e89b-12d3-a456-426614174000"
uuid2 #uuid "223e4567-e89b-12d3-a456-426614174000"]
(is (= {:new-passwords {uuid1 {:password ["last password is forbidden"]}}}
(-> schema
(m/explain {:last-password "secret"
:new-passwords {uuid1 {:password "secret"}
uuid2 {:password "different"}}})
(me/humanize))))
(is (= {:last-password ["missing required key"]
:new-passwords ["missing required key"]}
(-> schema
(m/explain {})
(me/humanize))))))

(testing "dynamic :error/path with all parameters available"
(let [schema [:and
[:map
[:user [:map
[:email string?]
[:age int?]]]]
[:fn {:error/message "user must be adult"
:error/path (fn [{:keys [data path in schema]}]
(when (and (map? data)
(contains? data :user)
(map? (:user data))
(contains? (:user data) :age)
(< (:age (:user data)) 18))
[:user :age]))}
(fn [{:keys [user]}]
(or (not (map? user))
(not (contains? user :age))
(>= (:age user) 18)))]]]
(is (= {:user {:age ["user must be adult"]}}
(-> schema
(m/explain {:user {:email "[email protected]"
:age 17}})
(me/humanize))))
(is (= nil
(-> schema
(m/explain {:user {:email "[email protected]"
:age 18}})
(me/humanize))))))

(testing "dynamic :error/path returns nil (fallback to original path)"
(let [schema [:and
[:map
[:value int?]]
[:fn {:error/message "value must be positive"
:error/path (fn [{:keys [data]}]
;; Return nil to test fallback to original path
nil)}
pos?]]]
(is (= {1 ["value must be positive"]}
(-> schema
(m/explain {:value -1})
(me/humanize))))))

(testing "refs #1106"
(is (= {:foo ["should be an integer"]}
(me/humanize
Expand Down