Search code examples
clojureclojure.spec

Using clojure.spec to decompose a map


I recognize that clojure.spec isn't intended for arbitrary data transformation, and as I understand it, it is intended for flexibly encoding domain knowledge via arbitrary predicates. It's an insanely powerful tool, and I love using it.

So much, perhaps, that I've run into a scenario where I am mergeing maps, component-a and component-b, each of which can take one of many forms, into a composite, and then later wanting to "unmix" the composite into its component parts.

This is modeled as two multi-specs for the components and an s/merge of those components for the composite:

;; component-a
(defmulti component-a :protocol)
(defmethod component-a :p1 [_]
  (s/keys :req-un [::x ::y ::z]))
(defmethod component-a :p2 [_]
  (s/keys :req-un [::p ::q ::r]))
(s/def ::component-a
  (s/multi-spec component-a :protocol))

;; component-b
(defmulti component-b :protocol)
(defmethod component-b :p1 [_]
  (s/keys :req-un [::i ::j ::k]))
(defmethod component-b :p2 [_]
  (s/keys :req-un [::s ::t]))
(s/def ::component-b
  (s/multi-spec component-b :protocol))

;; composite
(s/def ::composite
  (s/merge ::component-a ::component-b)

What I'd like to be able to do is the following:

(def p1a {:protocol :p1 :x ... :y ... :z ...})
(def p1b (make-b p1a)) ; => {:protocol :p1 :i ... :j ... :k ...}

(def a (s/conform ::component-a p1a))
(def b (s/conform ::component-b p1b))
(def ab1 (s/conform ::composite (merge a b))

(?Fn ::component-a ab1) ; => {:protocol :p1 :x ... :y ... :z ...}
(?Fn ::component-b ab1) ; => {:protocol :p1 :i ... :j ... :k ...}

(def ab2 {:protocol :p2 :p ... :q ... :r ... :s ... :t ...})
(?Fn ::component-a ab2) ; => {:protocol :p2 :p ... :q ... :r ...}
(?Fn ::component-b ab2) ; => {:protocol :p2 :s ... :t ...}

In other words, I'd like to reuse the domain knowledge encoded for component-a and component-b, to decompose a composite.

My first thought was to isolate the keys themselves from the call to s/keys:

(defmulti component-a :protocol)
(defmethod component-a :p1 [_]
  (s/keys :req-un <form>)) ; <form> must look like [::x ::y ::z]

However, approaches where the keys of s/keys are computed from "something else" fail because <form> must be an ISeq. That is, <form> can neither be a fn that computes an ISeq, nor a symbol that represents an ISeq.

I also experimented with using s/describe to read the keys dynamically at run-time, but this doesn't work generally with multi-specs as it would with a simple s/def. I won't say I exhausted this approach, but it seemed like a rabbit hole of recursive s/describes and accessing multifns underlying multi-specs directly, which felt dirty.

I also thought about adding a separate multifn based on :protocol:

(defmulti decompose-composite :protocol)
(defmethod decompose-composite :p1
  [composite]
  {:component-a (select-keys composite [x y z])
   :component-b (select-keys composite [i j k]))

But this obviously doesn't reuse domain knowledge, it just duplicates it and exposes another avenue of applying it. It's also specific to the one composite; we'd need a decompose-other-composite for a different composite.

So at this point this is just a fun puzzle. We could always nest the components in the composite, making them trivial to isolate again:

(s/def ::composite
  (s/keys :req-un [::component-a ::component-b]))
(def ab {:component-a a :component-b b})
(do-composite-stuff (apply merge (vals ab)))

But is there a better way to achieve ?Fn? Could a custom s/conformer do something like this? Or are merged maps more like physical mixtures, i.e. disproportionately harder to separate?


Solution

  • I also experimented with using s/describe to read the keys dynamically at run-time, but this doesn't work generally with multi-specs as it would with a simple s/def

    A workaround that comes to mind is defining the s/keys specs separate from/outside of the defmethods, then getting the s/keys form back and pulling the keywords out.

    ;; component-a
    (s/def ::component-a-p1-map
      (s/keys :req-un [::protocol ::x ::y ::z])) ;; NOTE explicit ::protocol key added
    (defmulti component-a :protocol)
    (defmethod component-a :p1 [_] ::component-a-p1-map)
    (s/def ::component-a
      (s/multi-spec component-a :protocol))
    ;; component-b
    (defmulti component-b :protocol)
    (s/def ::component-b-p1-map
      (s/keys :req-un [::protocol ::i ::j ::k]))
    (defmethod component-b :p1 [_] ::component-b-p1-map)
    (s/def ::component-b
      (s/multi-spec component-b :protocol))
    ;; composite
    (s/def ::composite (s/merge ::component-a ::component-b))
    
    (def p1a {:protocol :p1 :x 1 :y 2 :z 3})
    (def p1b {:protocol :p1 :i 4 :j 5 :k 6})
     (def a (s/conform ::component-a p1a))
    (def b (s/conform ::component-b p1b))
    (def ab1 (s/conform ::composite (merge a b)))
    

    With standalone specs for the s/keys specs, you can get the individual keys back using s/form:

    (defn get-spec-keys [keys-spec]
      (let [unqualify (comp keyword name)
            {:keys [req req-un opt opt-un]}
            (->> (s/form keys-spec)
                 (rest)
                 (apply hash-map))]
        (concat req (map unqualify req-un) opt (map unqualify opt-un))))
    
    (get-spec-keys ::component-a-p1-map)
    => (:protocol :x :y :z)
    

    And with that you can use select-keys on the composite map:

    (defn ?Fn [spec m]
      (select-keys m (get-spec-keys spec)))
    
    (?Fn ::component-a-p1-map ab1)
    => {:protocol :p1, :x 1, :y 2, :z 3}
    
    (?Fn ::component-b-p1-map ab1)
    => {:protocol :p1, :i 4, :j 5, :k 6}
    

    And using your decompose-composite idea:

    (defmulti decompose-composite :protocol)
    (defmethod decompose-composite :p1
      [composite]
      {:component-a (?Fn ::component-a-p1-map composite)
       :component-b (?Fn ::component-b-p1-map composite)})
    
    (decompose-composite ab1)
    => {:component-a {:protocol :p1, :x 1, :y 2, :z 3},
        :component-b {:protocol :p1, :i 4, :j 5, :k 6}}
    

    However, approaches where the keys of s/keys are computed from "something else" fail because must be an ISeq. That is, can neither be a fn that computes an ISeq, nor a symbol that represents an ISeq.

    Alternatively, you could eval a programmatically constructed s/keys form:

    (def some-keys [::protocol ::x ::y ::z])
    (s/form (eval `(s/keys :req-un ~some-keys)))
    => (clojure.spec.alpha/keys :req-un [:sandbox.core/protocol
                                         :sandbox.core/x
                                         :sandbox.core/y
                                         :sandbox.core/z])
    

    And then use some-keys directly later.