He reescrito el mismo filtro tres veces en mi carrera. No porque lo hubiera olvidado, sino porque cada vez vivía en un backend diferente, y cada backend habla un idioma distinto.
La primera vez fue un string SQL. La segunda fue un documento BSON de MongoDB. La tercera fue un query JSON de Elasticsearch. El predicado era idéntico en los tres casos: buscar usuarios mayores de 18 que estén activos. El significado no cambió nunca. Lo que cambió fue la sintaxis, el entrecomillado, el anidamiento, los nombres de los operadores. Y así abrí un archivo nuevo, escribí la misma lógica otra vez, y seguí adelante.
Este es el tipo de duplicación que no parece duplicación. Cada versión tiene un aspecto suficientemente diferente como para que te convenzas de que son cosas distintas. No lo son. Son la misma cosa con diferente ropa, y van a divergir. Un día alguien arreglará un bug en la versión SQL y se olvidará de la de MongoDB.
El Problema de la Expresión presentándose en el trabajo, sin avisar.
La misma frase en tres idiomas #
Yendo al grano, me he visto obligado a escribir la misma búsqueda de clientes mayores de edad activos para tres backends diferentes:
-- SQL
SELECT * FROM users WHERE age >= 18 AND active = true// MongoDB
{ "$and": [{ "age": { "$gte": 18 } }, { "active": { "$eq": true } }] }// Elasticsearch
{
"bool": {
"must": [
{ "range": { "age": { "gte": 18 } } },
{ "term": { "active": true } }
]
}
}Son la misma frase en tres idiomas distintos. age >= 18 AND active = true. Eso es todo lo que dicen. Pero la traducción no es mecánica, no puedes derivar una de otra con una regex. Cada backend tiene su propia gramática, su propia forma de componer predicados, sus propias convenciones para strings, números y operadores.
Y esto es antes de añadir BETWEEN, LIKE, IS NULL, o cualquier cosa más interesante.
La respuesta habitual a esto es pragmática: defines una repository interface, metes el código específico de cada base de datos detrás de ella, y sigues adelante. Es arquitectura correcta. El problema es que no elimina la duplicación, solo la contiene. Sigues teniendo tres implementaciones del mismo predicado. Ahora viven en archivos distintos, pero van a divergir igualmente.
Y si el predicado en sí fuera polimórfico?
Y si el propio predicado fuera polimórfico #
criteria4s es una librería que construí para responder esa pregunta. La idea es simple: una expresión de filtro parametrizada por un phantom type. El phantom type codifica el backend. El compilador resuelve el renderizado. Escribes la lógica una vez y dejas que el type system haga la traducción.
Así es como se ve:
import com.eff3ct.criteria4s.core.*
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))Lee la firma con atención. activeAdults es polimórfica en T. El upper bound T <: CriteriaTag dice que T debe ser un dialecto. Los context bounds : GEQ: EQ: AND dicen que sea cual sea el dialecto T, debe saber renderizar esas tres operaciones. El Show[Column, T] dice que también debe saber renderizar un nombre de columna. El cuerpo es el mismo predicado, escrito una vez.
Ahora llámala:
import com.eff3ct.criteria4s.dialect.sql.{given, *}
activeAdults[SQL].value
// (age >= 18) AND (active = true)
import com.eff3ct.criteria4s.dialect.mongodb.{given, *}
activeAdults[MongoDB].value
// {$and: [{"age": {$gte: 18}}, {"active": {$eq: true}}]}
import com.eff3ct.criteria4s.dialect.elasticsearch.{given, *}
activeAdults[Elasticsearch].value
// {"bool": {"must": [{"range": {"age": {"gte": 18}}}, {"term": {"active": true}}]}}La misma función. Tres salidas distintas. La única diferencia entre las llamadas es el import. Cambias el dialecto, obtienes un renderizado diferente. El predicado en sí nunca cambia.
Sin AST intermedio. Sin dispatch en runtime. Sin pattern matching sobre un enum de backends. El compilador sabe qué instancias given usar para cada T y lo resuelve en compile time.
Tagless final, aplicado a bases de datos #
Si has leído el post sobre tagless final, ya ves el patrón. criteria4s es tagless final aplicado al renderizado de queries en lugar de a los efectos. Cada predicado (EQ, GT, AND, LIKE, …) es una type class. Cada dialecto (SQL, MongoDB, Elasticsearch, …) proporciona instancias given para esas type classes. La type class Show[V, T] se encarga del renderizado de valores: entrecomillar strings, formatear números, nombrar columnas.
Es el Problema de la Expresión resuelto con la misma técnica:
SQL PostgreSQL MongoDB Elasticsearch
┌──────────┬──────────────┬───────────┬────────────────┐
EQ │ = │ = │ $eq │ term │
GT │ > │ > │ $gt │ range.gt │
AND │ AND │ AND │ $and │ bool.must │
LIKE │ LIKE │ LIKE │ $regex │ wildcard │
└──────────┴──────────────┴───────────┴────────────────┘Las filas son predicados. Las columnas son dialectos. Con un ADT concreto, eliges un eje. Con type classes, ambos ejes están abiertos. Añadir un predicado nuevo significa definir una nueva type class y proporcionar instancias para cada dialecto existente. Añadir un dialecto nuevo significa proporcionar instancias para cada type class existente. Ninguna operación modifica código que ya existe.
Hay algo satisfactorio en un diseño donde las abstracciones se mantienen de verdad. Sin sealed que reabrir, sin TODO: add MongoDB support.
El error que nunca llega a producción #
Algo que me resulta genuinamente útil en el uso diario. Esto no compila:
// Error de tipo: SQL vs MongoDB
val broken = F.col[SQL]("age") :> F.col[MongoDB]("age")El phantom type T se propaga a través de cada expresión. Si empiezas a construir en SQL, cada término debe ser también SQL. No puedes componer accidentalmente predicados de dialectos distintos. El compilador te lo dice antes de que corran los tests, antes de que arranque la app, antes de que falle en producción.
Eso es lo que me hubiera gustado tener las dos primeras veces que reescribí ese filtro.
Dos estilos, la misma salida #
criteria4s te da dos estilos de API. Producen la misma salida. Elige el que mejor se lea en tu contexto.
Estilo función, explícito y con namespace cualificado:
import com.eff3ct.criteria4s.functions as F
val filter = F.and(
F.geq(F.col[SQL]("age"), F.lit(18)),
F.===(F.col[SQL]("status"), F.lit("active"))
)Estilo extensión, encadenado y más cercano a la prosa:
import com.eff3ct.criteria4s.extensions.*
val filter =
(F.col[SQL]("age") :>= F.lit(18)) and
(F.col[SQL]("status") === F.lit("active"))Tiendo a usar el estilo extensión para predicados complejos, porque se lee más naturalmente cuando tienes varias condiciones. El estilo función es útil cuando compones programáticamente o cuando la explicitud ayuda.
Ocho dialectos, un import #
Ocho dialectos:
| Dialecto | Entrecomillado de columnas | Módulo |
|---|---|---|
| Base SQL | sin entrecomillar | criteria4s-sql |
| PostgreSQL | "column" |
criteria4s-postgresql |
| MySQL | `column` |
criteria4s-mysql |
| Spark SQL | `column` |
criteria4s-sparksql |
| DuckDB | "column" |
criteria4s-duckdb |
| ClickHouse | `column` |
criteria4s-clickhouse |
| MongoDB | Operadores JSON | criteria4s-mongodb |
| Elasticsearch | Query DSL | criteria4s-elasticsearch |
Los dialectos de la familia SQL son básicamente herencia. PostgreSQL solo hace override del column quoting; todo lo demás viene del dialecto SQL base. MongoDB y Elasticsearch empiezan desde cero.
Si necesitas llevar los predicados hasta el driver, los módulos de integración hacen el puente:
// build.sbt
"com.eff3ct" %% "criteria4s-core" % "1.0.0"
"com.eff3ct" %% "criteria4s-postgresql" % "1.0.0"
"com.eff3ct" %% "criteria4s-sql-jdbc" % "1.0.0" // .toWhereClause
"com.eff3ct" %% "criteria4s-mongodb-driver" % "1.0.0" // .toBson
"com.eff3ct" %% "criteria4s-elasticsearch-client" % "1.0.0" // .toQueryQué viene después #
Este es el primer post de una serie sobre criteria4s. Los siguientes van por debajo del capó:
- criteria4s 2. Phantom types en la práctica: cómo
CriteriaTagyShowimponen dialect safety en el type level, y por qué no hay ningún coste en runtime. - criteria4s 3. Construye tu propio dialecto: un tutorial paso a paso para añadir un backend nuevo en menos de 50 líneas.
- criteria4s 4. Arquitectura hexagonal con type classes: usando
T <: CriteriaTagcomo port para que tu domain layer nunca sepa contra qué base de datos está hablando. - criteria4s 5. De SQL a MongoDB sin reescribir tus queries: cómo es en la práctica una migración de backend.
La documentación completa cubre cada predicado, cada dialecto, cada integración. El código fuente tiene licencia MIT.
La tercera vez que reescribí ese filtro, decidí que no quería escribirlo una cuarta vez. criteria4s salió de esa decisión. El siguiente post abre el capó y enseña por qué esta idea funciona sin pagarla en runtime.