Entity Model

以降のコード例では、以下のimportを想定しています。

import ixias.model._

まず、IxiaSのEntity ModelはIdを持ちます。Idはコンパニオンオブジェクト内に定義を行い、Idの型と値を定義する必要があります。 Idは同一性を持つ識別子の役割を持っています。

object User {
  val  Id = the[Identity[Id]]
  type Id = Long @@ User
}

Idの型はshapeless.tag.@@を用いて定義します。 @@shapeless.tag.@@の型エイリアスです。

type @@[+T, U] = shapeless.tag.@@[T, U]

こちらはTagged Typeと呼ばれる手法によって、Long型の値を扱うIdという異なる名前の型を持たせています。IdはLong型またはString型を選択することができます。

EntityModelクラスには、id、updatedAt、createdAtの3つの値を定義します。これらはEntityModelトレイトに抽象な値として定義されており、命名と型を定められた通りに定義する必要があります。

case class User(
  id:        Option[User.Id],
  updatedAt: LocalDateTime,
  createdAt: LocalDateTime
) extends EntityModel[User.Id]

idの値は、Option[Id]型を持ちます。Option型にすることでIdがあるかないかを判別できます。

インスタンスの生成は、以下のように行います。

val user: User#WithNoId = User(
  id        = None,
  updatedAt = LocalDateTime.now(),
  createdAt = LocalDateTime.now()
).toWithNoId

WithNoIdとEmbeddedIdの型の定義

EntityModelトレイト内には先ほどUser#WithNoIdとして登場したWithNoId型と共に、WithNoId型と対になる関係を持つEmbeddedId型が定義されています。2つの型は以下のような実装になります。

type WithNoId = Entity.WithNoId [Id, this.type]
type EmbeddedId = Entity.EmbeddedId[Id, this.type]

これらは文脈を持つ型のような役割を持ち、WithNoIdはidを持たないEntity、EmbeddedIdはidを持つEntityという意味を持った型になります。

ここで呼び出しているEntity.WithNoIdとEntity.EmbeddedIdの型は以下のような実装になります。

type WithNoId [K <: @@[_, _], M <: EntityModel[K]] =
  Entity[K, M, IdStatus.Empty]
type EmbeddedId[K <: @@[_, _], M <: EntityModel[K]] =
  Entity[K, M, IdStatus.Exists]

いずれも、1つ目の型パラメータKにはコンパニオンオブジェクト内で定義したIdを、2つ目の型パラメータMにはEntityModelクラスを受け取ります。

呼び出し元であるEntityModelトレイト内では、コンパニオンオブジェクト内で定義したIdとEntityModelトレイトを継承しているthis.typeを与えることで型パラメータの制約を満たしています。

そしてEntity.WithNoIdとEntity.EmbeddedIdでは、受け取った型であるKとMを用いてEntityクラスを呼び出しています。

ここで注目すべきは、Entityクラスが受け取る3つ目の型パラメータです。WithNoIdとEmbeddedIdの型の定義において3つ目の型パラメータに与えているIdStatusオブジェクトは以下のような実装になります。

trait IdStatus
object IdStatus {
  trait Empty extends IdStatus
  trait Exists extends IdStatus
}

このIdStatusオブジェクト内に定義されているEmptyとExistsを用いて、WithNoIdとEmbeddedIdの判別を行います。

WithNoIdとEmbeddedIdの値の生成

次に、WithNoIdとEmbeddedIdの型を持つ値を生成するtoWithNoIdメソッドとtoEmbeddedIdメソッドについて説明します。2つのメソッドは以下のような実装になります。

def toWithNoId: WithNoId = Entity.WithNoId(this)
def toEmbeddedId: EmbeddedId = Entity.EmbeddedId(this)

それぞれ、自身のインスタンスであるthisを引数としてEntityクラス内のオブジェクトを呼び出しています。

toWithNoIdメソッドを例に挙げると、toWithNoIdメソッドが呼び出しているEntity.WithNoIdオブジェクトの実装は以下のようになっています。

object WithNoId {
  /** Create a entity object with no id. */
  def apply[K <: @@[_, _], M <: EntityModel[K]](data: M): WithNoId[K, M] =
    data.id match {
      case None => new Entity(data)
      case Some(_) =>
        throw new IllegalArgumentException(“The entity’s ...”)
    }
}

与えられたEntityModelクラスのidをmatch式で判別することで、エラーハンドリングがされるような実装となっています。new User(…)内のidの値にOption型のNoneを与えているのはそのためです。

toWithNoIdメソッドに対してtoEmbeddedIdメソッドは、EmbeddedId型の値を生成します。しかし、IxiaSではEntity.EmbeddedId()を用いるユースケースはあまり多くありません。

理由としては、ixais.slickパッケージを組み合わせて永続ストレージとの連携を行う際、永続ストレージへのデータ取得リクエストによる返り値の型がEmbeddedIdにマッピングできるようになっているからです。

