「2つのキーのどちらか一方のみを含むマップ」を表現するスペック

たとえば、「整数をとる:xというキー」と「文字列をとる:yというキー」をもつマップのスペックは以下のように表現できる:

(s/def ::x integer?)
(s/def ::y string?)

(s/keys :req-un [::x ::y])

;; (s/valid? (s/keys :req-un [::x ::y]) {:x 1})
;; => false
;; 両方のキーがないとダメ

もし、「:x:yの少なくともどちらか一方をもつ」という条件ならこう書ける:

(s/keys :req-un [(::x ::y)]

;; (s/valid? (s/keys :req-un [(or ::x ::y)]) {:x 1})
;; => true
;; 片方のキーだけでもOK

:req:req-unのベクタ内では、orだけでなくandも使えるようになっていて、キーの複雑な存在条件を記述することだできるようになっている。 この機能はあまり知られていないがs/keysのdocstringにもちゃんと書いてある:

The :req key vector supports 'and' and 'or' for key groups:

(s/keys :req [::x ::y (or ::secret (and ::user ::pwd))] :opt [::z])

ところで、上の例ではorを使っているので、当然:x:yも両方とも持つ場合もいいことになる:

(s/valid? (s/keys :req-un [(or ::x ::y)]) {:x 1 :y "foo"})
;; => true

たまに聞く話として「どちらか一方のキーのみをもつ場合しか受けつけたくないときはどうすればいいんだ?」というのがあるようだ (個人的には、今のところそういうスペックがほしいと思うケースに遭遇したことはない)。

そのような場合には、以下のようなスペックを書くのが常套的かと思う:

(s/and (s/keys :req-un [(or ::x ::y)])
       (fn [m] (or (and (contains? m :x) (not (contains? m :y)))
                   (and (not (contains? m :y)) (contains? m :y)))))

つまり、s/keysだけでは所望の条件を表現できないので、s/andないしs/mergeで追加の条件をつけてやるという話。

そういうものかと納得してしまえばそれまでの話なんだけど、たまたまs/keysの実装を読んでいたら、 s/keys内で使えるandorの位置には、実はそのスコープで見えてる任意の関数・マクロが使えるということのようだ。

これは単に今の時点でs/keysの構文チェックを厳密にしていないための不具合というか未定義動作なので、この挙動を利用して 何をかしようとするのはまったくもってお薦めしないのだけど、この挙動を使えばさっきの例はs/keysの中でしれっとnotを使ってやることで 以下のようにすっきりと書くことができる:

(s/keys :req-un [(or (and ::x (not ::y)) (and (not ::x) ::y))])

;; 両方のキーがあるとダメ
;; (s/valid? (s/keys :req-un [(or (and ::x (not ::y)) (and (not ::x) ::y))]) {:x 1 :y "foo"})
;; => false
;; 片方のキーのみならOK
;; => false
;; (s/valid? (s/keys :req-un [(or (and ::x (not ::y)) (and (not ::x) ::y))]) {:x 1})
;; => true
;; (s/valid? (s/keys :req-un [(or (and ::x (not ::y)) (and (not ::x) ::y))]) {:y "foo"})
;; => true
updatedupdated2017-08-032017-08-03