Estado: ✅ Aceptada Fecha: 2026-05-27 Bloque: Residual #3 del bloque L Bump on-disk: VERSION 11 → 12

🧭 Contexto

K2 (ADR-0019) entregó PRIMARY KEY compuesta y CREATE [UNIQUE] INDEX … (a, b, …) via fingerprint FNV-1a-64 i64. Residual #2 cerró nombres en PK/UNIQUE/FK + DROP CONSTRAINT. Pero FOREIGN KEY seguía limitada a single-column — un agujero notorio porque:

💡 Decisión

1. Persistencia: anchor en Column.references, extras en dos Vec paralelos

Mantenemos Column.references: Option<ForeignKeyMeta> (la FK queda anchored en la primera columna source) y agregamos dos campos:

pub struct ForeignKeyMeta {
    pub table: String,                       // target table
    pub column: String,                      // first target column
    pub on_delete: OnDelete,
    pub on_update: OnUpdate,
    pub name: Option<String>,
    pub extra_source_columns: Vec<String>,   // additional child columns
    pub extra_target_columns: Vec<String>,   // additional parent columns
}

Single-col FK → ambos Vec vacíos (idéntico al record pre-#3). Multi-col (a,b) REFERENCES p(x,y) → anchored en a/x, extra_source=[b], extra_target=[y].

Por qué anchor en lugar de moverlo a TableMeta: hubiera roto la ergonomía del código que itera meta.columns con column.references. Mantenerlo anchored deja todo ese código funcional con el cambio mínimo posible (cada uso simplemente extrae source_columns(&column.name) cuando necesita la lista completa).

Bump VERSION 11→12. V11 files rechazados con [GBY-1003].

2. Lookup vía fingerprint compuesto (reuso del encoder K2)

El insight clave: el parent persistió su PK compuesta como encode_composite_key(pk_cols, pk_values) → i64 fingerprint que ES la clave del B+Tree del parent (K2). Para chequear (pa, pb) ∈ parent, computamos fp = encode_composite_key(parent_pk_cols, [pa, pb]) y hacemos parent.get_row(parent.root_page, fp). Mismo lookup O(log n) que single-col, sólo cambia cómo se construye la clave.

Para que esto funcione, forzamos que los target_columns de la FK sean exactamente la PK compuesta del parent, en el mismo orden. El validator DDL rechaza cualquier desviación (subconjunto, reorder, columnas no-PK). Una FK contra UNIQUE arbitrario queda fuera de scope — el day la features pague el costo, agregaremos lookup via secondary index.

3. Parser: arity validation y anchor split

try_match_named_table_constraint_head ya consumía CONSTRAINT <name> FOREIGN KEY. El cuerpo en residual #2 leía 1 col + REFERENCES + 1 col. Lo extendí a (col [, col ...]) en ambos lados con validación de arity. Después de parsear, splittea la primera columna en el anchor y empuja el resto a extra_source_columns / extra_target_columns.

Column-inline id INT REFERENCES p(x) sigue siendo single-col (no hay forma de escribir multi-col inline — ANSI tampoco lo permite).

4. Runtime: helpers compartidos entre single y multi-col

Antes residual #3, las funciones FK trabajaban con target_pk: i64 y column_name: &str (single). Ahora trabajan con tuplas de Values en el orden source-target alineado:

5. DDL housekeeping

6. Formato on-disk V12

Trailer nuevo en cada FK record, sólo cuando flags & HAS_FK:

[fk_extra_count:u8]
[extra_source_col_1] … [extra_source_col_N]    ← N strings
[extra_target_col_1] … [extra_target_col_N]    ← N strings

Single-col FK → count=0, payload vacío.

🚧 Consecuencias y limitaciones

Tema Estado
FOREIGN KEY (a, b) REFERENCES p (x, y) table-level
CONSTRAINT name FOREIGN KEY (...) con multi-col + nombre
ON DELETE CASCADE / SET NULL / SET DEFAULT / RESTRICT multi-col
FK multi-col target debe ser la PK compuesta del parent ✅ (otro UNIQUE arbitrario no soportado)
Column-inline multi-col FK ❌ (ANSI tampoco lo permite)
Index fast-path para cascade multi-col ❌ — full scan; mejora futura usando índice UNIQUE compuesto sobre las source cols si existe
ALTER TABLE DROP COLUMN que participa en FK multi-col → [GBY-4061]
ALTER TABLE RENAME COLUMN arrastra rename a FKs multi-col
Migración V11 → V12 Manual — dump SELECT + recreate

🔄 Alternativas consideradas

📚 Referencias