feat: bring simple-mode insert arity diagnostics to parity with advanced

A wrong-count simple-mode insert now shows the friendly per-column arity
message at typing time (instead of a bare "expected `,`/`)`") and is
blocked from dispatch at submit — unifying simple and advanced mode onto
the one ADR-0027 model (structural parse + ERROR diagnostic), where they
had diverged.

Grammar: a simple-mode-only arity gate (dsl_insert_value_list) routes a
wrong-count DSL insert tuple to the type-blind fallback so it matches
structurally and the per-tuple arity diagnostic fires. The gate is gated
to simple mode, so advanced behaviour is unchanged. count_tuple_values
and the target-column selection (insert_target_columns) are now shared
by both grammars.

Diagnostic: dml_insert_arity_diagnostics is mode-aware — advanced Form B
expects all columns; simple Form B/C expects the user-fillable columns
(serial/shortid auto-fill). It counts the DSL Form A role and scans the
keyword-less Form C tuple. New catalog keys name the fillable/auto split
and the all-auto-table case.

Submit: a wrong-count DSL insert now parses Ok + carries the ERROR
diagnostic, so a unified Ok-arm pre-flight (dsl_insert_count_mismatch_notes)
blocks dispatch and teaches; the previous Err-arm note retires.
advanced_alternative_note's gate now reads the validity verdict so it
still fires for the parse-Ok-with-error shape.

