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 merge
ing 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-spec
s 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/describe
s and accessing multifn
s underlying multi-spec
s 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 merge
d maps more like physical mixtures, i.e. disproportionately harder to separate?
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 defmethod
s, 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.