Estado: ✅ Aceptada Fecha: 2026-05-27 Bloque: L1 (sub-bloque del bloque L del roadmap) Bump on-disk: VERSION 8 → 9

🧭 Contexto

El roadmap MISSING_COMMANDS.md marca como P1 tres huecos del bloque L (constraints):

Y como P1 queda también CHECK (expr). Este último abre una superficie distinta (parser de Expr persistida + evaluador en cada write) y se difiere a un sub-bloque propio L2, para mantener cada push focalizado y revisable.

Pre-L1:

💡 Decisión

1. Extender OnDelete con SetNull y SetDefault

Códigos binarios estables y aditivos:

0  Restrict       (default cuando se omite ON DELETE)
1  Cascade
2  SetNull        (L1)
3  SetDefault     (L1)

V8 nunca pudo persistir 2/3 (el parser los rechazaba), así que la extensión es forward-compatible en lectura del byte.

2. Nuevo enum OnUpdate persistido como byte adicional por FK

0  NoAction       (default cuando se omite ON UPDATE; equivale a ANSI/PostgreSQL)
1  Cascade
2  SetNull
3  SetDefault
4  Restrict

Hoy ON UPDATE no se dispara: gabysql prohíbe UPDATE sobre la PK del padre con [GBY-4008] UPDATE_PK_NOT_ALLOWED, así que no hay ocasión para que el motor evalúe la acción. Persistirla igual permite que un release futuro lift la restricción sin otro bump de formato.

📝 Actualización 2026-06-15: residual #4 (ADR-0024, 2026-05-27) levantó la restricción — UPDATE de PK ahora funciona y ON UPDATE CASCADE/SET NULL/SET DEFAULT se dispara en cascada sobre todas las FKs entrantes.

3. Parser: parse_fk_actions reemplaza a parse_on_delete

Acepta ON DELETE y ON UPDATE en cualquier orden, cada uno una sola vez. Acciones soportadas idénticas en ambos:

RESTRICT | CASCADE | SET NULL | SET DEFAULT | NO ACTION

NO ACTION se acepta como sinónimo de RESTRICT (no hay constraint mode diferido todavía).

4. Cascade engine extendido

delete_with_cascade gana dos ramas:

cascade_set_fk_value reusa el patrón del UPDATE normal: lee la fila, mantiene índices secundarios (single y compuestos), re-encodea, escribe.

5. UNIQUE table-level en CREATE TABLE

El parser reconoce UNIQUE (col1, col2, ...) como una claúsula al mismo nivel que PRIMARY KEY (...):

CREATE TABLE t (
    id INT PRIMARY KEY,
    a INT NOT NULL,
    b INT NOT NULL,
    UNIQUE (a, b)
);

Y materializa el índice idéntico al de CREATE UNIQUE INDEX uq_t_a_b ON t (a, b) — reusa el mismo encoder de K2 (fingerprint FNV-1a-64 i64). Para multi-col se aplican las restricciones de K2: all-INT NOT NULL ([GBY-4067] si no se cumple).

Single-col UNIQUE (col) también se admite y es equivalente a UNIQUE inline.

6. Parche al composite UNIQUE de K2

L1 cierra un hueco que K2 dejó sin notar: el path de INSERT/UPDATE/DELETE iteraba meta.indexes usando sólo idx.column, omitiendo extra_columns. Resultado: un UNIQUE (a, b) se chequeaba contra el bucket de a solo, y rechazaba INSERTs legítimos por colisión del primer componente.

L1 introduce cuatro helpers que separan claramente el camino composite del single-column:

El bucket key para composites es el fingerprint i64 crudo en el B+Tree — no la codificación OrderedInt con tag (0x01 + i64) que usa index_upsert_pk. Mantenemos ambos paths separados para no romper el contrato on-disk de los índices existentes.

🚧 Consecuencias y limitaciones

Tema Estado L1
ON DELETE SET NULL / SET DEFAULT ✅ Operativo
ON DELETE NO ACTION ✅ Alias de RESTRICT
ON UPDATE <action> ✅ Parsea y persiste; no se dispara (PK inmutable, [GBY-4008])
UNIQUE (a, b, ...) table-level ✅ Operativo (mismo encoder que K2)
Multi-col FOREIGN KEY ❌ Sigue limitado a single-col (gap K2 sin cerrar)
CHECK (expr) ❌ Diferido al sub-bloque L2
Migración V8 → V9 Manual — dump SELECT + recreate

🔄 Alternativas consideradas

📚 Referencias