Ir al contenido
  1. Posts/

criteria4s. ¿Hablas mi idioma?

·8 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
criteria4s - Este artículo es parte de una serie.
Parte 3: Este artículo
Parte 3: Este artículo

Un compañero leyó el post anterior y me escribió algo bastante razonable: “Mi base de datos no está en la lista. Me imagino que la librería no me sirve”. Estaba trabajando con un query engine interno, de esos que nacen dentro de una empresa, resuelven un problema muy concreto y no salen nunca al mundo exterior.

La intuición era comprensible, pero no era correcta. Una hora después tenía un dialecto funcionando. La función activeAdults del primer post, la misma que renderiza para SQL y MongoDB sin cambiar una línea, estaba produciendo también la sintaxis de su empresa. Misma función. Mismos predicados. Salida distinta.

Y ese es precisamente el recorrido de este post. Primero vamos a construir un dialecto desde cero, despacio y viendo cada pieza. Después veremos el atajo para esos casos mucho más comunes en los que tu base de datos ya habla SQL, aunque lo haga con un acento un poco raro.

criteria4s-hablas-mi-idioma-img-3.svg

De qué está hecho un dialecto
#

Si lo reduces a lo esencial, todo dialecto de criteria4s necesita tres cosas:

Un trait que extienda CriteriaTag. Es el phantom type del post anterior. No tiene campos ni métodos. Está ahí para que el compilador pueda distinguir tu dialecto de cualquier otro.

Instancias de Show. Le enseñan a criteria4s cómo renderizar valores en tu sintaxis. ¿Cómo se entrecomilla una columna? ¿Cómo se formatea un string literal? ¿Cómo se escribe una secuencia? Cada respuesta suele acabar siendo una instancia de Show de una línea.

Instancias given de predicados y conjunciones. Le dicen a criteria4s cómo ensamblar esos valores renderizados en expresiones completas. Tú pones una función template de strings, llamas a build y obtienes una type class instance entera.

Y ya está. No hay una interfaz enorme que implementar. No hay una clase abstracta con veinte métodos. No hay un registro central. Defines los givens, los traes al scope y el compilador se encarga del resto.

Algo que no se parece en nada a SQL
#

Para que la idea se vea de verdad, necesitamos un ejemplo donde la traducción salte a la vista. Si construyéramos otro dialecto SQL, la salida sería demasiado parecida y el cambio perdería fuerza. Así que vamos a hacer algo mucho menos familiar: un dialecto que produce S-expressions, la sintaxis de Lisp. Todo en notación prefija, todo entre paréntesis. age >= 18 se convierte en (gte age 18). a AND b se convierte en (and a b).

El trait:

package com.example.dialect

import com.eff3ct.criteria4s.core.*
import com.eff3ct.criteria4s.core.Criteria.CriteriaTag
import com.eff3ct.criteria4s.instances.build

trait SExpr extends CriteriaTag

Una línea. SExpr ya es un dialect tag. El compilador puede arrastrarlo a través de cada expresión y comprobar que no se mezcle con otros dialectos. Todavía no sabe renderizar nada, pero ya tiene un nombre para esa familia de criterios.

String templates
#

La lógica de renderizado de un dialecto suele caber en muy pocas funciones privadas. El template de igualdad en SQL es s"$left = $right". El de MongoDB es s"{$left: {$$eq: $right}}". Para S-expressions, todo va en prefijo:

object SExpr {

  private def predExpr(op: String)(left: String, right: String): String =
    s"($op $left $right)"

  private def predExpr1(op: String)(value: String): String =
    s"($op $value)"

  private def conjExpr(op: String)(left: String, right: String): String =
    s"($op $left $right)"

Son solo tres funciones. predExpr gestiona predicados binarios como (eq age 18). predExpr1 se queda con los unarios, como (null? age). conjExpr monta conjunciones como (and expr1 expr2). En este dialecto las tres tienen prácticamente la misma forma. En SQL no pasa eso: los predicados son infijos y las conjunciones suelen envolver cada operando en paréntesis. Toda esa diferencia de estilo vive, literalmente, en estas pocas líneas.

Renderizando valores
#

El siguiente paso es decidir cómo se ven los valores. Con cuatro instancias de Show cubrimos todo lo que necesitamos:

