Estado: ✅ Aceptada Fecha: 2026-05-27 Bloque: L2 (sub-bloque del bloque L del roadmap; cierra L) Bump on-disk: VERSION 9 → 10

🧭 Contexto

L1 cerró las acciones referenciales y UNIQUE multi-col, pero dejó pendiente la mitad de “constraints” del bloque L: CHECK (expr) column-level y table-level. Es el último constraint declarativo del SQL clásico que faltaba en gabysql.

Pre-L2:

💡 Decisión

1. Persistir el source SQL canónico — no el AST

Tres opciones evaluadas:

Opción Pros Contras
Serializar Expr AST en binario Lectura instantánea Acopla el formato on-disk al AST. Cada bloque G/H/I que agrega variantes a Expr fuerza un bump de VERSION.
Texto SQL canónico (elegida) Forward-compatible: el AST puede evolucionar sin tocar el catálogo. Catálogo legible (INTEGRITY CHECK muestra el CHECK literal). Pequeño overhead de re-parse en cada write (lex+parse de la expresión).
Texto SQL **original del usuario** Cero conversión al guardar Si la canonicalización cambia (e.g. comportamiento del lexer), el catálogo persistido se vuelve no-re-parseable silenciosamente.

Elegimos texto canónico porque (a) el costo de re-parse es despreciable comparado al I/O, (b) desacopla la evolución del AST del formato on-disk, y (c) el round-trip queda determinístico vía format_expr (no depende del whitespace del usuario).

2. format_expr(&Expr) -> DbResult<String>

Serializador que envuelve binarios (Compare, Arith, Like, InList, Between, IsNull) en paréntesis para que la salida sea precedencia-neutra. Strings con '...' y escape doble (''); floats con punto decimal explícito para que el lexer no los confunda con ints; EXTRACT(field FROM date) con su sintaxis especial. ScalarSubquery se rechaza con [GBY-4069].

3. parse_expr_str(source) -> DbResult<Expr>

Contraparte pública. Tokeniza + parsea un Expr standalone, exigiendo EOF al final. Usado en (a) el validator DDL (validate_check_constraints) y (b) el enforcement runtime (enforce_check_constraints).

4. Evaluación en cada write

enforce_check_constraints(meta, values) itera los CHECKs, re-parsea cada source, evalúa con eval_expr_as_predicate (3VL ANSI):

Hooks de invocación:

5. Validación en DDL

validate_check_constraints(meta) corre justo después de validate_fk_targets:

No hay type-checking estricto del predicado — la regla “debe evaluar a BOOL o NULL” se delega al evaluador en runtime, que ya emite errores claros. Reservamos [GBY-4070] CHECK_EXPR_NOT_BOOLEAN por si añadimos type-check estático más adelante.

6. Nombres

Si el usuario declara CONSTRAINT name CHECK (...), el name persiste tal cual y aparece en los mensajes de [GBY-3008]. Sin nombre, sintetizamos <table>_check_<N> con N monotónico empezando en 1. Esto da diagnósticos legibles tanto cuando el SQL es escrito a mano como cuando viene de un ORM.

7. Formato on-disk

Trailer nuevo al final del record de TableMeta:

[check_count:u16] · check × { [name:string][source:string] }

Las tablas pre-L2 escriben check_count = 0 y son indistinguibles del caso “sin CHECK”. V9 files rechazados con [GBY-1003] al abrir (sin migración automática — la regla del repo).

🚧 Consecuencias y limitaciones

Tema Estado L2
CHECK (expr) column-level
CHECK (expr) table-level
CONSTRAINT name CHECK (...) con nombre
3VL ANSI (NULL pasa)
CHECK con escalares (G1-G3)
Subqueries en CHECK ❌ rechazado en DDL con [GBY-4069] (ANSI lo prohíbe; gabysql alinea)
ALTER TABLE ADD CHECK ... ❌ requiere re-validar filas existentes; diferido
ALTER TABLE ADD COLUMN ... CHECK (...) ❌ misma razón
CONSTRAINT name PRIMARY KEY / UNIQUE / FOREIGN KEY (nombres en otros constraints) ❌ sólo CHECK lleva nombre por ahora
Migración V9 → V10 Manual — dump SELECT + recreate

🔄 Alternativas consideradas

📚 Referencias