Docs: ADR-0036 Amendment 2 (+ README index) and requirements.md H1a.
This commit is contained in:
claude@clouddev1
2026-05-29 20:45:21 +00:00
parent 7cccf4eabb
commit 10e5197c19
16 changed files with 812 additions and 240 deletions
@@ -422,6 +422,67 @@ So the live highlight catches *numeric-shape* mismatches (`int`/`decimal`/
`bool`), not malformed dates. The column-type **hint** still shows for `bool`), not malformed dates. The column-type **hint** still shows for
every type. every type.
## Amendment 2 — Simple-mode arity-diagnostic parity (issue #17, 2026-05-29)
Phase 3b (Amendment 1) introduced, for the **advanced** grammar, an
*arity-gating tuple lookahead* (`tuple_value_list`, `sql_insert.rs`):
a wrong-count `VALUES` tuple is routed to the type-blind fallback so it
**structurally matches** and the §8.1 arity diagnostic
(`dml_insert_arity_diagnostics`) fires its friendly *"N column(s) … M
value(s)"* message instead of the engine's raw error.
The **simple-mode DSL** insert (`data.rs` → `column_value_list`) never
had that gate: its value list is a fixed-length typed `Seq`, so a
wrong-count tuple **Mismatched** (parse error) and the arity diagnostic
never ran. Simple-mode learners — the primary audience — got a bare
`expected `,`/`)`` while advanced mode got the friendly message. Issue
#1 had papered over this with a *submit-time* teaching note bolted onto
the parse-error path; that workaround existed only because the
diagnostic couldn't fire in simple mode.
**This amendment brings the two modes onto one model.**
1. **Grammar (simple-mode only).** A `tuple_value_list`-style gate
(`dsl_insert_value_list`, `data.rs`) routes a wrong-count DSL insert
tuple to the shared type-blind `FALLBACK_VALUE_LIST`, so it matches
and the diagnostic fires. The gate is **gated to `Mode::Simple`**: in
advanced mode the DSL insert node stays strict (a non-SQL shape like
Form C must not spuriously match — advanced inserts are owned by
`sql_insert.rs`), so **advanced behaviour is byte-for-byte
unchanged**. `count_tuple_values` + the target-column selection
(`insert_target_columns`) are now shared by both grammars so the gate
and the typed slots never disagree.
2. **Diagnostic is mode-aware.** `dml_insert_arity_diagnostics` gains a
`mode` parameter. The expected count differs by mode **because the
modes' auto-fill differs** (the very distinction this ADR preserves):
- **advanced Form B** auto-fills nothing → expects **all** columns
(unchanged);
- **simple Form B/C** auto-fills `serial`/`shortid` (ADR-0018 §3) →
expects the **user-fillable** columns.
It also now counts the DSL Form A column role (`insert_first_item`)
alongside the SQL `insert_column`, and scans the simple-mode Form C
tuple (no `values` keyword). New catalog keys
`diagnostic.insert_arity_mismatch_form_b_simple` (names the fillable
*and* the auto-generated columns) and
`diagnostic.insert_arity_mismatch_all_auto` (every column
auto-generated).
3. **Unified submit pre-flight.** A wrong-count DSL insert now parses
`Ok` (so the typing-time diagnostic can fire), so dispatch is gated
by an Ok-arm pre-flight (`dsl_insert_count_mismatch_notes`) that
**blocks `ExecuteDsl`** and shows the teaching note — mirroring the
advanced Ok-arm pre-flight (`form_b_positional_count_mismatch_note`).
The issue #1 Err-arm note retires. The user-facing invalidity is the
ADR-0027 validity verdict (the `[ERR]` indicator), which reads the
diagnostic; the structural `InputState` is now `Valid` (it parses).
**Scope guard.** This is *arity-diagnostic UX parity only*. It does
**not** consolidate value-handling, execution, or auto-fill across modes
— the deliberate mode-distinctness this ADR was narrowed to preserve
stands. The count difference per mode is a *consequence* of that
distinctness, not a violation of it.
## See also ## See also
- ADR-0030 §4 / ADR-0033 §10 — the execute-path this ADR **augments** - ADR-0030 §4 / ADR-0033 §10 — the execute-path this ADR **augments**
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -4
View File
@@ -540,10 +540,15 @@ handoff-14 cleanup; 449 after B2/C2.)
names the schema-correct next token (`,` between values, `)` names the schema-correct next token (`,` between values, `)`
after the last) instead of the type-blind close-paren, and so a after the last) instead of the type-blind close-paren, and so a
wrong-arity closed tuple surfaces the real parse error rather wrong-arity closed tuple surfaces the real parse error rather
than a misleading "submit with Enter"). A systematic pass is than a misleading "submit with Enter"; **issue #17 / ADR-0036
still pending; simple-mode wrong-count tuples still get a generic Amendment 2, 2026-05-29** then brought the §8.1 arity diagnostic
expected-token message rather than the friendly arity diagnostic to **simple mode** at parity with advanced — a wrong-count DSL
advanced mode shows (issue #17). insert (Form A/B/C) now fires the friendly *"N value(s) for
`col`…"* message at typing time, counted against the user-fillable
columns, with `serial`/`shortid` auto-fill named; new keys
`diagnostic.insert_arity_mismatch_form_b_simple` /
`diagnostic.insert_arity_mismatch_all_auto`). A systematic pass is
still pending.
- [ ] **H2** `hint` provides contextual help for the current - [ ] **H2** `hint` provides contextual help for the current
input or the most recent error. input or the most recent error.
- [ ] **H3** `help` provides general reference and per-command - [ ] **H3** `help` provides general reference and per-command
+77 -13
View File
@@ -1361,6 +1361,30 @@ impl App {
source: input.to_string(), source: input.to_string(),
}]; }];
} }
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
// DSL insert now parses `Ok` (so the typing-time arity
// diagnostic can fire), so dispatch is gated here — the
// same teaching the old parse-error path showed, now with
// the insert reliably blocked from reaching the worker.
if let Some(notes) = crate::input_render::dsl_insert_count_mismatch_notes(
input,
&cmd,
&self.schema_cache,
) {
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: mode,
styled_runs: None,
});
for note in notes {
self.note_error(note);
}
self.note_error(render_usage_block(input));
return vec![Action::JournalFailure {
source: input.to_string(),
}];
}
self.push_output(OutputLine { self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
@@ -1426,19 +1450,13 @@ impl App {
{ {
self.note_error(note); self.note_error(note);
} }
// Issue #1 sub-task 2: append a teaching note when the // Issue #1 sub-task 2's Form B teaching note used to be
// Form B `insert into <T> values (…)` line failed // appended here, because a wrong-count Form B insert
// because the user supplied more values than the // failed to parse and landed in this Err arm. As of issue
// non-auto-generated columns expect. The parse error // #17 such tuples parse `Ok` (so the typing-time arity
// shows the literal "expected `)`"; the note explains // diagnostic fires) and the teaching + dispatch block now
// *why* fewer values are expected and shows the // live in the Ok arm's `dsl_insert_count_mismatch_notes`
// column-list override path. // pre-flight — a single model shared with advanced mode.
if mode == Mode::Simple
&& let Some(note) =
crate::input_render::form_b_extra_values_note(input, &self.schema_cache)
{
self.note_error(note);
}
// ADR-0021 §2: append the usage block (if a // ADR-0021 §2: append the usage block (if a
// known command-entry keyword was consumed) or // known command-entry keyword was consumed) or
// the available-commands fallback (§5). // the available-commands fallback (§5).
@@ -3129,6 +3147,52 @@ mod tests {
); );
} }
#[test]
fn simple_mode_submit_of_form_b_count_mismatch_does_not_dispatch() {
// Issue #17 EXECUTION SAFETY. Once simple-mode wrong-count Form B
// tuples parse `Ok` (so the typing-time arity diagnostic can
// fire), the submit path must still NOT dispatch the insert — a
// wrong-count insert would otherwise reach the worker and fail
// (or, worse, write the wrong row). The unified Ok-arm pre-flight
// must block dispatch exactly as the old parse-error path did.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
);
}
#[test]
fn simple_mode_submit_of_form_b_undersupply_does_not_dispatch() {
// Companion to the above for under-supply.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
);
}
#[test]
fn simple_mode_submit_of_form_a_count_mismatch_does_not_dispatch() {
// Form A (explicit column list) wrong count must also not
// dispatch — it previously parse-errored; the unified pre-flight
// must keep it blocked.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
);
}
#[test] #[test]
fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() { fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
// ADR-0033 Amendment 3 (+ Amendment 5): submitting a line in // ADR-0033 Amendment 3 (+ Amendment 5): submitting a line in
+55 -12
View File
@@ -27,7 +27,10 @@
use crate::dsl::command::{Command, Expr, RowFilter}; use crate::dsl::command::{Command, Expr, RowFilter};
use crate::dsl::grammar::{ use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr, CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{column_value_list, current_column_value}, shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
current_column_value, insert_target_columns,
},
sql_delete, sql_insert, sql_select, sql_update, sql_delete, sql_insert, sql_select, sql_update,
}; };
use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::context::WalkContext;
@@ -141,12 +144,13 @@ static INSERT_COMMA: Node = Node::Punct(',');
/// First-paren resolver (ADR-0024 §Phase D Form-C type-awareness). /// First-paren resolver (ADR-0024 §Phase D Form-C type-awareness).
/// Peeks the first token after `(` to route to Form A's /// Peeks the first token after `(` to route to Form A's
/// column-name list or Form C's typed value list. /// column-name list or Form C's typed value list.
fn insert_first_paren(_ctx: &WalkContext, source: &str, pos: usize) -> Node { fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
if first_paren_item_is_value_literal(source, pos) { if first_paren_item_is_value_literal(source, pos) {
// Form C — bare value list. `column_value_list` with no // Form C — bare value list. Arity-gated exactly like Form B's
// user-listed columns dispatches per non-auto-generated // `values (…)`: a correct-count tuple gets the typed per-column
// column, exactly as Form B does. // slots; a wrong-count tuple routes to the type-blind fallback
Node::DynamicSubgrammar(column_value_list) // so it still matches and the arity diagnostic fires (issue #17).
dsl_insert_value_list(ctx, source, pos)
} else { } else {
// Form A (or Form A in progress / empty paren). // Form A (or Form A in progress / empty paren).
Node::Repeated { Node::Repeated {
@@ -189,12 +193,51 @@ fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
const INSERT_PAREN_LIST: Node = Node::Lookahead(insert_first_paren); const INSERT_PAREN_LIST: Node = Node::Lookahead(insert_first_paren);
/// Schema-aware value list: when the walker has a populated /// Insert value-list arity gate (issue #17) — the simple-mode DSL
/// `current_table_columns`, unfolds to a `Seq` of typed slots /// counterpart of the advanced grammar's `tuple_value_list`
/// per column (`int_slot`, `text_slot`, …). When schemaless, /// (`sql_insert.rs`). Routes a correct-arity tuple to the typed
/// falls back to the pre-Phase-D `Repeated(VALUE_LITERAL, ',', 1)` /// per-column slots ([`column_value_list`]) and a wrong-arity tuple to
/// shape (ADR-0024 §Phase D §column_value_list). /// the type-blind [`FALLBACK_VALUE_LIST`], so the wrong-count tuple
const INSERT_VALUES_LIST: Node = Node::DynamicSubgrammar(column_value_list); /// still structurally matches and the per-tuple arity diagnostic
/// (ADR-0033 §8.1, made mode-aware for issue #17) fires its friendly
/// message instead of a bare "expected `,`/`)`".
///
/// Target arity comes from [`insert_target_columns`] — the same source
/// `column_value_list` uses, so gate and slots never disagree. `None`
/// (schemaless / unknown table / all-auto-generated) → fallback: either
/// we can't gate (schemaless) or the all-auto case wants the tuple to
/// match so the diagnostic can explain it.
///
/// **Simple-mode only.** The fallback routing is what lets a wrong-count
/// tuple structurally match (so the diagnostic fires); that is a
/// simple-mode behaviour. In advanced mode the DSL insert node must stay
/// strict — otherwise a non-SQL shape like Form C (`insert into T
/// (1, 2)`, no `values`) would spuriously match here and be accepted in
/// advanced mode, where SQL requires `values` and the dedicated SQL
/// grammar (`sql_insert.rs`) owns inserts. Keeping advanced strict
/// preserves the pre-#17 advanced behaviour exactly (issue #17).
fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
if ctx.mode != crate::mode::Mode::Simple {
return Node::DynamicSubgrammar(column_value_list);
}
let Some(cols) = insert_target_columns(ctx) else {
return FALLBACK_VALUE_LIST;
};
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
if arity_ok {
Node::DynamicSubgrammar(column_value_list)
} else {
FALLBACK_VALUE_LIST
}
}
/// Schema-aware value list, arity-gated (issue #17): a correct-count
/// tuple unfolds to a `Seq` of typed slots per column (`int_slot`,
/// `text_slot`, …); a wrong-count tuple or a schemaless walk falls back
/// to the type-blind `Repeated(VALUE_LITERAL, ',', 1)` shape (ADR-0024
/// §Phase D §column_value_list).
const INSERT_VALUES_LIST: Node = Node::Lookahead(dsl_insert_value_list);
const INSERT_OPTIONAL_VALUES_NODES: &[Node] = &[ const INSERT_OPTIONAL_VALUES_NODES: &[Node] = &[
Node::Word(Word::keyword("values")), Node::Word(Word::keyword("values")),
+109 -43
View File
@@ -371,12 +371,116 @@ pub(crate) const FALLBACK_VALUE_LITERAL: Node = Node::Hinted {
inner: &FALLBACK_VALUE_LITERAL_INNER, inner: &FALLBACK_VALUE_LITERAL_INNER,
}; };
const FALLBACK_VALUE_LIST: Node = Node::Repeated { /// The type-blind value list. `pub(crate)` so the insert value-list
/// arity gate (`data.rs`, issue #17) can route a wrong-count tuple here
/// — exactly as the advanced grammar's `tuple_value_list` does — so the
/// tuple still structurally matches and the per-tuple arity diagnostic
/// (ADR-0033 §8.1) fires instead of a bare "expected `,`/`)`".
pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
inner: &FALLBACK_VALUE_LITERAL, inner: &FALLBACK_VALUE_LITERAL,
separator: Some(&Node::Punct(',')), separator: Some(&Node::Punct(',')),
min: 1, min: 1,
}; };
/// The columns an insert value tuple maps onto (ADR-0024 §Phase D).
///
/// Mirrors `db::do_insert`'s `user_cols` logic. `None` when the walker
/// is schemaless, the table is unknown, or — for Form B/C — every column
/// is auto-generated (callers fall back to the type-blind value list).
///
/// - **Form A** (`user_listed_columns` set): the listed columns, in the
/// user's order; names the schema doesn't know are dropped.
/// - **Form B/C** (no column list): the table's non-auto-generated
/// columns, in declaration order. `serial` / `shortid` are skipped
/// because the simple-mode dispatch auto-fills them (ADR-0018 §3).
///
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
}
let cols: Vec<&TableColumn> = ctx.user_listed_columns.as_ref().map_or_else(
|| {
table_cols
.iter()
.filter(|c| !matches!(c.user_type, Type::Serial | Type::ShortId))
.collect()
},
|user_listed| {
user_listed
.iter()
.filter_map(|name| {
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
})
.collect()
},
);
if cols.is_empty() { None } else { Some(cols) }
}
/// Count the value positions in a `VALUES`/insert 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 `()`.
///
/// Shared by the advanced grammar's `tuple_value_list` (`sql_insert.rs`)
/// and the simple-mode DSL insert arity gate (`data.rs`) so both modes
/// count tuple values identically (issue #17).
pub(crate) 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)
}
/// Value slot keyed on `WalkContext::current_column`. /// Value slot keyed on `WalkContext::current_column`.
/// ///
/// Picks the typed slot for the column whose name was most /// Picks the typed slot for the column whose name was most
@@ -399,50 +503,12 @@ pub fn current_column_value(ctx: &WalkContext) -> Node {
/// `Repeated(VALUE_LITERAL, ',', 1)` shape so existing /// `Repeated(VALUE_LITERAL, ',', 1)` shape so existing
/// callers/tests continue to work. /// callers/tests continue to work.
pub fn column_value_list(ctx: &WalkContext) -> Node { pub fn column_value_list(ctx: &WalkContext) -> Node {
let Some(table_cols) = ctx.current_table_columns.as_ref() else { // Target columns per the shared insert mapping (Form A = listed,
// Form B/C = non-auto-generated). `None` → schemaless / unknown
// table / all-auto-generated → the type-blind fallback list.
let Some(target_cols) = insert_target_columns(ctx) else {
return FALLBACK_VALUE_LIST; return FALLBACK_VALUE_LIST;
}; };
if table_cols.is_empty() {
return FALLBACK_VALUE_LIST;
}
// Three dispatch shapes (ADR-0024 §Phase D §column_value_list,
// matching `db::do_insert`'s user_cols logic):
//
// 1. Form A — user listed explicit columns
// (`insert into T (col1, col2, …) values (…)`): one slot
// per listed column, in the user's order, types resolved
// from the schema.
// 2. Form B — bare values keyword
// (`insert into T values (…)`): one slot per non-auto-
// generated column of T, in declaration order. Serial /
// shortid columns are skipped because the dispatch path
// auto-fills them (ADR-0018 §3).
// 3. Schemaless / fallback: the generic value-literal list.
let target_cols: Vec<&TableColumn> = ctx.user_listed_columns.as_ref().map_or_else(
|| {
// Form B — exclude auto-generated columns.
table_cols
.iter()
.filter(|c| !matches!(c.user_type, Type::Serial | Type::ShortId))
.collect()
},
|user_listed| {
// Form A — resolve each listed name from the schema.
// Names the schema doesn't know about silently drop;
// the bind-time path catches unknown columns.
user_listed
.iter()
.filter_map(|name| {
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
})
.collect()
},
);
if target_cols.is_empty() {
return FALLBACK_VALUE_LIST;
}
// Build a Seq of typed slots interleaved with commas. Each // Build a Seq of typed slots interleaved with commas. Each
// slot embeds its column name so the hint resolver can // slot embeds its column name so the hint resolver can
// mention the column by name ("for `Email`: Type a quoted // mention the column by name ("for `Email`: Type a quoted
+4 -52
View File
@@ -14,7 +14,7 @@
//! sub-phases. //! sub-phases.
use crate::completion::TableColumn; use crate::completion::TableColumn;
use crate::dsl::grammar::shared::SET_VALUE; use crate::dsl::grammar::shared::{SET_VALUE, count_tuple_values};
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,
@@ -127,57 +127,9 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
) )
} }
/// Count the value positions in the `VALUES` tuple whose contents begin // `count_tuple_values` moved to `grammar::shared` (issue #17) so the
/// at `pos` (just past the opening `(`), and whether the tuple is // simple-mode DSL insert arity gate can share it; the advanced grammar
/// *closed* (a depth-0 `)` was reached) vs still being typed (scan hit // imports it above.
/// 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 /// 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 /// per-column path on arity so the typed `Seq` is used only where it
+317 -43
View File
@@ -1465,10 +1465,16 @@ fn dml_auto_column_diagnostics(
fn dml_insert_arity_diagnostics( fn dml_insert_arity_diagnostics(
path: &MatchedPath, path: &MatchedPath,
schema: Option<&crate::completion::SchemaCache>, schema: Option<&crate::completion::SchemaCache>,
mode: crate::mode::Mode,
) -> Vec<outcome::Diagnostic> { ) -> Vec<outcome::Diagnostic> {
use crate::dsl::grammar::IdentSource; use crate::dsl::grammar::IdentSource;
use crate::dsl::types::Type;
use outcome::{Diagnostic, MatchedKind, Severity}; use outcome::{Diagnostic, MatchedKind, Severity};
// Form A column count: the explicit `(col, …)` list. The SQL
// (advanced) grammar tags these `insert_column`; the DSL (simple)
// grammar tags them `insert_first_item` (issue #17) — count both so
// a DSL Form A insert isn't mis-classified as Form B.
let col_arity = path let col_arity = path
.items .items
.iter() .iter()
@@ -1477,31 +1483,70 @@ fn dml_insert_arity_diagnostics(
it.kind, it.kind,
MatchedKind::Ident { MatchedKind::Ident {
source: IdentSource::Columns, source: IdentSource::Columns,
role: "insert_column", role: "insert_column" | "insert_first_item",
} }
) )
}) })
.count(); .count();
// Resolve the expected arity + which message template to use. // Is this an INSERT? `into` is insert-exclusive in both grammars
// Form A: explicit column list → its own length. // (update uses `set`, delete `from`, show `data`), so it tells the
// Form B: no list → the target table's column count, *if* we know // DSL insert apart from other commands that also tag a `table_name`.
// it. Without a schema or a recognised target the pass goes let is_insert = path
// silent (the unknown-table case is flagged by the schema- .items
// existence pass instead). .iter()
let (expected, message_key): (usize, &'static str) = if col_arity > 0 { .any(|it| matches!(it.kind, MatchedKind::Word("into")));
(col_arity, "diagnostic.insert_arity_mismatch") // Insert target table. The SQL grammar tags it `insert_target_table`
} else { // (insert-specific); the DSL grammar reuses the generic `table_name`
let Some(schema) = schema else { // role, so only trust that when `into` confirmed an insert (issue
return Vec::new(); // #17).
}; let target_table: Option<&str> = path
let Some(target) = path.items.iter().find_map(|it| match it.kind { .items
.iter()
.find_map(|it| match it.kind {
MatchedKind::Ident { MatchedKind::Ident {
source: IdentSource::Tables, source: IdentSource::Tables,
role: "insert_target_table", role: "insert_target_table",
} => Some(it.text.as_str()), } => Some(it.text.as_str()),
MatchedKind::Ident {
source: IdentSource::Tables,
role: "table_name",
} if is_insert => Some(it.text.as_str()),
_ => None, _ => None,
}) else { });
// Resolve the expected arity + a message builder. The builder
// captures the per-case args because the message key (and its
// placeholders) differ by form and mode.
//
// - **Form A** (both modes): the listed-column count, mode-neutral.
// - **Advanced Form B**: every column needs a value (auto-fills
// nothing, ADR-0036) → the full table count.
// - **Simple Form B** (issue #17): the dispatch auto-fills
// serial/shortid (ADR-0018 §3), so only the user-fillable columns
// take values. When some columns are skipped the message names
// both sets; when none are skipped the count equals the table's
// and the plain Form B wording is accurate; when *every* column is
// auto-generated no value belongs at all.
type MsgFn<'a> = Box<dyn Fn(usize) -> String + 'a>;
let (expected, make_message): (usize, MsgFn) = if col_arity > 0 {
(
col_arity,
Box::new(move |actual| {
crate::friendly::translate(
"diagnostic.insert_arity_mismatch",
&[
("expected", &col_arity as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
)
}),
)
} else {
let Some(schema) = schema else {
return Vec::new();
};
let Some(target) = target_table else {
return Vec::new(); return Vec::new();
}; };
let Some(cols) = schema.table_columns.get(target) else { let Some(cols) = schema.table_columns.get(target) else {
@@ -1510,35 +1555,123 @@ fn dml_insert_arity_diagnostics(
if cols.is_empty() { if cols.is_empty() {
return Vec::new(); return Vec::new();
} }
(cols.len(), "diagnostic.insert_arity_mismatch_form_b") let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
let fillable: Vec<&str> = cols
.iter()
.filter(|c| !is_auto(c.user_type))
.map(|c| c.name.as_str())
.collect();
if mode == crate::mode::Mode::Advanced || fillable.len() == cols.len() {
// Advanced Form B (all columns), or simple Form B over a
// table with no auto-generated columns — "all N" is accurate.
let expected = if mode == crate::mode::Mode::Advanced {
cols.len()
} else {
fillable.len()
};
(
expected,
Box::new(move |actual| {
crate::friendly::translate(
"diagnostic.insert_arity_mismatch_form_b",
&[
("expected", &expected as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
)
}),
)
} else if fillable.is_empty() {
// Simple Form B, every column auto-generated → no value
// belongs at all.
let table = target.to_string();
(
0,
Box::new(move |actual| {
crate::friendly::translate(
"diagnostic.insert_arity_mismatch_all_auto",
&[
("table", &table as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
)
}),
)
} else {
// Simple Form B with a mix — name the user-fillable and the
// auto-generated columns so the learner understands the skip.
let expected = fillable.len();
let columns = fillable
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ");
let skipped = cols
.iter()
.filter(|c| is_auto(c.user_type))
.map(|c| format!("`{}`", c.name))
.collect::<Vec<_>>()
.join(", ");
(
expected,
Box::new(move |actual| {
crate::friendly::translate(
"diagnostic.insert_arity_mismatch_form_b_simple",
&[
("expected", &expected as &dyn std::fmt::Display),
("columns", &columns as &dyn std::fmt::Display),
("skipped", &skipped as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
)
}),
)
}
}; };
// Index of the row-source keyword (first VALUES / SELECT / WITH). // Locate the row source to scan. Form A/B and INSERT…SELECT carry a
let Some(kw_idx) = path // `values`/`select`/`with` keyword. The DSL Form C (`insert into T
// (1, 2)` — bare value list, no `values`) has none: its single value
// tuple follows the target table directly, with no column-name list
// (col_arity == 0 here), so scan from just after the table and treat
// it with the VALUES tuple logic (issue #17).
let (kw, tail): (&str, &[outcome::MatchedItem]) = if let Some(kw_idx) = path
.items .items
.iter() .iter()
.position(|it| matches!(it.kind, MatchedKind::Word("values" | "select" | "with"))) .position(|it| matches!(it.kind, MatchedKind::Word("values" | "select" | "with")))
else { {
return Vec::new(); let MatchedKind::Word(kw) = path.items[kw_idx].kind else {
}; return Vec::new();
let MatchedKind::Word(kw) = path.items[kw_idx].kind else { };
(kw, &path.items[kw_idx + 1..])
} else if is_insert && mode == crate::mode::Mode::Simple {
// DSL Form C (`insert into T (1, 2)` — bare value list, no
// `values` keyword) is simple-mode only; advanced SQL always
// carries a `values`/`select`/`with` keyword, so this branch
// stays out of the advanced path (its arity is handled above).
let Some(tbl_idx) = path.items.iter().position(|it| {
matches!(
it.kind,
MatchedKind::Ident {
source: IdentSource::Tables,
role: "insert_target_table" | "table_name",
}
)
}) else {
return Vec::new();
};
("values", &path.items[tbl_idx + 1..])
} else {
return Vec::new(); return Vec::new();
}; };
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
let tail = &path.items[kw_idx + 1..];
let emit = |span: (usize, usize), actual: usize, diagnostics: &mut Vec<Diagnostic>| { let emit = |span: (usize, usize), actual: usize, diagnostics: &mut Vec<Diagnostic>| {
diagnostics.push(Diagnostic { diagnostics.push(Diagnostic {
severity: Severity::Error, severity: Severity::Error,
span, span,
message: crate::friendly::translate( message: make_message(actual),
message_key,
&[
("expected", &expected as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
),
}); });
}; };
@@ -1600,7 +1733,8 @@ fn dml_insert_arity_diagnostics(
} }
} }
if proj_arity != expected { if proj_arity != expected {
emit(anchor.unwrap_or(path.items[kw_idx].span), proj_arity, &mut diagnostics); let fallback = tail.first().map_or((0, 0), |it| it.span);
emit(anchor.unwrap_or(fallback), proj_arity, &mut diagnostics);
} }
} }
diagnostics diagnostics
@@ -2872,7 +3006,7 @@ fn walk_one_command<'a>(
// INSERT…SELECT projection. Form A uses the column list's // INSERT…SELECT projection. Form A uses the column list's
// length; Form B uses the schema's column count for the // length; Form B uses the schema's column count for the
// target table. // target table.
d.extend(dml_insert_arity_diagnostics(&path, ctx.schema)); d.extend(dml_insert_arity_diagnostics(&path, ctx.schema, ctx.mode));
// ADR-0033 §8.3 — WARNING when an INSERT's column list omits // ADR-0033 §8.3 — WARNING when an INSERT's column list omits
// a NOT-NULL-no-default (non-auto-gen) column. // a NOT-NULL-no-default (non-auto-gen) column.
d.extend(dml_not_null_missing_diagnostics(&path, ctx.schema)); d.extend(dml_not_null_missing_diagnostics(&path, ctx.schema));
@@ -4252,23 +4386,30 @@ mod tests {
#[test] #[test]
fn phase_d_insert_form_b_skips_serial_column() { fn phase_d_insert_form_b_skips_serial_column() {
// Form B: `insert into <T> values (…)` excludes // Form B: `insert into <T> values (…)` excludes auto-generated
// auto-generated columns from the value list. Supplying // columns from the value list. Supplying a value for the serial
// a value for the serial column is a count mismatch. // column is a count mismatch. As of issue #17 such a wrong-count
// tuple parses **structurally** (routed to the type-blind
// fallback) so the friendly arity diagnostic can fire — the
// mismatch is now reported as an ERROR diagnostic rather than a
// bare parse error, matching advanced mode. Dispatch is gated by
// the submit pre-flight, not by a parse failure.
let schema = schema_with( let schema = schema_with(
"Customers", "Customers",
&[("id", Type::Serial), ("Name", Type::Text)], &[("id", Type::Serial), ("Name", Type::Text)],
); );
// Two values where Form B expects one (Name only): // Two values where Form B expects one (Name only): structurally
let err = parse_command_with_schema( // parses, but the simple-mode arity diagnostic flags it (Form B
// expects 1 value for `Name`; `id` is auto-generated).
let diags = diag_keys_simple(
"insert into Customers values (1, 'Alice')", "insert into Customers values (1, 'Alice')",
&schema, &schema,
) );
.expect_err("Form B should reject user-supplied serial"); assert!(
match err { diags.iter().any(|d| d.contains("1 value(s)") && d.contains("2 given")),
crate::dsl::ParseError::Invalid { .. } => {} "Form B serial-skip count mismatch must fire the arity \
other => panic!("expected Invalid, got {other:?}"), diagnostic (expected 1, got 2); got {diags:?}",
} );
} }
#[test] #[test]
@@ -4703,6 +4844,139 @@ mod tests {
.collect() .collect()
} }
/// Simple-mode counterpart of [`diag_keys`] — the DSL surface
/// (ADR-0003). Issue #17: the arity diagnostic must fire in simple
/// mode too, with the user-fillable (serial-skipped) Form B count.
fn diag_keys_simple(source: &str, schema: &SchemaCache) -> Vec<String> {
let mut ctx = super::context::WalkContext::with_schema(schema);
ctx.mode = crate::mode::Mode::Simple;
let (result, _cmd) =
super::walk(source, super::outcome::WalkBound::EndOfInput, &mut ctx);
result.map_or_else(Vec::new, |r| {
r.diagnostics.into_iter().map(|d| d.message).collect()
})
}
#[test]
fn insert_arity_mismatch_simple_form_b_uses_user_fillable_count() {
// Issue #17: simple-mode Form B skips serial/shortid (auto-
// filled), so `Customers(id serial, Name, Age, SerNo)` expects
// 2 values (Name, Age). Three values is a mismatch — and simple
// mode must surface the friendly arity diagnostic (as advanced
// mode already does), counted against the user-fillable columns,
// not the full table.
let schema = schema_with(
"Customers",
&[
("id", Type::Serial),
("Name", Type::Text),
("Age", Type::Int),
("SerNo", Type::Serial),
],
);
let diags = diag_keys_simple(
"insert into Customers values ('Oli', 52, 3)",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("2 value(s)") && d.contains("3 given")),
"simple Form B must fire arity diagnostic with user-fillable \
count (2) and actual (3); got {diags:?}",
);
// Pedagogical: names the user-fillable and the auto-generated
// columns so the learner understands the skip.
assert!(
diags.iter().any(|d| d.contains("Name")
&& d.contains("Age")
&& d.contains("id")
&& d.contains("SerNo")),
"message should name fillable (Name, Age) and auto-gen (id, \
SerNo) columns; got {diags:?}",
);
}
#[test]
fn insert_arity_mismatch_simple_form_a_uses_listed_count() {
// Simple Form A: the explicit column list sets the expected
// count (mode-neutral). `(Name, Age)` lists 2 columns; one value
// is a mismatch. The DSL Form A column role is `insert_first_item`
// (issue #17 — the diagnostic counts it as well as the SQL
// `insert_column`).
let schema = schema_with(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Age", Type::Int)],
);
let diags = diag_keys_simple(
"insert into Customers (Name, Age) values ('Oli')",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("names 2 column(s)") && d.contains("1 value(s)")),
"simple Form A must fire the column-list arity diagnostic; got {diags:?}",
);
}
#[test]
fn insert_arity_mismatch_simple_form_c_uses_user_fillable_count() {
// Simple Form C (`insert into T (vals)`, no `values` keyword)
// shares Form B's count semantics — the diagnostic must fire even
// though there is no `values` keyword to anchor on (issue #17).
let schema = schema_with(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Age", Type::Int)],
);
let diags = diag_keys_simple(
"insert into Customers ('Oli', 52, 3)",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("2 value(s)") && d.contains("3 given")),
"simple Form C must fire the arity diagnostic (expected 2, got 3); got {diags:?}",
);
}
#[test]
fn insert_arity_mismatch_simple_all_auto_table() {
// Edge: every column auto-generated (all serial/shortid). The
// user-fillable count is 0, so any supplied value is a mismatch —
// the tailored "all auto-generated" message fires (issue #17).
let schema = schema_with(
"Counters",
&[("id", Type::Serial), ("seq", Type::Serial)],
);
let diags = diag_keys_simple(
"insert into Counters values (1)",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("all auto-generated")
|| d.contains("auto-generated, so no values")),
"all-auto table must fire the tailored message; got {diags:?}",
);
}
#[test]
fn insert_arity_advanced_form_b_still_uses_full_column_count() {
// Guard: issue #17 made the diagnostic mode-aware, but advanced
// Form B must keep the full-column-count semantics (auto-fills
// nothing, ADR-0036) — `('Oli', 52, 3)` for a 4-column table
// reports "all 4 column(s)", not the simple-mode user-fillable 2.
let schema = schema_with(
"Customers",
&[
("id", Type::Serial),
("Name", Type::Text),
("Age", Type::Int),
("SerNo", Type::Serial),
],
);
let diags = diag_keys("insert into Customers values ('Oli', 52, 3)", &schema);
assert!(
diags.iter().any(|d| d.contains("all 4 column(s)") && d.contains("3 value(s)")),
"advanced Form B must keep the full-column count (4); got {diags:?}",
);
}
#[test] #[test]
fn unknown_qualifier_in_qualified_ref_is_error() { fn unknown_qualifier_in_qualified_ref_is_error() {
let schema = two_table_schema(); let schema = two_table_schema();
+9
View File
@@ -51,6 +51,15 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"diagnostic.insert_arity_mismatch_form_b", "diagnostic.insert_arity_mismatch_form_b",
&["expected", "actual"], &["expected", "actual"],
), ),
// ADR-0036 Amendment 1 / issue #17: simple-mode Form B variants.
(
"diagnostic.insert_arity_mismatch_form_b_simple",
&["expected", "columns", "skipped", "actual"],
),
(
"diagnostic.insert_arity_mismatch_all_auto",
&["table", "actual"],
),
("diagnostic.not_null_missing", &["column"]), ("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]), ("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]), ("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
+9 -1
View File
@@ -577,8 +577,16 @@ diagnostic:
# ADR-0033 §8 — Phase-3 DML diagnostic keys. # ADR-0033 §8 — Phase-3 DML diagnostic keys.
auto_column_overridden: "column `{column}` is auto-generated (`{type}`); providing an explicit value bypasses the auto-counter and may collide with later auto-generated values" auto_column_overridden: "column `{column}` is auto-generated (`{type}`); providing an explicit value bypasses the auto-counter and may collide with later auto-generated values"
insert_arity_mismatch: "the column list names {expected} column(s) but {actual} value(s) are given" insert_arity_mismatch: "the column list names {expected} column(s) but {actual} value(s) are given"
# ADR-0033 §8.1 / Amendment 5: Form B (no column list) variant. # ADR-0033 §8.1 / Amendment 5: Form B (no column list) variant
# (advanced mode — auto-fills nothing, so every column needs a value).
insert_arity_mismatch_form_b: "with no column list, all {expected} column(s) need a value but {actual} value(s) are given" insert_arity_mismatch_form_b: "with no column list, all {expected} column(s) need a value but {actual} value(s) are given"
# ADR-0036 Amendment 1 / issue #17: simple-mode Form B. The DSL
# auto-fills serial/shortid columns, so only the user-fillable columns
# take values — name both sets so the learner understands the skip.
insert_arity_mismatch_form_b_simple: "without a column list, supply {expected} value(s) for {columns} — {skipped} auto-generated; {actual} given"
# ADR-0036 Amendment 1 / issue #17: simple-mode Form B where every
# column is auto-generated, so the values list takes nothing.
insert_arity_mismatch_all_auto: "every column of `{table}` is auto-generated, so no values are needed, but {actual} value(s) are given"
not_null_missing: "column `{column}` is required (`NOT NULL`, no default); the statement will fail when run" not_null_missing: "column `{column}` is required (`NOT NULL`, no default); the statement will fail when run"
# Engine-error translations: an engine-rejected SQL statement # Engine-error translations: an engine-rejected SQL statement
+90 -4
View File
@@ -323,10 +323,20 @@ pub fn advanced_alternative_note(
input: &str, input: &str,
cache: &crate::completion::SchemaCache, cache: &crate::completion::SchemaCache,
) -> Option<String> { ) -> Option<String> {
let definite_dsl_error = matches!( // The line must be *definitely* invalid in simple mode — a definite
classify_input_with_schema_in_mode(input, cache, Mode::Simple), // parse error, or (issue #17) a parse that succeeds structurally but
InputState::DefiniteErrorAt(_) // carries a blocking ERROR diagnostic such as a value-count
); // mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke.
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
{
InputState::DefiniteErrorAt(_) => true,
InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
== Some(crate::dsl::walker::outcome::Severity::Error)
}
InputState::Empty | InputState::IncompleteAtEof => false,
};
if !definite_dsl_error { if !definite_dsl_error {
return None; return None;
} }
@@ -363,6 +373,82 @@ pub fn advanced_alternative_note(
/// without adding pedagogy. The cross-mode pointer wins because it /// without adding pedagogy. The cross-mode pointer wins because it
/// only fires when switching modes actually works (issue #1 sub-task /// only fires when switching modes actually works (issue #1 sub-task
/// 1's gate); when it doesn't fire, this note steps in. /// 1's gate); when it doesn't fire, this note steps in.
/// Submit-time pre-flight for a simple-mode (DSL) `Command::Insert`
/// whose positional value count doesn't match the expected count
/// (issue #17). Returns the advice line(s) to display when there is a
/// mismatch — the caller (`dispatch_dsl`) blocks dispatch whenever this
/// is `Some`, so a wrong-count insert never reaches the worker. `None`
/// when the command isn't an insert, the table is unknown, or the count
/// already matches.
///
/// This is the simple-mode counterpart of the advanced Ok-arm pre-flight
/// (`form_b_positional_count_mismatch_note`). Both modes now parse a
/// wrong-count insert as `Ok` (so the typing-time arity diagnostic can
/// fire — issue #17), so dispatch is gated here, uniformly, rather than
/// by a parse error.
///
/// Expected count: Form A (explicit `(col, …)`) → the listed count;
/// Form B/C (no list) → the user-fillable (non-auto-generated) count,
/// since the dispatch auto-fills serial/shortid (ADR-0018 §3).
///
/// Advice selection mirrors the previous Err-arm logic: the cross-mode
/// pointer wins when the same text is valid in advanced mode; otherwise
/// Form B/C shows the rich teaching note (names the fillable + auto
/// columns and the Form-A override) and Form A shows the column-list
/// arity message.
#[must_use]
pub fn dsl_insert_count_mismatch_notes(
input: &str,
cmd: &crate::dsl::command::Command,
cache: &crate::completion::SchemaCache,
) -> Option<Vec<String>> {
use crate::dsl::command::Command;
use crate::dsl::types::Type;
let Command::Insert {
table,
columns,
values,
} = cmd
else {
return None;
};
let table_cols = cache.table_columns.get(table)?;
let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
let expected = columns.as_ref().map_or_else(
|| table_cols.iter().filter(|c| !is_auto(c.user_type)).count(),
Vec::len,
);
if values.len() == expected {
return None; // counts match — nothing to flag, dispatch proceeds
}
// Count mismatch → the caller blocks dispatch. Build the advice.
// The cross-mode pointer is the single most useful line when the
// same text is valid in advanced mode, so it suppresses the rest.
if let Some(pointer) = advanced_alternative_note(input, cache) {
return Some(vec![pointer]);
}
let note = if columns.is_some() {
// Form A: the column-list arity message.
crate::t!(
"diagnostic.insert_arity_mismatch",
expected = expected,
actual = values.len()
)
} else {
// Form B/C: the rich teaching note. Falls back to the all-auto
// explanation for a table whose columns are all auto-generated
// (the override note doesn't apply there).
form_b_extra_values_note(input, cache).unwrap_or_else(|| {
crate::t!(
"diagnostic.insert_arity_mismatch_all_auto",
table = table,
actual = values.len()
)
})
};
Some(vec![note])
}
#[must_use] #[must_use]
pub fn form_b_extra_values_note( pub fn form_b_extra_values_note(
input: &str, input: &str,
+25 -18
View File
@@ -154,16 +154,21 @@ fn form_b_in_progress_without_closing_paren_is_incomplete() {
#[test] #[test]
fn form_b_with_too_few_values_is_invalid_at_close_paren() { fn form_b_with_too_few_values_is_invalid_at_close_paren() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end("insert into Customers values ('Alice')", &schema); let input = "insert into Customers values ('Alice')";
// Only one value supplied; Form B for Customers needs two. let a = assess_at_end(input, &schema);
// The grammar's typed slot list expects another `,<value>` // Only one value supplied; Form B for Customers needs two. As of
// before the `)`. Classify as DefiniteError or Incomplete // issue #17 a wrong-count tuple parses *structurally* (so the
// (which one depends on whether the closing `)` is past // friendly arity diagnostic can fire) — the user-facing invalidity
// the missing slot). // is the validity verdict (the `[ERR]` indicator), not the
assert!( // structural `state`.
!matches!(a.state, InputState::Valid), assert_eq!(
"input with too-few values must NOT be Valid, got {:?}", rdbms_playground::dsl::walker::input_verdict_in_mode(
a.state, input,
Some(&schema),
rdbms_playground::mode::Mode::Simple,
),
Some(rdbms_playground::dsl::walker::Severity::Error),
"too-few values must light the [ERR] verdict",
); );
crate::snap!("form_b_too_few_values", a); crate::snap!("form_b_too_few_values", a);
} }
@@ -174,14 +179,16 @@ fn form_b_with_extra_value_for_serial_column_is_invalid() {
// (treating it as the first slot) means an extra value // (treating it as the first slot) means an extra value
// overall — Customers has 3 columns but Form B accepts 2. // overall — Customers has 3 columns but Form B accepts 2.
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
"insert into Customers values (1, 'Alice', 'a@b.c')", let a = assess_at_end(input, &schema);
&schema, assert_eq!(
); rdbms_playground::dsl::walker::input_verdict_in_mode(
assert!( input,
!matches!(a.state, InputState::Valid), Some(&schema),
"Form B with a value-for-serial must be invalid, got {:?}", rdbms_playground::mode::Mode::Simple,
a.state, ),
Some(rdbms_playground::dsl::walker::Severity::Error),
"Form B with a value-for-serial must light the [ERR] verdict",
); );
crate::snap!("form_b_extra_serial_value", a); crate::snap!("form_b_extra_serial_value", a);
} }
+14 -11
View File
@@ -85,18 +85,21 @@ fn form_c_rejects_number_for_text_column() {
#[test] #[test]
fn form_c_wrong_value_count_is_invalid() { fn form_c_wrong_value_count_is_invalid() {
// Customers Form C expects exactly two values (id:serial // Customers Form C expects exactly two values (id:serial skipped).
// skipped). Three values is a count mismatch — caught at // Three values is a count mismatch. As of issue #17 it parses
// parse time now. // structurally (so the friendly arity diagnostic fires); the
// user-facing invalidity is the validity verdict (`[ERR]`).
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let input = "insert into Customers ('Alice', 'a@b.c', 'extra')";
"insert into Customers ('Alice', 'a@b.c', 'extra')", let a = assess_at_end(input, &schema);
&schema, assert_eq!(
); rdbms_playground::dsl::walker::input_verdict_in_mode(
assert!( input,
!matches!(a.state, InputState::Valid), Some(&schema),
"Form C with too many values must be invalid, got {:?}", rdbms_playground::mode::Mode::Simple,
a.state, ),
Some(rdbms_playground::dsl::walker::Severity::Error),
"Form C with too many values must light the [ERR] verdict",
); );
crate::snap!("form_c_wrong_count", a); crate::snap!("form_c_wrong_count", a);
} }
@@ -1,37 +1,20 @@
--- ---
source: tests/typing_surface/insert_form_b.rs source: tests/typing_surface/insert_form_b.rs
assertion_line: 186 assertion_line: 193
description: "input=\"insert into Customers values (1, 'Alice', 'a@b.c')\" cursor=50" description: "input=\"insert into Customers values (1, 'Alice', 'a@b.c')\" cursor=50"
expression: "& a" expression: "& a"
--- ---
Assessment { Assessment {
input: "insert into Customers values (1, 'Alice', 'a@b.c')", input: "insert into Customers values (1, 'Alice', 'a@b.c')",
cursor: 50, cursor: 50,
state: DefiniteErrorAt( state: Valid,
30,
),
hint: Some( hint: Some(
Prose( Prose(
"for `Name`: Type a quoted string (e.g. 'Alice') or null (`id` auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.) (trying to write SQL? switch with `mode advanced`, or prefix `:` to run once)", "without a column list, supply 2 value(s) for `Name`, `Email` — `id` auto-generated; 3 given (trying to write SQL? switch with `mode advanced`, or prefix `:` to run once)",
), ),
), ),
completion: Some( completion: None,
Completion { parse_result: Ok(
replaced_range: ( "Insert",
50,
50,
),
partial_prefix: "",
candidates: [
Candidate {
text: "null",
kind: Keyword,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(definite)",
), ),
} }
@@ -1,22 +1,20 @@
--- ---
source: tests/typing_surface/insert_form_b.rs source: tests/typing_surface/insert_form_b.rs
assertion_line: 168 assertion_line: 173
description: "input=\"insert into Customers values ('Alice')\" cursor=38" description: "input=\"insert into Customers values ('Alice')\" cursor=38"
expression: "& a" expression: "& a"
--- ---
Assessment { Assessment {
input: "insert into Customers values ('Alice')", input: "insert into Customers values ('Alice')",
cursor: 38, cursor: 38,
state: DefiniteErrorAt( state: Valid,
37,
),
hint: Some( hint: Some(
Prose( Prose(
"after `insert into Customers values ('Alice'`, expected `,` — usage: insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])", "without a column list, supply 2 value(s) for `Name`, `Email` — `id` auto-generated; 1 given",
), ),
), ),
completion: None, completion: None,
parse_result: Err( parse_result: Ok(
"Invalid(definite)", "Insert",
), ),
} }
@@ -1,22 +1,35 @@
--- ---
source: tests/typing_surface/insert_form_c.rs source: tests/typing_surface/insert_form_c.rs
assertion_line: 101 assertion_line: 104
description: "input=\"insert into Customers ('Alice', 'a@b.c', 'extra')\" cursor=49" description: "input=\"insert into Customers ('Alice', 'a@b.c', 'extra')\" cursor=49"
expression: "& a" expression: "& a"
--- ---
Assessment { Assessment {
input: "insert into Customers ('Alice', 'a@b.c', 'extra')", input: "insert into Customers ('Alice', 'a@b.c', 'extra')",
cursor: 49, cursor: 49,
state: DefiniteErrorAt( state: Valid,
39,
),
hint: Some( hint: Some(
Prose( Prose(
"after `insert into Customers ('Alice', 'a@b.c'`, expected `)` — usage: insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])", "without a column list, supply 2 value(s) for `Name`, `Email` — `id` auto-generated; 3 given",
), ),
), ),
completion: None, completion: Some(
parse_result: Err( Completion {
"Invalid(definite)", replaced_range: (
49,
49,
),
partial_prefix: "",
candidates: [
Candidate {
text: "values",
kind: Keyword,
mode: Both,
},
],
},
),
parse_result: Ok(
"Insert",
), ),
} }