カスタム データ型
ロギングの設定方法を学んだところで、今度はldbcでより表現力豊かなコードを書くために、独自のデータ型をサポートする方法を見ていきましょう。このページでは、基本型だけでなく、ドメイン固有の型を使ってデータベース操作を行う方法を説明します。
実際のアプリケーションでは、単純な基本型だけでなく、ドメインに特化した型を使用したいことがよくあります。例えば、Status
やCurrency
などのカスタム型を定義し、それらをデータベースの基本型(文字列や整数)とマッピングしたいことがあります。ldbcはこのような操作を簡単に行うための仕組みを提供しています。
この章では、ldbcで構築したテーブル定義でユーザー独自の型もしくはサポートされていない型を使用するための方法について説明します。
セットアップで作成したテーブル定義に新たにカラムを追加します。
ALTER TABLE user ADD COLUMN status BOOLEAN NOT NULL DEFAULT TRUE;
Encoder
ldbcではstatementに受け渡す値をEncoder
で表現しています。Encoder
はstatementにバインドする値を表現するためのtraitです。
Encoder
を実装することでstatementに受け渡す値をカスタム型で表現することができます。
基本的な使い方
ユーザー情報にそのユーザーのステータスを表すStatus
を追加します。以下はenum
を使った例です:
enum Status(val done: Boolean, val name: String):
case Active extends Status(false, "Active")
case InActive extends Status(true, "InActive")
以下のコード例では、カスタム型のEncoder
を定義しています。contramap
メソッドを使用して、カスタム型から基本型(ここではBoolean
)への変換方法を指定します:
given Encoder[Status] = Encoder[Boolean].contramap(_.done)
これによりstatementにカスタム型をバインドすることができるようになります。具体的な使用例は次のとおりです:
val program1: DBIO[Int] =
sql"INSERT INTO user (name, email, status) VALUES (${ "user 1" }, ${ "user@example.com" }, ${ Status.Active })".update
複合型のEncoder
Encoderは複数の型を合成して新しい型を作成することもできます。*:
演算子を使用して型を合成できます:
val encoder: Encoder[(Int, String)] = Encoder[Int] *: Encoder[String]
合成した型は任意のクラスに変換することもできます。以下の例では、to
メソッドを使用してタプルからケースクラスへの変換を行っています:
case class Status(code: Int, name: String)
given Encoder[Status] = (Encoder[Int] *: Encoder[String]).to[Status]
この場合、Status
クラスのフィールドは順番通りに対応する必要があります。つまり、code
はInt
型のエンコーダーに、name
はString
型のエンコーダーに対応します。
Decoder
ldbcではパラメーターの他に実行結果から独自の型を取得するためのDecoder
も提供しています。
Decoder
を実装することでstatementの実行結果から独自の型を取得することができます。
基本的な使い方
以下のコード例では、Decoder
を使用してBoolean
型の値をStatus
型に変換する方法を示しています:
given Decoder[Status] = Decoder[Boolean].map {
case true => Status.InActive
case false => Status.Active
}
これを使って、クエリ結果からStatus
型の値を直接取得できるようになります:
val program2: DBIO[(String, String, Status)] =
sql"SELECT name, email, status FROM user WHERE id = 1".query[(String, String, Status)].unsafe
複合型のDecoder
Decoderも複数の型を合成して新しい型を作成することができます。*:
演算子を使用して型を合成します:
val decoder: Decoder[(Int, String)] = Decoder[Int] *: Decoder[String]
合成した型は任意のクラスに変換することもできます:
case class Status(code: Int, name: String)
given Decoder[Status] = (Decoder[Int] *: Decoder[String]).to[Status]
この定義により、データベースから取得した2つのカラム(整数と文字列)を自動的にStatus
クラスのインスタンスに変換できます。
Codec
Encoder
とDecoder
を組み合わせたCodec
を使用することで、statementに受け渡す値とstatementの実行結果の両方で独自の型を使用できます。これにより、コードの重複を減らし、一貫した型変換を実現できます。
基本的な使い方
以下のコード例では、Codec
を使用して先ほどのEncoder
とDecoder
を統合した方法を示しています:
given Codec[Status] = Codec[Boolean].imap(_.done)(Status(_))
複合型のCodec
Codecも複数の型を合成して新しい型を作成することができます:
val codec: Codec[(Int, String)] = Codec[Int] *: Codec[String]
合成した型は任意のクラスに変換することもできます:
case class Status(code: Int, name: String)
given Codec[Status] = (Codec[Int] *: Codec[String]).to[Status]
EncoderとDecoderを個別に取得
Codecは、Encoder
とDecoder
を組み合わせたものであるため、それぞれの型への変換処理を個別に取得することもできます:
val encoder: Encoder[Status] = Codec[Status].asEncoder
val decoder: Decoder[Status] = Codec[Status].asDecoder
複雑なオブジェクト構造の変換
Codec
, Encoder
, Decoder
はそれぞれ合成することができるため、複数の型を組み合わせて複雑なオブジェクト構造を作成することができます。
これにより、ユーザーは取得したレコードをネストした階層データに変換できます:
case class City(id: Int, name: String, countryCode: String)
case class Country(code: String, name: String)
case class CityWithCountry(city: City, country: Country)
// 都市と国の情報を結合して取得する例
val program3: DBIO[List[CityWithCountry]] =
sql"""
SELECT c.id, c.name, c.country_code, co.code, co.name
FROM city c
JOIN country co ON c.country_code = co.code
""".query[CityWithCountry].list
上記の例では、クエリの結果から自動的にCityWithCountry
オブジェクトが構築されます。ldbcはコンパイル時に型を解決し、適切なエンコーダーとデコーダーを生成します。
大きなオブジェクトの扱い
Codecを始めEncoder
とDecoder
は暗黙的に解決されるため、通常はユーザーがこれらの型を明示的に指定する必要はありません。
しかし、モデル内に多くのプロパティがある場合、暗黙的な解決処理が複雑になりすぎて失敗する可能性があります:
[error] |Implicit search problem too large.
[error] |an implicit search was terminated with failure after trying 100000 expressions.
[error] |The root candidate for the search was:
[error] |
[error] | given instance given_Decoder_P in object Decoder for ldbc.dsl.codec.Decoder[City]}
このような場合は、以下のいずれかの解決策が有効です:
- コンパイルオプションの検索制限を上げる:
scalacOptions += "-Ximplicit-search-limit:100000"
ただし、この方法はコンパイル時間が長くなる可能性があります。
- 手動で型変換を明示的に構築する:
// 明示的にDecoderを構築
given Decoder[City] = (
Decoder[Int] *:
Decoder[String] *:
Decoder[String]
).to[City]
// 明示的にEncoderを構築
given Encoder[City] = (
Encoder[Int] *:
Encoder[String] *:
Encoder[String]
).to[City]
Codec
を使用して一度に定義する:
given Codec[City] = (
Codec[Int] *:
Codec[String] *:
Codec[String]
).to[City]
実際の応用例
以下は、より実践的な例として、ドメイン固有の型を使用したコード例を示します:
// メールアドレスを表す値
opaque type Email = String
object Email:
def apply(value: String): Email = value
def unapply(email: Email): String = email
// ユーザーID
opaque type UserId = Long
object UserId:
def apply(value: Long): UserId = value
def unapply(userId: UserId): Long = userId
// ユーザークラス
case class User(id: UserId, name: String, email: Email, status: Status)
object User:
// ユーザーID用のCodec
given Codec[UserId] = Codec[Long].imap(UserId.apply)(_.value)
// メールアドレス用のCodec
given Codec[Email] = Codec[String].imap(Email.apply)(Email.unapply)
// これでユーザーの取得や更新が型安全に行える
val getUser: DBIO[Option[User]] =
sql"SELECT id, name, email, status FROM user WHERE id = ${UserId(1)}".query[User].option
val updateEmail: DBIO[Int] =
sql"UPDATE user SET email = ${Email("new@example.com")} WHERE id = ${UserId(1)}".update
次のステップ
これでカスタムデータ型をldbcで使用する方法がわかりました。独自の型を定義することで、より表現力豊かで型安全なコードを書くことができます。また、ドメインの概念を正確にコードで表現できるようになり、バグの発生を減らすことができます。
次はクエリビルダーに進み、SQLを直接書かずに型安全なクエリを構築する方法を学びましょう。