feat: ADR-0036 Phase 3b — live typed-slot hints + highlighting for INSERT VALUES

Give each positional INSERT VALUES position its column identity so a lone
literal gets the column-typed slot (live per-column hint + mismatch
highlight) and any expression falls through to sql_expr — completing the
typed-DML-values feature for the INSERT surface (single/multi-row, Form A
and Form B).

New zero-width Node::SetColumn(&TableColumn) primitive establishes the
active column for the value position that follows (sets current_column +
pending_value_column, like an Ident{writes_column} but without consuming
input); a DynamicSubgrammar emits SetColumn(col) + the shared SET_VALUE
per position. Column mapping mirrors do_sql_insert: Form A → listed
columns; Form B → all columns in declaration order (advanced-mode Form B
auto-fills nothing; an omitted shortid in Form A is auto-filled and has no
VALUES position).

Reconcile with the per-tuple arity diagnostic (ADR-0033 §8.1): a
fixed-length typed Seq would reject wrong-arity tuples and suppress that
post-walk diagnostic, so the tuple value list is an arity-gating lookahead
— a correct-arity tuple uses the typed Seq; a wrong-arity tuple keeps the
type-blind sql_expr repeat so §8.1 fires unchanged. Correct-arity tuples
get full live feedback, including a wrong-kind literal like 'text' into an
int column.

Records ADR-0036 Amendment 1 (Phase 3b detail + the arity reconciliation);
ADR-0036 is now fully implemented.

