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:
@@ -20,8 +20,13 @@ validated, execution stays verbatim). **Phase 3a implemented 2026-05-26**
|
|||||||
— live typed-slot hints + numeric-shape highlighting for advanced-mode
|
— live typed-slot hints + numeric-shape highlighting for advanced-mode
|
||||||
`UPDATE`/UPSERT `SET col = <literal>` value positions, via a
|
`UPDATE`/UPSERT `SET col = <literal>` value positions, via a
|
||||||
**boundary-aware lookahead** (not the naive `Choice` this ADR originally
|
**boundary-aware lookahead** (not the naive `Choice` this ADR originally
|
||||||
sketched in §5 — see **Amendment 1**). Phase 3b (`INSERT … VALUES` typed
|
sketched in §5 — see **Amendment 1**). **Phase 3b implemented 2026-05-27**
|
||||||
slots — needs a per-position grammar restructure + multi-row) pending.
|
— live per-position typed-slot hints + highlighting for advanced-mode
|
||||||
|
`INSERT … VALUES (…)` (single- and multi-row, Form A and Form B), via a
|
||||||
|
new zero-width `Node::SetColumn` primitive that gives each positional
|
||||||
|
value its column identity, plus an arity-gating tuple lookahead that
|
||||||
|
preserves the per-tuple arity diagnostic (ADR-0033 §8.1); see
|
||||||
|
**Amendment 1**. ADR-0036 is now fully implemented.
|
||||||
|
|
||||||
**Augments** **ADR-0030 §4** and **ADR-0033 §10** — it does **not**
|
**Augments** **ADR-0030 §4** and **ADR-0033 §10** — it does **not**
|
||||||
supersede them and does **not** change the execution model. Advanced-mode
|
supersede them and does **not** change the execution model. Advanced-mode
|
||||||
@@ -373,13 +378,44 @@ deliberate throwaway.
|
|||||||
UPDATE SET`. Mismatch examples now caught **live** (e.g. `set k = 3.14`
|
UPDATE SET`. Mismatch examples now caught **live** (e.g. `set k = 3.14`
|
||||||
at an `int` column), matching what simple mode already does — earlier,
|
at an `int` column), matching what simple mode already does — earlier,
|
||||||
better feedback than Phase 2's execution-time catch.
|
better feedback than Phase 2's execution-time catch.
|
||||||
- **Phase 3b (pending) — `INSERT … VALUES (…)`.** Harder: the values list
|
- **Phase 3b (implemented 2026-05-27) — `INSERT … VALUES (…)`.** Harder,
|
||||||
is `Repeated(VALUE_EXPR)` with **no per-position column identity**, and
|
because the values list is positional (no per-position column ident)
|
||||||
multi-row `values (..),(..)` must be handled. It needs the DSL-style
|
and multi-row. The resolution, agreed with the user (full-parity
|
||||||
per-position restructure (a `DynamicSubgrammar` emitting one
|
option):
|
||||||
boundary-aware position per column), tracked as its own step.
|
- **A new zero-width `Node::SetColumn(&'static TableColumn)` primitive**
|
||||||
|
establishes the active column for the value position that follows
|
||||||
|
(sets `current_column` + `pending_value_column`, exactly as an
|
||||||
|
`Ident { writes_column: true }` would, but without consuming an
|
||||||
|
identifier). A `DynamicSubgrammar` (`sql_insert::sql_value_list`)
|
||||||
|
emits `SetColumn(colᵢ)` then the shared `SET_VALUE` per position, so
|
||||||
|
each positional value reuses 3a's routing. One new enum variant; one
|
||||||
|
new walker arm.
|
||||||
|
- **Column mapping** mirrors `do_sql_insert`'s positional rule:
|
||||||
|
Form A → the listed columns in the user's order; Form B → **all**
|
||||||
|
columns in declaration order. Note on auto-fill (correcting a loose
|
||||||
|
statement made while scoping): advanced mode **does** auto-fill an
|
||||||
|
*omitted* `shortid` in **Form A** (`plan_shortid_autofill`), so that
|
||||||
|
column has no `VALUES` position and is correctly absent from the
|
||||||
|
mapping; it does **not** auto-fill `serial` (the X4 gap); and **Form
|
||||||
|
B auto-fills nothing** (the function returns early on an empty column
|
||||||
|
list), so the user supplies a value for every column — hence the
|
||||||
|
Form-B-maps-all-columns rule.
|
||||||
|
- **Coexistence with the §8.1 arity diagnostic.** A fixed-length typed
|
||||||
|
`Seq` would *reject* a wrong-arity tuple, suppressing the friendly
|
||||||
|
per-tuple `insert_arity_mismatch` (ADR-0033 §8.1), which is a
|
||||||
|
post-walk pass over the matched path and so needs the tuple to be
|
||||||
|
accepted. So the tuple value list is an **arity-gating lookahead**
|
||||||
|
(`tuple_value_list`): a closed tuple uses the typed `Seq` only on an
|
||||||
|
exact value/column match; an open (mid-typing) tuple uses it while
|
||||||
|
`count <= columns` (so the hint shows from `(` onward); every other
|
||||||
|
case falls back to the type-blind `Repeated(sql_expr)`, leaving the
|
||||||
|
§8.1 diagnostic to fire unchanged. Correct-arity tuples (the common
|
||||||
|
case) thus get full live typed feedback — including a wrong-*kind*
|
||||||
|
lone literal such as `('text')` into an `int` column, the case the
|
||||||
|
cheaper structural option would have missed — while wrong-arity
|
||||||
|
tuples keep the friendly arity message.
|
||||||
|
|
||||||
**Known limitation (both phases, matches the DSL).** `date` / `shortid` /
|
**Known limitation (all phases, matches the DSL).** `date` / `shortid` /
|
||||||
`datetime` **format** is still not validated at parse — those slots accept
|
`datetime` **format** is still not validated at parse — those slots accept
|
||||||
any quoted string; the format is checked at bind/execution time (Phase 2).
|
any quoted string; the format is checked at bind/execution time (Phase 2).
|
||||||
So the live highlight catches *numeric-shape* mismatches (`int`/`decimal`/
|
So the live highlight catches *numeric-shape* mismatches (`int`/`decimal`/
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -375,6 +375,25 @@ pub enum Node {
|
|||||||
/// type-awareness). Not memoized: the output depends on the
|
/// type-awareness). Not memoized: the output depends on the
|
||||||
/// source, not just `ctx`.
|
/// source, not just `ctx`.
|
||||||
Lookahead(fn(&WalkContext, &str, usize) -> Self),
|
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).
|
/// Typed value-literal slot (ADR-0024 §Phase D §typed-value-slots).
|
||||||
///
|
///
|
||||||
/// Walks `inner` to consume the literal but records the
|
/// Walks `inner` to consume the literal but records the
|
||||||
|
|||||||
+163
-10
@@ -13,12 +13,14 @@
|
|||||||
//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later
|
//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later
|
||||||
//! sub-phases.
|
//! sub-phases.
|
||||||
|
|
||||||
|
use crate::completion::TableColumn;
|
||||||
use crate::dsl::grammar::shared::SET_VALUE;
|
use crate::dsl::grammar::shared::SET_VALUE;
|
||||||
use crate::dsl::grammar::sql_expr;
|
use crate::dsl::grammar::sql_expr;
|
||||||
use crate::dsl::grammar::sql_select::{
|
use crate::dsl::grammar::sql_select::{
|
||||||
RETURNING_CLAUSE, SQL_SELECT_COMPOUND, WHERE_CLAUSE, reject_internal_table,
|
RETURNING_CLAUSE, SQL_SELECT_COMPOUND, WHERE_CLAUSE, reject_internal_table,
|
||||||
};
|
};
|
||||||
use crate::dsl::grammar::{IdentSource, Node, Word};
|
use crate::dsl::grammar::{IdentSource, Node, Word};
|
||||||
|
use crate::dsl::walker::context::WalkContext;
|
||||||
|
|
||||||
static COMMA: Node = Node::Punct(',');
|
static COMMA: Node = Node::Punct(',');
|
||||||
|
|
||||||
@@ -41,10 +43,13 @@ const TARGET_TABLE: Node = Node::Ident {
|
|||||||
|
|
||||||
/// One column name inside the optional `(col1, col2, …)` list.
|
/// One column name inside the optional `(col1, col2, …)` list.
|
||||||
///
|
///
|
||||||
/// `writes_user_listed_column` stays `false` in 3b — the worker
|
/// `writes_user_listed_column: true` records the listed columns into
|
||||||
/// requires explicit values for every column, so the listed-column
|
/// `WalkContext::user_listed_columns` so the `VALUES` factory
|
||||||
/// set isn't needed yet. Sub-phase 3d (`shortid` auto-fill) turns
|
/// (`sql_value_list`, ADR-0036 Phase 3b) maps each value position to
|
||||||
/// it on and threads `listed_columns` into `Command::SqlInsert`.
|
/// 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 {
|
static COLUMN_NAME: Node = Node::Ident {
|
||||||
source: IdentSource::Columns,
|
source: IdentSource::Columns,
|
||||||
role: "insert_column",
|
role: "insert_column",
|
||||||
@@ -52,7 +57,7 @@ static COLUMN_NAME: Node = Node::Ident {
|
|||||||
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: true,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: 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
|
/// One value expression inside a `VALUES` tuple. Consumes the
|
||||||
/// shared `sql_expr` grammar (ADR-0031), so literals, operators,
|
/// shared `sql_expr` grammar (ADR-0031), so literals, operators,
|
||||||
/// `CASE`, function calls, etc. are all admitted; the engine
|
/// `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_EXPR: Node = Node::Subgrammar(&sql_expr::SQL_OR_EXPR);
|
||||||
|
|
||||||
static VALUE_TUPLE_NODES: &[Node] = &[
|
/// The fallback value list — the pre-Phase-3b type-blind
|
||||||
Node::Punct('('),
|
/// `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 {
|
Node::Repeated {
|
||||||
inner: &VALUE_EXPR,
|
inner: &VALUE_EXPR,
|
||||||
separator: Some(&COMMA),
|
separator: Some(&COMMA),
|
||||||
min: 1,
|
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(')'),
|
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 VALUE_TUPLE: Node = Node::Seq(VALUE_TUPLE_NODES);
|
||||||
|
|
||||||
static VALUES_CLAUSE_NODES: &[Node] = &[
|
static VALUES_CLAUSE_NODES: &[Node] = &[
|
||||||
|
|||||||
@@ -250,6 +250,19 @@ fn walk_node_inner(
|
|||||||
Box::leak(Box::new(factory(ctx, source, pos)));
|
Box::leak(Box::new(factory(ctx, source, pos)));
|
||||||
walk_node(source, pos, resolved, ctx, path, per_byte)
|
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 {
|
Node::TypedValueSlot {
|
||||||
ty,
|
ty,
|
||||||
column_name,
|
column_name,
|
||||||
|
|||||||
+156
-1
@@ -24,7 +24,9 @@ use rdbms_playground::completion::{SchemaCache, TableColumn};
|
|||||||
use rdbms_playground::db::{Database, DbError, InsertResult};
|
use rdbms_playground::db::{Database, DbError, InsertResult};
|
||||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||||||
use rdbms_playground::event::AppEvent;
|
use rdbms_playground::event::AppEvent;
|
||||||
use rdbms_playground::input_render::{AmbientHint, ambient_hint_in_mode};
|
use rdbms_playground::input_render::{
|
||||||
|
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
|
||||||
|
};
|
||||||
use rdbms_playground::mode::Mode;
|
use rdbms_playground::mode::Mode;
|
||||||
use rdbms_playground::persistence::Persistence;
|
use rdbms_playground::persistence::Persistence;
|
||||||
use rdbms_playground::project;
|
use rdbms_playground::project;
|
||||||
@@ -1217,3 +1219,156 @@ fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
|
|||||||
"text-column hint says `quoted string`: {prose:?}"
|
"text-column hint says `quoted string`: {prose:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// ADR-0036 Phase 3b — live typed-slot hints + highlighting for the
|
||||||
|
// INSERT `VALUES (…)` positions (per-position column mapping via the
|
||||||
|
// `Node::SetColumn` primitive; boundary-aware lookahead per position).
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/// Build a `SchemaCache` for the advanced-mode typing-surface tests.
|
||||||
|
fn vschema(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
|
||||||
|
let mut cache = SchemaCache::default();
|
||||||
|
for (table, cols) in tables {
|
||||||
|
let table_cols: Vec<TableColumn> = cols
|
||||||
|
.iter()
|
||||||
|
.map(|(n, t)| TableColumn {
|
||||||
|
name: (*n).to_string(),
|
||||||
|
user_type: *t,
|
||||||
|
not_null: false,
|
||||||
|
has_default: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
cache.tables.push((*table).to_string());
|
||||||
|
for c in &table_cols {
|
||||||
|
if !cache.columns.contains(&c.name) {
|
||||||
|
cache.columns.push(c.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.table_columns.insert((*table).to_string(), table_cols);
|
||||||
|
}
|
||||||
|
cache
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prose_at(input: &str, schema: &SchemaCache) -> String {
|
||||||
|
let hint = ambient_hint_in_mode(input, input.len(), None, schema, Mode::Advanced);
|
||||||
|
match hint {
|
||||||
|
Some(AmbientHint::Prose(p)) => p,
|
||||||
|
other => panic!("expected a Prose hint for {input:?}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_form_a_value_offers_typed_slot_hint() {
|
||||||
|
// Form A (explicit column list): the value position maps to the
|
||||||
|
// user-listed column, so the hint is that column's typed prose.
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
let prose = prose_at("insert into Things (note) values (", &schema);
|
||||||
|
assert!(prose.contains("note"), "names listed column `note`: {prose:?}");
|
||||||
|
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_form_b_value_maps_first_column() {
|
||||||
|
// Form B (no column list): positions map to ALL columns in
|
||||||
|
// declaration order, so the first position is the first column.
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
let prose = prose_at("insert into Things values (", &schema);
|
||||||
|
assert!(prose.contains("k"), "names first column `k`: {prose:?}");
|
||||||
|
assert!(prose.contains("integer"), "int-column prose: {prose:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_second_position_hints_second_column() {
|
||||||
|
// Per-position mapping advances: after the first value + comma, the
|
||||||
|
// hint is the SECOND column's typed prose.
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
let prose = prose_at("insert into Things (k, note) values (5, ", &schema);
|
||||||
|
assert!(prose.contains("note"), "second position names `note`: {prose:?}");
|
||||||
|
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_value_int_mismatch_is_caught_live() {
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
let bad = classify_input_with_schema_in_mode(
|
||||||
|
"insert into Things (k) values (3.14)",
|
||||||
|
&schema,
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
assert!(!matches!(bad, InputState::Valid), "decimal into int rejected live: {bad:?}");
|
||||||
|
let ok = classify_input_with_schema_in_mode(
|
||||||
|
"insert into Things (k) values (5)",
|
||||||
|
&schema,
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
assert!(matches!(ok, InputState::Valid), "valid int literal parses: {ok:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_string_into_int_is_caught_live() {
|
||||||
|
// The Option-A win over the structural fallback: a wrong-KIND lone
|
||||||
|
// literal (a string into an int column) is rejected WHILE TYPING,
|
||||||
|
// not only at execution.
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
let bad = classify_input_with_schema_in_mode(
|
||||||
|
"insert into Things (k) values ('text')",
|
||||||
|
&schema,
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
assert!(!matches!(bad, InputState::Valid), "string into int rejected live: {bad:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_multi_row_typed_and_mismatch_caught() {
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
let ok = classify_input_with_schema_in_mode(
|
||||||
|
"insert into Things (k, note) values (1, 'a'), (2, 'b')",
|
||||||
|
&schema,
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
assert!(matches!(ok, InputState::Valid), "well-formed multi-row parses: {ok:?}");
|
||||||
|
let bad = classify_input_with_schema_in_mode(
|
||||||
|
"insert into Things (k, note) values (1, 'a'), (3.14, 'b')",
|
||||||
|
&schema,
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!matches!(bad, InputState::Valid),
|
||||||
|
"a mismatch in the second row is caught: {bad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_form_b_maps_all_columns_including_serial() {
|
||||||
|
// SQL Form B supplies a value for EVERY column (no auto-fill), so
|
||||||
|
// the position count = all columns, and a serial column's position
|
||||||
|
// takes an int literal (unlike the DSL, which omits auto-gen cols).
|
||||||
|
let schema = vschema(&[(
|
||||||
|
"Customers",
|
||||||
|
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||||||
|
)]);
|
||||||
|
let state = classify_input_with_schema_in_mode(
|
||||||
|
"insert into Customers values (1, 'Bob', 'b@c')",
|
||||||
|
&schema,
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
assert!(matches!(state, InputState::Valid), "Form B maps all 3 columns: {state:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_insert_value_expressions_still_parse_via_sql_expr() {
|
||||||
|
// Regression guard: a non-lone-literal value position (arithmetic,
|
||||||
|
// literal-prefixed, function call, signed number) falls through to
|
||||||
|
// sql_expr unchanged — the typed slot must not steal it.
|
||||||
|
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||||
|
for input in [
|
||||||
|
"insert into Things (k) values (1 + 2)",
|
||||||
|
"insert into Things (k, note) values (5, upper(note))",
|
||||||
|
"insert into Things (k) values (-5)",
|
||||||
|
"insert into Things (k) values ((select 1))",
|
||||||
|
] {
|
||||||
|
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
|
||||||
|
assert!(matches!(state, InputState::Valid), "{input:?} must parse: {state:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user