マイグレーションノート (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]
カスタムデータ型のサポート
ユーザー定義のデータ型を使用する際は、ResultSetReader
とParameter
を使用してカスタムデータ型をサポートしていました。
今回の更新で、ResultSetReader
とParameter
を使用してカスタムデータ型をサポートする方法が変更されました。
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
は、Encoder
とDecoder
を組み合わせたもので、Codec
を使用することで、Encoder
とDecoder
を組み合わせることができます。
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は、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)
sql"SELECT city.Id, city.Name, city.CountryCode, country.Code, country.Name FROM city JOIN country ON city.CountryCode = country.Code".query[CityWithCountry]
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"
しかし、オプションでの制限拡張はコンパイル時間の増幅につながる可能性があります。その場合は、以下のように手動で任意の型を構築することで解決することもできます。
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
を使用してEncoder
とDecoder
を組み合わせることで解決することもできます。
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)
この変更により、テーブルの結合条件はon
APIで設定する必要があります。この変更は内部的な実装変更の結果です。
cityTable.join(countryTable).on((c, co) => c.countryCode === co.code)