fix: INSERT Form B value-count UX (ADR-0033 Amendment 5)

Three layered fixes for advanced/simple-mode positional INSERT
value-count mismatches (e.g. `insert into T values (...)` with the
wrong number of values for T's column count), plus ADR-0033
Amendment 5 recording the gate refinement.

Walker diagnostic (dml_insert_arity_diagnostics): the function's own
doc-comment recorded the no-column-list (Form B) case as deferred.
This commit closes that gap. Form B mismatches now emit a new
diagnostic.insert_arity_mismatch_form_b ERROR per offending tuple,
keyed off the target table's column count from the schema cache. The
[ERR] validity indicator (ADR-0027) lights up at typing time for the
reported scenario, no longer needing a submit.

Cross-mode pointer gate (advanced_alternative_note): refactored from
a hand-rolled Form B count check to a single input_verdict_in_mode(
input, schema, Mode::Advanced) call. The pointer fires only when the
verdict is None — the ADR-0027 sense of "valid". Any future static
check added to the verdict pipeline participates automatically; no
per-feature maintenance.

Teaching notes for the value-count cases users can hit before the
indicator turns red:
  - simple-mode submit: insert.form_b_extra_values_note covers under-,
    in-window, and over-supply against the Form B contract; suppressed
    when the cross-mode pointer fires (to avoid parallel advice).
  - advanced-mode dispatch pre-flight:
    insert.form_b_positional_count_mismatch_note catches a submitted
    mismatch with a teaching message before the engine produces its
    raw NOT-NULL / type error.

The advanced_mode.also_valid_sql pointer wording was reworked to
"trying to write SQL? switch with `mode advanced`, or prefix `:` to
run once". One insta snapshot regenerated.

ADR-0033 Amendment 5 records the gate change: Amendment 3's "would
parse in advanced mode" now reads as "valid in advanced mode" in the
explicit verdict-is-None sense, with the precise definition spelled
out so future readers can't drift back to the syntactic-only reading.
ADR-0000 index entry updated; docs/requirements.md H1a citation added
listing the three new pedagogical strings.

Tests added (8): four walker arity tests (under-supply, over-supply,
match, unknown-table); two app-level teaching-note tests for the
sibling cases (under-supply, over-supply beyond total); one pointer-
gate unit test pinning the bug-case suppression; one gate-precedence
test ensuring only one advice line per error. Existing
simple_mode_submit_of_sql_construct_appends_advanced_pointer updated
to use a known schema (the new validity gate requires it).

Full suite: 2031 passed, 0 failed, 0 unexpected skips. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-28 16:38:33 +00:00
parent 9468324d56
commit c12ed1da9a
9 changed files with 898 additions and 48 deletions
+157 -25
View File
@@ -1397,11 +1397,12 @@ fn dml_auto_column_diagnostics(
diagnostics
}
/// `insert_arity_mismatch` ERROR (ADR-0033 §8.1, sub-phase 3i).
/// `insert_arity_mismatch` ERROR (ADR-0033 §8.1, sub-phase 3i; Form B
/// branch added 2026-05-28 per issue #1 / ADR-0033 Amendment 5).
///
/// Walker pre-flight when the explicit `(column_name_list)` arity
/// disagrees with a row's arity — pre-empting the engine's
/// less-helpful "N values for M columns". Two row-source shapes:
/// Walker pre-flight when the value-tuple arity disagrees with the
/// expected arity — pre-empting the engine's less-helpful
/// "N values for M columns". Two row-source shapes:
///
/// - **VALUES**: each `value_tuple` is checked independently;
/// **each** offending row emits its own diagnostic on that tuple's
@@ -1412,10 +1413,17 @@ fn dml_auto_column_diagnostics(
/// projection isn't the first `select` token) — the engine still
/// reports it; a false positive would be worse than deferring.
///
/// Only fires when an explicit column list is present; the
/// no-column-list form (arity vs the table's full column count)
/// is deferred (needs the schema and is outside the 3i exit gate).
fn dml_insert_arity_diagnostics(path: &MatchedPath) -> Vec<outcome::Diagnostic> {
/// Expected arity comes from one of two sources:
/// - **Form A** (explicit `(col, …)` list): the list's length.
/// - **Form B** (no column list): the target table's column count —
/// requires the schema and a known table; otherwise this pass is
/// silent and the engine reports the mismatch as before. This
/// covers the user-reported issue #1 case (advanced positional
/// INSERT with wrong value count).
fn dml_insert_arity_diagnostics(
path: &MatchedPath,
schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
use crate::dsl::grammar::IdentSource;
use outcome::{Diagnostic, MatchedKind, Severity};
@@ -1432,9 +1440,36 @@ fn dml_insert_arity_diagnostics(path: &MatchedPath) -> Vec<outcome::Diagnostic>
)
})
.count();
if col_arity == 0 {
return Vec::new();
}
// 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 {
MatchedKind::Ident {
source: IdentSource::Tables,
role: "insert_target_table",
} => Some(it.text.as_str()),
_ => None,
}) else {
return Vec::new();
};
let Some(cols) = schema.table_columns.get(target) else {
return Vec::new();
};
if cols.is_empty() {
return Vec::new();
}
(cols.len(), "diagnostic.insert_arity_mismatch_form_b")
};
// Index of the row-source keyword (first VALUES / SELECT / WITH).
let Some(kw_idx) = path
@@ -1456,9 +1491,9 @@ fn dml_insert_arity_diagnostics(path: &MatchedPath) -> Vec<outcome::Diagnostic>
severity: Severity::Error,
span,
message: crate::friendly::translate(
"diagnostic.insert_arity_mismatch",
message_key,
&[
("expected", &col_arity as &dyn std::fmt::Display),
("expected", &expected as &dyn std::fmt::Display),
("actual", &actual as &dyn std::fmt::Display),
],
),
@@ -1483,7 +1518,7 @@ fn dml_insert_arity_diagnostics(path: &MatchedPath) -> Vec<outcome::Diagnostic>
}
MatchedKind::Punct(')') => {
depth -= 1;
if depth == 0 && tuple_arity != col_arity {
if depth == 0 && tuple_arity != expected {
emit((tuple_start, it.span.1), tuple_arity, &mut diagnostics);
}
}
@@ -1522,7 +1557,7 @@ fn dml_insert_arity_diagnostics(path: &MatchedPath) -> Vec<outcome::Diagnostic>
}
}
}
if proj_arity != col_arity {
if proj_arity != expected {
emit(anchor.unwrap_or(path.items[kw_idx].span), proj_arity, &mut diagnostics);
}
}
@@ -2483,10 +2518,13 @@ fn decide(
// (ADR-0033 Amendment 3). "This is SQL" is reserved for
// entry words with no DSL form (`select` / `with`): there
// the DSL surface has nothing to offer. For a DSL line
// that fails here but *would* run in advanced mode, a
// "(valid as SQL in advanced mode)" pointer is appended at
// the rendering layer (see `advanced_alternative_note`),
// combining the DSL fix with the mode hint.
// that fails here but would be *valid* in advanced mode (the
// ADR-0027 sense, per ADR-0033 Amendment 5: verdict `None`,
// i.e. parse succeeds + no Warning/Error from any
// diagnostic pass), a "Trying to write SQL?" pointer is
// appended at the rendering layer (see
// `advanced_alternative_note`), combining the DSL fix with
// the mode hint.
match simple.first() {
Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode },
None => {
@@ -2768,10 +2806,12 @@ fn walk_one_command<'a>(
// ADR-0033 §8.2 — WARNING when a SQL INSERT lists a
// serial/shortid (auto-generated) column explicitly.
d.extend(dml_auto_column_diagnostics(&path, ctx.schema));
// ADR-0033 §8.1 — ERROR when a column list's arity disagrees
// with a VALUES tuple (per row) or the INSERT…SELECT
// projection.
d.extend(dml_insert_arity_diagnostics(&path));
// ADR-0033 §8.1 (+ Amendment 5, issue #1) — ERROR when the
// arity disagrees with a VALUES tuple (per row) or the
// 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));
// 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));
@@ -5019,6 +5059,96 @@ mod tests {
);
}
#[test]
fn insert_form_b_arity_mismatch_under_supply_fires() {
// Issue #1: advanced-mode positional INSERT without a column
// list requires a value for every column. Three values for a
// 4-column table (the user's reported bug case) is a
// mismatch — the diagnostic powers the validity verdict that
// drives the [ERR] indicator and ADR-0033 Amendment 5's
// `also_valid_sql` gate.
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("value(s) are given")),
"Form B under-supply must fire arity diagnostic; got {diags:?}",
);
}
#[test]
fn insert_form_b_arity_mismatch_over_supply_fires() {
// Symmetric case: more values than columns — engine would
// reject; the diagnostic catches it pre-flight.
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, 13, 99)",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("value(s) are given")),
"Form B over-supply must fire arity diagnostic; got {diags:?}",
);
}
#[test]
fn insert_form_b_arity_match_is_silent() {
// The user's confirmed happy path: every column supplied
// positionally (autos included) — no arity diagnostic.
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 (13, 'Oli', 42, 13)",
&schema,
);
assert!(
!diags.iter().any(|d| d.contains("value(s) are given")),
"matched Form B arity must not fire; got {diags:?}",
);
}
#[test]
fn insert_form_b_arity_unknown_table_is_silent() {
// Defensive: when the target table isn't in the schema cache,
// we can't know the expected count — defer to the engine
// (the schema-existence pass flags the unknown table, not the
// arity pass).
let schema = schema_with("Customers", &[("Name", Type::Text)]);
let diags = diag_keys(
"insert into Strangers values ('Oli', 52, 3)",
&schema,
);
assert!(
!diags.iter().any(|d| d.contains("value(s) are given")),
"unknown table must not trigger an arity diagnostic; got {diags:?}",
);
}
#[test]
fn insert_arity_match_is_silent() {
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
@@ -6211,8 +6341,10 @@ mod dispatch_3a_tests {
// DSL candidate, even when the input matches only the SQL
// tail. The DSL grammar then rejects the SQL-only tail with a
// normal parse error (a `Mismatch`), surfacing the actionable
// DSL detail; the "(valid as SQL in advanced mode)" pointer is
// added at the rendering layer, not here. "This is SQL" as a
// DSL detail; the "Trying to write SQL?" pointer (ADR-0033
// Amendment 3, gated per Amendment 5 on the line being *valid*
// in advanced mode — verdict `None`) is added at the rendering
// layer, not here. "This is SQL" as a
// dispatch outcome is reserved for entry words with no DSL
// form (see `simple_mode_sql_only_entry_word_is_this_is_sql`).
let cands = shared();