Ir al contenido
  1. Posts/

criteria4s. El tipo que la JVM nunca ve

·9 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 2: Este artículo
Parte 2: Este artículo

Cuando le expliqué criteria4s a un compañero por primera vez, su reacción fue: “o sea que el parámetro de tipo simplemente… desaparece en runtime?” Sí. Exactamente eso es lo que pasa. Y ahí fue cuando supe que el siguiente post no podía quedarse en la API pública.

En el post anterior mostré la superficie de la API: escribe un predicado una vez, evalúalo contra cualquier backend cambiando un parámetro de tipo. Lo que no mostré es lo que ocurre por dentro. Este post lo hace. Recorremos el código fuente juntos, archivo por archivo, y vemos cómo algo que parece magia es simplemente el uso cuidadoso del type system.

criteria4s-phantom-types-en-la-practica-img-3.svg

Un trait vacío con trabajo a tiempo completo
#

Un phantom type es un parámetro de tipo que el compilador rastrea pero el runtime nunca ve. Piénsalo como la etiqueta del equipaje en el aeropuerto. La etiqueta le dice al sistema a dónde va tu maleta. La maleta en sí no cambia. Y cuando aterrizas, la etiqueta se descarta.

En criteria4s, la etiqueta del equipaje es CriteriaTag:

// core/src/main/scala/.../Criteria.scala

trait Criteria[T <: CriteriaTag] {
  def value: String
}

object Criteria {
  private[criteria4s] def pure[T <: CriteriaTag](v: String): Criteria[T] =
    new Criteria[T] {
      override def value: String = v
    }

  private[core] trait CriteriaTag
}

CriteriaTag es un trait vacío. Sin campos. Sin métodos. Sin estado. Existe únicamente en el type level. Criteria[T] envuelve un String. Eso es todo. En runtime, un Criteria[SQL] y un Criteria[MongoDB] son objetos idénticos: ambos son solo wrappers alrededor de un string. El T ha desaparecido, borrado por la JVM después de la compilación.

La pregunta obvia es: si es vacío, para qué sirve?

Sirve exactamente porque es vacío. CriteriaTag no es un contenedor de datos, es un type tag. Su único propósito es darle al compilador un vocabulario para razonar sobre dialectos. El upper bound T <: CriteriaTag en Criteria[T] le dice al compilador: “solo los tipos que yo haya declarado expresamente como dialectos pueden ir aquí”. No puedes pasar un String, ni un Int, ni un tipo arbitrario. Tienes que usar SQL, MongoDB, Elasticsearch o cualquier otro dialecto que herede de CriteriaTag. El trait vacío actúa como pasaporte. Vacío de contenido, lleno de significado.

La otra pieza crítica es pure. Es private[criteria4s]. No puedes llamarlo. La única manera de producir un Criteria[T] es a través de los predicados y conjunciones que proporciona criteria4s. Esto significa que el string de dentro siempre está bien formado. Nadie puede colar un "DROP TABLE users" y etiquetarlo como Criteria[SQL].

Valores que no saben nada de formateo
#

Cada operando en una expresión de criteria4s es un Ref[D, V]:

sealed trait Ref[D <: CriteriaTag, V] {
  def asString(using show: Show[V, D]): String
}

object Ref {
  trait Value[D <: CriteriaTag, V]      extends Ref[D, V]
  trait Col[D <: CriteriaTag]           extends Ref[D, Column]
  trait Collection[D <: CriteriaTag, V] extends Ref[D, Seq[V]]
  trait Range[D <: CriteriaTag, V]      extends Ref[D, (V, V)]
}

case class Column(colName: String) extends AnyVal

Mira asString. No produce un string por sí solo. Requiere un Show[V, D] en scope. Un Ref guarda un valor pero no tiene ni idea de cómo formatearlo. Formatear es trabajo de otro.

Ese otro es Show.

La línea que lo cambia todo
#

trait Show[-V, D <: CriteriaTag] {
  def show(v: V): String
}

