diff --git a/src/db.rs b/src/db.rs index 5d921b1..ec4b416 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5832,6 +5832,15 @@ fn plan_shortid_autofill( // as concrete rows for the listed columns. let listed_count = listed_columns.len(); let mut stmt = conn.prepare(row_source).map_err(DbError::from_rusqlite)?; + // Arity guard: if the row source's column count disagrees with + // the user's column list, do NOT auto-fill — reading + // `listed_count` cells would silently drop extra columns (or + // error opaquely on too few). Defer to the verbatim statement + // so the engine reports the mismatch as it does on the + // non-auto-fill path (a friendly pre-flight lands in 3i). + if stmt.column_count() != listed_count { + return Ok((sql.to_string(), Vec::new())); + } let mut rows: Vec> = Vec::new(); { let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?; diff --git a/tests/sql_insert.rs b/tests/sql_insert.rs index 011bf26..687696e 100644 --- a/tests/sql_insert.rs +++ b/tests/sql_insert.rs @@ -661,3 +661,56 @@ fn compound_pk_with_shortid_member_autofills() { ); assert_eq!(rows[0][1], "1", "{rows:?}"); } + +#[test] +fn autofill_does_not_mask_arity_mismatch() { + // A column-list / value-count mismatch must still be rejected + // even when a shortid auto-fill would otherwise kick in — the + // auto-fill path must not silently drop the extra value. (3i + // adds a friendly pre-flight; until then we defer to the + // engine rather than mask the error.) + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('a', 'b')"); + assert!( + outcome.is_err(), + "arity mismatch must be rejected, not masked: {outcome:?}", + ); + let rows = csv_rows(&project, "t"); + assert!(rows.is_empty(), "no row should land on a rejected insert: {rows:?}"); +} + +#[test] +fn autofill_insert_select_wider_projection_is_rejected() { + // SELECT projects more columns than the list: the guard defers + // to the engine instead of dropping the extra projection. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "src", &[("a", Type::Text), ("b", Type::Text)], &["a"]); + create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + run_sqlinsert(&db, &rt, "sqlinsert into src (a, b) values ('p', 'q')").expect("seed"); + let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (label) select a, b from src"); + assert!(outcome.is_err(), "wider projection must be rejected: {outcome:?}"); + assert!(csv_rows(&project, "t").is_empty(), "nothing should land"); +} + +#[test] +fn autofill_insert_select_narrower_projection_is_rejected() { + // SELECT projects fewer columns than the list: the guard + // prevents an out-of-range read and defers to the engine. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "src", &[("a", Type::Text)], &["a"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("x", Type::Text), ("y", Type::Text)], + &["id"], + ); + run_sqlinsert(&db, &rt, "sqlinsert into src (a) values ('p')").expect("seed"); + let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (x, y) select a from src"); + assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}"); + assert!(csv_rows(&project, "t").is_empty(), "nothing should land"); +}