Scala のモデルクラスでプライマリキーとかをどう扱うかという話

お悩み相談です。

Java とか Ruby、少なくとも ActiveRecord とか Hibernate とかではあまり気にならない話です。

Scala で例えば Slick や Anorm, scalikejdbc などのクエリのサポートのみでモデルクラスの設計はユーザーに任されているものだと、プライマリキーなどのデータベースにレコードを保存した時点で値が決まるフィールドの型をどうすべきか悩みます。

例えば次のような user テーブルについて考えてみます。id カラムがプライマリキーで、データベースの自動採番を利用します。また、created_at は省略するとデフォルト値をデータベースから取得します。

-- postgresql

CREATE TABLE user(
  id serial PRIMARY KEY,
  firstName VARCHAR(30) NOT NULL,
  lastName VARCHAR(30) NOT NULL,
  age INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
)

さて、このテーブルに対する Entity と Dao のインタフェースがどうなるか考えてみます。一番単純で、理想的と思うのはこうです。

// Entity
case class User(
  id: Int,
  firstName: String,
  lastName: String,
  age: Int,
  createdAt: DateTime
)

// Dao
object UserDao {
    
  def find(id: Int): Option[User] = ???

  def create(user: User): User = ???

  def update(user: User): User = ???

  def delete(user: User): Unit = ???

}

User のそれぞれのフィールドの型はデータベースの型と一対一に対応させます。そして UserDao のメソッドの引数は全て User 型です。
ですが、この API はうまくいきません。なぜなら User の id フィールドはデータベースに保存しないので、User#create に渡す User クラスのインスタンスが作れないからです。

Pk 型を使う

Play の Anorm では Pk 型というプライマリキーを表す型が用意されています。
Pk[T] は Option に似たコンテナで、Option[T] に Some[T] と None の 2 種類があるように、Pk[T] には Id[T] と NotAssigned の 2 種類があります。実装も単に Option をラップしただけです。

abstract class Pk[+ID] {

  def toOption: Option[ID] = this match {
    case Id(x) => Some(x)
    case NotAssigned => None
  }

  def isDefined: Boolean = toOption.isDefined
  def get: ID = toOption.get
  def getOrElse[V >: ID](id: V): V = toOption.getOrElse(id)
  def map[B](f: ID => B) = toOption.map(f)
  def flatMap[B](f: ID => Option[B]) = toOption.flatMap(f)
  def foreach(f: ID => Unit) = toOption.foreach(f)

}

case class Id[ID](id: ID) extends Pk[ID] {
  override def toString() = id.toString
}

case object NotAssigned extends Pk[Nothing] {
  override def toString() = "NotAssigned"
}


これを使って User エンティティ を定義しなおしてみましょう。

case class User(
  id: Pk[Int],
  firstName: String,
  lastName: String,
  age: Int,
  createdAt: DateTime
)

これで、データベースに保存する前のユーザーも、保存した後のユーザーも User クラスで表せるようになります。
UserDao#create は id が NotAssigned な User クラスを受け取り、データベースに保存し、id がセットされた User クラスを返すように実装します。
使い方は次のようになります。

val u = User(NotAssigned, "John", "Lennon", 30, DateTime.now)

val u2 = UserDao.craete(u)

println(u2.get) //=> 1 (保存した後は、プライマリキーがセットされた値になっている)

で、この解決法を最初見たときはなるほどと思いましたが、使ってみるとすぐにイマイチだと気づきます。

まず、データベースに保存しないと値が決まらないのはなにもプライマリキーだけではありません。今まで User の cratedAt フィールドはアプリケーションコードの方でセットしていましたが、これをデータベースへ保存するときにデータベースから取得する方針に変えたらどうなるでしょう。
プライマリキー意外にも Pk 型みたいなのが欲しいですね。createdAt に Pk 型を使うのはあまりにも変なので、Option を使うことにしてみましょうか。

case class User(
  id: Pk[Int],
  firstName: String,
  lastName: String,
  age: Int,
  createdAt: Option[DateTime]
)

createdAt を Option[DateTime] にしてみましたが、これでは createdAt が Nullable という風に見えますね。だめだこりゃ。
だからといって自分で Pk 型みたいなのを別途作るのもめんどう。それからそもそもの話、内部でデータベースをどう使ってるかという細かい話題が表に出てきすぎている感があります。

そして、Pk 型にはもう一つ欠点があります。いちいちプライマリキーの値が Id[T] なのか Assigned かを気にしてコードを書かなければいけないのです。データベースから SELECT したら Id[T] と決まっているのに Pk[T] という文脈で扱わなければいけないのは(無駄に)すごく面倒です。面倒過ぎるので残念な気持ちになりながら Pk#get を使うことになります。


というわけで、Pk 型はやめときましょう。

でも、一体どうするのがいいんだろう。。。というのが以下、本題です。

Dao#create の API を変える

User クラスは「データベースに保存されているユーザーのデータ」を表すことと考え、Dao#create で User 型を引数にとるのをやめます。

だからといって次のように保存する前だけ別の型にするのは堅(型)苦しい気がします。

object UserDao {

  def find(id: Int): Option[User] = ???

  def create(user: NotPersistedUser): User = ???

  def update(user: User): User = ???

  def delete(user: User): Unit = ???

}

なので次のようにするのが妥当ではないでしょうか。

object UserDao {
  ...
  def create(firstName: String, lastName: String, age: Int, createdAt: DateTime): User = ???
  ...
}

欠点はフィールドの値が増えるとだんだんしんどくなることです。引数が増えると引数の順番を間違えてしまうなどのミスも起きますし。名前付き引数とかを使えばミスは減りますが、冗長さが増します。

適当にデフォルト値を用意する

プライマリキーのデフォルト値を適当に 0 とか決めてやればとりあえず User のインスタンスを作れます。欠点は「気持ち悪い」こと。「0 がデフォルト」という実装のためだけの謎ルールが生まれること。

val u = User(0, "John", "Lennon", 30, DateTime.now)

UserDao.create(u)

エンティティを case class にするのを諦める

案2 を少し進める感じです。ミュータブルにはなるけれど、「0がデフォルト」のルールは隠蔽できます。Squeryl っぽい感じですね。

class User(
  val id: Int,
  var firstName: String,
  var lastName: String,
  var age: Int,
  var createdAt: DateTime) {

  def this() = this(0, "", "", 0, DateTime.now)

}
val u = new User()
u.lastName = "John"
u.firstName = "Lennon"
u.age = 30
UserDao.create(u)

案外これが一番使いやすいかも。。。Scala っぽくないけど。

まとめ

細かいっちゃ細かいですが、いつも気になるので書いてみました。ご意見募集中です。