TDDでLiftのバリデータを実装していく

この記事はScala Advent Calendar jp 2010の13日目です。12/19予定だったけど、日付超えてしもたわ…。

この記事の全てのコードは https://github.com/katzchang/TDD-with-Lift にあります。

はじめに

LiftはScalaで最も有名なフレームワークだろう。Scalaの能力が駆使され、ともすると取っ付きにくいとの噂もあるが、この際それはどうでもいい。フレームワークが用意するライブラリの随所に改造ポイントが見られ、もちろん、modelに対するバリデーションも独自に実装できる。…が、意外にもビルトインのバリデータは少ない。文字列の長さを検査する「ValidateLength」トレイトくらいしかないはず。

というわけで、今回はliftのmodelに対する「not null」バリデータを実装することにした。

使い心地として、ValidateLenthのようにトレイトとして定義し、

object someColumn extends MappedString(this, 10) with ValidateLength
object otherColumn extends MappedString(this, 10) with NotNull //<-コレ!

のように使えるようにすることを目標にしよう。

この記事の環境設定

インストールその他の説明は省略する。

足がかり

まずは、バリデータを指定する場合の改造ポイントを知りたい。

ありがたいことに http://exploring.liftweb.net/ にて、Liftの解説本が公開されている。その中を探すと、 8.2.3 Validation の箇所にコード例が見つかる。

import _root_.java.util.Date

class Expense extends LongKeyedMapper[Expense] with IdPK {
  ...
  object dateOf extends MappedDateTime(this) {
    def noFutureDates (time : Date) = {
      if (time.getTime > System.currentTimeMillis) {
        List(FieldError(this, "You cannot make future expense entries"))
      } else {
        List[FieldError]()
      }
    }
  }
  ...
}

これで大丈夫、問題なさそうだ。しかしなんというか、毎回こんなバリデーションを記述しろってのも面倒だわ。これはマジ何とかすべき。

今回はLift2.2-RC1をベースに開発することに決めた(なんとなく新しいほうがよさそうなので)。解説本の記事はLift2.0をベースに書かれているようで、若干注意が必要だが、概ねこれでいけそうだ。引っかかったら戻ればいい。

仕様化テストを記述

commit: https://github.com/katzchang/TDD-with-Lift/commit/25a6be505c07cd7cc23e51b81f47220ae172ec4c

足がかりは分かったが、実際にバリデーションで引っかかった場合にどのような動作になるか、まだよくわからない。そのため、仕様化テスト(characterization test)を書くことにする。

常にinvalidとなるようなバリデータ alwaysError を定義し(名前の良し悪しはとりあえず置いておいて…)、validとなる場合とinvalidとなる場合にどのような動作となるか、確かめておく。

object ValidatorsTestSpecs extends Specification {
  class SomeMapper extends LongKeyedMapper[SomeMapper] with IdPK {
    def getSingleton = SomeMapper
    def alwaysError(field: FieldIdentifier)(s: String) =
      List(FieldError(field, "always error..."))
    
    object validField extends MappedString(this, 60)
    object someField extends MappedString(this, 60) {
      override def validations = alwaysError(someField) _ :: Nil
    }
  }
  object SomeMapper extends SomeMapper with LongKeyedMetaMapper[SomeMapper]
  "Validationのキャラクタライズ" should {
    "Validationがない場合、validate結果はNil" in {
      val target = SomeMapper.create
      target.validField.validate must equalTo(Nil)
    }
    "Validationにかかる場合、validate結果はNilではない" in {
      val target = SomeMapper.create
      target.someField.validate.size must equalTo(1)
    }
  }
}

validateメソッドが返すList[FieldError]の内容がNilかどうかで判定すればよさそうだ。

not null バリデータをとりあえず実装

commit: https://github.com/katzchang/TDD-with-Lift/commit/a4e54a0fe1374dfd2ea8daf28919ed0caa4e97c5

テストを記述するため、とりあえず、仕様化テストの内容を書き換えた。仕様化テストとは別に記述すべきかも知れないが、仕様化テストは挙動が理解できたらすぐに消すつもりなので、この時点では別に記述しなくてもいーや的に判断した。

    "Validationにかかる場合、validate結果はNilではない" in {
      target.someField.setFromAny(null)
      target.someField.validate.size must equalTo(1)
      target.someField("hoge")
      target.someField.validate must equalTo(Nil)
    }

noNull関数を定義し、バリデータとして登録した。

    object someField extends MappedString(this, 60) {
      def noNull(s: String): List[FieldError] =
if (s == null) List(FieldError(this, "should be not null"))
else List[FieldError]()

      override def validations = noNull _ :: Nil
    }

"notNull"としたかったが、MappedStringに同名の関数があるため、とりあえずnoNullと名付けた。エラーメッセージはまだ適当。matchingを使ってもいいかも知れないが、nullチェック程度だとこれでいいだろー。この程度はもう少しあとで考えてもいい。

仕様化テストの跡を消す

