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
+77 -13
View File
@@ -1361,6 +1361,30 @@ impl App {
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 {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
@@ -1426,19 +1450,13 @@ impl App {
{
self.note_error(note);
}
// Issue #1 sub-task 2: append a teaching note when the
// Form B `insert into <T> values (…)` line failed
// because the user supplied more values than the
// non-auto-generated columns expect. The parse error
// shows the literal "expected `)`"; the note explains
// *why* fewer values are expected and shows the
// column-list override path.
if mode == Mode::Simple
&& let Some(note) =
crate::input_render::form_b_extra_values_note(input, &self.schema_cache)
{
self.note_error(note);
}
// Issue #1 sub-task 2's Form B teaching note used to be
// appended here, because a wrong-count Form B insert
// failed to parse and landed in this Err arm. As of issue
// #17 such tuples parse `Ok` (so the typing-time arity
// diagnostic fires) and the teaching + dispatch block now
// live in the Ok arm's `dsl_insert_count_mismatch_notes`
// pre-flight — a single model shared with advanced mode.
// ADR-0021 §2: append the usage block (if a
// known command-entry keyword was consumed) or
// 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]
fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
// 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::grammar::{
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,
};
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).
/// Peeks the first token after `(` to route to Form A's
/// 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) {
// Form C — bare value list. `column_value_list` with no
// user-listed columns dispatches per non-auto-generated
// column, exactly as Form B does.
Node::DynamicSubgrammar(column_value_list)
// Form C — bare value list. Arity-gated exactly like Form B's
// `values (…)`: a correct-count tuple gets the typed per-column
// slots; a wrong-count tuple routes to the type-blind fallback
// so it still matches and the arity diagnostic fires (issue #17).
dsl_insert_value_list(ctx, source, pos)
} else {
// Form A (or Form A in progress / empty paren).
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);
/// Schema-aware value list: when the walker has a populated
/// `current_table_columns`, unfolds to a `Seq` of typed slots
/// per column (`int_slot`, `text_slot`, …). When schemaless,
/// falls back to the pre-Phase-D `Repeated(VALUE_LITERAL, ',', 1)`
/// shape (ADR-0024 §Phase D §column_value_list).
const INSERT_VALUES_LIST: Node = Node::DynamicSubgrammar(column_value_list);
/// Insert value-list arity gate (issue #17) — the simple-mode DSL
/// counterpart of the advanced grammar's `tuple_value_list`
/// (`sql_insert.rs`). Routes a correct-arity tuple to the typed
/// per-column slots ([`column_value_list`]) and a wrong-arity tuple to
/// the type-blind [`FALLBACK_VALUE_LIST`], so the wrong-count tuple
/// 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] = &[
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,
};
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,
separator: Some(&Node::Punct(',')),
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`.
///
/// 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
/// callers/tests continue to work.
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;
};
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
// slot embeds its column name so the hint resolver can
// mention the column by name ("for `Email`: Type a quoted
+4 -52
View File
@@ -14,7 +14,7 @@
//! sub-phases.
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_select::{
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
/// 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)
}
// `count_tuple_values` moved to `grammar::shared` (issue #17) so the
// simple-mode DSL insert arity gate can share it; the advanced grammar
// imports it above.
/// 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
+317 -43
View File
@@ -1465,10 +1465,16 @@ fn dml_auto_column_diagnostics(
fn dml_insert_arity_diagnostics(
path: &MatchedPath,
schema: Option<&crate::completion::SchemaCache>,
mode: crate::mode::Mode,
) -> Vec<outcome::Diagnostic> {
use crate::dsl::grammar::IdentSource;
use crate::dsl::types::Type;
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
.items
.iter()
@@ -1477,31 +1483,70 @@ fn dml_insert_arity_diagnostics(
it.kind,
MatchedKind::Ident {
source: IdentSource::Columns,
role: "insert_column",
role: "insert_column" | "insert_first_item",
}
)
})
.count();
// Resolve the expected arity + which message template to use.
// Form A: explicit column list → its own length.
// Form B: no list → the target table's column count, *if* we know
// it. Without a schema or a recognised target the pass goes
// silent (the unknown-table case is flagged by the schema-
// existence pass instead).
let (expected, message_key): (usize, &'static str) = if col_arity > 0 {
(col_arity, "diagnostic.insert_arity_mismatch")
} else {
let Some(schema) = schema else {
return Vec::new();
};
let Some(target) = path.items.iter().find_map(|it| match it.kind {
// Is this an INSERT? `into` is insert-exclusive in both grammars
// (update uses `set`, delete `from`, show `data`), so it tells the
// DSL insert apart from other commands that also tag a `table_name`.
let is_insert = path
.items
.iter()
.any(|it| matches!(it.kind, MatchedKind::Word("into")));
// Insert target table. The SQL grammar tags it `insert_target_table`
// (insert-specific); the DSL grammar reuses the generic `table_name`
// role, so only trust that when `into` confirmed an insert (issue
// #17).
let target_table: Option<&str> = path
.items
.iter()
.find_map(|it| match it.kind {
MatchedKind::Ident {
source: IdentSource::Tables,
role: "insert_target_table",
} => Some(it.text.as_str()),
MatchedKind::Ident {
source: IdentSource::Tables,
role: "table_name",
} if is_insert => Some(it.text.as_str()),
_ => 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();
};
let Some(cols) = schema.table_columns.get(target) else {
@@ -1510,35 +1555,123 @@ fn dml_insert_arity_diagnostics(
if cols.is_empty() {
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).
let Some(kw_idx) = path
// Locate the row source to scan. Form A/B and INSERT…SELECT carry a
// `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
.iter()
.position(|it| matches!(it.kind, MatchedKind::Word("values" | "select" | "with")))
else {
return Vec::new();
};
let MatchedKind::Word(kw) = path.items[kw_idx].kind else {
{
let MatchedKind::Word(kw) = path.items[kw_idx].kind else {
return Vec::new();
};
(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();
};
let mut diagnostics = Vec::new();
let tail = &path.items[kw_idx + 1..];
let emit = |span: (usize, usize), actual: usize, diagnostics: &mut Vec<Diagnostic>| {
diagnostics.push(Diagnostic {
severity: Severity::Error,
span,
message: crate::friendly::translate(
message_key,
&[
("expected", &expected as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
),
message: make_message(actual),
});
};
@@ -1600,7 +1733,8 @@ fn dml_insert_arity_diagnostics(
}
}
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
@@ -2872,7 +3006,7 @@ fn walk_one_command<'a>(
// INSERT…SELECT projection. Form A uses the column list's
// length; Form B uses the schema's column count for the
// 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
// a NOT-NULL-no-default (non-auto-gen) column.
d.extend(dml_not_null_missing_diagnostics(&path, ctx.schema));
@@ -4252,23 +4386,30 @@ mod tests {
#[test]
fn phase_d_insert_form_b_skips_serial_column() {
// Form B: `insert into <T> values (…)` excludes
// auto-generated columns from the value list. Supplying
// a value for the serial column is a count mismatch.
// Form B: `insert into <T> values (…)` excludes auto-generated
// columns from the value list. Supplying a value for the serial
// 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(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
// Two values where Form B expects one (Name only):
let err = parse_command_with_schema(
// Two values where Form B expects one (Name only): structurally
// 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')",
&schema,
)
.expect_err("Form B should reject user-supplied serial");
match err {
crate::dsl::ParseError::Invalid { .. } => {}
other => panic!("expected Invalid, got {other:?}"),
}
);
assert!(
diags.iter().any(|d| d.contains("1 value(s)") && d.contains("2 given")),
"Form B serial-skip count mismatch must fire the arity \
diagnostic (expected 1, got 2); got {diags:?}",
);
}
#[test]
@@ -4703,6 +4844,139 @@ mod tests {
.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]
fn unknown_qualifier_in_qualified_ref_is_error() {
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",
&["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.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
+9 -1
View File
@@ -577,8 +577,16 @@ diagnostic:
# 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"
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"
# 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"
# Engine-error translations: an engine-rejected SQL statement
+90 -4
View File
@@ -323,10 +323,20 @@ pub fn advanced_alternative_note(
input: &str,
cache: &crate::completion::SchemaCache,
) -> Option<String> {
let definite_dsl_error = matches!(
classify_input_with_schema_in_mode(input, cache, Mode::Simple),
InputState::DefiniteErrorAt(_)
);
// The line must be *definitely* invalid in simple mode — a definite
// parse error, or (issue #17) a parse that succeeds structurally but
// 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 {
return None;
}
@@ -363,6 +373,82 @@ pub fn advanced_alternative_note(
/// without adding pedagogy. The cross-mode pointer wins because it
/// only fires when switching modes actually works (issue #1 sub-task
/// 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]
pub fn form_b_extra_values_note(
input: &str,