  // Las columnas se renderizan como nombres sin decorar
  given showColumn: Show[Column, SExpr] =
    Show.create(_.colName)

  // Los strings se renderizan con comillas dobles
  given showString: Show[String, SExpr] =
    Show.create(s => s""""$s"""")

  // Las secuencias se renderizan como listas separadas por espacios entre paréntesis
  given showSeq[V](using show: Show[V, SExpr]): Show[Seq[V], SExpr] =
    Show.create(_.map(show.show).mkString("(", " ", ")"))

  // Los rangos se renderizan como dos valores uno al lado del otro
  given showTuple[V](using show: Show[V, SExpr]): Show[(V, V), SExpr] =
    Show.create { case (l, r) => s"${show.show(l)} ${show.show(r)}" }

Aquí es donde se ve bien el papel de Show. En SQL, los strings llevan comillas simples con escaping. En MongoDB, las columnas llevan comillas dobles JSON. Aquí, en cambio, las columnas van sin decorar y los strings usan comillas dobles. La type class es la misma. Lo que cambia es la convención de cada dialecto.

Puede que notes que no hay Show[Int, SExpr]. No hace falta. La core library trae instancias por defecto de Show para tipos AnyVal que básicamente llaman a toString. Como 18.toString ya nos da "18", nos vale tal cual. Solo hace falta escribir una instancia de Show cuando el comportamiento por defecto no encaja con la sintaxis que quieres.

Una línea por operación
#

Y ahora llega la parte donde el diseño empieza a devolver el esfuerzo. Catorce operaciones, catorce líneas:

  // Predicados
  given eqPred: EQ[SExpr]               = build[SExpr, EQ](predExpr("eq"))
  given neqPred: NEQ[SExpr]             = build[SExpr, NEQ](predExpr("neq"))
  given gtPred: GT[SExpr]               = build[SExpr, GT](predExpr("gt"))
  given geqPred: GEQ[SExpr]             = build[SExpr, GEQ](predExpr("gte"))
  given ltPred: LT[SExpr]               = build[SExpr, LT](predExpr("lt"))
  given leqPred: LEQ[SExpr]             = build[SExpr, LEQ](predExpr("lte"))
  given likePred: LIKE[SExpr]           = build[SExpr, LIKE](predExpr("like"))
  given inPred: IN[SExpr]               = build[SExpr, IN](predExpr("in"))
  given isnullPred: ISNULL[SExpr]       = build[SExpr, ISNULL](predExpr1("null?"))
  given isnotnullPred: ISNOTNULL[SExpr] = build[SExpr, ISNOTNULL](predExpr1("not-null?"))
  given betweenPred: BETWEEN[SExpr]     = build[SExpr, BETWEEN](predExpr("between"))

  // Conjunciones
  given andConj: AND[SExpr] = build[SExpr, AND](conjExpr("and"))
  given orConj: OR[SExpr]   = build[SExpr, OR](conjExpr("or"))
  given notConj: NOT[SExpr] = build[SExpr, NOT](predExpr1("not"))
}

Cada línea sigue exactamente el mismo patrón: build[Dialect, Predicate](templateFunction("operator")). El helper build toma tu template y te devuelve una type class instance completa. Por detrás, BuilderBinary o BuilderUnary llama a asString en los operandos, dispara Show, pasa los strings renderizados a tu template y envuelve el resultado en Criteria.pure.

La única información realmente nueva en cada línea es el nombre del operador. Todo lo demás ya estaba resuelto por la infraestructura de la librería.

Ejecutándolo
#

Una vez hecho eso, no hay ninguna ceremonia especial. Traes los givens al scope y llamas a la misma función activeAdults del primer post:

import com.example.dialect.SExpr.{given, *}
import com.eff3ct.criteria4s.core.*
import com.eff3ct.criteria4s.extensions.*
import com.eff3ct.criteria4s.functions as F

def activeAdults[T <: CriteriaTag: GEQ: EQ: AND](
    using Show[Column, T]
): Criteria[T] =
  (F.col[T]("age") geq F.lit(18)) and (F.col[T]("active") === F.lit(true))

activeAdults[SExpr].value
// (and (gte age 18) (eq active true))

Esa función se escribió antes de que SExpr existiera. Lo único que pedía era un GEQ[T], un EQ[T] y un AND[T]. El import puso esas piezas sobre la mesa. El compilador encontró lo que necesitaba y la función siguió su camino. No sabe, ni le importa, que esta vez está produciendo algo con sabor a Lisp.

Y la seguridad en compile time se mantiene intacta:

val broken = F.col[SExpr]("age") :> F.col[SQL]("age")
// No compila. SExpr no es SQL.

No hay código de seguridad adicional ni un registro mágico por debajo. El phantom type sigue fluyendo a través del expression tree exactamente igual que con cualquier dialecto built-in.

El atajo de cinco líneas
#

Hasta aquí hemos hecho el camino largo, que viene muy bien para entender la mecánica. Pero muchas veces no hace falta llegar tan lejos. Si tu base de datos habla SQL y solo cambia, por ejemplo, la forma de entrecomillar columnas, puedes heredar y listo.

Una base de datos que envuelve los nombres de columna en angle brackets (<column>):

trait AngleBracketSQL extends SQL

object AngleBracketSQL extends SQL.SQLExpr[AngleBracketSQL] {
  given showColumn: Show[Column, AngleBracketSQL] =
    Show.create(col => s"<${col.colName}>")
}

Cinco líneas. Cada predicado, cada conjunción y cada regla de formateo de strings se heredan de SQLExpr[AngleBracketSQL]. Lo único que hemos especificado es cómo se renderizan los nombres de columna. Y esto no es un truco de demo. Es, en esencia, cómo están implementados PostgreSQL, MySQL, DuckDB, ClickHouse y Spark SQL dentro de la librería: un override por dialecto y poco más.

activeAdults[AngleBracketSQL].value
// (<age> >= 18) AND (<active> = true)

Lo que te dan treinta líneas
#

Si cuentas el dialecto SExpr con cierta generosidad, el coste real es este:

