fix: X4 — advanced-mode SQL INSERT auto-fills omitted non-PK serial (MAX+1)

A Form-A advanced-mode INSERT that omitted a non-PK serial column left it
silently NULL (the column is INTEGER UNIQUE, not NOT NULL, so SQLite
permits it), while simple-mode do_insert auto-fills it with MAX+1. That
violated ADR-0018 §1's "auto-generated on every path" contract and was the
unprincipled serial-vs-shortid asymmetry the ADR set out to remove
(advanced mode already auto-fills shortid).

Fix (decision: advanced mode matches simple mode): the advanced-mode
auto-fill reconstruction — renamed plan_shortid_autofill →
plan_autogen_autofill — now also fills an omitted non-PK serial with
MAX(col)+1 … MAX+n per row (single- and multi-row), reading MAX once under
the worker's single-writer serialisation. PK serial stays on the rowid
alias; Form B (no column list) still supplies every column. Honours
ADR-0018 §1/§5; no ADR amendment needed (the contract already said "every
path"). requirements.md X4 marked resolved.

Tests: 1949 passing (+1), 0 failed, 0 skipped, 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-27 11:18:57 +00:00
parent 4928a4c4fd
commit 42306d33e3
4 changed files with 121 additions and 28 deletions
+17 -13
View File
@@ -576,19 +576,23 @@ handoff-14 cleanup; 449 after B2/C2.)
- [~] **X3** Accessibility: TUI screen-reader support is - [~] **X3** Accessibility: TUI screen-reader support is
best-effort and not a v1 commitment; revisit if user need best-effort and not a v1 commitment; revisit if user need
emerges. emerges.
- [~] **X4** Auto-fill semantics differ between simple and advanced - [x] **X4** Auto-fill semantics differ between simple and advanced
mode — **possible bug, to investigate** (raised 2026-05-26). The mode — **resolved 2026-05-27** (raised 2026-05-26). Was: simple-mode
simple-mode insert (`do_insert`, `db.rs`) auto-fills an omitted `do_insert` auto-fills an omitted **non-PK `serial`** column with
**non-PK `serial`** column with `MAX(col)+1` *and* generates `MAX(col)+1`, but the advanced-mode SQL insert auto-filled **only
`shortid`s; the advanced-mode SQL insert (`plan_shortid_autofill`) `shortid`**, leaving an omitted non-PK serial **silently NULL** —
auto-fills **only `shortid`** and does **not** do non-PK `serial` violating ADR-0018 §1's "auto-generated on every path" contract (the
MAX+1. The `shortid` distinction is intentional; the non-PK-`serial` column is `INTEGER UNIQUE`, not `NOT NULL`, so SQLite permits the
gap looks unintended (no obvious reason advanced mode should skip NULL). Confirmed by characterization, escalated, and fixed (decision:
filling a `serial`). Decide whether advanced mode should match advanced mode matches simple mode): the advanced-mode auto-fill
simple mode's serial auto-fill (likely yes) or whether the reconstruction (`db.rs`, renamed `plan_shortid_autofill` →
difference is deliberate, and align ADR-0018 accordingly. Left **`plan_autogen_autofill`**) now also fills an omitted non-PK serial
untouched by the ADR-0036 value-validation work (which is surgical with `MAX+1` per row (single- and multi-row), mirroring `do_insert`
and does not change auto-fill). and the existing shortid fill. PK serial is excluded (rowid alias);
Form B (no column list) still supplies every column. Covered by
`tests/sql_insert.rs::sql_insert_autofills_omitted_nonpk_serial`.
Honours ADR-0018 §1/§5; no ADR amendment needed (the contract already
said "every path"). ADR-0036 was correct that it did not touch this.
- [~] **X5** Framework cohesion / restructuring — **strategic, - [~] **X5** Framework cohesion / restructuring — **strategic,
revisit later** (raised 2026-05-26). The grammar/execution revisit later** (raised 2026-05-26). The grammar/execution
framework (lexer → walker `Node`s → unified grammar tree → typed framework (lexer → walker `Node`s → unified grammar tree → typed
+72 -14
View File
@@ -8306,7 +8306,22 @@ fn existing_shortids(
/// ///
/// `serial` columns are not handled here — an omitted `serial` /// `serial` columns are not handled here — an omitted `serial`
/// primary key is filled by the engine's rowid (ADR-0033 §6). /// primary key is filled by the engine's rowid (ADR-0033 §6).
fn plan_shortid_autofill( /// Auto-fill the omitted **auto-generated** columns of an advanced-mode
/// Form-A `INSERT` (a `(col, …)` list is present) by reconstructing a
/// parameterised statement, honouring ADR-0018 §1's "auto-generated on
/// every path" contract — the same contract simple-mode `do_insert`
/// honours:
/// - **`shortid`** columns omitted from the list get a fresh distinct
/// id per row (ADR-0018 §3).
/// - **non-PK `serial`** columns omitted from the list get `MAX(col)+1`,
/// `MAX+2`, … per row (ADR-0018 §5; the X4 fix — advanced mode used
/// to leave them NULL). PK serial relies on the engine's rowid alias
/// and is never in this set.
///
/// Returns the original `sql` + an empty param vec when there is nothing
/// to fill (Form B / no omitted auto-gen column / zero rows), so the
/// caller executes the verbatim statement unchanged.
fn plan_autogen_autofill(
conn: &Connection, conn: &Connection,
target_table: &str, target_table: &str,
sql: &str, sql: &str,
@@ -8319,18 +8334,29 @@ fn plan_shortid_autofill(
} }
let schema = read_schema(conn, target_table)?; let schema = read_schema(conn, target_table)?;
// Identifiers are case-preserving but matched case-insensitively // Identifiers are case-preserving but matched case-insensitively
// (ADR-0009): a shortid column counts as omitted unless the user // (ADR-0009): a column counts as omitted unless the user listed a
// listed a name equal to it ignoring ASCII case. // name equal to it ignoring ASCII case.
let listed_ci: Vec<String> = let listed_ci: Vec<String> =
listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect(); listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect();
let is_omitted = |c: &&ReadColumn| !listed_ci.contains(&c.name.to_ascii_lowercase());
let omitted_shortids: Vec<String> = schema let omitted_shortids: Vec<String> = schema
.columns .columns
.iter() .iter()
.filter(|c| c.user_type == Some(Type::ShortId)) .filter(|c| c.user_type == Some(Type::ShortId))
.filter(|c| !listed_ci.contains(&c.name.to_ascii_lowercase())) .filter(is_omitted)
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.collect(); .collect();
if omitted_shortids.is_empty() { // ADR-0018 §5 + X4: a non-PK serial omitted from the list is
// auto-filled MAX+1 per row (PK serial uses the rowid alias and is
// excluded). Mirrors simple-mode `do_insert`.
let omitted_serials: Vec<String> = schema
.columns
.iter()
.filter(|c| c.user_type == Some(Type::Serial) && !c.primary_key)
.filter(is_omitted)
.map(|c| c.name.clone())
.collect();
if omitted_shortids.is_empty() && omitted_serials.is_empty() {
return Ok((sql.to_string(), Vec::new())); return Ok((sql.to_string(), Vec::new()));
} }
@@ -8377,10 +8403,39 @@ fn plan_shortid_autofill(
id_batches.push(generate_shortid_batch(n, &existing)?); id_batches.push(generate_shortid_batch(n, &existing)?);
} }
// Reconstruct: listed columns followed by the omitted shortid // A serial sequence per omitted non-PK serial column: MAX(col)+1 …
// columns; one parameterised tuple per materialised row. // MAX(col)+n. MAX is read once (current table state); the worker-
let all_cols: Vec<&String> = // thread serialisation (ADR-0010) makes the read-then-insert safe.
listed_columns.iter().chain(omitted_shortids.iter()).collect(); // All values exceed the current MAX, so they collide with neither
// existing rows nor each other (the column's UNIQUE holds). MAX+1
// (gaps jumped, not back-filled) matches `do_insert` and the rowid
// PK case (ADR-0018 §5 / Resolution 2).
let mut serial_batches: Vec<Vec<rusqlite::types::Value>> =
Vec::with_capacity(omitted_serials.len());
for col in &omitted_serials {
let max: i64 = conn
.query_row(
&format!(
"SELECT COALESCE(MAX({col}), 0) FROM {tbl};",
col = quote_ident(col),
tbl = quote_ident(target_table),
),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
serial_batches
.push((1..=n as i64).map(|i| rusqlite::types::Value::Integer(max + i)).collect());
}
// Reconstruct: listed columns, then the omitted shortid columns,
// then the omitted non-PK serial columns; one parameterised tuple
// per materialised row. The param push order below matches this.
let all_cols: Vec<&String> = listed_columns
.iter()
.chain(omitted_shortids.iter())
.chain(omitted_serials.iter())
.collect();
let cols_csv = all_cols let cols_csv = all_cols
.iter() .iter()
.map(|c| quote_ident(c)) .map(|c| quote_ident(c))
@@ -8397,6 +8452,9 @@ fn plan_shortid_autofill(
for batch in &id_batches { for batch in &id_batches {
params.push(batch[row_idx].clone()); params.push(batch[row_idx].clone());
} }
for batch in &serial_batches {
params.push(batch[row_idx].clone());
}
let placeholders = (0..per_tuple) let placeholders = (0..per_tuple)
.map(|_| { .map(|_| {
let s = format!("?{ph}"); let s = format!("?{ph}");
@@ -8433,10 +8491,10 @@ fn plan_shortid_autofill(
/// persistence failure rolls the insert back), then commit. /// persistence failure rolls the insert back), then commit.
/// ///
/// Grammar-as-text (ADR-0030 §4): normally the values are literals /// Grammar-as-text (ADR-0030 §4): normally the values are literals
/// in `sql` and no parameters are bound. The sub-phase 3d shortid /// in `sql` and no parameters are bound. The auto-fill path (an
/// auto-fill path is the exception — it reconstructs a /// omitted `shortid` or non-PK `serial` column) is the exception — it
/// parameterised `INSERT` (see `plan_shortid_autofill`); either /// reconstructs a parameterised `INSERT` (see `plan_autogen_autofill`);
/// way `history.log` records the original `source`, never the /// either way `history.log` records the original `source`, never the
/// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL /// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL
/// engine errors surface enriched via `execute_with_fk_enrichment` /// engine errors surface enriched via `execute_with_fk_enrichment`
/// + the friendly-error layer. /// + the friendly-error layer.
@@ -8512,7 +8570,7 @@ fn do_sql_insert(
// params vec with the original `sql` means "no auto-fill — // params vec with the original `sql` means "no auto-fill —
// execute verbatim" (the 3b path). // execute verbatim" (the 3b path).
let (exec_sql, params) = let (exec_sql, params) =
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source, &trailing_tail)?; plan_autogen_autofill(conn, target_table, sql, listed_columns, row_source, &trailing_tail)?;
let tx = conn let tx = conn
.unchecked_transaction() .unchecked_transaction()
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
+1 -1
View File
@@ -105,7 +105,7 @@ fn fallback_value_list() -> Node {
/// `VALUES` position, so it is correctly absent here. /// `VALUES` position, so it is correctly absent here.
/// - **Form B** (no column list): ALL columns in declaration order, /// - **Form B** (no column list): ALL columns in declaration order,
/// including `serial` / `shortid` — advanced-mode Form B auto-fills /// including `serial` / `shortid` — advanced-mode Form B auto-fills
/// *nothing* (`plan_shortid_autofill` returns early on an empty /// *nothing* (`plan_autogen_autofill` returns early on an empty
/// column list), so the user supplies a value for every column. /// column list), so the user supplies a value for every column.
/// ///
/// Empty when schemaless, the table is unknown, or a Form A list /// Empty when schemaless, the table is unknown, or a Form A list
+31
View File
@@ -707,6 +707,37 @@ fn autofill_does_not_mask_arity_mismatch() {
assert!(rows.is_empty(), "no row should land on a rejected insert: {rows:?}"); assert!(rows.is_empty(), "no row should land on a rejected insert: {rows:?}");
} }
#[test]
fn sql_insert_autofills_omitted_nonpk_serial() {
// ADR-0018 §1/§5 + X4: advanced-mode SQL INSERT auto-fills an omitted
// non-PK `serial` column with MAX+1 (per row), exactly as simple-mode
// `do_insert` does — honouring the "auto-generated on every path"
// contract. (Was: silently inserting NULL.) Mirrors the existing
// shortid auto-fill, which already runs on this path.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("seq", Type::Serial)], &["id"]);
// Single row, omitting the non-PK serial `seq`.
run_sqlinsert(&db, &rt, "insert into t (id) values (10)").expect("single-row insert runs");
// Multi-row, omitting `seq` — each row gets a distinct, increasing
// serial continuing from the current MAX.
run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)")
.expect("multi-row insert runs");
let rows = csv_rows(&project, "t");
// No NULL serials, and the sequence is 1, 2, 3 across the three rows.
assert_eq!(
rows,
vec![
vec!["10".to_string(), "1".to_string()],
vec!["20".to_string(), "2".to_string()],
vec!["30".to_string(), "3".to_string()],
],
"omitted non-PK serial auto-filled MAX+1 per row (no NULLs): {rows:?}",
);
}
#[test] #[test]
fn autofill_insert_select_wider_projection_is_rejected() { fn autofill_insert_select_wider_projection_is_rejected() {
// SELECT projects more columns than the list: the guard defers // SELECT projects more columns than the list: the guard defers