マイグレーションノート (0.2.xから0.3.xへの移行)

パッケージ

パッケージ名の変更

0.2.x 0.3.x
ldbc-core ldbc-schema

新たなパッケージ

新たに3種類のパッケージが追加されました。

Module / Platform JVM Scala Native Scala.js
ldbc-connector
jdbc-connector
ldbc-statement

全てのパッケージ

Module / Platform JVM Scala Native Scala.js
ldbc-sql
ldbc-connector
jdbc-connector
ldbc-dsl
ldbc-statement
ldbc-query-builder
ldbc-schema
ldbc-schemaSpy
ldbc-codegen
ldbc-hikari
ldbc-plugin

機能変更

コネクタ切り替え機能

Scala MySQL コネクタに、jdbc と ldbc の接続切り替えのサポートが追加されました。

この変更により、開発者はプロジェクトの要件に応じて jdbc または ldbc ライブラリを使用したデータベース接続を柔軟に選択できるようになりました。これにより、開発者は異なるライブラリの機能を利用できるようになり、接続の設定や操作の柔軟性が向上します。

変更方法

まず、共通の依存関係を設定する。

libraryDependencies += "io.github.takapi327" %% "ldbc-dsl" % "0.3.0-beta11"

クロスプラットフォームプロジェクトでは(JVM、JS、ネイティブ)

libraryDependencies += "io.github.takapi327" %%% "ldbc-dsl" % "0.3.0-beta11"

使用される依存パッケージは、データベース接続が Java API を使用するコネクタを介して行われるか、または ldbc によって提供されるコネクタを介して行われるかによって異なります。

jdbcコネクタの使用

libraryDependencies += "io.github.takapi327" %% "jdbc-connector" % "0.3.0-beta11"

ldbcコネクタの使用

libraryDependencies += "io.github.takapi327" %% "ldbc-connector" % "0.3.0-beta11"

クロスプラットフォームプロジェクトでは(JVM、JS、ネイティブ)

libraryDependencies += "io.github.takapi327" %%% "ldbc-connector" % "0.3.0-beta11"

使用方法

jdbcコネクタの使用

import jdbc.connector.*

val ds = new com.mysql.cj.jdbc.MysqlDataSource()
ds.setServerName("127.0.0.1")
ds.setPortNumber(13306)
ds.setDatabaseName("world")
ds.setUser("ldbc")
ds.setPassword("password")

val provider = ConnectionProvider.fromDataSource(ex, ExecutionContexts.synchronous)

ldbcコネクタの使用

import ldbc.connector.*

val provider =
  ConnectionProvider
    .default[IO]("127.0.0.1", 3306, "ldbc", "password", "ldbc")
    .setSSL(SSL.Trusted)

データベースへの接続処理は、それぞれの方法で確立されたコネクションを使って行うことができる。

val result: IO[(List[Int], Option[Int], Int)] = provider.use { conn =>
  (for
    result1 <- sql"SELECT 1".query[Int].to[List]
    result2 <- sql"SELECT 2".query[Int].to[Option]
    result3 <- sql"SELECT 3".query[Int].unsafe
  yield (result1, result2, result3)).readOnly(conn)
}

破壊的変更

プレーン・クエリ構築の拡張

プレーン・クエリを用いたデータベース接続メソッドによる検索対象の型の決定は、検索対象の型とそのフォーマット(リストまたはオプション)を一括して指定していた。

今回の修正ではこれを変更し、取得する型とその形式の指定を分離することで内部ロジックを共通化した。これにより、プレーン・クエリの構文はよりdoobieに近くなり、doobieのユーザは混乱することなく使用できるはずである。

before

sql"SELECT id, name, age FROM user".toList[(Long, String, Int)].readOnly(connection)
sql"SELECT id, name, age FROM user WHERE id = ${1L}".headOption[User].readOnly(connection)

after

sql"SELECT id, name, age FROM user".query[(Long, String, Int)].to[List].readOnly(connection)
sql"SELECT id, name, age FROM user WHERE id = ${1L}".query[User].to[Option].readOnly(connection)

AUTO INCREMENT値取得メソッド命名変更

更新 API で AUTO INCREMENT 列によって生成された値を変換する API updateReturningAutoGeneratedKey の名前が returning に変更されました。

これはMySQLの特徴で、MySQLはデータ挿入時にAUTO INCREMENTで生成された値を返しますが、他のRDBは動作が異なり、AUTO INCREMENTで生成された値以外の値を返すことがあります。 API 名は、将来の拡張を考慮して、限定的な API 名をより拡張しやすくするために早い段階で変更されました。

