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:
@@ -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
|
||||
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
|
||||
|
||||
- ADR-0030 §4 / ADR-0033 §10 — the execute-path this ADR **augments**
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -540,10 +540,15 @@ handoff-14 cleanup; 449 after B2/C2.)
|
||||
names the schema-correct next token (`,` between values, `)`
|
||||
after the last) instead of the type-blind close-paren, and so a
|
||||
wrong-arity closed tuple surfaces the real parse error rather
|
||||
than a misleading "submit with Enter"). A systematic pass is
|
||||
still pending; simple-mode wrong-count tuples still get a generic
|
||||
expected-token message rather than the friendly arity diagnostic
|
||||
advanced mode shows (issue #17).
|
||||
than a misleading "submit with Enter"; **issue #17 / ADR-0036
|
||||
Amendment 2, 2026-05-29** then brought the §8.1 arity diagnostic
|
||||
to **simple mode** at parity with advanced — a wrong-count DSL
|
||||
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
|
||||
input or the most recent error.
|
||||
- [ ] **H3** `help` provides general reference and per-command
|
||||
|
||||
+77
-13
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+315
-41
@@ -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 {
|
||||
{
|
||||
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();
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -154,16 +154,21 @@ fn form_b_in_progress_without_closing_paren_is_incomplete() {
|
||||
#[test]
|
||||
fn form_b_with_too_few_values_is_invalid_at_close_paren() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers values ('Alice')", &schema);
|
||||
// Only one value supplied; Form B for Customers needs two.
|
||||
// The grammar's typed slot list expects another `,<value>`
|
||||
// before the `)`. Classify as DefiniteError or Incomplete
|
||||
// (which one depends on whether the closing `)` is past
|
||||
// the missing slot).
|
||||
assert!(
|
||||
!matches!(a.state, InputState::Valid),
|
||||
"input with too-few values must NOT be Valid, got {:?}",
|
||||
a.state,
|
||||
let input = "insert into Customers values ('Alice')";
|
||||
let a = assess_at_end(input, &schema);
|
||||
// Only one value supplied; Form B for Customers needs two. As of
|
||||
// issue #17 a wrong-count tuple parses *structurally* (so the
|
||||
// friendly arity diagnostic can fire) — the user-facing invalidity
|
||||
// is the validity verdict (the `[ERR]` indicator), not the
|
||||
// structural `state`.
|
||||
assert_eq!(
|
||||
rdbms_playground::dsl::walker::input_verdict_in_mode(
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
// overall — Customers has 3 columns but Form B accepts 2.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values (1, 'Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
!matches!(a.state, InputState::Valid),
|
||||
"Form B with a value-for-serial must be invalid, got {:?}",
|
||||
a.state,
|
||||
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
|
||||
let a = assess_at_end(input, &schema);
|
||||
assert_eq!(
|
||||
rdbms_playground::dsl::walker::input_verdict_in_mode(
|
||||
input,
|
||||
Some(&schema),
|
||||
rdbms_playground::mode::Mode::Simple,
|
||||
),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -85,18 +85,21 @@ fn form_c_rejects_number_for_text_column() {
|
||||
|
||||
#[test]
|
||||
fn form_c_wrong_value_count_is_invalid() {
|
||||
// Customers Form C expects exactly two values (id:serial
|
||||
// skipped). Three values is a count mismatch — caught at
|
||||
// parse time now.
|
||||
// Customers Form C expects exactly two values (id:serial skipped).
|
||||
// Three values is a count mismatch. As of issue #17 it parses
|
||||
// structurally (so the friendly arity diagnostic fires); the
|
||||
// user-facing invalidity is the validity verdict (`[ERR]`).
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers ('Alice', 'a@b.c', 'extra')",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
!matches!(a.state, InputState::Valid),
|
||||
"Form C with too many values must be invalid, got {:?}",
|
||||
a.state,
|
||||
let input = "insert into Customers ('Alice', 'a@b.c', 'extra')";
|
||||
let a = assess_at_end(input, &schema);
|
||||
assert_eq!(
|
||||
rdbms_playground::dsl::walker::input_verdict_in_mode(
|
||||
input,
|
||||
Some(&schema),
|
||||
rdbms_playground::mode::Mode::Simple,
|
||||
),
|
||||
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);
|
||||
}
|
||||
|
||||
+6
-23
@@ -1,37 +1,20 @@
|
||||
---
|
||||
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"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers values (1, 'Alice', 'a@b.c')",
|
||||
cursor: 50,
|
||||
state: DefiniteErrorAt(
|
||||
30,
|
||||
),
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
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 {
|
||||
replaced_range: (
|
||||
50,
|
||||
50,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(definite)",
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"Insert",
|
||||
),
|
||||
}
|
||||
|
||||
+5
-7
@@ -1,22 +1,20 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_b.rs
|
||||
assertion_line: 168
|
||||
assertion_line: 173
|
||||
description: "input=\"insert into Customers values ('Alice')\" cursor=38"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers values ('Alice')",
|
||||
cursor: 38,
|
||||
state: DefiniteErrorAt(
|
||||
37,
|
||||
),
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
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,
|
||||
parse_result: Err(
|
||||
"Invalid(definite)",
|
||||
parse_result: Ok(
|
||||
"Insert",
|
||||
),
|
||||
}
|
||||
|
||||
+21
-8
@@ -1,22 +1,35 @@
|
||||
---
|
||||
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"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers ('Alice', 'a@b.c', 'extra')",
|
||||
cursor: 49,
|
||||
state: DefiniteErrorAt(
|
||||
39,
|
||||
),
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
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,
|
||||
parse_result: Err(
|
||||
"Invalid(definite)",
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
49,
|
||||
49,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "values",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Ok(
|
||||
"Insert",
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user