malli icon indicating copy to clipboard operation
malli copied to clipboard

Proper way of annotating existing keys with extra options?

Open sjamaan opened this issue 4 years ago • 3 comments

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?

sjamaan avatar Nov 11 '20 15:11 sjamaan

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

ikitommi avatar Nov 14 '20 19:11 ikitommi

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}]]

ikitommi avatar Nov 14 '20 19:11 ikitommi

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.

sjamaan avatar Nov 16 '20 07:11 sjamaan