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
+3
View File
@@ -52,6 +52,7 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const IMPORT_TARGET: Node = Node::Hinted { const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"), mode: HintMode::ForceProse("hint.ambient_typing_name"),
@@ -85,6 +86,7 @@ const MODE_CHOICES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES); const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -100,6 +102,7 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES); const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
+7
View File
@@ -38,6 +38,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
/// Table-name slot variant that populates /// Table-name slot variant that populates
@@ -52,6 +53,7 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_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_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: true, writes_user_listed_column: true,
writes_table_alias: false,
}; };
static INSERT_COMMA: Node = Node::Punct(','); static INSERT_COMMA: Node = Node::Punct(',');
@@ -221,6 +224,7 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
/// Column-name slot in `set col = …` — resolves the column's /// Column-name slot in `set col = …` — resolves the column's
@@ -234,6 +238,7 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: true, writes_column: true,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
/// Value slot resolved at walk time from /// Value slot resolved at walk time from
@@ -389,6 +394,7 @@ const SELECT_ALIAS_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
static SELECT_AS_ALIAS_NODES: &[Node] = &[ static SELECT_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")), Node::Word(Word::keyword("as")),
@@ -432,6 +438,7 @@ const SELECT_FROM_TABLE: Node = Node::Ident {
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
/// `where <sql_expr>`. /// `where <sql_expr>`.
+19
View File
@@ -42,6 +42,7 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const TABLE_NAME_NEW: Node = Node::Hinted { const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -63,6 +64,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const COLUMN_NAME: Node = Node::Ident { const COLUMN_NAME: Node = Node::Ident {
@@ -73,6 +75,7 @@ const COLUMN_NAME: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -83,6 +86,7 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const COLUMN_NAME_NEW: Node = Node::Hinted { const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -97,6 +101,7 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -107,6 +112,7 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted { const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -121,6 +127,7 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const INDEX_NAME_NEW_IDENT: Node = Node::Ident { const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -131,6 +138,7 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const INDEX_NAME_NEW: Node = Node::Hinted { const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -202,6 +210,7 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -212,6 +221,7 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES); const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -225,6 +235,7 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -235,6 +246,7 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES); const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
@@ -327,6 +339,7 @@ const AR_PARENT_NODES: &[Node] = &[
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -337,6 +350,7 @@ const AR_PARENT_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES); const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
@@ -350,6 +364,7 @@ const AR_CHILD_NODES: &[Node] = &[
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -360,6 +375,7 @@ const AR_CHILD_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES); 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_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const NEW_COLUMN_NAME: Node = Node::Hinted { const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -890,6 +907,7 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const COL_NAME: Node = Node::Hinted { const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -998,6 +1016,7 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
Node::Punct(')'), Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX, COLUMN_CONSTRAINT_SUFFIX,
+1
View File
@@ -79,6 +79,7 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: true, writes_column: true,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
/// Operand alternatives. The literal keywords (`null` / `true` /// Operand alternatives. The literal keywords (`null` / `true`
+9
View File
@@ -251,6 +251,15 @@ pub enum Node {
/// user's explicit selection instead of the /// user's explicit selection instead of the
/// auto-filtered schema default. /// auto-filtered schema default.
writes_user_listed_column: bool, 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 /// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce /// the matched text (used by Phase D value slots to enforce
+3
View File
@@ -54,6 +54,7 @@ pub const TYPE_SLOT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
// --- Qualified column reference (`<Table>.<Column>`) -------------- // --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -67,6 +68,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -77,6 +79,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}, },
]; ];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
+2
View File
@@ -82,6 +82,7 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_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_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[ static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
Node::Punct('.'), Node::Punct('.'),
+38 -10
View File
@@ -173,7 +173,7 @@ fn projection_bare_alias_factory(
{ {
Node::Subgrammar(&EMPTY_NOMATCH) Node::Subgrammar(&EMPTY_NOMATCH)
} }
Some(_) => BARE_ALIAS_IDENT, Some(_) => PROJECTION_BARE_ALIAS_IDENT,
None => Node::Subgrammar(&EMPTY_NOMATCH), None => Node::Subgrammar(&EMPTY_NOMATCH),
} }
} }
@@ -189,7 +189,7 @@ fn table_source_bare_alias_factory(
{ {
Node::Subgrammar(&EMPTY_NOMATCH) Node::Subgrammar(&EMPTY_NOMATCH)
} }
Some(_) => BARE_ALIAS_IDENT, Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
None => Node::Subgrammar(&EMPTY_NOMATCH), None => Node::Subgrammar(&EMPTY_NOMATCH),
} }
} }
@@ -198,24 +198,48 @@ fn table_source_bare_alias_factory(
// Alias slot // 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, source: IdentSource::NewName,
role: "select_alias", role: "projection_alias",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_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")), 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] = &[ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&AS_ALIAS_EXPLICIT), Node::Subgrammar(&PROJECTION_AS_ALIAS),
Node::Lookahead(projection_bare_alias_factory), Node::Lookahead(projection_bare_alias_factory),
]; ];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES); static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
@@ -223,7 +247,7 @@ static PROJECTION_ALIAS_OPTIONAL: Node =
Node::Optional(&PROJECTION_ALIAS_CHOICE); Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[ static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&AS_ALIAS_EXPLICIT), Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory), Node::Lookahead(table_source_bare_alias_factory),
]; ];
static TABLE_SOURCE_ALIAS_CHOICE: Node = static TABLE_SOURCE_ALIAS_CHOICE: Node =
@@ -243,6 +267,7 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
static QUALIFIED_STAR_NODES: &[Node] = &[ static QUALIFIED_STAR_NODES: &[Node] = &[
@@ -319,9 +344,10 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
role: "table_name", role: "table_name",
validator: Some(reject_internal_table), validator: Some(reject_internal_table),
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
static TABLE_SOURCE_NODES: &[Node] = &[ static TABLE_SOURCE_NODES: &[Node] = &[
@@ -555,6 +581,7 @@ const CTE_NAME_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
const CTE_COLUMN_IDENT: Node = Node::Ident { const CTE_COLUMN_IDENT: Node = Node::Ident {
@@ -565,6 +592,7 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false,
}; };
static CTE_COLUMN_LIST_NODES: &[Node] = &[ static CTE_COLUMN_LIST_NODES: &[Node] = &[
+158 -9
View File
@@ -184,6 +184,7 @@ fn walk_node_inner(
writes_table, writes_table,
writes_column, writes_column,
writes_user_listed_column, writes_user_listed_column,
writes_table_alias,
} => walk_ident( } => walk_ident(
source, source,
pos, pos,
@@ -193,6 +194,7 @@ fn walk_node_inner(
*writes_table, *writes_table,
*writes_column, *writes_column,
*writes_user_listed_column, *writes_user_listed_column,
*writes_table_alias,
ctx, ctx,
path, path,
per_byte, per_byte,
@@ -366,6 +368,7 @@ fn walk_ident(
writes_table: bool, writes_table: bool,
writes_column: bool, writes_column: bool,
writes_user_listed_column: bool, writes_user_listed_column: bool,
writes_table_alias: bool,
ctx: &mut WalkContext, ctx: &mut WalkContext,
path: &mut MatchedPath, path: &mut MatchedPath,
per_byte: &mut Vec<ByteClass>, per_byte: &mut Vec<ByteClass>,
@@ -385,17 +388,46 @@ fn walk_ident(
kind: FailureKind::Validation(err), kind: FailureKind::Validation(err),
}; };
} }
// ADR-0024 §Phase D: schema-aware writes. When the ident is // ADR-0024 §Phase D / ADR-0032 §10.1: schema-aware writes.
// a Tables source with `writes_table`, resolve the matched // When the ident is a `Tables` source with `writes_table`,
// name against the schema cache and populate current_table / // resolve the matched name against the schema cache and:
// current_table_columns so subsequent dynamic sub-grammars // 1. populate `current_table` / `current_table_columns`
// can read them. `writes_column` resolves against the // (preserved for DSL paths that read those fields
// already-populated `current_table_columns`. // 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) { if writes_table && matches!(src, crate::dsl::grammar::IdentSource::Tables) {
ctx.current_table = Some(text.clone()); let resolved_columns: Vec<crate::completion::TableColumn> = ctx
ctx.current_table_columns = ctx
.schema .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) { if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
ctx.current_column = ctx.current_table_columns.as_ref().and_then(|cols| { 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", "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());
}
} }