walker: populate from_scope table bindings (ADR-0032 §10.1)

Sub-phase 2b checkpoint 3 — the `writes_table` / `writes_table_alias`
flags now drive the multi-binding `from_scope` accumulator on
the top `ScopeFrame`.

Node::Ident gains `writes_table_alias: bool`. When set on an
ident-name slot, the matched name lands on the most-recently-
pushed `TableBinding`'s `alias`. All 46 existing Ident sites
across the codebase are updated to `writes_table_alias: false`
(mechanical — no behavioral change for DSL paths).

walk_ident's `writes_table` semantics extend:

- `IdentSource::Tables` matches with `writes_table: true` still
  populate `current_table` / `current_table_columns` as before
  (preserved for DSL paths that read those fields directly via
  the dynamic-subgrammar / column-writes machinery), AND now
  also push a fresh `TableBinding` onto the top ScopeFrame's
  `from_scope`. The two mechanisms coexist additively —
  current_table reflects the most-recent `writes_table` write
  (single-binding view, as before); from_scope is the
  authoritative multi-binding accumulator that SQL JOINs,
  subqueries, and CTE bodies use.

sql_select.rs splits the alias slot into two ident variants:

- `PROJECTION_BARE_ALIAS_IDENT` (role `projection_alias`) —
  no scope writes; capture into `projection_aliases` is 2b-5.
- `TABLE_SOURCE_BARE_ALIAS_IDENT` (role `table_alias`,
  `writes_table_alias: true`) — sets the top binding's alias.

The `AS alias` form likewise splits into PROJECTION_AS_ALIAS
and TABLE_SOURCE_AS_ALIAS so each path threads through the
correct ident. The bare-alias lookahead factories return the
projection or table-source ident accordingly.

`TABLE_NAME_IDENT` in sql_select.rs gets `writes_table: true`
so each FROM / JOIN table source pushes a binding. The
schema-resolved columns are stored on the TableBinding for
later use by qualified-prefix completion (2e) and the
schema-existence diagnostic (2d).

Tests (9 new, all green):

- single from-table → one binding
- AS alias / bare alias on from-table → alias captured
- two-way JOIN → two bindings, correct order
- two-way JOIN with both aliased → two bindings with aliases
- three-way JOIN (left + bare) → three bindings in order
- subquery from_scope does not leak to outer scope (the
  ScopedSubgrammar push/pop discipline at work)
- CTE body from_scope does not leak to outer scope (the outer
  scope sees only the CTE-name reference, not the body's
  internals)
- SELECT without FROM → empty from_scope

All 1351 previous tests still pass — DSL paths untouched.
Test totals: 1358 passing, 0 failed, 1 ignored. Clippy clean.