before

sql"INSERT INTO `table`(`id`, `c1`) VALUES ($None, ${ "column 1" })".updateReturningAutoGeneratedKey[Long]

after

sql"INSERT INTO `table`(`id`, `c1`) VALUES ($None, ${ "column 1" })".returning[Long]

クエリビルダーの構築方法

以前まではクエリビルダーはテーブルスキーマを構築しなければ使用することができませんでした。

今回の更新で、より簡易的にクエリビルダーを使用できるように変更を行いました。

before

まずモデルに対応したテーブルスキーマを作成し、

case class User(
  id: Long,
  name: String,
  age: Option[Int],
)

val userTable = Table[User]("user")(                 // CREATE TABLE `user` (
  column("id", BIGINT, AUTO_INCREMENT, PRIMARY_KEY), //   `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  column("name", VARCHAR(255)),                      //   `name` VARCHAR(255) NOT NULL,
  column("age", INT.UNSIGNED.DEFAULT(None)),         //   `age` INT unsigned DEFAULT NULL
)

次にテーブルスキーマを使用してTableQueryの構築を行います。

val tableQuery = TableQuery[IO, User](userTable)

最後にクエリ構築を行っていました。

val result: IO[List[User]] = connection.use { conn =>
  tableQuery.selectAll.toList[User].readOnly(conn)
  // "SELECT `id`, `name`, `age` FROM user"
}

after

今回の変更によって、モデルを構築し

import ldbc.dsl.codec.Codec
import ldbc.query.builder.Table

case class User(
  id: Long,
  name: String,
  age: Option[Int],
) derives Table
object User:
  given Codec[User] = Codec.derived[User]

次にTableを初期化を行います。

import ldbc.query.builder.Table

val userTable = TableQuery[User]

最後にクエリ構築を行うことで利用可能となります。

val result: IO[List[User]] = provider.use { conn =>
  userTable.selectAll.query.to[List].readOnly(conn)
  // "SELECT `id`, `name`, `age` FROM user"
}

Schemaを使用したクエリビルダーの構築

以前までのスキーマを模したTableの構築方法は、Schemaプロジェクトを使用してテーブル型を構築する方法に変わります。 以下では、Userモデルに対応するTable型の構築について見ていきます。

case class User(
  id: Long,
  name: String,
  age: Option[Int],
)

Before

これまでは、Tableのインスタンスを直接作成する必要があった。Tableの引数には、Userクラスが持つプロパティと同じ順序で対応するカラムを渡す必要があり、カラムのデータ型も設定することが必須だった。

このテーブル型を使ったTableQueryは、型安全なアクセスが可能なDynamicを使って実装したが、開発ツールでは補完ができなかった。

また、この構築方法は、クラス生成に比べてコンパイル時間が少し遅かった。

val userTable = Table[User]("user")(
  column("id", BIGINT, AUTO_INCREMENT, PRIMARY_KEY),
  column("name", VARCHAR(255)),
  column("age", INT.UNSIGNED.DEFAULT(None)),
)

After

今回の修正では、Table型の生成は、Tableを継承してクラスを作成する方法に変更された。また、カラムのデータ型は必須ではなくなり、実装者が任意に設定できるようになりました。

このようにSlickと同様の構築方法に変更することで、実装者にとってより馴染みやすいものになっています。

class UserTable extends Table[User]("user"):
  def id: Column[Long] = column[Long]("id")
  def name: Column[String] = column[String]("name")
  def age: Column[Option[Int]] = column[Option[Int]]("age")

  override def * : Column[User] = (id *: name *: age).to[User]

カラムのデータ型はまだ設定できます。この設定は、たとえば、このテーブル・クラスを使ってスキーマを生成するときに使われます。

class UserTable extends Table[User]("user"):
  def id: Column[Long] = column[Long]("id", BIGINT, AUTO_INCREMENT, PRIMARY_KEY)
  def name: Column[String] = column[String]("name", VARCHAR(255))
  def age: Column[Option[Int]] = column[Option[Int]]("age", INT.UNSIGNED.DEFAULT(None))

  override def * : Column[User] = (id *: name *: age).to[User]

また、データ型を表現したカラム定義方法も存在します。上記の定義方法は以下のように書き換えることが可能です。 この定義方法では、カラム名を変数名として使用できるためカラム名を引数として渡す必要はありません。

