はじめに
先日Server-Side Kotlin MeetupのLT大会で登壇してきましたので、その内容をブログ記事でも公開します。
テーマはKotlin製のORマッパー、Ktormのクラス構造と機能拡張についてです。
Ktormの紹介
Ktormは、いくつかあるJDBCベースのKotlin製ORマッパーのうちの1つです。
それらの中でもっともメジャーと思われるExposedと比較すると、次のような特長があります。
シンプルな実行モデル
遅延実行やキャッシュなどの仕組みを持たないので、コードを読んで理解しやすく、SQLの実行タイミング等も把握しやすいです。
生SQLに近いDSL
select()
で検索条件を指定し slice()
でカラムを指定する形式のExposedのDSL 1 に比べて、生SQLに近い文法を採用しており、SQLに習熟していれば少ない認知コストで読み書きできます。
充実したドキュメント
上記の公式ドキュメントページの内容が充実しており、カラムの型定義追加などの機能拡張方法も一通りサポートされています。2
テーブル定義
本題に入る前に、今回利用するテーブルのスキーマを定義します。
年齢と住んでいる都道府県のデータを持った、何かしらのユーザーのデータです。
object SampleUserTable : Table<Nothing> ("sample_users") { val id: Column<Int> = int("id").primaryKey() val name: Column<String> = varchar("name") val age: Column<Int> = varchar("age") val prefecture: Column<String> = varchar("prefecture") }
テーブル定義方法についての詳細については、公式ドキュメントのSchema Definitionのページにありますので、そちらを参照してください。
今回の目標
今回は、「タプル形式で2つのカラムの値を指定する IN
条件」をKtormで書けるようにすることを目標としたいと思います。
具体的には次のようなクエリで、「東京都の15歳 or 埼玉県の16歳 or 神奈川県の17歳」のような条件に該当するユーザー名の検索を想定しています。
SELECT name FROM sample_users WHERE (age, prefecture) IN (?, ?), (?, ?), (?, ?)
Ktormの実装では、単一カラムの IN
は用意されていますが、この例のような複数カラムの同時指定はサポートされていません。
また、 IN
条件にはカラムが3つ以上になるパターンや右辺にサブクエリをとるパターン、そして否定条件の NOT IN
の演算子も存在しますが、今回は実装例をシンプルにするためにこれらは対応しないことにします。
KtormのSELECT文の仕組み
KtormのSQL DSLを使ってSELECT文を書く場合、次のような書き方になります。
fun searchNamesByAge( db: org.ktorm.database.Database, targetAge: Int, ): List<String> { val t = SimpleUserTable return db.from(t) // => QuerySource .select(t.name) // => Query .where(t.age eq targetAge) // => Query .map { row -> row.get[t.name] ?: error("name is non-null column") } // => List<String> }
DAOである Database
オブジェクトに対して from()
と select()
でテーブル定義や取得するカラムを指定することで、Query
オブジェクトが生成されます。
この Query
がBuilderパターンになっており、これに対して where()
や orderBy()
などを呼び出すことで内部でクエリが組み立てられていき、最後に map()
で配列に変換するタイミングでクエリが実行されます。
Query
クラスの内部構造は以下の通りで、クエリを実行するための Database
とクエリ構造を表現する QueryExpression
から構成されます。
public class Query( public val database: Database, public val expression: QueryExpression, )
SqlExpressionの拡張
QueryExpression
の主要な継承先である SqlExpression
の構造は以下の通りです。
今回は where
の条件を追加したいので、ScalarExpression<Boolean>
というのを作れば良いことになります。
public data class SelectExpression( val columns: List<ColumnDeclaringExpression<*>> = emptyList(), val from: QuerySourceExpression, val where: ScalarExpression<Boolean>? = null, // 以下略。groupBy や orderBy などが続く ) : QueryExpression()
SelectExpression
のメンバには ScalarExpression
以外にも様々な種類のExpressionが存在していますが、これらはどう使い分けられているのでしょう?
Expressionまわりの継承関係の一部をまとめたクラス図が以下です。
SQLに変換可能な表現全ての基底クラスとして SqlExpression
が存在し、その下に次のようなサブカテゴリが存在します。
- SELECT文のFROM句に渡せる表現としての
QuerySourceExpression
QuerySourceExpression
から取得対象カラムを指定した状態のQueryExpression
- カラム指定や演算結果など、単一の戻り値を持つ表現としての
ScalarExpression<T>
というわけで、ScalarExpression<Boolean>
を継承し、必要な抽象メンバを定義して作った「2つのカラムの値を指定する IN
条件」のExpressionがこちらになります。
DSLを使ってSQLっぽく書くための infix関数も一緒に定義します。
data class TupleInListExpression<A: Any, B: Any>( internal val left: Pair<Column<A>, Column<B>>, internal val right: List<Pair<A, B>>, ) : ScalarExpression<Boolean>() { override val sqlType: SqlType<Boolean> = BooleanSqlType override val isLeafNode: Boolean = false override val extraProperties: Map<String, Any> = emptyMap() } infix fun <A : Any, B : Any> Pair<Column<A>, Column<B>>.inList( rightValues: List<Pair<A, B>>, ): TupleInListExpression<A, B> { return TupleInListExpression(this, rightValues) }
これを使うことで、今回の目標となるSQLを以下のように型チェックを活用しながら書くことができます。
fun searchNamesByAgesAndPrefectures(conditions: List<Pair<Int, String>>): List<String> { val t = SimpleUserTable return db.from(t) .select(t.name) .where((t.age to t.prefecture) inList conditions) .map { row -> row.get[t.name] ?: error("name is non-null column") } }
しかし、上記のコードはコンパイルは通るのですが、実行してみると DialectFeatureNotSupportedException
という例外が出て失敗してしまいます。
SqlFormatterの拡張
実は、TupleInListExpression
を定義したときに、ある違和感に気づく必要がありました。
SQLは最終的に文字列として表現されるので、新たに定義した SqlExpression
はどのように文字列変換されるかが指定されなければならないのですが、前述のクラス定義にはそれがありませんでした。
Query
クラスには sql
という文字列表現を取得するプロパティを持っているので、そのコードを追ってみます。
- https://github.com/kotlin-orm/ktorm/blob/v3.6.x/ktorm-core/src/main/kotlin/org/ktorm/dsl/Query.kt#L78
- https://github.com/kotlin-orm/ktorm/blob/v3.6.x/ktorm-core/src/main/kotlin/org/ktorm/database/Database.kt#L370
- https://github.com/kotlin-orm/ktorm/blob/v3.6.x/ktorm-core/src/main/kotlin/org/ktorm/database/SqlDialect.kt#L63
- https://github.com/kotlin-orm/ktorm/blob/v3.6.x/ktorm-core/src/main/kotlin/org/ktorm/expression/SqlFormatter.kt#L188
- https://github.com/kotlin-orm/ktorm/blob/v3.6.x/ktorm-core/src/main/kotlin/org/ktorm/expression/SqlExpressionVisitor.kt#L45
登場したクラスの関係をまとめると以下の通りです。
MySQLやPostgreSQLなどのDBMS間の差異を吸収するための SqlDialect
というクラスがあって、それが生成する SqlFormatter
が文字列の組み立ての責務を担っています。
public fun visit(expr: SqlExpression): SqlExpression { return when (expr) { is ScalarExpression<*> -> visitScalar(expr) is QueryExpression -> visitQuery(expr) is QuerySourceExpression -> visitQuerySource(expr) is InsertExpression -> visitInsert(expr) is InsertFromQueryExpression -> visitInsertFromQuery(expr) is UpdateExpression -> visitUpdate(expr) is DeleteExpression -> visitDelete(expr) is ColumnAssignmentExpression<*> -> visitColumnAssignment(expr) is OrderByExpression -> visitOrderBy(expr) is WindowSpecificationExpression -> visitWindowSpecification(expr) is WindowFrameBoundExpression -> visitWindowFrameBound(expr) else -> visitUnknown(expr) } }
SqlFormatter.visit()
は上のような愚直なクラス分岐によって書かれており、Ktormに定義されていないクラスについては、visitUnknown()
で処理されるようになっています。
よって、SqlFormatter
を継承したクラスを新しく作り、visitUnknown()
をオーバーライドしてあげる必要があります。
internal class CustomSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) : PostgreSqlFormatter(database, beautifySql, indentSize) { override fun visitUnknown(expr: SqlExpression): SqlExpression { when (expr) { is TupleInListExpression<*,*> -> visitTupleInList(expr) else -> super.visitUnknown(expr) } return expr } private fun visitTupleInList(expr: TupleInListExpression<*,*>) { write("(") visitScalar(expr.left.first) write(", ") visitScalar(expr.left.second) write(") ") writeKeyword(" in ") // 以下略 } }
SqlFormatter
は write()
を呼び出すことで内部の _builder
に文字列が追加されて、それが最後にSQL文字列として出力される仕組みになっています。
最後に、作成した CustomSqlFormatter
を利用する SqlDialect
オブジェクトも新しく定義して、それを Database
のコンストラクタに渡してあげれば、無事に TupleInListExpression
を使ったSQLが実行できるようになります。
object CustomSqlDialect : SqlDialect { override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int): SqlFormatter { return CustomSqlFormatter(database, beautifySql, indentSize) } } val db = DataBase.connect( dataSource = someDataSource, dialect = CustomSqlDialect, ) val conditions = listOf(15 to "Tokyo", 16 to "Saitama", 17 to "Kanagawa") val names = db.from(t) .select(t.name) .where((t.age to t.prefecture) inList conditions) .map { row -> row.get[t.name] ?: error("name is non-null column") }
まとめ
以上、KtormのSQL DSLを拡張するには以下の2つが必要だ、という発表をしてきました。
SqlExpression
を継承した独自クラスを作成するSqlFormatter
を継承したクラスを作って上記のExpressionを文字列に変換できるようにする
今回は5分という短い時間の登壇でしたが、サーバーサイドKotlinの普及のためにも、またの機会に積極的に挑戦したいと思います。
We are hiring!
株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!
バックエンドチームでは、サーバーサイドをKotlinで開発したいメンバーを積極募集中です!
開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。