Fecha: 2026-06-15 Estado: Aceptado Bloque: R9 (reparación post-P5 — residual ADR-0066 Gap 1) Origen: docs/ANALISIS_POST_P5.md §3 R9 y docs/adr/0066-bench-exposed-gaps.md Gap 1 residual. Relacionado: F2 (count_distinct base implementation).

Contexto

COUNT(DISTINCT col) sobre una tabla simple funciona desde F2 (compute_aggregate rama (Count, DistinctColumn) — encode_group_key sobre cada valor no-NULL, deduplicar, contar).

Sobre SELECT ... JOIN ... GROUP BY ... rebotaba con error [GBY-4028] AGGREGATE_OVER_JOIN_UNSUPPORTED:

COUNT(DISTINCT col) sobre SELECT con JOIN aún no se soporta;
usar subquery agregada sobre la tabla base

Causa: en el path single-table las filas se indexan por column key plano ("prod"); en el path joined se indexan por column key cualificado ("o.prod"). compute_aggregate (rama DistinctColumn) hace normalize_ident(col) que devuelve el último segmento después del . en minúsculas — "o.prod""prod". El lookup row.get("prod") sobre la fila joined falla porque la fila contiene "o.prod". Resultado: todos los valores resueltos a Value::Null y siempre devolvía 0, pero antes de eso el path se cortaba con [GBY-4028] para evitar la falla silenciosa.

Decisión

Tratar AggArg::DistinctColumn en exec_aggregate_joined como un agregado especial, evaluado inline sin pasar por compute_aggregate.

Cambio local en exec_aggregate_joined

Se introduce un enum interno al método (no pub, no exportado) que distingue dos paths para el prepared_args:

enum JoinedAggPrep {
    Standard(AggArg),     // dispatch normal a compute_aggregate
    DistinctExpr(Expr),   // count-distinct inline
}

El rewrite de AggArg::DistinctColumn(c) resuelve la columna a su forma cualificada con resolve_joined_column_key(scope, c) —el mismo helper que ya usaba AggArg::Column(c)— y guarda JoinedAggPrep::DistinctExpr(Expr::Column(qualified_key)).

En el bucket loop, cuando el prepared es DistinctExpr(expr):

if !matches!(func, AggFunc::Count) {
    return Err(coded(codes::AGGREGATE_OVER_JOIN_UNSUPPORTED,
        format!("DISTINCT solo es válido en COUNT, no en {:?}", func)));
}
let mut seen: HashSet<Vec<u8>> = HashSet::new();
for row in &bucket_rows {
    let v = eval_expr(expr, row)?;
    if matches!(v, Value::Null) { continue; }
    seen.insert(encode_group_key(&[v]));
}
Value::Integer(seen.len() as i64)

Cierres

Consecuencias

Positivas

Negativas / deuda

Alternativas consideradas

  1. Agregar AggArg::DistinctExpr(Expr) a la enum pública. Más reutilizable, pero requiere actualizar 4+ exhaustive matches (output_name, validación pre-bucket, window functions, etc.) por un caso que sólo aparece en joined-context. Overkill para R9.
  2. Re-keyar las filas joined a forma plana antes de pasar al path single-table. Costo de memoria + bug surface si dos tablas tienen la misma columna. Rechazado.
  3. Llamar a compute_aggregate con el qualified key pero modificando compute_aggregate para no normalizar cuando ve un ‘.’. Cambia comportamiento del dispatch base por un caso particular — fragiliza el helper. Rechazado.

Tests

Tres tests nuevos (r9_* en tests/integration_test.rs):

Suite total: 801 → 804 (+3). Sin regresiones.

Referencias