1. 概要

このチュートリアルでは、タイプセーフなDSLを構築するためにKotlin言語機能をどのように使用できるかを説明します。

この例では、SQLクエリを作成するためのシンプルなツールを作成します。これは、概念を説明するのに十分な大きさです。

一般的な考え方は、静的に型指定されたユーザー提供の関数リテラルを使用して、呼び出されたときにクエリビルダーの状態を変更することです。すべてが呼び出された後、ビルダーの状態が検証され、結果のSQL文字列が生成されます。

2. エントリポイントの定義

機能のエントリポイントを定義することから始めましょう。

class SqlSelectBuilder {
    fun build(): String {
        TODO("implement")
    }
}

fun query(initializer: SqlSelectBuilder.() -> Unit): SqlSelectBuilder {
    return SqlSelectBuilder().apply(initializer)
}

次に、定義された関数を簡単に使用できます。

val sql = query {
}.build()

3. 列の追加

使用するターゲット列を定義するためのサポートを追加しましょう。 DSLでは次のようになります。

query {
    select("column1", "column2")
}

そして、 select関数の実装:

class SqlSelectBuilder {

    private val columns = mutableListOf<String>()

    fun select(vararg columns: String) {
        if (columns.isEmpty()) {
            throw IllegalArgumentException("At least one column should be defined")
        }
        if (this.columns.isNotEmpty()) {
            throw IllegalStateException("Detected an attempt to re-define columns to fetch. " 
              + "Current columns list: "
              + "${this.columns}, new columns list: $columns")
        }
        this.columns.addAll(columns)
    }
}

4. テーブルの定義

また、使用するターゲットテーブルを指定できるようにする必要があります。

query {
    select ("column1", "column2")
    from ("myTable")
}

from 関数は、クラスプロパティで受け取ったテーブル名を設定するだけです。

class SqlSelectBuilder {

    private lateinit var table: String

    fun from(table: String) {
        this.table = table
    }
}

5. 最初のマイルストーン

実際、これで簡単なクエリを作成してテストするのに十分です。 やってみましょう!

まず、SqlSelectBuilder.buildメソッドを実装する必要があります。

class SqlSelectBuilder {

    fun build(): String {
        if (!::table.isInitialized) {
            throw IllegalStateException("Failed to build an sql select - target table is undefined")
        }
        return toString()
    }

    override fun toString(): String {
        val columnsToFetch =
                if (columns.isEmpty()) {
                    "*"
                } else {
                    columns.joinToString(", ")
                }
        return "select $columnsToFetch from $table"
    }
}

ここで、いくつかのテストを紹介できます。

private fun doTest(expected: String, sql: SqlSelectBuilder.() -> Unit) {
    assertThat(query(sql).build()).isEqualTo(expected)
}

@Test
fun `when no columns are specified then star is used`() {
    doTest("select * from table1") {
        from ("table1")
    }
}
@Test
fun `when no condition is specified then correct query is built`() {
    doTest("select column1, column2 from table1") {
        select("column1", "column2")
        from ("table1")
    }
}

6. AND状態

ほとんどの場合、クエリで条件を指定する必要があります。

DSLがどのように見えるかを定義することから始めましょう:

query {
    from("myTable")
    where {
        "column3" eq 4
        "column3" eq null
    }
}

これらの条件は実際にはSQLANDオペランドであるため、ソースコードに同じ概念を導入しましょう。

class SqlSelectBuilder {
    fun where(initializer: Condition.() -> Unit) {
        condition = And().apply(initializer)
    }
}

abstract class Condition

class And : Condition()

class Eq : Condition()

クラスを1つずつ実装してみましょう。

abstract class Condition {
    infix fun String.eq(value: Any?) {
        addCondition(Eq(this, value))
    }
}
class Eq(private val column: String, private val value: Any?) : Condition() {

    init {
        if (value != null && value !is Number && value !is String) {
            throw IllegalArgumentException(
              "Only <null>, numbers and strings values can be used in the 'where' clause")
        }
    }