  • 1 declaración de trait
  • 3 funciones template (6 líneas con espaciado)
  • 4 instancias de Show (8 líneas)
  • 14 givens de predicados y conjunciones (14 líneas)

Treinta líneas. A cambio obtienes cobertura completa de predicados (=, !=, >, >=, <, <=, LIKE, IN, IS NULL, IS NOT NULL, BETWEEN), soporte para conjunciones (AND, OR, NOT), seguridad en compile time y compatibilidad con cualquier función polimórfica que ya se haya escrito contra criteria4s. Los dos estilos de API siguen funcionando sin pedirte código extra.

Y aquí es donde la historia vuelve al principio. El compañero que hizo la pregunta original tuvo su dialecto funcionando esa misma tarde. Su equipo lo usa junto a PostgreSQL y MongoDB en el mismo codebase, con los mismos predicados y sin una translation layer extra en medio. Un idioma más. Cero drama.

Lo que viene después
#

El siguiente post se mueve de la librería a la arquitectura que la rodea: criteria4s 4. Tu dominio no tiene base de datos. Dejamos de hablar de cómo se construye un dialecto y pasamos a una pregunta bastante más incómoda: en qué punto del sistema debería saberse realmente qué base de datos estamos usando.

El código fuente completo de todos los dialectos built-in está en github.com/eff3ct0/criteria4s. El directorio examples/ incluye un dialecto WeirdDatastore que sigue el mismo patrón from scratch que hemos construido aquí.

criteria4s - Este artículo es parte de una serie.
Parte 3: Este artículo
Parte 3: Este artículo