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
|
||||
`UPDATE`/UPSERT `SET col = <literal>` value positions, via a
|
||||
**boundary-aware lookahead** (not the naive `Choice` this ADR originally
|
||||
sketched in §5 — see **Amendment 1**). Phase 3b (`INSERT … VALUES` typed
|
||||
slots — needs a per-position grammar restructure + multi-row) pending.
|
||||
sketched in §5 — see **Amendment 1**). **Phase 3b implemented 2026-05-27**
|
||||
— 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**
|
||||
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`
|
||||
at an `int` column), matching what simple mode already does — earlier,
|
||||
better feedback than Phase 2's execution-time catch.
|
||||
- **Phase 3b (pending) — `INSERT … VALUES (…)`.** Harder: the values list
|
||||
is `Repeated(VALUE_EXPR)` with **no per-position column identity**, and
|
||||
multi-row `values (..),(..)` must be handled. It needs the DSL-style
|
||||
per-position restructure (a `DynamicSubgrammar` emitting one
|
||||
boundary-aware position per column), tracked as its own step.
|
||||
- **Phase 3b (implemented 2026-05-27) — `INSERT … VALUES (…)`.** Harder,
|
||||
because the values list is positional (no per-position column ident)
|
||||
and multi-row. The resolution, agreed with the user (full-parity
|
||||
option):
|
||||
- **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
|
||||
any quoted string; the format is checked at bind/execution time (Phase 2).
|
||||
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
|
||||
/// 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
@@ -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] = &[
|
||||
|
||||
@@ -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,
|
||||
|
||||
+156
-1
@@ -24,7 +24,9 @@ use rdbms_playground::completion::{SchemaCache, TableColumn};
|
||||
use rdbms_playground::db::{Database, DbError, InsertResult};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||||
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::persistence::Persistence;
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 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