class UserTable extends Table[User]("user"):
-  def id: Column[Long] = column[Long]("id", BIGINT, AUTO_INCREMENT, PRIMARY_KEY)
-  def name: Column[String] = column[String]("name", VARCHAR(255))
-  def age: Column[Option[Int]] = column[Option[Int]]("age", INT.UNSIGNED.DEFAULT(None))
+  def id: Column[Long] = bigint().autoIncrement.primaryKey
+  def name: Column[String] = varchar(255)
+  def age: Column[Option[Int]] = int().unsigned.defaultNull

  override def * : Column[User] = (id *: name *: age).to[User]

カラム名はNamingを暗黙的に渡すことで書式を変更することができます。 デフォルトはキャメルケースですが、パスカルケースに変更するには以下のようにします。

class UserTable extends Table[User]("user"):
  given Naming = Naming.PASCAL

  def id: Column[Long] = bigint().autoIncrement.primaryKey
  def name: Column[String] = varchar(255)
  def age: Column[Option[Int]] = int().unsigned.defaultNull

  override def * : Column[User] = (id *: name *: age).to[User]

特定のカラムの書式を変更したい場合は、カラム名を引数として渡すことで定義できます。

class UserTable extends Table[User]("user"):
  def id: Column[Long] = bigint("ID").autoIncrement.primaryKey
  def name: Column[String] = varchar("NAME", 255)
  def age: Column[Option[Int]] = int("AGE").unsigned.defaultNull

  override def * : Column[User] = (id *: name *: age).to[User]

カスタムデータ型のサポート

ユーザー定義のデータ型を使用する際は、ResultSetReaderParameterを使用してカスタムデータ型をサポートしていました。

今回の更新で、ResultSetReaderParameterを使用してカスタムデータ型をサポートする方法が変更されました。

Encoder

クエリ文字列に動的に埋め込むために、ParameterからEncoderに変更。

これにより、ユーザはEffect Typeを受け取るための冗長な処理を記述する必要がなくなり、よりシンプルな実装とカスタムデータ型のパラメータとしての使用が可能になります。

enum Status(val code: Int, val name: String):
  case Active   extends Status(1, "Active")
  case InActive extends Status(2, "InActive")
-given Parameter[Status] with
-  override def bind[F[_]](
-    statement: PreparedStatement[F],
-    index: Int,
-    status: Status
-  ): F[Unit] = statement.setInt(index, status.code)

+given Encoder[Status] = Encoder[Int].contramap(_.code)

Encoderのエンコード処理では、PreparedStatementで扱えるScala型しか返すことができません。

現在、以下のタイプがサポートされている。

Scala Type Methods called in PreparedStatement
Boolean setBoolean
Byte setByte
Short setShort
Int setInt
Long setLong
Float setFloat
Double setDouble
BigDecimal setBigDecimal
String setString
Array[Byte] setBytes
java.time.LocalDate setDate
java.time.LocalTime setTime
java.time.LocalDateTime setTimestamp
None setNull

また、Encoderは複数の型を合成して新しい型を作成することができます。

val encoder: Encoder[(Int, String)] = Encoder[Int] *: Encoder[String]

合成した型は任意のクラスに変換することもできます。

case class Status(code: Int, name: String)
given Encoder[Status] = (Encoder[Int] *: Encoder[String]).to[Status]

Decoder

ResultSetからデータを取得する処理をResultSetReaderからDecoderに変更。

-given ResultSetReader[IO, Status] =
-  ResultSetReader.mapping[IO, Int, Status](code => Status.fromCode(code))
+given Decoder[Status] = Decoder[Int].map(code => Status.fromCode(code))

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]

Codecの導入

Codecは、EncoderDecoderを組み合わせたもので、Codecを使用することで、EncoderDecoderを組み合わせることができます。

enum Status(val code: Int, val name: String):
  case Active   extends Status(1, "Active")
  case InActive extends Status(2, "InActive")

given Codec[Status] = Codec[Int].imap(Status.fromCode)(_.code)

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]

Codecは、EncoderDecoderを組み合わせたものであるため、それぞれの型への変換処理を行うことができます。

val encoder: Encoder[Status] = Codec[Status].asEncoder
val decoder: Decoder[Status] = Codec[Status].asDecoder

今回の変更により、ユーザーはCodecを使用して、EncoderDecoderを組み合わせることができるようになりました。

これにより、ユーザーは取得したレコードをネストした階層データに変換できます。

case class City(id: Int, name: String, countryCode: String)
case class Country(code: String, name: String)
case class CityWithCountry(city: City, country: Country)

sql"SELECT city.Id, city.Name, city.CountryCode, country.Code, country.Name FROM city JOIN country ON city.CountryCode = country.Code".query[CityWithCountry]

