malli
malli copied to clipboard
Proper way of annotating existing keys with extra options?
Hi there,
I'm trying to figure out the correct usage of Malli, but the documentation doesn't seem to give enough info on how to achieve my goal in the "intended" way. I found the code in #273 which helped me to make custom transformers, but I didn't find anything similar for custom validators.
I'm trying to make certain map keys mutually exclusive (so if one key is supplied, another should not be there).
I came up with a hack to make it work, which I'll supply below:
(ns mutex-schema
"Custom schema registry for ensuring mutually exclusive map keys"
(:require [malli.core :as m]
[clojure.set :as set]))
;; TODO: Figure out how to check that the entries are actually
;; children of the map...
(defn- mutex-entries-validator [props]
"Given a sequence of key sets from mutex/entries, check that none of
them occur more than once"
(let [entries (:mutex/entries props)]
(fn [m]
(and (map? m)
(not (some (fn [mutex-set]
(> (count (select-keys m mutex-set)) 1))
entries))))))
(def mutex-registry
(merge
(malli.core/default-schemas)
{::mutex-map (m/-simple-schema
{:type ::mutex-map
:pred map?
:property-pred mutex-entries-validator})}))
(defn mutex-schema
"Converts existing map entries into mutex-map entries.
This is a terrible hack which we will use for the time being, until
we grok how to make a non-simple schema which extends an existing
schema for :map."
([?schema]
(mutex-schema ?schema nil))
([?schema options]
(m/walk
?schema
(m/schema-walker
(fn [schema]
(let [props (m/-properties schema)]
(if (and (= :map (m/type schema options))
(:mutex/entries props))
(let [children (m/-children schema)]
(m/schema `[:and
[:map ~(dissoc props :mutex/entries) ~@children]
[::mutex-map ~props]]
{:registry mutex-registry}))
schema))))
options)))
Now I can use it like this:
(ns mutex-schema-test
(:require [malli.core :as m]
[mutex-schema :as ms]
[clojure.test :refer [deftest is testing]]))
(deftest mutex-schema-mutex-keys
(let [test-mutex-entry-map
(ms/mutex-schema
[:map {:mutex/entries [[:hah :bar] [:foo :hah]]}
[:foo {:optional true} :int]
[:blabla {:optional true} :int]
[:hah {:optional true} :int]
[:bar {:optional true} :int]])]
(testing "Validating maps with mutually exclusive keys raises an error"
(is (not (m/validate test-mutex-entry-map {:foo 1 :hah 2})))
(is (not (m/validate test-mutex-entry-map {:bar 1 :hah 2}))))
(testing "Validating maps with keys that aren't mutually exclusive is fine"
(is (m/validate test-mutex-entry-map {:foo 1 :bar 2}))
(is (m/validate test-mutex-entry-map {:foo 1 :bar 2 :blabla 3}))
(is (m/validate test-mutex-entry-map {:blabla 3})))))
While this works, it's hardly elegant, and like the TODO
says I'd like to ensure that the mentioned keys actually exist in the map, as well. What would be the idiomatic Malli way of doing this?
You could just just add the mutex-rules with a :fn
schema, composed with :and
. With a tiny helper, something like this:
(defn exclusive-keys [keys]
[:fn {:error/message (str "the following keys are mutually exclusive: " (str/join ", " keys))}
(fn [m] (not (every? (partial contains? m) keys)))])
(def MyMap
(m/schema
[:and
[:map
[:foo {:optional true} :int]
[:blabla {:optional true} :int]
[:hah {:optional true} :int]
[:bar {:optional true} :int]]
(exclusive-keys [:hah :bar])
(exclusive-keys [:hah :foo])]))
(-> MyMap
(m/explain {:foo 1 :hah 2})
(me/humanize))
; => #:malli{:error ["the following keys are mutually exclusive: :hah, :foo"]}
(-> MyMap
(m/explain {:bar 1 :hah 2})
(me/humanize))
; => #:malli{:error ["the following keys are mutually exclusive: :hah, :bar"]}
(-> MyMap
(m/explain {:foo 1 :bar 2})
(me/humanize))
; => nil
another option is to write real new Schema
object for the exclusive keys:
(def Exclusive
(m/-simple-schema
(fn [_ [keys]]
{:type 'Exclusive
:min 1
:max 1
:pred (fn [m] (not (every? (partial contains? m) keys)))
:type-properties {:error/message (str "the following keys are mutually exclusive: " (str/join ", " keys))}})))
(def MyMap
(m/schema
[:and
[:map
[:foo {:optional true} :int]
[:blabla {:optional true} :int]
[:hah {:optional true} :int]
[:bar {:optional true} :int]]
[Exclusive #{:hah :bar}]
[Exclusive #{:hah :foo}]]))
(-> MyMap
(m/explain {:foo 1 :hah 2})
(me/humanize))
; => #:malli{:error ["the following keys are mutually exclusive: :hah, :foo"]}
(-> MyMap
(m/explain {:bar 1 :hah 2})
(me/humanize))
; => #:malli{:error ["the following keys are mutually exclusive: :hah, :bar"]}
(-> MyMap
(m/explain {:foo 1 :bar 2})
(me/humanize))
; => nil
MyMap
;[:and
; [:map
; [:foo {:optional true} :int]
; [:blabla {:optional true} :int]
; [:hah {:optional true} :int]
; [:bar {:optional true} :int]]
; [Exclusive #{:hah :bar}]
; [Exclusive #{:hah :foo}]]
Thanks for replying so quickly! My initial approach looked like this, but I thought it would be cleaner to annotate the options of :map
with the exclusive keys, as it is IMO a property of the map. Would there be an easy way to do this, or would I have to copy the implementation of the existing -map-schema
? I couldn't find a way to do "inheritance", so to speak, calling the original -map-schema
's code from my own.