Construindo DSLs em Kotlin

Construindo DSLs em Kotlin

*1. Visão geral *

Neste tutorial,* veremos como os poderosos recursos da linguagem Kotlin podem ser usados ​​para criar DSLs com segurança de tipo. *

Para o nosso exemplo, criaremos uma ferramenta simples para construir consultas SQL, grandes o suficiente para ilustrar o conceito.

*A idéia geral é usar literais de função fornecidos estaticamente pelo usuário que modificam o estado do construtor de consultas quando invocados.* Depois que todos eles são chamados, o estado do construtor é verificado e a sequência SQL resultante é gerada.

*2. Definindo o ponto de entrada *

Vamos começar definindo um ponto de entrada para nossa funcionalidade:

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

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

Então podemos simplesmente usar as funções definidas:

val sql = query {
}.build()

===* 3. Adicionando colunas *

Vamos adicionar suporte para definir as colunas de destino a serem usadas. Veja como isso fica na DSL:

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

E a implementação da função 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. Definindo a tabela *

Também precisamos permitir a especificação da tabela de destino a ser usada:

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

A função from simplesmente definirá o nome da tabela recebida na propriedade da classe:

class SqlSelectBuilder {

    private lateinit var table: String

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

===* 5. O primeiro marco *

Na verdade, agora temos o suficiente para criar consultas simples e testá-las. Vamos fazer isso!

Primeiro, precisamos implementar o método 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"
    }
}

Agora podemos apresentar alguns testes:

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 Condição *

Na maioria das vezes, precisamos especificar condições em nossas consultas.

Vamos começar definindo a aparência da DSL:

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

Essas condições são na verdade SQL e operandos, então vamos introduzir o mesmo conceito no código-fonte:

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

abstract class Condition

class And : Condition()

class Eq : Condition()

Vamos implementar as classes uma por uma:

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"
        }
    }
}

Por fim, criaremos a classe And que contém a lista de condições e implementa o método addCondition:

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 ")
        }
    }
}

A parte complicada aqui é apoiar os critérios DSL. Declaramos Condition.eq como uma função de extensão de cadeia de caracteres infix para isso. Portanto, podemos usá-lo tradicionalmente como _column.eq (value) _ ou sem ponto e parênteses - column eq value.

A função é definida em um contexto da classe Condition, é por isso que podemos usá-la (lembre-se de que SqlSelectBuilder.where recebe uma função literal que é executada no contexto de Condition).

Agora podemos verificar se tudo funciona como esperado:

@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. OR Condição *

A última parte do nosso exercício é dar suporte a condições SQL OR. Como sempre, vamos definir como isso deve aparecer em nossa DSL primeiro:

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

Em seguida, forneceremos uma implementação. Como OR e AND são muito semelhantes, podemos reutilizar a implementação existente:

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")

Por fim, adicionaremos o suporte correspondente às condições sub-DSL:

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

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

Vamos verificar se tudo funciona:

@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. Diversão Extra *

Fora do escopo deste tutorial, os mesmos conceitos podem ser usados ​​para expandir nossa DSL. Por exemplo, podemos aprimorá-lo adicionando suporte para LIKE, GROUP BY, HAVING, ORDER BY. Sinta-se livre para postar soluções nos comentários!

===* 9. Conclusão*

Neste artigo, vimos um exemplo de criação de uma DSL simples para consultas SQL. Não é um guia completo, mas estabelece uma boa base e fornece uma visão geral de toda a abordagem DSL segura para o tipo Kotlin.

Como sempre, o código fonte completo deste artigo está disponível over no GitHub.