Codecを始めEncoderDecoderは暗黙的に解決されるため、ユーザーはこれらの型を明示的に指定する必要はありません。

しかし、モデル内に多くのプロパティがある場合、暗黙的な検索は失敗する可能性があります。

[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"

しかし、オプションでの制限拡張はコンパイル時間の増幅につながる可能性があります。その場合は、以下のように手動で任意の型を構築することで解決することもできます。

given Decoder[City] = Decoder.derived[City]
// Or given Decoder[City] = (Decoder[Int] *: Decoder[String] *: Decoder[Int] *: ....).to[City]
given Encoder[City] = Encoder.derived[City]
// Or given Encoder[City] = (Encoder[Int] *: Encoder[String] *: Encoder[Int] *: ....).to[City]

もしくは、Codecを使用してEncoderDecoderを組み合わせることで解決することもできます。

given Codec[City] = Codec.derived[City]
// Or given Codec[City] = (Codec[Int] *: Codec[String] *: Codec[Int] *: ....).to[City]

列の絞り込み方法の変更

これまで、列の絞り込みは単にタプルとして使用される列をグループ化してきました。

cityTable.select(city => (city.id, city.name))

しかし、これには問題がありました。カラムは1つの型パラメータを持つ型です。Scala2ではTupleの数に制限があったため、ボイラープレートか何かでTupleの数をすべて扱えるものを作る必要がありました。 この場合、動的TupleはTupleまたはTuple.Mapとして扱われるため、Column型にアクセスしたい場合、その型はTupleとしてしか扱えないため、asInstanceOfを使って型をキャストする必要がありました。 型をキャストすると、もちろん型の安全性が失われコードが複雑になってしまいます。

この問題を解決するために、同じTypeLevelプロジェクトの一つであるtwiddlesを採用することにしました。

twiddlesを使うことで、カラムをより簡単に合成することができるようになります。

cityTable.select(city => city.id *: city.name)

また、内部コードではTupleの代わりにColumn[T]を使用すればよいので、安全でない型キャストは必要なくなります。

Twiddlesはまた、合成結果を別の型に変換することを容易にします。

case class City(id: Long, name: String)

def id: Column[Int] = column[Int]("ID")
def name: Column[String] = column[String]("Name")

def city: Column[City] = (id *: name).to[City]

TableからTableQueryへの変更

これまでは、同じTable型を使って、モデルからテーブル型とテーブル情報を使ってクエリを構築していました。

case class City(id: Long, name: String) derives Table
val cityTable = Table[City]

しかしこの実装は、同じ型が2つのものを表すのに使われるため間違った実装をするのは簡単でした。

cityTable.select(city => city.insert(???))

IDEのような開発ツールは、利用可能なすべてのAPIを補完するため実装者に少なからぬ混乱を引き起こす可能性があります。

この問題を解決するために、Table型とTableQuery型を分離しました。

case class City(id: Long, name: String) derives Table
val cityTable = TableQuery[City]

テーブル名のカスタマイズはTableのderivedで行うことができます

case class City(
  id: Int,
  name: String,
  countryCode: String,
  district: String,
  population: Int
)

object City:
  given Table[City] = Table.derived[City]("city")

アップデート文の構築方法変更

以前は、Update Statementは更新するカラムごとに1つずつ設定する必要がありました。この実装は、いくつかのカラムを個別に更新したい場合には便利ですが、更新したいカラムが追加されるたびにセットを記述するのは非常に面倒です。

cityTable
  .update("id", 1L)
  .set("name", "Tokyo")
  .set("population", 1, false)

今回のアップデートにより、カラムを組み合わせることができるようになり、複数のカラムを一緒に指定して更新処理を行うことができるようになりました。

cityTable
  .update(city => city.id *: city.name)((1L, "Tokyo"))
  .set(_.population, 1, false)

今回のアップデートにより、カラムを組み合わせることができるようになり、複数のカラムを一緒に指定して更新処理を行うことができるようになった。setを使用して特定の列のみを更新することは可能です。また、set を使用して更新条件を設定できるため、条件が正の場合にのみ追加カラムを更新するクエリを作成できます。

テーブル結合の構築方法の変更

以前は、テーブル結合は第2引数に結合条件を設定することで構築されていました。

cityTable.join(countryTable)((c, co) => c.countryCode === co.code)

この変更により、テーブルの結合条件はonAPIで設定する必要があります。この変更は内部的な実装変更の結果です。

cityTable.join(countryTable).on((c, co) => c.countryCode === co.code)