Cada dialecto proporciona sus propias instancias de Show. Aquí está el renderizado de columnas en SQL:

given showColumn: Show[Column, SQL] =
  Show.create(col => col.colName)

Y aquí está el de MongoDB:

given showColumn: Show[Column, MongoDB] =
  Show.create(col => s"\"${col.colName}\"")

Una línea de diferencia. SQL escribe el nombre de la columna tal cual. MongoDB lo envuelve en comillas dobles JSON. PostgreSQL usa comillas dobles SQL. MySQL usa backticks. Toda la diferencia de renderizado entre cuatro dialectos vive en cuatro líneas de código, una por instancia Show[Column, D].

La pregunta obvia es: para qué todo esto si Scala ya tiene toString? Todos los objetos saben convertirse a string. La respuesta es que toString no lleva información de dialecto. Column("age").toString te da algo como Column(age). Eso no es el nombre de una columna renderizado para ningún backend. Y, más importante, toString no puede distinguir entre SQL y MongoDB. Show[Column, SQL] y Show[Column, MongoDB] son tipos distintos. El compilador elige el correcto basándose en el T que fluye a través de la expresión. toString te daría el mismo resultado independientemente del dialecto, que es exactamente el problema que intentamos resolver.

La type class también es contravariant en V. Show[-V, D] significa que un Show[Any, SQL] puede renderizar cualquier cosa como SQL, mientras que un Show[String, SQL] renderiza strings específicamente. El compilador elige la instancia más concreta disponible. toString no te da esa precisión. Es una solución de talla única. Show es quirúrgico.

El resto, la lógica de >, =, AND, LIKE, la parentetización, el anidamiento, se gestiona por separado. El diseño de type classes mantiene estas responsabilidades limpias y separadas. Show sabe cómo imprimir un valor. Los predicados saben cómo combinar valores impresos. No necesitan saber nada el uno del otro.

Cómo un predicado se convierte en string
#

EQ[T], GT[T], AND[T]: cada predicado es una type class que sabe cómo combinar dos strings renderizados en una expresión para su dialecto. El mecanismo real es un BuilderBinary:

trait BuilderBinary[H[_ <: CriteriaTag]] {
  def build[T <: CriteriaTag](F: (String, String) => String): H[T]
}

Dale a un builder una función (String, String) => String y te devuelve una instancia de predicado. Para el EQ de SQL:

given eqPred: EQ[T] = build[T, EQ](predExpr("="))
// predExpr("=")("age", "18") == "age = 18"

Para el EQ de MongoDB:

given eqPred: EQ[T] = build[T, EQ](predExpr("eq"))
// predExpr("eq")("age", "18") == {"age": {$eq: 18}}

La misma type class. La misma interfaz. Diferente template de string. Esto es lo que hace extensible a la librería: añadir un nuevo dialecto es añadir nuevos templates de string, no tocar la lógica de los predicados. Añadir un nuevo predicado es definir una nueva type class y conectarla a los dialectos, no tocar los predicados existentes.

Ambos ejes permanecen abiertos. Es lo que vimos en el post sobre el Problema de la Expresión.

Un override. Cuatro dialectos.
#

El dialecto SQL define todos los predicados una vez, en un trait compartido:

trait SQL extends CriteriaTag

object SQL {
  trait SQLExpr[T <: SQL] {
    given eqPred: EQ[T]         = build[T, EQ](predExpr("="))
    given gtPred: GT[T]         = build[T, GT](predExpr(">"))
    given andConj: AND[T]       = build[T, AND](conjExpr("AND"))
    given likePred: LIKE[T]     = build[T, LIKE](predExpr("LIKE"))
    given isnullPred: ISNULL[T] = build[T, ISNULL](predExpr1("IS NULL"))
    // ... todos los predicados, definidos una vez para toda la familia SQL
  }
}

package object sql extends SQL.SQLExpr[SQL]

PostgreSQL hereda todo eso y sobreescribe exactamente una cosa:

trait PostgreSQL extends SQL

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