    override fun addCondition(condition: Condition) {
        throw IllegalStateException("Can't add a nested condition to the sql 'eq'")
    }

    override fun toString(): String {
        return when (value) {
            null -> "$column is null"
            is String -> "$column = '$value'"
            else -> "$column = $value"
        }
    }
}

最後に、条件のリストを保持し、addConditionメソッドを実装するAndクラスを作成します。

class And : Condition() {

    private val conditions = mutableListOf<Condition>()

    override fun addCondition(condition: Condition) {
        conditions += condition
    }

    override fun toString(): String {
        return if (conditions.size == 1) {
            conditions.first().toString()
        } else {
            conditions.joinToString(prefix = "(", postfix = ")", separator = " and ")
        }
    }
}

ここで注意が必要なのは、DSL基準のサポートです。 そのためのインフィックス文字列拡張関数としてCondition.eqを宣言します。 したがって、従来は column.eq(value)のように使用することも、ドットと括弧なしで column eq valueを使用することもできます。

関数はConditionクラスのコンテキストで定義されているため、使用できます(SqlSelectBuilder.whereのコンテキストで実行される関数リテラルを受け取ることに注意してください。状態)。

これで、すべてが期待どおりに機能することを確認できます。

@Test
fun `when a list of conditions is specified then it's respected`() {
    doTest("select * from table1 where (column3 = 4 and column4 is null)") {
        from ("table1")
        where {
            "column3" eq 4
            "column4" eq null
        }
    }
}

7. または状態

演習の最後の部分は、SQLOR条件のサポートです。 いつものように、最初にDSLでどのように見えるかを定義しましょう。

query {
    from("myTable")
    where {
        "column1" eq 4
        or {
            "column2" eq null
            "column3" eq 42
        }
    }
}

次に、実装を提供します。 ORとANDは非常に似ているため、既存の実装を再利用できます。

open class CompositeCondition(private val sqlOperator: String) : Condition() {
    private val conditions = mutableListOf<Condition>()

    override fun addCondition(condition: Condition) {
        conditions += condition
    }

    override fun toString(): String {
        return if (conditions.size == 1) {
            conditions.first().toString()
        } else {
            conditions.joinToString(prefix = "(", postfix = ")", separator = " $sqlOperator ")
        }
    }
}

class And : CompositeCondition("and")

class Or : CompositeCondition("or")

最後に、対応するサポートを条件サブDSLに追加します。

abstract class Condition {
    fun and(initializer: Condition.() -> Unit) {
        addCondition(And().apply(initializer))
    }

    fun or(initializer: Condition.() -> Unit) {
        addCondition(Or().apply(initializer))
    }
}

すべてが機能することを確認しましょう。

@Test
fun `when 'or' conditions are specified then they are respected`() {
    doTest("select * from table1 where (column3 = 4 or column4 is null)") {
        from ("table1")
        where {
            or {
                "column3" eq 4
                "column4" eq null
            }
        }
    }
}

@Test
fun `when either 'and' or 'or' conditions are specified then they are respected`() {
    doTest("select * from table1 where ((column3 = 4 or column4 is null) and column5 = 42)") {
        from ("table1")
        where {
            or {
                "column3" eq 4
                "column4" eq null
            }
            "column5" eq 42
        }
    }
}

8. 余分な楽しみ

このチュートリアルの範囲外ですが、DSLを拡張するために同じ概念を使用できます。 たとえば、 LIKE GROUP BY HAVING ORDERBYのサポートを追加することで拡張できます。 コメントに解決策を投稿してください!

9. 結論

この記事では、SQLクエリ用の単純なDSLを構築する例を見ました。 これは完全なガイドではありませんが、優れた基盤を確立し、KotlinのタイプセーフDSLアプローチ全体の概要を提供します。

いつものように、この記事の完全なソースコードは、GitHubから入手できます。