Tests: 1947 passing (+8), 0 failed, 0 skipped, 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-27 07:22:44 +00:00
parent 49ea03b0d5
commit 8906661f69
6 changed files with 396 additions and 20 deletions
+19
View File
@@ -375,6 +375,25 @@ pub enum Node {
/// type-awareness). Not memoized: the output depends on the
/// source, not just `ctx`.
Lookahead(fn(&WalkContext, &str, usize) -> Self),
/// Zero-width node that *establishes the active column* for the
/// value slot that follows it (ADR-0036 Phase 3b). Matches the
/// empty string and, as a side effect, sets
/// `WalkContext::current_column` to the referenced column and
/// `pending_value_column` to its name — exactly as an
/// `Ident { writes_column: true }` does, but without consuming a
/// column identifier from the input.
///
/// This is the primitive that gives `INSERT … VALUES (…)`
/// positions a per-position column identity: the positions are
/// positional (no per-position column ident to write
/// `current_column`), so a `DynamicSubgrammar` factory
/// (`sql_insert::sql_value_list`) emits `SetColumn(colᵢ)` before
/// each value position, then the shared boundary-aware `SET_VALUE`
/// slot routes a lone literal to that column's typed slot and any
/// expression to `sql_expr`. The referenced `TableColumn` is
/// leaked by the factory (bounded by the column count, like the
/// `DynamicSubgrammar` `Box::leak`).
SetColumn(&'static crate::completion::TableColumn),
/// Typed value-literal slot (ADR-0024 §Phase D §typed-value-slots).
///
/// Walks `inner` to consume the literal but records the
+163 -10
View File
@@ -13,12 +13,14 @@
//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later
//! sub-phases.
use crate::completion::TableColumn;
use crate::dsl::grammar::shared::SET_VALUE;
use crate::dsl::grammar::sql_expr;
use crate::dsl::grammar::sql_select::{
RETURNING_CLAUSE, SQL_SELECT_COMPOUND, WHERE_CLAUSE, reject_internal_table,
};
use crate::dsl::grammar::{IdentSource, Node, Word};
use crate::dsl::walker::context::WalkContext;
static COMMA: Node = Node::Punct(',');
@@ -41,10 +43,13 @@ const TARGET_TABLE: Node = Node::Ident {
/// One column name inside the optional `(col1, col2, …)` list.
///
/// `writes_user_listed_column` stays `false` in 3b — the worker
/// requires explicit values for every column, so the listed-column
/// set isn't needed yet. Sub-phase 3d (`shortid` auto-fill) turns
/// it on and threads `listed_columns` into `Command::SqlInsert`.
/// `writes_user_listed_column: true` records the listed columns into
/// `WalkContext::user_listed_columns` so the `VALUES` factory
/// (`sql_value_list`, ADR-0036 Phase 3b) maps each value position to
/// the listed column in the user's order (Form A). `build_sql_insert`
/// still collects `listed_columns` independently from the matched
/// `insert_column` idents, so this flag only adds the live typed-slot
/// mapping — nothing else reads `user_listed_columns` on the SQL path.
static COLUMN_NAME: Node = Node::Ident {
source: IdentSource::Columns,
role: "insert_column",
@@ -52,7 +57,7 @@ static COLUMN_NAME: Node = Node::Ident {
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_user_listed_column: true,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
@@ -72,19 +77,167 @@ const OPTIONAL_COLUMN_LIST: Node = Node::Optional(&Node::Seq(COLUMN_LIST_NODES))
/// One value expression inside a `VALUES` tuple. Consumes the
/// shared `sql_expr` grammar (ADR-0031), so literals, operators,
/// `CASE`, function calls, etc. are all admitted; the engine
/// evaluates them at execution time.
/// evaluates them at execution time. Used as the schemaless / fallback
/// value (see `sql_value_list`).
static VALUE_EXPR: Node = Node::Subgrammar(&sql_expr::SQL_OR_EXPR);
static VALUE_TUPLE_NODES: &[Node] = &[
Node::Punct('('),
/// The fallback value list — the pre-Phase-3b type-blind
/// `Repeated(sql_expr)`. Used for schemaless walks and (crucially) for
/// any tuple whose value-count does NOT match the target column count,
/// so the post-walk per-tuple arity diagnostic (ADR-0033 §8.1) still
/// sees all the values in the matched path and fires its friendly
/// message — a fixed-length typed `Seq` would instead reject the tuple
/// and suppress that diagnostic.
fn fallback_value_list() -> Node {
Node::Repeated {
inner: &VALUE_EXPR,
separator: Some(&COMMA),
min: 1,
},
}
}
/// The target columns a `VALUES` tuple's positions map onto (ADR-0036
/// Phase 3b). Mirrors `db::do_sql_insert`'s positional rule — NOT the
/// DSL's `column_value_list`:
/// - **Form A** (`user_listed_columns` set, from the `(col, …)`
/// list): the listed columns, in the user's order. An *omitted*
/// `shortid` is auto-filled at execution (the X4 note) and has no
/// `VALUES` position, so it is correctly absent here.
/// - **Form B** (no column list): ALL columns in declaration order,
/// including `serial` / `shortid` — advanced-mode Form B auto-fills
/// *nothing* (`plan_shortid_autofill` returns early on an empty
/// column list), so the user supplies a value for every column.
///
/// Empty when schemaless, the table is unknown, or a Form A list
/// resolves to nothing (callers fall back to the type-blind list).
fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
let Some(table_cols) = ctx.current_table_columns.as_ref() else {
return Vec::new();
};
ctx.user_listed_columns.as_ref().map_or_else(
|| table_cols.clone(),
|listed| {
listed
.iter()
.filter_map(|name| {
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
})
.collect()
},
)
}
/// Count the value positions in the `VALUES` tuple whose contents begin
/// at `pos` (just past the opening `(`), and whether the tuple is
/// *closed* (a depth-0 `)` was reached) vs still being typed (scan hit
/// end-of-input first). Depth-aware: commas nested in a function call /
/// subquery (paren depth ≥ 1) or inside a string literal are not
/// separators. Returns `(0, _)` for an empty tuple `()`.
fn count_tuple_values(source: &str, pos: usize) -> (usize, bool) {
let bytes = source.as_bytes();
let mut i = pos;
let mut depth: i32 = 0;
let mut commas = 0usize;
let mut seen_value = false;
let mut closed = false;
while i < bytes.len() {
match bytes[i] {
b'\'' => {
// Skip a single-quoted string literal (`''` escape).
i += 1;
seen_value = true;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
continue;
}
b'(' => {
depth += 1;
seen_value = true;
}
b')' => {
if depth == 0 {
closed = true;
break; // tuple close
}
depth -= 1;
}
b',' if depth == 0 => commas += 1,
b if !b.is_ascii_whitespace() => seen_value = true,
_ => {}
}
i += 1;
}
(if seen_value { commas + 1 } else { 0 }, closed)
}
/// Tuple value-list lookahead (ADR-0036 Phase 3b). Gates the typed
/// per-column path on arity so the typed `Seq` is used only where it
/// can succeed, leaving wrong-arity tuples to the type-blind path (and
/// thus to the per-tuple arity diagnostic, ADR-0033 §8.1, which a
/// fixed-length `Seq` would otherwise suppress by rejecting the tuple):
/// - a **closed** tuple routes to typed slots only on an *exact*
/// match (`count == columns`);
/// - an **open** (still-typing) tuple routes to typed slots while
/// there is still room (`count <= columns`), so the per-column hint
/// shows from the moment `(` is opened through each position.
///
/// Returns a small node — the heavy typed `Seq` is built + memoized by
/// the `DynamicSubgrammar` — matching `insert_first_paren`'s leak
/// discipline. Schemaless / unknown table → type-blind fallback.
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list)
} else {
fallback_value_list()
}
}
/// Schema-aware typed value list for one correct-arity `VALUES` tuple
/// (ADR-0036 Phase 3b). Emits, per target column, a zero-width
/// `SetColumn(col)` marker (establishes the active column) followed by
/// the shared boundary-aware [`SET_VALUE`] slot — so a lone literal
/// routes to the column's typed slot (live hint + numeric-shape
/// highlight) and any expression falls through to `sql_expr`. Reached
/// only via [`tuple_value_list`] when arity matches and the schema is
/// known; the empty-cols guard is defensive.
fn sql_value_list(ctx: &WalkContext) -> Node {
let cols = target_value_columns(ctx);
if cols.is_empty() {
return fallback_value_list();
}
let mut children: Vec<Node> = Vec::with_capacity(cols.len() * 3);
for (i, col) in cols.into_iter().enumerate() {
if i > 0 {
children.push(Node::Punct(','));
}
let leaked: &'static TableColumn = Box::leak(Box::new(col));
children.push(Node::SetColumn(leaked));
children.push(SET_VALUE);
}
Node::Seq(Box::leak(children.into_boxed_slice()))
}
static VALUE_TUPLE_NODES: &[Node] = &[
Node::Punct('('),
Node::Lookahead(tuple_value_list),
Node::Punct(')'),
];
/// `'(' sql_expr (',' sql_expr)* ')'` — one row of values.
/// `'(' <value-list> ')'` — one row of values. The value list is the
/// arity-gated `tuple_value_list` (ADR-0036 Phase 3b): a correct-arity
/// tuple gets per-column typed slots; a wrong-arity tuple keeps the
/// type-blind `sql_expr` repeat so the §8.1 arity diagnostic fires.
static VALUE_TUPLE: Node = Node::Seq(VALUE_TUPLE_NODES);
static VALUES_CLAUSE_NODES: &[Node] = &[
+13
View File
@@ -250,6 +250,19 @@ fn walk_node_inner(
Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::SetColumn(col) => {
// ADR-0036 Phase 3b: zero-width — establish the active
// column for the value position that follows, exactly as an
// `Ident { writes_column: true }` would (current_column for
// the typed slot's dispatch; pending_value_column for the
// hint's "for `col`:" framing), but without consuming a
// column identifier (VALUES positions are positional). The
// following `SET_VALUE` slot reads `current_column`.
let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.clone());
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
}
Node::TypedValueSlot {
ty,
column_name,