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:
+325
-8
@@ -1340,6 +1340,27 @@ impl App {
|
||||
vec![Action::Replay { path }]
|
||||
}
|
||||
Ok(cmd) => {
|
||||
// Issue #1 sub-task 3: advanced-mode positional
|
||||
// `INSERT INTO T VALUES (…)` (no column list) with a
|
||||
// value count that doesn't match the column count gets
|
||||
// a teaching note here, *before* dispatch. The engine
|
||||
// would otherwise surface a raw NOT-NULL / type error
|
||||
// that doesn't mention the column-list override.
|
||||
if let Some(note) = crate::input_render::form_b_positional_count_mismatch_note(
|
||||
&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,
|
||||
});
|
||||
self.note_error(note);
|
||||
return vec![Action::JournalFailure {
|
||||
source: input.to_string(),
|
||||
}];
|
||||
}
|
||||
self.push_output(OutputLine {
|
||||
text: crate::t!("dsl.running", input = input),
|
||||
kind: OutputKind::Echo,
|
||||
@@ -1405,6 +1426,19 @@ 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);
|
||||
}
|
||||
// ADR-0021 §2: append the usage block (if a
|
||||
// known command-entry keyword was consumed) or
|
||||
// the available-commands fallback (§5).
|
||||
@@ -2842,15 +2876,293 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: install a `Customers(id serial, Name text, Age int,
|
||||
/// SerNo serial)` schema cache on `app` for the Form-B education
|
||||
/// tests (issue #1).
|
||||
fn install_customers_schema_two_serials(app: &mut App) {
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::types::Type;
|
||||
let cols = [
|
||||
("id", Type::Serial),
|
||||
("Name", Type::Text),
|
||||
("Age", Type::Int),
|
||||
("SerNo", Type::Serial),
|
||||
];
|
||||
app.schema_cache.tables.push("Customers".to_string());
|
||||
let tc: Vec<TableColumn> = cols
|
||||
.iter()
|
||||
.map(|(n, t)| TableColumn {
|
||||
name: (*n).to_string(),
|
||||
user_type: *t,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
})
|
||||
.collect();
|
||||
for c in &tc {
|
||||
app.schema_cache.columns.push(c.name.clone());
|
||||
}
|
||||
app.schema_cache
|
||||
.table_columns
|
||||
.insert("Customers".to_string(), tc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_form_b_extra_value_shows_only_one_advice_line() {
|
||||
// Issue #1 sub-task 2 + sub-task 1 interaction: in the
|
||||
// 3-column Form B case (`Customers(id serial, Name, Email)` +
|
||||
// 3 values, e.g. `insert into Customers values (1, 'Alice',
|
||||
// 'a@b.c')`) the line IS valid in advanced mode, so the
|
||||
// cross-mode pointer fires. The sub-task 2 teaching note
|
||||
// ("list every column") would be parallel advice — both are
|
||||
// valid escape hatches — and showing both clutters the error.
|
||||
// The teaching note must defer to the cross-mode pointer.
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::types::Type;
|
||||
let mut app = App::new();
|
||||
let cols = [
|
||||
("id", Type::Serial),
|
||||
("Name", Type::Text),
|
||||
("Email", Type::Text),
|
||||
];
|
||||
app.schema_cache.tables.push("Customers".to_string());
|
||||
let tc: Vec<TableColumn> = cols
|
||||
.iter()
|
||||
.map(|(n, t)| TableColumn {
|
||||
name: (*n).to_string(),
|
||||
user_type: *t,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
})
|
||||
.collect();
|
||||
for c in &tc {
|
||||
app.schema_cache.columns.push(c.name.clone());
|
||||
}
|
||||
app.schema_cache
|
||||
.table_columns
|
||||
.insert("Customers".to_string(), tc);
|
||||
type_str(
|
||||
&mut app,
|
||||
"insert into Customers values (1, 'Alice', 'a@b.c')",
|
||||
);
|
||||
let _ = submit(&mut app);
|
||||
let out = error_lines(&app);
|
||||
// The cross-mode pointer (sub-task 1) fires — the line works
|
||||
// in advanced. Substring "mode advanced" is the durable
|
||||
// actionable fragment.
|
||||
assert!(
|
||||
out.contains("mode advanced"),
|
||||
"cross-mode pointer must fire for the 3-col line: {out}",
|
||||
);
|
||||
// The sub-task 2 teaching note is suppressed: no "list every
|
||||
// column" / "auto-generated and filled" prose alongside it.
|
||||
assert!(
|
||||
!out.contains("auto-generated and filled"),
|
||||
"sub-task 2 note must defer to the cross-mode pointer: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_submit_of_form_b_value_count_mismatch_shows_preflight_note() {
|
||||
// Issue #1 sub-task 3: advanced-mode positional `INSERT INTO T
|
||||
// VALUES (…)` (no column list) requires every column. When the
|
||||
// value count doesn't match, today's flow lets the engine
|
||||
// produce a raw constraint or type error; we'd rather catch it
|
||||
// at dispatch time and surface a teaching note that names the
|
||||
// table's columns and shows the column-list override.
|
||||
let mut app = App::new();
|
||||
install_customers_schema_two_serials(&mut app);
|
||||
app.mode = Mode::Advanced;
|
||||
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
|
||||
let actions = submit(&mut app);
|
||||
// The pre-flight rejected the line — no ExecuteDsl dispatch.
|
||||
assert!(
|
||||
!actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
"advanced-mode pre-flight must suppress dispatch on Form-B count mismatch; got: {actions:?}",
|
||||
);
|
||||
let out = error_lines(&app);
|
||||
// The teaching note names the rule …
|
||||
assert!(
|
||||
out.contains("every column"),
|
||||
"missing the positional-VALUES rule in: {out}",
|
||||
);
|
||||
// … names the table's columns so the user can see what's needed …
|
||||
assert!(
|
||||
out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"),
|
||||
"missing the column-name list in: {out}",
|
||||
);
|
||||
// … and shows the column-list override targeting the non-auto columns.
|
||||
assert!(
|
||||
out.contains("insert into Customers (Name, Age)"),
|
||||
"missing the column-list override example in: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_of_form_b_extra_value_teaches_serial_skip() {
|
||||
// Issue #1 sub-task 2: when the user supplies too many positional
|
||||
// values in simple-mode Form B, the bare "expected `)`" parse
|
||||
// error doesn't explain why fewer values are expected. The
|
||||
// submit error gets a teaching line that names the columns
|
||||
// Form B fills automatically, names the columns it expects
|
||||
// values for, and points at the column-list (Form A) override.
|
||||
let mut app = App::new();
|
||||
install_customers_schema_two_serials(&mut app);
|
||||
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
|
||||
let _ = submit(&mut app);
|
||||
let out = error_lines(&app);
|
||||
// The teaching line names the user-supplied columns …
|
||||
assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}");
|
||||
// … the auto-generated columns …
|
||||
assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}");
|
||||
// … signals the contract …
|
||||
assert!(
|
||||
out.contains("auto-generated"),
|
||||
"missing the contract word in: {out}",
|
||||
);
|
||||
// … and shows the Form-A override path with every column listed.
|
||||
assert!(
|
||||
out.contains("insert into Customers (id, Name, Age, SerNo)"),
|
||||
"missing the Form-A override example in: {out}",
|
||||
);
|
||||
// Issue #1 sub-task 1 gate: the cross-mode pointer must be
|
||||
// suppressed for the user's reported case (count mismatches in
|
||||
// advanced too — switching modes wouldn't help). Without this
|
||||
// assertion, a regression of the gate would silently restore
|
||||
// the misleading pointer alongside the teaching note.
|
||||
assert!(
|
||||
!out.contains("mode advanced"),
|
||||
"cross-mode pointer must NOT fire for the 4-col mismatch case: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_of_form_b_undersupply_teaches() {
|
||||
// Issue #1 siblings task: simple-mode under-supply
|
||||
// (`insert into Customers values ('Oli')` for a 4-col table
|
||||
// whose Form B expects 2 values for `Name`, `Age`). Today the
|
||||
// user sees a bare parse error; with the extended teaching
|
||||
// note they get the same forward-looking explanation as the
|
||||
// over-supply case — what's expected, what's auto-filled, how
|
||||
// to use the column-list form to override.
|
||||
let mut app = App::new();
|
||||
install_customers_schema_two_serials(&mut app);
|
||||
type_str(&mut app, "insert into Customers values ('Oli')");
|
||||
let _ = submit(&mut app);
|
||||
let out = error_lines(&app);
|
||||
assert!(
|
||||
out.contains("Name") && out.contains("Age"),
|
||||
"missing non-auto column names in: {out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("id") && out.contains("SerNo"),
|
||||
"missing auto column names in: {out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("auto-generated"),
|
||||
"missing contract word in: {out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("insert into Customers (id, Name, Age, SerNo)"),
|
||||
"missing column-list override in: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_of_form_b_oversupply_beyond_total_teaches() {
|
||||
// Issue #1 siblings task: simple-mode over-supply *beyond* the
|
||||
// total column count (5 values for a 4-col table). The
|
||||
// teaching note still fires — the explanation of Form B's
|
||||
// contract applies regardless of how far past Form B's count
|
||||
// the user went.
|
||||
let mut app = App::new();
|
||||
install_customers_schema_two_serials(&mut app);
|
||||
type_str(
|
||||
&mut app,
|
||||
"insert into Customers values ('Oli', 52, 3, 13, 99)",
|
||||
);
|
||||
let _ = submit(&mut app);
|
||||
let out = error_lines(&app);
|
||||
assert!(
|
||||
out.contains("Name") && out.contains("Age"),
|
||||
"missing non-auto column names in: {out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("auto-generated"),
|
||||
"missing contract word in: {out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("insert into Customers (id, Name, Age, SerNo)"),
|
||||
"missing column-list override in: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_submit_with_all_values_for_serials_succeeds_no_preflight() {
|
||||
// Issue #1 — the user explicitly confirmed that supplying every
|
||||
// column including the serials works in advanced mode
|
||||
// (`insert into Customers values (13, 'Oli', 42, 13)`). The
|
||||
// pre-flight must not interfere with that happy path: it fires
|
||||
// only on count mismatch, and the dispatch reaches the worker
|
||||
// normally. This locks down the bottom edge of the pre-flight
|
||||
// gate.
|
||||
let mut app = App::new();
|
||||
install_customers_schema_two_serials(&mut app);
|
||||
app.mode = Mode::Advanced;
|
||||
type_str(
|
||||
&mut app,
|
||||
"insert into Customers values (13, 'Oli', 42, 13)",
|
||||
);
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
"the line must dispatch normally; pre-flight must not fire when value count matches column count; got: {actions:?}",
|
||||
);
|
||||
// And no teaching note prose appears in the output …
|
||||
let out = error_lines(&app);
|
||||
assert!(
|
||||
!out.contains("requires a value for every column"),
|
||||
"pre-flight must stay silent when counts match: {out}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
|
||||
// ADR-0033 Amendment 3: submitting a line in simple mode that
|
||||
// fails as DSL but would run as SQL in advanced mode appends
|
||||
// the `advanced_mode.also_valid_sql` pointer to the parse
|
||||
// error — keeping the DSL detail and pointing at advanced
|
||||
// mode. Multi-row VALUES is a definite DSL error and valid SQL
|
||||
// (no schema needed).
|
||||
// ADR-0033 Amendment 3 (+ Amendment 5): submitting a line in
|
||||
// simple mode that fails as DSL but would be valid in advanced
|
||||
// mode appends the `advanced_mode.also_valid_sql` pointer to
|
||||
// the parse error — keeping the DSL detail and pointing at
|
||||
// advanced mode. Multi-row VALUES is a definite DSL error and
|
||||
// valid SQL with a real schema (the validity verdict needs the
|
||||
// table to exist; an unknown-table diagnostic would correctly
|
||||
// suppress the pointer).
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::types::Type;
|
||||
let mut app = App::new();
|
||||
app.schema_cache.tables.push("T".to_string());
|
||||
let tc = vec![
|
||||
TableColumn {
|
||||
name: "a".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
TableColumn {
|
||||
name: "b".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
];
|
||||
for c in &tc {
|
||||
app.schema_cache.columns.push(c.name.clone());
|
||||
}
|
||||
app.schema_cache
|
||||
.table_columns
|
||||
.insert("T".to_string(), tc);
|
||||
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
@@ -2860,7 +3172,7 @@ mod tests {
|
||||
let has_pointer = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.kind == OutputKind::Error && l.text.contains("advanced mode"));
|
||||
.any(|l| l.kind == OutputKind::Error && l.text.contains("mode advanced"));
|
||||
assert!(
|
||||
has_pointer,
|
||||
"expected the advanced-mode pointer on submit; output:\n{}",
|
||||
@@ -2876,10 +3188,15 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "frobulate widgets");
|
||||
let _ = submit(&mut app);
|
||||
// The pointer's current phrasing (`also_valid_sql`) is
|
||||
// "trying to write SQL? …"; an unknown command produces no
|
||||
// advanced-mode hint at all, so we look for any line carrying
|
||||
// the "mode advanced" actionable fragment that the pointer
|
||||
// always emits.
|
||||
let has_pointer = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.text.contains("valid as SQL in advanced mode"));
|
||||
.any(|l| l.text.contains("mode advanced"));
|
||||
assert!(!has_pointer, "unknown command must not point at advanced mode");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user