klibsを眺めていたところ面白そうなものを見つけたので試してみた。
これが結構面白い。リポジトリのAboutに「Generates typesafe Kotlin APIs from SQL」とあって「ORM?」と思ったけど、どうもちょっと違う気がする*1。ORMはオブジェクトを定義して、オブジェクトを操作することでSQLを扱う。が、SQLDelightは逆で、SQLを元に型安全なコードを自動生成するというアプローチを取っている。正しく表現できてるかアレだけど、簡単に違いを表にしてみる。
特徴 | SQLDelight | 典型的なORM |
---|---|---|
SQL操作 | SQLを直接記述し、それを元に型安全なコードを生成 | SQLを抽象化してオブジェクトとして操作 |
型安全性 | SQLを元にオブジェクトを定義する。オブジェクトの作成はSQLDelightが自動で定義してくれる。 | SQLを元にオブジェクトを定義する。*2。オブジェクトの作成は実装者に委ねられる。 |
自由度 | SQL文を自由に書ける。複雑なクエリやパフォーマンスチューニングが容易。 | ORMの抽象化が強い場合、複雑なクエリや最適化が困難なことがある |
学習コスト | SQLの知識のみ | SQLの知識+ORM独自のAPIの学習が必要 |
こんな感じでORM独自のAPIを覚える必要がなく、生クエリベースでガリガリ書きつつ型安全にしたいという人に向いていそうだ。
今回は比較も兼ねて、Kotlin製ORMのExposedと共存させながらSQLDelightを使ってみた。Exposedとの比較とかはコードの転記がめんどくさいのでこの記事には直接書かない。けどこの後に書くリポジトリを見たら比較できるようにしておく。なので、書きっぷりの違いが気になる人はそっちを見てほしい。*3
環境・リポジトリ
このsqldelight
ブランチが今回の環境。DockerやSQLファイルも置いてあるのですぐに試せると思う。DBは今回はMySQLを使う。*4
元々はExposedだけを使い倒した本を書いたときの成果物だったりする。興味があればExposedの本も見てくれると嬉しい。
準備
SQLDelightはIntelliJプラグインも提供しているので、あらかじめこれを入れておくと便利。便利になる場面は後ほど触れる。
Gradleプラグインの適用
まずはSQLから型定義ファイルを作るために必要なプラグインの設定をしていく。
build.gradle.kts
を以下のように修正する。
val ktor_version: String by project val kotlin_version: String by project val logback_version: String by project plugins { kotlin("jvm") version "1.9.0" id("io.ktor.plugin") version "2.3.3" + id("app.cash.sqldelight") version "2.0.2" } group = "example.koin" version = "0.0.1" application { mainClass.set("io.ktor.server.tomcat.EngineMain") val isDevelopment: Boolean = project.ext.has("development") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") } repositories { + google() mavenCentral() } +sqldelight { + databases { + create("Database") { + packageName.set("com.example") + dialect("app.cash.sqldelight:mysql-dialect:2.0.2") + } + } +}
sq
ファイルの作成
今回はFresh Schemaの方式で型定義をSQLDelightにやってもらう。
まずはsrc/main
の下にsqldelightディレクトリを作る。この下にできた
.sq`ファイルをSQLDelightは認識してKotlinのソースに変換してくれる。
この時IntelliJのプラグインが有効になっているとファイルを編集したタイミングでKotlinのソースを作成・修正してくれる。
プラグインがない場合はgenerateMainDatabaseInterface
タスクを実行する必要がある。
試しにsrc/main/sqldelight/employee/data/Employee.sq
を作成し以下の内容で保存すると…
CREATE TABLE EMPLOYEE ( EMPLOYEE_ID INT, DEPARTMENT_ID INT, ENROLLMENT_STATUS INT, -- 在籍状況 0:在籍中 1:休職中 2:退職済 FIRST_NAME VARCHAR(128), LAST_NAME VARCHAR(128), CREATED_AT DATETIME, UPDATED_AT DATETIME ); selectAll: SELECT * FROM EMPLOYEE; selectById: SELECT * FROM EMPLOYEE WHERE EMPLOYEE_ID = ?;
build
ディレクトリ以下にファイルができている。
ファイルを見るとスキーマとSQLを実行する関数ができている。
package employee.`data` import java.time.LocalDateTime import kotlin.Int import kotlin.String public data class EMPLOYEE( public val EMPLOYEE_ID: Int?, public val DEPARTMENT_ID: Int?, public val ENROLLMENT_STATUS: Int?, public val FIRST_NAME: String?, public val LAST_NAME: String?, public val CREATED_AT: LocalDateTime?, public val UPDATED_AT: LocalDateTime?, )
package employee.`data` import app.cash.sqldelight.Query import app.cash.sqldelight.TransacterImpl import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlCursor import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.JdbcCursor import app.cash.sqldelight.driver.jdbc.JdbcPreparedStatement import java.time.LocalDateTime import kotlin.Any import kotlin.Int import kotlin.String public class EmployeeQueries( driver: SqlDriver, ) : TransacterImpl(driver) { public fun <T : Any> selectAll(mapper: ( EMPLOYEE_ID: Int?, DEPARTMENT_ID: Int?, ENROLLMENT_STATUS: Int?, FIRST_NAME: String?, LAST_NAME: String?, CREATED_AT: LocalDateTime?, UPDATED_AT: LocalDateTime?, ) -> T): Query<T> = Query(-1_033_296_007, arrayOf("EMPLOYEE"), driver, "Employee.sq", "selectAll", """ |SELECT EMPLOYEE.EMPLOYEE_ID, EMPLOYEE.DEPARTMENT_ID, EMPLOYEE.ENROLLMENT_STATUS, EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME, EMPLOYEE.CREATED_AT, EMPLOYEE.UPDATED_AT |FROM EMPLOYEE """.trimMargin()) { cursor -> check(cursor is JdbcCursor) mapper( cursor.getLong(0)?.let { it.toInt() }, cursor.getLong(1)?.let { it.toInt() }, cursor.getLong(2)?.let { it.toInt() }, cursor.getString(3), cursor.getString(4), cursor.getObject<LocalDateTime>(5), cursor.getObject<LocalDateTime>(6) ) } public fun selectAll(): Query<EMPLOYEE> = selectAll { EMPLOYEE_ID, DEPARTMENT_ID, ENROLLMENT_STATUS, FIRST_NAME, LAST_NAME, CREATED_AT, UPDATED_AT -> EMPLOYEE( EMPLOYEE_ID, DEPARTMENT_ID, ENROLLMENT_STATUS, FIRST_NAME, LAST_NAME, CREATED_AT, UPDATED_AT ) } public fun <T : Any> selectById(EMPLOYEE_ID: Int?, mapper: ( EMPLOYEE_ID: Int?, DEPARTMENT_ID: Int?, ENROLLMENT_STATUS: Int?, FIRST_NAME: String?, LAST_NAME: String?, CREATED_AT: LocalDateTime?, UPDATED_AT: LocalDateTime?, ) -> T): Query<T> = SelectByIdQuery(EMPLOYEE_ID) { cursor -> check(cursor is JdbcCursor) mapper( cursor.getLong(0)?.let { it.toInt() }, cursor.getLong(1)?.let { it.toInt() }, cursor.getLong(2)?.let { it.toInt() }, cursor.getString(3), cursor.getString(4), cursor.getObject<LocalDateTime>(5), cursor.getObject<LocalDateTime>(6) ) } public fun selectById(EMPLOYEE_ID: Int?): Query<EMPLOYEE> = selectById(EMPLOYEE_ID) { EMPLOYEE_ID_, DEPARTMENT_ID, ENROLLMENT_STATUS, FIRST_NAME, LAST_NAME, CREATED_AT, UPDATED_AT -> EMPLOYEE( EMPLOYEE_ID_, DEPARTMENT_ID, ENROLLMENT_STATUS, FIRST_NAME, LAST_NAME, CREATED_AT, UPDATED_AT ) } private inner class SelectByIdQuery<out T : Any>( public val EMPLOYEE_ID: Int?, mapper: (SqlCursor) -> T, ) : Query<T>(mapper) { override fun addListener(listener: Query.Listener) { driver.addListener("EMPLOYEE", listener = listener) } override fun removeListener(listener: Query.Listener) { driver.removeListener("EMPLOYEE", listener = listener) } override fun <R> execute(mapper: (SqlCursor) -> QueryResult<R>): QueryResult<R> = driver.executeQuery(null, """ |SELECT EMPLOYEE.EMPLOYEE_ID, EMPLOYEE.DEPARTMENT_ID, EMPLOYEE.ENROLLMENT_STATUS, EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME, EMPLOYEE.CREATED_AT, EMPLOYEE.UPDATED_AT |FROM EMPLOYEE |WHERE EMPLOYEE_ID ${ if (EMPLOYEE_ID == null) "IS" else "=" } ? """.trimMargin(), mapper, 1) { check(this is JdbcPreparedStatement) bindLong(0, EMPLOYEE_ID?.let { it.toLong() }) } override fun toString(): String = "Employee.sq:selectById" } }
KotlinでSQLを発行する
準備
SQLを実行する用にライブラリが必要なので、build.gradle.kts
のdependencies
に以下を追加する。
implementation("app.cash.sqldelight:jdbc-driver:2.0.2")
あとはSQLDelightがSQL喋るために必要なdatabase
なるものを用意してあげる。今回はHikariCP
を使っている。この辺の詳しい実装はリポジトリを見てほしい。*5
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.asJdbcDriver import com.example.Database import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource open class DatabaseService { fun getHikariDataSource(): HikariDataSource{ val config = HikariConfig().apply { password = "secret" jdbcUrl = "jdbc:mysql://127.0.0.1:13308/exposed_local" driverClassName = "com.mysql.cj.jdbc.Driver" username = "exposer" password = "secret" maximumPoolSize = 5 } return HikariDataSource(config) } fun getDatabase(): Database{ val driver: SqlDriver = getHikariDataSource().asJdbcDriver() val database = Database(driver) return database; } }
selectを試してみる
以下のように書くことでDBからデータを取ってこれる。
package example.koin.service import employee.data.EmployeeQueries class SqlDelightService() : DatabaseService() { fun getAllEmployees(): String { val employeeQueries: EmployeeQueries = getDatabase().employeeQueries return employeeQueries.selectAll().executeAsList().joinToString { "${it.LAST_NAME} ${it.FIRST_NAME}" } } fun getEmployeeById(id: Int): String { val employeeQueries: EmployeeQueries = getDatabase().employeeQueries return employeeQueries.selectById(id).executeAsOne().let{ "従業員の名前は「${it.LAST_NAME} ${it.FIRST_NAME}」です!" } } }
insert
などの他のDMLに関してもSQLDelightが勝手に関数などを作成してくれるっぽい。Transactionも張れそう。
joinするとどうなるのか?
気になったのでやってみた。Employee.sq
ファイルに以下を追加する。
joinDepartment: SELECT * FROM EMPLOYEE JOIN DEPARTMENT ON EMPLOYEE.DEPARTMENT_ID = DEPARTMENT.DEPARTMENT_ID;
すると、JoinDepartment
なるものができあがった。
package employee.`data` import java.time.LocalDateTime import kotlin.Int import kotlin.String public data class JoinDepartment( public val EMPLOYEE_ID: Int?, public val DEPARTMENT_ID: Int?, public val ENROLLMENT_STATUS: Int?, public val FIRST_NAME: String?, public val LAST_NAME: String?, public val CREATED_AT: LocalDateTime?, public val UPDATED_AT: LocalDateTime?, public val DEPARTMENT_ID_: Int?, public val DEPARTMENT_NAME: String?, public val CREATED_AT_: LocalDateTime?, public val UPDATED_AT_: LocalDateTime?, )
つまりsq
ファイルのラベル(というかクエリ)の結果ごとにDTOを定義してくれるらしい。EmployeeQueries
の方も見てみると、joinDepartment
ができている。
public fun <T : Any> joinDepartment(mapper: ( EMPLOYEE_ID: Int?, DEPARTMENT_ID: Int?, ENROLLMENT_STATUS: Int?, FIRST_NAME: String?, LAST_NAME: String?, CREATED_AT: LocalDateTime?, UPDATED_AT: LocalDateTime?, DEPARTMENT_ID_: Int?, DEPARTMENT_NAME: String?, CREATED_AT_: LocalDateTime?, UPDATED_AT_: LocalDateTime?, ) -> T): Query<T> = Query(-799_577_048, arrayOf("EMPLOYEE", "DEPARTMENT"), driver, "Employee.sq", "joinDepartment", """ |SELECT EMPLOYEE.EMPLOYEE_ID, EMPLOYEE.DEPARTMENT_ID, EMPLOYEE.ENROLLMENT_STATUS, EMPLOYEE.FIRST_NAME, EMPLOYEE.LAST_NAME, EMPLOYEE.CREATED_AT, EMPLOYEE.UPDATED_AT, DEPARTMENT.DEPARTMENT_ID, DEPARTMENT.DEPARTMENT_NAME, DEPARTMENT.CREATED_AT, DEPARTMENT.UPDATED_AT |FROM EMPLOYEE |JOIN DEPARTMENT ON EMPLOYEE.DEPARTMENT_ID = DEPARTMENT.DEPARTMENT_ID """.trimMargin()) { cursor -> check(cursor is JdbcCursor) mapper( cursor.getLong(0)?.let { it.toInt() }, cursor.getLong(1)?.let { it.toInt() }, cursor.getLong(2)?.let { it.toInt() }, cursor.getString(3), cursor.getString(4), cursor.getObject<LocalDateTime>(5), cursor.getObject<LocalDateTime>(6), cursor.getLong(7)?.let { it.toInt() }, cursor.getString(8), cursor.getObject<LocalDateTime>(9), cursor.getObject<LocalDateTime>(10) ) }
インスペクションしてみると、いい感じにデータが取れている。
感想
SQLクエリファーストで考えていて、後から型をつけてくれるのが新鮮だった。ネイティブクエリを書きながらも勝手に型をつけてくれるのが嬉しいっていう開発者は結構多そう。
それに学習コストがかなり低いのもいい。試してみてExposedと共存させられることもわかった。ネイティブクエリを扱いたいときだけSQLDelightからクエリを発行するのもありだと思う。Exposedを軸に考えるなら、DDLを一緒に管理しておけばよいだけなので。DTOはその分増えてしまうが、そっちはSQLDelightが勝手に管理してくれるのであまり二重管理の負担にはならないんじゃないだろうか。
あと気になるとしたら、ストアドを動かせるのかどうかが気になる。これは誰か試した人がいたら是非教えてほしい。
総じていうとGitHub Starが6.3kもついてる*6のも納得だった。今度Kotlinで何か作るときはExposedもいいけどこっちを使ってみたい。
*1:この記事のタイトル見た方もそう思われたのでは
*2:ここは表現が難しい。型安全に書くことには違いないがORMによっても違いがあるかも。例えばEloquentだとPHPの世界でオブジェクトを作り、それを元にMigrationされるのでSQLDelightとは順番が違う。Exposedの場合はそれができない。
*3:この辺の実装を比較してみて!
*4:Exposedの本を書いた時に使ったのがMySQLだった都合。SQLDelight的にはPostgreSQLはじめ他のDBにも対応している。
*5:ローカルに作ったコンテナ&&ただの個人の検証環境なのでSQL接続情報はハードコードしている。言うまでもないけど業務で使う時なんかは開発環境でもちゃんと隠してね。こんなの書くまでもなくて当たり前のことすぎるんだけど一応脚注つけとかないと「機密情報ハードコードするな警察」が出てきてうざいので。
*6:2025年1月24日現在