Estado: ✅ Aceptada Fecha: 2026-05-28 Bloque: W2 (segundo sub-bloque del bloque W del roadmap) Bump on-disk: ninguno (la materialización vive solo en runtime)

🧭 Contexto

W1 (ADR-0026) entregó las CTEs no-recursivas como inlining en parse-time — cada FROM cte se substituía por una derived table clonada del body. Ese truco no funciona para WITH RECURSIVE: el body de una CTE recursive se refiere a sí mismo, así que un inlining ingenuo entra en loop infinito.

W2 implementa el algoritmo de fixpoint estándar (base + step iterativo) y reusa el mismo bridge de W1 para inyectar el resultado final en el body principal.

💡 Decisión

1. Sintaxis canónica restringida

Sólo aceptamos la forma:

WITH RECURSIVE name AS (
    <anchor_select>     -- proyecta el schema base
    UNION [ALL]
    <step_select>       -- referencia `name` en algún FROM
)
<body_select>

2. Algoritmo de fixpoint con delta semantics

accum := exec(anchor)            // set inicial
delta := accum                   // "filas nuevas" de la última iteración
seen  := { anchor_rows }         // sólo si UNION (no ALL)

loop:
    if delta vacío → terminar
    if iter ≥ MAX_ITER → [GBY-4083]
    if |accum| ≥ MAX_ROWS → [GBY-4084]

    step_iter := step.clone() con `FROM name` reescrito a VALUES(delta)
    new_rows  := exec(step_iter)
    if arity(new_rows) ≠ arity(anchor) → [GBY-4085]

    new_delta := new_rows
        filtradas por `seen.insert(row_key)` si UNION (no ALL)
    accum.extend(new_delta)
    delta := new_delta

Donde MAX_ITER = 1000 y MAX_ROWS = 100_000. Los guards son cinturones de seguridad — recursión bien terminada no debería acercarse.

Por qué delta y no cumulative: ANSI requiere delta. PostgreSQL lo implementa así. SQLite también. Es la opción correcta para recursión lineal (cada iteración procesa solo las filas más nuevas) y termina naturalmente para queries bien formadas.

3. Bridge a través del inlining de W1

Después del fixpoint, el accum final se convierte a un SelectStmt con values_source = Some((ValuesClause de Vec<Vec<Expr::Literal>>, anchor.columns)) vía rows_to_values_select. Ese SelectStmt sintético se inyecta al body reusando inline_cte_into_query de W1.

El executor downstream ve una derived table con VALUES — infraestructura entregada en el bloque I. No necesita saber que hubo recursión.

Caso degenerado: rows.is_empty(). ANSI VALUES exige ≥1 fila, así que generamos un wrapper (SELECT * FROM (VALUES (NULL, NULL, ...)) AS t(c1, c2) LIMIT 0) que preserva el schema con cero filas.

4. Dedup vía format!("{:?}", row)

Value no implementa Hash/Eq por la variant Float (NaN rompe reflexividad). Para UNION (no ALL), usamos HashSet<String> con clave format!("{:?}", row) — estable dentro de un proceso, suficiente para dedup. La opción más correcta (OrderedFloat o key bytes) queda diferida.

5. Sin bump de formato on-disk

La materialización vive en memoria de la transacción. Ninguna estructura nueva en el Catalog, ningún byte nuevo en TableMeta. VERSION se mantiene en 13.

📐 Códigos de error

Código Nombre Cuándo
GBY-4082 RECURSIVE_CTE_MULTIPLE_NOT_SUPPORTED Más de una CTE recursive en el mismo WITH.
GBY-4083 RECURSIVE_CTE_MAX_ITERATIONS_EXCEEDED Fixpoint pasó las 1000 iteraciones. Falta condición de corte.
GBY-4084 RECURSIVE_CTE_MAX_ROWS_EXCEEDED Fixpoint acumuló 100K filas. Mismo diagnóstico.
GBY-4085 RECURSIVE_CTE_SCHEMA_MISMATCH Step proyecta arity distinta al anchor.
GBY-4086 RECURSIVE_CTE_BODY_NOT_UNION Body de la CTE no es la forma canónica anchor UNION [ALL] step.

El código 4080 (CTE_RECURSIVE_NOT_SUPPORTED de W1) queda retirado — el slot se mantiene reservado para no reciclarlo.

🧪 Validación

Suite w2_* en tests/integration_test.rs (8 tests):

Suite total: 395/395 pass (cargo test --lib --tests).

🔭 Futuro