Frame is_cte_body marker, body-projection harvest, and
projection_aliases population are the remaining 2b work
(2b-4 and 2b-5).
This commit is contained in:
claude@clouddev1
2026-05-20 15:25:10 +00:00
parent 98a74b23d3
commit b522d09f5a
9 changed files with 240 additions and 19 deletions
+158 -9
View File
@@ -184,6 +184,7 @@ fn walk_node_inner(
writes_table,
writes_column,
writes_user_listed_column,
writes_table_alias,
} => walk_ident(
source,
pos,
@@ -193,6 +194,7 @@ fn walk_node_inner(
*writes_table,
*writes_column,
*writes_user_listed_column,
*writes_table_alias,
ctx,
path,
per_byte,
@@ -366,6 +368,7 @@ fn walk_ident(
writes_table: bool,
writes_column: bool,
writes_user_listed_column: bool,
writes_table_alias: bool,
ctx: &mut WalkContext,
path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>,
@@ -385,17 +388,46 @@ fn walk_ident(
kind: FailureKind::Validation(err),
};
}
// ADR-0024 §Phase D: schema-aware writes. When the ident is
// a Tables source with `writes_table`, resolve the matched
// name against the schema cache and populate current_table /
// current_table_columns so subsequent dynamic sub-grammars
// can read them. `writes_column` resolves against the
// already-populated `current_table_columns`.
// ADR-0024 §Phase D / ADR-0032 §10.1: schema-aware writes.
// When the ident is a `Tables` source with `writes_table`,
// resolve the matched name against the schema cache and:
// 1. populate `current_table` / `current_table_columns`
// (preserved for DSL paths that read those fields
// directly);
// 2. push a `TableBinding` onto the top `ScopeFrame`'s
// `from_scope` (ADR-0032 §10.1 — for SQL multi-table
// contexts).
if writes_table && matches!(src, crate::dsl::grammar::IdentSource::Tables) {
ctx.current_table = Some(text.clone());
ctx.current_table_columns = ctx
let resolved_columns: Vec<crate::completion::TableColumn> = ctx
.schema
.and_then(|s| s.columns_for_table(&text).map(<[_]>::to_vec));
.and_then(|s| s.columns_for_table(&text).map(<[_]>::to_vec))
.unwrap_or_default();
ctx.current_table = Some(text.clone());
ctx.current_table_columns = if resolved_columns.is_empty() {
None
} else {
Some(resolved_columns.clone())
};
if let Some(frame) = ctx.from_scope_stack.last_mut() {
frame
.from_scope
.push(crate::dsl::walker::context::TableBinding {
table: text.clone(),
alias: None,
columns: resolved_columns,
});
}
}
// ADR-0032 §10.1: the optional `[ AS ] alias` slot on a
// `from_clause` / `join_clause` table source. The flag is
// expected on `IdentSource::NewName` slots; the just-pushed
// binding (the most recent entry in the top frame's
// `from_scope`) gets its alias set.
if writes_table_alias
&& let Some(frame) = ctx.from_scope_stack.last_mut()
&& let Some(binding) = frame.from_scope.last_mut()
{
binding.alias = Some(text.clone());
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
ctx.current_column = ctx.current_table_columns.as_ref().and_then(|cols| {
@@ -1194,4 +1226,121 @@ mod tests {
"WalkContext::new should seed exactly one bottom frame",
);
}
// ---- from_scope binding population (ADR-0032 §10.1) ----
/// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk(
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result = walk_node(
input,
0,
&crate::dsl::grammar::sql_select::SQL_SELECT_STATEMENT,
&mut ctx,
&mut path,
&mut per_byte,
);
assert!(
matches!(result, NodeWalkResult::Matched { .. }),
"{input:?} should match: got {result:?}"
);
// The bottom frame survives the walk; any ScopedSubgrammar
// frames have been popped by now.
ctx.from_scope_stack[0].from_scope.clone()
}
#[test]
fn single_from_table_pushes_one_binding() {
let bindings = from_scope_after_walk("select * from users");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "users");
assert_eq!(bindings[0].alias, None);
}
#[test]
fn as_alias_on_from_table_is_captured() {
let bindings = from_scope_after_walk("select * from users as u");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "users");
assert_eq!(bindings[0].alias, Some("u".to_string()));
}
#[test]
fn bare_alias_on_from_table_is_captured() {
let bindings = from_scope_after_walk("select * from users u");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "users");
assert_eq!(bindings[0].alias, Some("u".to_string()));
}
#[test]
fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y",
);
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
}
#[test]
fn join_with_aliases() {
let bindings = from_scope_after_walk(
"select * from a as x join b as y on x.id = y.id",
);
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string()));
assert_eq!(bindings[1].table, "b");
assert_eq!(bindings[1].alias, Some("y".to_string()));
}
#[test]
fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y left join c on y = z",
);
assert_eq!(bindings.len(), 3);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
assert_eq!(bindings[2].table, "c");
}
#[test]
fn subquery_bindings_do_not_leak_to_outer_scope() {
// The inner `(SELECT id FROM inner_t)` pushes its
// binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk(
"select * from outer_t where id in (select id from inner_t)",
);
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t");
}
#[test]
fn cte_body_bindings_do_not_leak_to_outer_scope() {
// The CTE body's `from base_table` pushes into the CTE
// body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk(
"with cte_x as (select * from base_table) select * from cte_x",
);
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x");
}
#[test]
fn from_scope_empty_for_select_without_from() {
let bindings = from_scope_after_walk("select 1");
assert!(bindings.is_empty());
}
}