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.