Entityクラスの実装

EntityクラスにおいてWithNoId型とEmbeddedId型の違いを見ていきます。

まずはUserモデルのWithNoId型を生成して見ましょう。

scala> val user: User#WithNoId = User(
  id        = None,
  updatedAt = LocalDateTime.now(),
  createdAt = LocalDateTime.now()
).toWithNoId

// val user: ixias.model.Entity[Long with shapeless.tag.Tagged[User],User,ixias.model.IdStatus.Empty] = Entity(User(None,2024-01-23T16:32:02.347987,2024-01-23T16:32:02.347994))

WithNoId型であるため、idの値がNoneになっていることが分かります。

この状態でidにアクセスを行なって見ましょう

scala> user.id
// error: Cannot prove that ixias.model.IdStatus.Empty =:= ixias.model.IdStatus.Exists.

idの値が存在しないため、コンパイルエラーが発生します。 なぜコンパイルエラーとなるのか?それはEntityクラス内のid実装を見てみると分かります。

/** get id value when id is exists */
def id(implicit ev: S =:= IdStatus.Exists): K = v.id.get

Entityクラス内に定義されたidには=:=を用いた型制約が暗黙的に与えられており、型パラメータSにIdStatus.Existsが与えられているEmbeddedIdでないとidメソッドが提供されない仕様になっているためです。

そのため、型がWithNoIdであるuserはidメソッドを呼ぶとエラーが起きます。

その他のプロパティに関しては{model}.v.{property}のようにv(value)に続けて入力することで、EntityModelクラス内の値を取得できます。(EmbeddedId型の場合も同様です。)

試しにv経由ではなく直説アクセスを行うとエラーが発生します。

scala> user.updatedAt
            ^
       error: value updatedAt is not a member of ixias.model.Entity[Long with shapeless.tag.Tagged[User],User,ixias.model.IdStatus.Empty]

vを経由することでアクセスすることができます。

scala> user.v.updatedAt
val res3: java.time.LocalDateTime = 2024-01-23T16:32:02.347987

次に、UserモデルのEmbeddedId型を生成して見ましょう。

まずEntityクラスの識別子となるIdは以下のように生成します。

scala> val userId = User.Id(1)
val userId: User.Id = 1

次に、EmbeddedId型を生成します。

scala> val user: User#EmbeddedId = User(
     |   id = Some(userId),
     |   updatedAt = LocalDateTime.now(),
     |   createdAt = LocalDateTime.now()
     | ).toEmbeddedId
val user: ixias.model.Entity[Long with shapeless.tag.Tagged[User],User,ixias.model.IdStatus.Exists] = Entity(User(Some(1),2024-01-23T16:46:03.249446,2024-01-23T16:46:03.249462))

idの値がSome(userId)になっていることが分かります。

EmbeddedId型の場合はエラーが起きずにidを取得することができます。

scala> user.id
val res0: Long with shapeless.tag.Tagged[User] = 1

EmbeddedIdはidを持っていることを保証する型であるため、Option型で返す必要がありません。

この制約によりコンパイル時にエラーとなり、障害を減らすことができるようです。

最初の方に記載した文脈を持つ型の説明に戻ります。

これらは文脈を持つ型のような役割を持ち、WithNoIdはidを持たないEntity、EmbeddedIdはidを持つEntityという意味を持った型になります。

IxiaSを使用してモデルを定義する際、EntityModelトレイトを継承したクラスを定義することで、Idを持たないWithNoId型とIdを持つEmbeddedId型の2つの型を取り扱います。 NextbeatではIxiaSのEntityを使用したモデルは、DBのテーブルと1対1で対応するデータ型として使用しています。 また、NextbeatではDBのテーブルには識別子としてAuto Incrementの値を持つidカラムを設定しています。

ここでいうIdを持たないWithNoId型とはDBにデータを格納する前(Auto Incrementによって採番される前)のレコードを表現して、Idを持つEmbeddedId型とはDBにデータを格納した後(Auto Incrementによって採番された後)のレコードを表現しています。

EntityModelの更新

Entityにはmapメソッドが用意されており、Functionを適用した新たなEntityを返すことができます。実装は以下のようになっています。

/** Builds a new `Entity` by applying a function to values. */
@inline def map[M2 <: EntityModel[K]](f: M => M2): Entity[K, M2, S] = new Entity(f(v))

Entityの更新を行いたい場合は、mapメソッドを用いて以下のように実装します。

scala> val updatedUser: User#EmbeddedId = user.map(_.copy(id = Some(User.Id(2))))
val updatedUser: ixias.model.Entity[Long with shapeless.tag.Tagged[User],User,ixias.model.IdStatus.Exists] = Entity(User(Some(2),2024-01-23T16:46:03.249446,2024-01-23T16:46:03.249462))

引用元

自社OSS「IxiaS」の紹介 ~ ixais.modelパッケージのサンプルコード ~

The source code for this page can be found here.