Fecha: 2026-06-15 Estado: Aceptado Bloque: R10 (reparación post-P5) Origen: docs/ANALISIS_POST_P5.md §3 R10 — tensión #2.6 relacionada. Refina: ADR-0073 (P5e).

Contexto

P5e (ADR-0073) anota el algoritmo real del JOIN en EXPLAIN: index-loop, hash join, o nested-loop según las reglas del dispatcher.

Para ON l.col = r.col el análisis ya era completo — si r.col es PK o tiene un índice secundario, se anota index-loop. Para USING(col) y NATURAL JOIN, el comentario in-code decía:

// USING/NATURAL: si el nombre coincide con PK o índice del right,
// también irían a index-loop. Pero no resolvemos los nombres acá
// sin scope completo — reportamos hash conservadoramente.
"hash join, ~O(N+M)".to_string()

El fallback “hash” era seguro pero impreciso: el dispatcher real desazucara USING/NATURAL a un equi-predicate con la misma columna en ambos lados, así que el path de ejecución es idéntico al del ON explícito. La heurística estática estaba más conservadora que el runtime.

Decisión

Extender classify_join_algorithm para resolver los nombres de columnas de USING y NATURAL y aplicar el mismo check de PK/índice del right que ya hace el path ON.

Caso USING

let usn_keys: Vec<String> = if let Some(cols) = &join.using {
    cols.clone()
} else if join.natural { ... } else { Vec::new() };

join.using ya tiene la lista de nombres explícitos del usuario — se usa tal cual.

Caso NATURAL

NATURAL JOIN no lleva nombres explícitos; el dispatcher real calcula intersección de columnas en runtime con el scope completo. Para la heurística estática agrego un helper:

fn natural_join_keys(&mut self, left_table: &str, right_table: &str) -> Vec<String> {
    // intersección por nombre (case-insensitive) entre
    // left_meta.columns y right_meta.columns
}

Recibe base_table (que ahora deja de ser _base_table) como aproximación del lado izquierdo del JOIN.

Check shared con ON

Una vez resueltas las keys candidatas, el loop es idéntico al del path ON: para cada col, si normalizado matchea right_meta.primary_key → index-loop PK; si está en right_meta.index_for_column → index-loop con nombre del índice; sino → fallback hash.

Consecuencias

Positivas

Negativas / deuda

Alternativas consideradas

  1. Resolver USING/NATURAL en runtime, no en EXPLAIN. Más preciso pero requiere ejecutar el planner. Rechazado: EXPLAIN debe ser instantáneo.
  2. Mantener “hash join” para NATURAL en chain joins. Más honesto que sub-estimar, pero pierde precisión en el 90% del uso real (single join). Rechazado: la deuda está documentada.
  3. No hacer nada (mantener el comportamiento P5e original). Falla silenciosa que confunde al usuario que ve “hash join” cuando el tiempo real es de index-loop. Rechazado.

Tests

Cuatro tests nuevos (r10_* en tests/integration_test.rs):

Suite total: 804 → 808 (+4). Sin regresiones.

Referencias