walker: 2e prereq — §10.3 stage-2 CTE harvest + cte_arity_mismatch

Implements the six ADR-0032 §10.3 output-column derivation rules
at CTE body-frame exit, populating the placeholder CteBinding's
columns. Unblocks `diagnostic.cte_arity_mismatch` (which compares
declared col-list arity vs derived projection arity) and the
upcoming qualified-prefix completion in 2e proper.

- `WalkContext::pending_cte_harvest`: bookkeeping for an in-progress
  CTE harvest, armed by writes_cte_name + extended by cte_column
  idents, consumed by the next walk_scoped_subgrammar invocation
  (CTE syntax has no intervening ScopedSubgrammar, so timing is
  deterministic). Cleared on every walk_scoped_subgrammar entry
  to prevent stale state surviving a speculative walk rollback.

- `run_cte_harvest`: post-walk path-scan classifier that
  reconstructs the body's first leg's projection-list and applies
  the six derivation rules. Compound bodies take columns from the
  first leg per spec; recursive CTE bodies take the non-recursive
  (first) leg. Optional (col-list) renames positionally with
  preserved types.

- `expand_binding`: bridges a TableBinding to a CteColumn list,
  resolving CTE-source bindings (empty columns + table-name
  matches an in-scope CteBinding) through to the CTE's harvested
  columns. Enables sibling CTEs to project correctly: in
  \`WITH a AS (...), b AS (SELECT * FROM a) ...\`, b's harvest sees
  a's derived columns through the body's from_scope binding.

- `WalkContext::pending_diagnostics`: accumulator for diagnostics
  emitted DURING the walk by node handlers with context the
  post-walk passes can't reconstruct. Drained by the top-level
  walk function on both match and non-match paths so a re-used
  context can't leak entries between walks.

Test totals: 1399 → 1414 passing (+15: 10 derivation rules + 1
sibling CTE + 4 arity match/mismatch tests). Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-20 17:42:17 +00:00
parent c20c6e05ca
commit dd37a1cbfc
3 changed files with 859 additions and 15 deletions
+78
View File
@@ -1797,8 +1797,16 @@ pub fn walk<'a>(
// operator slot is highlighted rather than the engine
// wording shown at execution time.
d.extend(compound_arity_diagnostics(&path));
// ADR-0032 §10.3 / §11.2 — diagnostics emitted during
// the walk by node handlers with direct context the
// post-walk passes can't reconstruct (primarily the
// CTE harvest's arity-check at body-frame exit). Drain
// unconditionally so accumulated entries don't leak
// into a subsequent walk via a re-used WalkContext.
d.extend(std::mem::take(&mut ctx.pending_diagnostics));
d
} else {
ctx.pending_diagnostics.clear();
Vec::new()
};
// Expression WARNING diagnostics — type-mismatched
@@ -4038,6 +4046,76 @@ mod tests {
);
}
// ---- ADR-0032 §11.2 — cte_arity_mismatch ----
#[test]
fn cte_arity_mismatch_when_col_list_shorter() {
// `WITH x(a, b) AS (SELECT 1, 2, 3)` — declared 2,
// derived 3 → fires.
let schema = schema_with("base", &[("id", Type::Int)]);
let diags = diag_keys(
"with x (a, b) as (select 1, 2, 3) select * from x",
&schema,
);
assert!(
diags.iter().any(|d| {
d.contains("CTE `x`")
&& d.contains("declares 2 columns")
&& d.contains("body has 3")
}),
"expected cte_arity_mismatch (declared 2, actual 3); got {diags:?}",
);
}
#[test]
fn cte_arity_mismatch_when_col_list_longer() {
// `WITH x(a, b, c) AS (SELECT 1)` — declared 3,
// derived 1 → fires.
let schema = schema_with("base", &[("id", Type::Int)]);
let diags = diag_keys(
"with x (a, b, c) as (select 1) select * from x",
&schema,
);
assert!(
diags.iter().any(|d| {
d.contains("CTE `x`")
&& d.contains("declares 3 columns")
&& d.contains("body has 1")
}),
"expected cte_arity_mismatch (declared 3, actual 1); got {diags:?}",
);
}
#[test]
fn cte_arity_match_no_diagnostic() {
// `WITH x(a, b) AS (SELECT 1, 2)` — matched arity, no
// diagnostic.
let schema = schema_with("base", &[("id", Type::Int)]);
let diags = diag_keys(
"with x (a, b) as (select 1, 2) select * from x",
&schema,
);
assert!(
!diags.iter().any(|d| d.contains("declares")),
"matched arity should not fire; got {diags:?}",
);
}
#[test]
fn cte_arity_no_col_list_no_diagnostic() {
// No explicit col-list → no arity check (derived
// columns are the canonical view).
let schema = schema_with("base", &[("id", Type::Int)]);
let diags = diag_keys(
"with x as (select 1, 2, 3) select * from x",
&schema,
);
assert!(
!diags.iter().any(|d| d.contains("declares")),
"no col-list should suppress arity check; got {diags:?}",
);
}
#[test]
fn alias_in_inner_subquery_does_not_affect_outer_aliases() {
// The inner `AS y` is inside parens (depth > 0) and