From b522d09f5af651b0c01192e6e89b42a9b2e76652 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 20 May 2026 15:25:10 +0000 Subject: [PATCH] =?UTF-8?q?walker:=20populate=20from=5Fscope=20table=20bin?= =?UTF-8?q?dings=20(ADR-0032=20=C2=A710.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/dsl/grammar/app.rs | 3 + src/dsl/grammar/data.rs | 7 ++ src/dsl/grammar/ddl.rs | 19 ++++ src/dsl/grammar/expr.rs | 1 + src/dsl/grammar/mod.rs | 9 ++ src/dsl/grammar/shared.rs | 3 + src/dsl/grammar/sql_expr.rs | 2 + src/dsl/grammar/sql_select.rs | 48 ++++++++-- src/dsl/walker/driver.rs | 167 ++++++++++++++++++++++++++++++++-- 9 files changed, 240 insertions(+), 19 deletions(-) diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index ef96045..69625fa 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -52,6 +52,7 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const IMPORT_TARGET: Node = Node::Hinted { mode: HintMode::ForceProse("hint.ambient_typing_name"), @@ -85,6 +86,7 @@ const MODE_CHOICES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; const MODE_VALUE: Node = Node::Choice(MODE_CHOICES); @@ -100,6 +102,7 @@ const MESSAGES_CHOICES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES); diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index b3e6b60..9ee710c 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -38,6 +38,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; /// Table-name slot variant that populates @@ -52,6 +53,7 @@ const TABLE_NAME_INSERT: Node = Node::Ident { writes_table: true, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; // ================================================================= @@ -110,6 +112,7 @@ static FORM_A_COLUMN: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: true, +writes_table_alias: false, }; static INSERT_COMMA: Node = Node::Punct(','); @@ -221,6 +224,7 @@ const TABLE_NAME_WRITES: Node = Node::Ident { writes_table: true, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; /// Column-name slot in `set col = …` — resolves the column's @@ -234,6 +238,7 @@ const SET_COLUMN: Node = Node::Ident { writes_table: false, writes_column: true, writes_user_listed_column: false, +writes_table_alias: false, }; /// Value slot resolved at walk time from @@ -389,6 +394,7 @@ const SELECT_ALIAS_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; static SELECT_AS_ALIAS_NODES: &[Node] = &[ Node::Word(Word::keyword("as")), @@ -432,6 +438,7 @@ const SELECT_FROM_TABLE: Node = Node::Ident { writes_table: true, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; /// `where `. diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index fbd620b..222c5b6 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -42,6 +42,7 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const TABLE_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -63,6 +64,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident { writes_table: true, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const COLUMN_NAME: Node = Node::Ident { @@ -73,6 +75,7 @@ const COLUMN_NAME: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { @@ -83,6 +86,7 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const COLUMN_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -97,6 +101,7 @@ const RELATIONSHIP_NAME: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { @@ -107,6 +112,7 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const RELATIONSHIP_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -121,6 +127,7 @@ const INDEX_NAME_EXISTING: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const INDEX_NAME_NEW_IDENT: Node = Node::Ident { @@ -131,6 +138,7 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const INDEX_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -202,6 +210,7 @@ const DR_PARENT_NODES: &[Node] = &[ writes_table: true, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, Node::Punct('.'), Node::Ident { @@ -212,6 +221,7 @@ const DR_PARENT_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES); @@ -225,6 +235,7 @@ const DR_CHILD_NODES: &[Node] = &[ writes_table: true, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, Node::Punct('.'), Node::Ident { @@ -235,6 +246,7 @@ const DR_CHILD_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES); @@ -327,6 +339,7 @@ const AR_PARENT_NODES: &[Node] = &[ writes_table: true, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, Node::Punct('.'), Node::Ident { @@ -337,6 +350,7 @@ const AR_PARENT_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES); @@ -350,6 +364,7 @@ const AR_CHILD_NODES: &[Node] = &[ writes_table: true, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, Node::Punct('.'), Node::Ident { @@ -360,6 +375,7 @@ const AR_CHILD_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES); @@ -427,6 +443,7 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const NEW_COLUMN_NAME: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -890,6 +907,7 @@ const COL_NAME_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const COL_NAME: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -998,6 +1016,7 @@ const COL_SPEC_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, Node::Punct(')'), COLUMN_CONSTRAINT_SUFFIX, diff --git a/src/dsl/grammar/expr.rs b/src/dsl/grammar/expr.rs index fdc81b7..3a44a37 100644 --- a/src/dsl/grammar/expr.rs +++ b/src/dsl/grammar/expr.rs @@ -79,6 +79,7 @@ const EXPR_COLUMN: Node = Node::Ident { writes_table: false, writes_column: true, writes_user_listed_column: false, +writes_table_alias: false, }; /// Operand alternatives. The literal keywords (`null` / `true` diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index cdcc9d5..38f8166 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -251,6 +251,15 @@ pub enum Node { /// user's explicit selection instead of the /// auto-filtered schema default. writes_user_listed_column: bool, + /// Set the matched text as the alias of the most- + /// recently-pushed `TableBinding` on the top + /// `ScopeFrame`'s `from_scope` (ADR-0032 §10.1). Used by + /// the `[ AS ] alias` slot on `from_clause` / + /// `join_clause` table sources in `sql_select.rs`; a + /// no-op on `IdentSource::NewName` slots that do not + /// follow a table-name push, or when the top frame's + /// `from_scope` is empty. + writes_table_alias: bool, }, /// A number literal. The optional `validator` runs against /// the matched text (used by Phase D value slots to enforce diff --git a/src/dsl/grammar/shared.rs b/src/dsl/grammar/shared.rs index fff71ad..eaef222 100644 --- a/src/dsl/grammar/shared.rs +++ b/src/dsl/grammar/shared.rs @@ -54,6 +54,7 @@ pub const TYPE_SLOT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; // --- Qualified column reference (`.`) -------------- @@ -67,6 +68,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, Node::Punct('.'), Node::Ident { @@ -77,6 +79,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }, ]; pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); diff --git a/src/dsl/grammar/sql_expr.rs b/src/dsl/grammar/sql_expr.rs index 521e2ca..668a43f 100644 --- a/src/dsl/grammar/sql_expr.rs +++ b/src/dsl/grammar/sql_expr.rs @@ -82,6 +82,7 @@ const EXPR_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; // ================================================================= @@ -464,6 +465,7 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; static QUALIFIED_REF_TAIL_NODES: &[Node] = &[ Node::Punct('.'), diff --git a/src/dsl/grammar/sql_select.rs b/src/dsl/grammar/sql_select.rs index 5fc96d6..3cdd3ff 100644 --- a/src/dsl/grammar/sql_select.rs +++ b/src/dsl/grammar/sql_select.rs @@ -173,7 +173,7 @@ fn projection_bare_alias_factory( { Node::Subgrammar(&EMPTY_NOMATCH) } - Some(_) => BARE_ALIAS_IDENT, + Some(_) => PROJECTION_BARE_ALIAS_IDENT, None => Node::Subgrammar(&EMPTY_NOMATCH), } } @@ -189,7 +189,7 @@ fn table_source_bare_alias_factory( { Node::Subgrammar(&EMPTY_NOMATCH) } - Some(_) => BARE_ALIAS_IDENT, + Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT, None => Node::Subgrammar(&EMPTY_NOMATCH), } } @@ -198,24 +198,48 @@ fn table_source_bare_alias_factory( // Alias slot // ================================================================= -const BARE_ALIAS_IDENT: Node = Node::Ident { +/// Projection-list alias slot. `writes_table_alias` stays +/// `false` — the projection alias is not a table binding's +/// alias. (Capture into `projection_aliases` lands in 2b-5.) +const PROJECTION_BARE_ALIAS_IDENT: Node = Node::Ident { source: IdentSource::NewName, - role: "select_alias", + role: "projection_alias", validator: None, highlight_override: None, writes_table: false, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }; -static AS_ALIAS_NODES: &[Node] = &[ +/// Table-source alias slot — `writes_table_alias: true` so the +/// matched name lands on the most-recently-pushed +/// `TableBinding`'s `alias` (ADR-0032 §10.1). +const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident { + source: IdentSource::NewName, + role: "table_alias", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: true, +}; + +static PROJECTION_AS_ALIAS_NODES: &[Node] = &[ Node::Word(Word::keyword("as")), - BARE_ALIAS_IDENT, + PROJECTION_BARE_ALIAS_IDENT, ]; -static AS_ALIAS_EXPLICIT: Node = Node::Seq(AS_ALIAS_NODES); +static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES); + +static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[ + Node::Word(Word::keyword("as")), + TABLE_SOURCE_BARE_ALIAS_IDENT, +]; +static TABLE_SOURCE_AS_ALIAS: Node = Node::Seq(TABLE_SOURCE_AS_ALIAS_NODES); static PROJECTION_ALIAS_CHOICES: &[Node] = &[ - Node::Subgrammar(&AS_ALIAS_EXPLICIT), + Node::Subgrammar(&PROJECTION_AS_ALIAS), Node::Lookahead(projection_bare_alias_factory), ]; static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES); @@ -223,7 +247,7 @@ static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE); static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[ - Node::Subgrammar(&AS_ALIAS_EXPLICIT), + Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS), Node::Lookahead(table_source_bare_alias_factory), ]; static TABLE_SOURCE_ALIAS_CHOICE: Node = @@ -243,6 +267,7 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; static QUALIFIED_STAR_NODES: &[Node] = &[ @@ -319,9 +344,10 @@ const TABLE_NAME_IDENT: Node = Node::Ident { role: "table_name", validator: Some(reject_internal_table), highlight_override: None, - writes_table: false, + writes_table: true, writes_column: false, writes_user_listed_column: false, + writes_table_alias: false, }; static TABLE_SOURCE_NODES: &[Node] = &[ @@ -555,6 +581,7 @@ const CTE_NAME_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; const CTE_COLUMN_IDENT: Node = Node::Ident { @@ -565,6 +592,7 @@ const CTE_COLUMN_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, +writes_table_alias: false, }; static CTE_COLUMN_LIST_NODES: &[Node] = &[ diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index af4c5c5..4c7e753 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -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, @@ -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 = 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 { + 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()); + } }