Ktorm のクラス設計を読み解いて、DSLを拡張する

はじめに

先日Server-Side Kotlin MeetupのLT大会で登壇してきましたので、その内容をブログ記事でも公開します。

テーマはKotlin製のORマッパー、Ktormのクラス構造と機能拡張についてです。

Ktormの紹介

ktorm.org

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>

SqlExpressionのクラス図

というわけで、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 という文字列表現を取得するプロパティを持っているので、そのコードを追ってみます。

登場したクラスの関係をまとめると以下の通りです。

SqlFormatterのクラス図

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 ")
        // 以下略
    }
}

SqlFormatterwrite() を呼び出すことで内部の _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で開発したいメンバーを積極募集中です!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com


  1. Exposedのこの書き方は昨年11月に公開された 0.46.0 からdeprecatedになり、Exposedでも select() でカラムを指定して where() で検索条件を指定する書き方になりました。しかし、それでもjoinの書き方などの細かいところではKtormの方が生SQLに近い書き方になっています。
  2. 今回のテーマである演算子の追加も、こちらで一通りの手順が説明されています。今回のLTではこれをさらに、SqlExpressionのクラス構造に着目して掘り下げてみました。