Extra validator function

みんな大好き Prismatic の schema について Tips です。

たぶん多くの人が Prismatic/schema を使っていると思いますが、あまり俗な感じの記事を見かけないのでたまには書いてみる。

例えば、「商品」というレコードを定義したいときに、 schema を使うとこう書けると思います。

(require '[schema.core :as s])

(s/defrecord Item
    [type :- (s/enum :solid :liquid)
     unit :- (s/enum :kg :ml)])

これは商品が個体/液体で単位として kg か ml が使えますよ、といった感じの定義です。 実際に使ってみるとこう。

user=> (s/validate Item (map->Item {:type :liquid :unit :ml}))
#user.Item{:type :liquid, :unit :ml}

user=> (s/validate Item (map->Item {:type :foo :unit :bar}))
ExceptionInfo Value does not match schema: {:type (not (#{:solid :liquid} :foo)), :unit (not (#{:kg :ml} :bar))}  schema.core/validate (core.clj:176)

いい感じですね。

ですが、こういう場合はどうでしょうか?

user=> (s/validate Item (map->Item {:type :solid :unit :ml}))
#user.Item{:type :solid, :unit :ml}

本当は個体の商品に対して単位は kg しか使えないようにしたい。じゃあこういうときどうやってバリデーションしたらいいのでしょうか、というところで今回の本題です。

schema には pred や both という補助的な関数が定義されていて、これを使えば単一のカラムに対して追加のバリデーション情報を付与することができます。

(s/defrecord Person
    [name :- s/Str
     age  :- (s/both Long (s/pred pos? 'pos?))])

こういう具合に年齢は Long で値はネガティブ(マイナス)ではないと定義できるわけです。

user=> (s/validate Person (map->Person {:name "ayato_p" :age -17}))
ExceptionInfo Value does not match schema: {:age (not (pos? -17))}  schema.core/validate (core.clj:176)

user=> (s/validate Person (map->Person {:name "ayato_p" :age 24}))
#user.Person{:name "ayato_p", :age 24}

ですが、今回の問題範囲では各カラムの情報が必要となります。つまり、 type の情報を知っていないと unit を制限出来ないということです。 こういうときの為に schema ではバリデータを追加出来るようになっています。便利ですね。

具体的にはこうなります。

(s/defrecord Item
    [type :- (s/enum :solid :liquid)
     unit :- (s/enum :kg :ml)]
  (fn [{:as this :keys [type unit]}]
    (or (and (= type :solid) (= unit :kg))
        (and (= type :liquid) (= unit :ml)))))

まぁ例えばこんな感じ。

user=> (s/validate Item (map->Item {:type :solid :unit :kg}))
#user.Item{:type :solid, :unit :kg}

user=> (s/validate Item (map->Item {:type :solid :unit :ml}))
clojure.lang.ExceptionInfo: Value does not match schema: (not (#object[user$eval3589$fn__3591 0x568ca817 "user$eval3589$fn__3591@568ca817"] #user.Item{:type :solid, :unit :ml}))

良さそうですね。

ということで簡単に schema でカラムをまたぐようなバリデーション出来るよ、という話でした。

余談

何故かこれ、 README に書いてない気がするんですよね。わりと必要になるシーンちょいちょいあると思うんですが。

schema.core/defrecord のドキュメントにはちゃんと書いてあります。あとテストにも申し訳程度にテストケース書いてありますね。

https://github.com/Prismatic/schema/blob/schema-0.4.3/src/cljx/schema/core.cljx#L1008

schema.core/defrecord
([name field-schema extra-key-schema? extra-validator-fn? & opts+specs])
Macro
  Define a record with a schema.

   In addition to the ordinary behavior of defrecord, this macro produces a schema
   for the Record, which will automatically be used when validating instances of
   the Record class:

   (m/defrecord FooBar
    [foo :- Int
     bar :- String])

   (schema.utils/class-schema FooBar)
   ==> (record user.FooBar {:foo Int, :bar java.lang.String})

   (s/check FooBar (FooBar. 1.2 :not-a-string))
   ==> {:foo (not (integer? 1.2)), :bar (not (instance? java.lang.String :not-a-string))}

   See (doc schema.core) for details of the :- syntax for record elements.

   Moreover, optional arguments extra-key-schema? and extra-validator-fn? can be
   passed to augment the record schema.
    - extra-key-schema is a map schema that defines validation for additional
      key-value pairs not in the record base (the default is to not allow extra
       mappings).
    - extra-validator-fn? is an additional predicate that will be used as part
      of validating the record value.

   The remaining opts+specs (i.e., protocol and interface implementations) are
   passed through directly to defrecord.

   Finally, this macro replaces Clojure's map->name constructor with one that is
   more than an order of magnitude faster (as of Clojure 1.5), and provides a
   new strict-map->name constructor that throws or drops extra keys not in the
   record base.