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
+44 -8
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -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
View File
@@ -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] = &[
+13
View File
@@ -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
View File
@@ -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:?}");
}
}