I'm writing a spec for a square, which is very simply a pair of integer coordinates (keys ::sq-x ::sq-y) combined with a sequential collection of vertices (key ::vtxs).
Speccing this constraint:
(s/def ::square
(s/and
map? ; this is probably not needed
(s/keys :req [::sq-x ::sq-y ::vtxs])))
The above checks the presence of the keys only. To also check the key values, I add specs named the same as the keys to be checked. This implicit link between specs is always live:
(s/def ::sq-x ::int-val)
(s/def ::sq-y ::int-val)
(s/def ::vtxs sequential?)
In the above ::int-val is another spec checking value integer-ness (we are basically aliasing the specs: ::sq-x -> ::int-val):
(s/def ::int-val #(= (Math/floor %) (* 1.0 %)))
This works perfectly well. From another package, which imports the package above as sut ("system under test"), I can run this test code with err... "good effect on target":
(t/deftest test-good-squares
(t/is (s/valid? ::sut/square
{ ::sut/sq-x 1 ::sut/sq-y -1 ::sut/vtxs [] }))
(t/is (s/valid? ::sut/square
{ ::sut/sq-x 5.0 ::sut/sq-y 5.0 ::sut/vtxs [] }))
(t/is (s/valid? ::sut/square
{ ::sut/sq-x 0.0 ::sut/sq-y 0.0 ::sut/vtxs [] })))
(t/deftest test-bad-squares-bad-coords
(t/is (not (s/valid? ::sut/square
{ ::sut/sq-x 1.1 ::sut/sq-y -1 ::sut/vtxs [] })))
(t/is (not (s/valid? ::sut/square
{ ::sut/sq-x -1 ::sut/sq-y 1.1 ::sut/vtxs [] })))
(t/is (not (s/valid? ::sut/square
{ ::sut/sq-x 1.1 ::sut/sq-y 1.1 ::sut/vtxs [] }))))
(t/deftest test-bad-squares-bad-vertexes
(t/is (not (s/valid? ::sut/square
{ ::sut/sq-x 1.1 ::sut/sq-y -1 ::sut/vtxs #{1 2 3} }))))
(t/deftest test-bad-squares-bad-type
(t/is (not (s/valid? ::sut/square [:a :b :c]))))
(t/deftest test-bad-squares-missing-keys
(t/is (not (s/valid? ::sut/square { ::sut/sq-y 0 ::sut/vtxs [] })))
(t/is (not (s/valid? ::sut/square { ::sut/sq-x 0 ::sut/vtxs [] })))
(t/is (not (s/valid? ::sut/square { ::sut/vtxs [] }))))
; call the above hierarchically
(t/deftest test-square
(test-good-squares)
(test-bad-squares-bad-coords)
(test-bad-squares-bad-vertexes)
(test-bad-squares-bad-type)
(test-bad-squares-missing-keys))
; call ONLY the test-square from "lein test", don't call individual
; tests a second time
(defn test-ns-hook [] (test-square))
So far so good.
Now, complication:
Prior to this I had tried to find a way to check the map values directly, without passing via another spec. I didn't find a way to make this palatable to Clojure. For example, this doesn't work:
(s/def ::square
(s/and
map?
(s/keys :req [::sq-x ::sq-y ::vtxs])
(::int-val #(get % ::sq-x))
(::int-val #(get % ::sq-y))
(sequential? #(get % ::vtxs))))
Runtime is ouch time:
java.lang.IllegalArgumentException: No implementation of method:
:specize* of protocol: #'clojure.spec.alpha/Specize found for class: nil
Ok. That code looks dodgy. Is there a way to reach into the map directly or am I always supposed to define another spec and call it implicitly through the naming?
am I always supposed to define another spec and call it implicitly through the naming?
To use clojure.spec as intended/designed, the natural approach is to register your key specs as you've done here:
(s/def ::sq-x ::int-val)
(s/def ::sq-y ::int-val)
(s/def ::vtxs sequential?)
This gives "global" meaning to the keywords ::sq-x, ::sq-y, etc. Using this approach allows you to define a s/keys spec for a map with those keys:
(s/def ::square (s/keys :req [::sq-x ::sq-y ::vtxs]))
Then if you conform a map against ::square, spec will resolve each key's spec (if they exist in the spec registry) and conform each key's value respectively:
(s/conform ::square {::sq-x 1 ::sq-y 0 ::vtxs ["hey"]})
The intention here is to tie specs to strong names/keywords, so that ::sq-x means the same thing everywhere (though it's actually :whatever-namespace-foo/sq-x.
Is there a way to reach into the map directly
Yes, you can certainly define custom predicates/functions to inspect/conform whatever data you like. Your example above has a couple issues:
(s/def ::square
(s/and
map? ;; unnecessary with s/keys
(s/keys :req [::sq-x ::sq-y ::vtxs])
;; the following forms don't evaluate to functions, so they aren't used as predicates
(::int-val #(get % ::sq-x))
(::int-val #(get % ::sq-y))
(sequential? #(get % ::vtxs))))
To get a better sense of that, try evaluating one of the forms individually and see that it evaluates to nil.
user=> (::int-val #(get % ::sq-x))
nil
What you want instead is a function that will be passed some value and either return a value or perhaps :clojure.spec.alpha/invalid. This example would work without registering individual key specs, but I don't think it aligns well with spec's design:
(s/def ::square
(s/and
(s/keys :req [::sq-x ::sq-y ::vtxs])
#(= (Math/floor (::sq-x %)) (* 1.0 (::sq-x %)))
#(= (Math/floor (::sq-y %)) (* 1.0 (::sq-y %)))
#(sequential? (::vtxs %))))
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With