Ese es el dialecto PostgreSQL completo. Un override. El resto viene de SQLExpr[PostgreSQL], donde los builders heredados producen instancias tipadas como PostgreSQL en lugar de SQL. MySQL es igual, con backticks. DuckDB es igual, con comillas dobles. Cuatro dialectos de la familia SQL, y el código específico de tres de ellos cabe en una pantalla.

Cuando vi esto por primera vez quise mostrárselo a cada desarrollador que alguna vez ha copiado y pegado una implementación de repositorio entre backends.

El compilador como última línea de defensa
#

Del post anterior:

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

Ahora la razón es obvia. F.col[SQL]("age") es un Col[SQL]. F.col[MongoDB]("age") es un Col[MongoDB]. El operador :> resuelve GT[T].eval, que requiere que ambos operandos sean Ref[T, _] para el mismo T. SQL y MongoDB son tipos distintos. El compilador no puede unificarlos.

Sin check en runtime. Sin excepción lanzada. Sin mensaje de error al arrancar. El programa no compila. El phantom type T fluye a través de todo el expression tree, y cualquier inconsistencia emerge antes de que exista el bytecode.

Vale la pena detenerse en lo que pasaría sin esta garantía. Recuerda que los phantom types se borran en runtime. Un Criteria[SQL] y un Criteria[MongoDB] son lo mismo a nivel de JVM: un wrapper alrededor de un string. No hay ningún ClassCastException esperando. La type information ha desaparecido. Si la librería permitiera mezclar dialectos, obtendrías un Criteria que contiene un string malformado. Lo enviarías a la base de datos. La base de datos podría rechazarlo con un error críptico del driver. Podría devolver resultados vacíos en silencio. Podría hacer algo peor. Y te encontrarías depurando en runtime, en un archivo de log, posiblemente después de que llegara a usuarios.

El phantom type convierte un semantic bug, el tipo que pasa desapercibido al compilador y emerge tranquilamente en producción, en un structural error que el compilador atrapa antes de que el código exista como artifact ejecutable. Esa es una diferencia cualitativa, no solo una comodidad.

Cómo es en la práctica el coste cero
#

Cuando mi compañero preguntó “o sea que el parámetro de tipo simplemente desaparece en runtime?”, la siguiente pregunta fue: “y cuánto cuesta toda esta maquinaria?”

Nada. Literalmente nada.

Los phantom types se borran en compile time. Las instancias given se resuelven estáticamente. El helper build construye un closure que concatena strings. En runtime, criteria4s es concatenación de strings. La abstracción vive enteramente en el type system, que es lo mismo que decir que vive enteramente en el compilador. La JVM nunca la ve.

Esto es lo que significa zero-cost abstraction: la abstracción es real, el overhead no lo es.

Ocho pasos, cero sorpresas
#

Cuando escribes F.col[SQL]("age") geq F.lit(18), esto es lo que ocurre realmente:

  1. F.col[SQL]("age") crea un Col[SQL] que envuelve Column("age")
  2. F.lit(18) crea un Value[SQL, Int] que envuelve 18
  3. geq resuelve GEQ[SQL] del scope implícito
  4. GEQ[SQL].eval llama a asString en ambos operandos
  5. Col[SQL].asString pregunta a Show[Column, SQL] y obtiene "age"
  6. Value[SQL, Int].asString pregunta a Show[Int, SQL] y obtiene "18"
  7. GEQ[SQL] aplica su template de string: "age >= 18"
  8. Criteria.pure[SQL]("age >= 18") envuelve el resultado

Ocho pasos. Todos resueltos en compile time. La JVM ejecuta concatenación de strings.

Lo que viene después
#

El siguiente post convierte todo esto en práctica: criteria4s 3. Construye tu propio dialecto. Dejamos de mirar por dentro y usamos build y Show para enseñar a criteria4s un idioma nuevo, paso a paso.

El código fuente está en github.com/eff3ct0/criteria4s. Leer Criteria.scala, después Show.scala, después SQL.scala en ese orden te da la imagen completa en unos veinte minutos.

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