commit: https://github.com/katzchang/TDD-with-Lift/commit/1647423e3e7ea47b6d5aa8be26f6c094248d5d28

ここらでテストコードが気になってきたので整理する。

テストの名前をnot null用に変更し、alwaysErrorバリデータはもう不要なので「ありがとう」と言いながら綺麗に削除した。

今回、仕様化テストは理解のためのコードでしかないので、後々まで残す必要はない。

traitに切り出す *1

commit: https://github.com/katzchang/TDD-with-Lift/commit/85ff04eac663d17e50786b50cfd8d64abc700cac

すでに、noNull関数に切りだしているので、それをそのままtraitとして定義し、withっちゃえばいい。
"noNull"だとやっぱり名前が分かりづらいので、"notNull"関数に変更し、日付項目である「MappedDate」にのみmix-inできるようにした。とりあえず、ですよ。

テストコードは

    "値が設定されている場合、validate結果はNil" in {
      target.notNullField(new java.util.Date)
      target.validate must equalTo(Nil)
    }
    "Validationにかかる場合、validate結果はNilではない" in {
      target.notNullField.setFromAny(null)
      target.validate.size must equalTo(1)
    }
trait NotNull {
      selftype: MappedDate[_] =>
      def notNull(s: AnyRef): List[FieldError] =
if (s == null) List(FieldError(this, "should be not null"))
else List[FieldError]()

      override def validations = notNull _ :: Nil
    }

    object notNullField extends MappedDate(this) with NotNull

MappedDateでのみ使えるのが気になるが、これもあとで何とかしよう。

そしてプロダクトコードへ

commit: https://github.com/katzchang/TDD-with-Lift/commit/8cb3bef3a5809d3b72961e2b5cd1c681217f9a4c
ここまで、mainのコードには一切さわっていないことに気づいただろうか。このままでは全く仕事をしていないことになってしまう。が、大丈夫。プロダクトコードに手を付ける用意がようやく整った。

そう、今までは全て、仮実装だったわけだ。

trait NotNullを、mainのmodelパッケージに移そう。なんとなく、「object Validators」として管理してみることにする。

main側にコードをペーストし、動作を確認する。コンパイルが通れば、テストも通るはず。

日付項目以外でも動作するように、今まで気になっていたMappedStringを用いたテストを追加、定義を変更した。また、今までは他のバリデータを無効にしてしまっていたので、そのテストも追加し、既存のバリデータ"ValidateLength"を参考に実装を変更している。

object Validators {
  trait NotNull extends MixableMappedField {
    selftype: MappedField[_, _] =>
      
    def validateNotNull(s: ValueType): List[FieldError] =
      if (s == null) List(FieldError(this, "this should be not null"))
      else List[FieldError]()
    
    abstract override def validations = validateNotNull _ :: super.validations
  }
}

構造として当初の目的に達したので、今回はここまでとしよう。エラー時のメッセージなど細かい点は気になるが、修正するとしても大した手間ではない。

画面上での動作を確認する

commit: https://github.com/katzchang/TDD-with-Lift/blob/1960bd2e23b16f00526a6a68a7ba13d33c36b1c9/src/main/scala/hello/lift/model/Busho.scala

今までは、仕様化テストのみに基づいて、半ば強引に物事を進めてきた部分がある。ここらで、画面上にちゃんと反映されるか見てみた方がよさそうな気がしませんか?

Busho(部署)という情報(命名はSI業界の慣習)に対して、NotNullな項目を作り、ちゃんとバリデーションが働いているか確認しよう。

import net.liftweb.mapper._

object Busho extends Busho with LongKeyedMetaMapper[Busho] with LongCRUDify[Busho] {
}

class Busho extends LongKeyedMapper[Busho] with IdPK {
  def getSingleton = Busho

  object name extends MappedString(this, 60)
  object hoge extends MappedString(this, 10) with ValidateLength
  object startedAt extends MappedDate(this) with Validators.NotNull
}

Busho.startedAtをNotNullとした。object Bushoにtrait LongCRUDifyをwithし、Boot.scalaに適当に*2登録すれば、CRUD画面が使えるようになる。

登録画面上で、startedAtを未入力のまま登録しようとすると:

動いた! *3

まとめ

共通モジュール作成を目標とした場合、具体的な実装から始めて共通モジュールに切りだしていく方法はかなり有効です。「こうなると使いやすいかも」「これが出来るとかっこいいんだけど…」というアイデアがあるのに実現までのイメージが持てないときは、まずその美意識を横に置いておきましょう。

この方法は、スケーラブルな言語であるScalaには、特に向いているような気がしました。

参考

*1:その間にも紆余曲折あったり、小さいのコミットがあるが、説明は省略する。

*2:SchemefierにBushoを追加、SiteMapにBusho.menusを追加すれば、画面左のメニューに表示され、DBとの連携ができる。SitemMapの雛形が、Lift2.1時代と違っていたので戸惑った。

*3:Lift2.2のCRUDifyは、Lift2.1と違い、各項目の横にエラーメッセージを表示させるようになったらしい。大変結構です。