diff --git a/src/db.rs b/src/db.rs index fffd516..5d921b1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -591,6 +591,8 @@ enum Request { sql: String, source: Option, target_table: String, + listed_columns: Vec, + row_source: String, reply: oneshot::Sender>, }, /// Capture the query plan for an explainable command via @@ -1060,12 +1062,16 @@ impl Database { sql: String, source: Option, target_table: String, + listed_columns: Vec, + row_source: String, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RunSqlInsert { sql, source, target_table, + listed_columns, + row_source, reply, }) .await?; @@ -1521,6 +1527,8 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req sql, source, target_table, + listed_columns, + row_source, reply, } => { let _ = reply.send(do_sql_insert( @@ -1529,6 +1537,8 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req source.as_deref(), &sql, &target_table, + &listed_columns, + &row_source, )); } Request::RebuildFromText { @@ -5747,6 +5757,149 @@ fn do_run_select_request( Ok(data) } +/// Currently-stored non-NULL values of one column, for shortid +/// collision-avoidance (passed to `generate_shortid_batch`). +fn existing_shortids( + conn: &Connection, + table: &str, + column: &str, +) -> Result, DbError> { + let mut stmt = conn + .prepare(&format!( + "SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;", + col = quote_ident(column), + tbl = quote_ident(table), + )) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(DbError::from_rusqlite)?); + } + Ok(out) +} + +/// Plan `shortid` auto-fill for a SQL `INSERT` (ADR-0033 §6, +/// sub-phase 3d). +/// +/// Returns the SQL the worker should execute plus its bound +/// params. When the user's `(column_list)` omits one or more +/// `shortid` columns, this materialises the row source (Option B: +/// run it as a query), synthesises a fresh distinct id per row via +/// `generate_shortid_batch`, and reconstructs a parameterised +/// multi-row `INSERT` over the listed columns plus the omitted +/// shortid columns. Otherwise it returns the original `sql` +/// verbatim with no params (the 3b path): +/// +/// - no explicit column list → the row source supplies every +/// column positionally (a listed shortid is the user's value); +/// - no omitted shortid column → nothing to fill; +/// - the row source yields zero rows → nothing to fill (the +/// verbatim INSERT inserts nothing without a NOT-NULL violation). +/// +/// `serial` columns are not handled here — an omitted `serial` +/// primary key is filled by the engine's rowid (ADR-0033 §6). +fn plan_shortid_autofill( + conn: &Connection, + target_table: &str, + sql: &str, + listed_columns: &[String], + row_source: &str, +) -> Result<(String, Vec), DbError> { + if listed_columns.is_empty() { + return Ok((sql.to_string(), Vec::new())); + } + let schema = read_schema(conn, target_table)?; + // Identifiers are case-preserving but matched case-insensitively + // (ADR-0009): a shortid column counts as omitted unless the user + // listed a name equal to it ignoring ASCII case. + let listed_ci: Vec = + listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect(); + let omitted_shortids: Vec = schema + .columns + .iter() + .filter(|c| c.user_type == Some(Type::ShortId)) + .filter(|c| !listed_ci.contains(&c.name.to_ascii_lowercase())) + .map(|c| c.name.clone()) + .collect(); + if omitted_shortids.is_empty() { + return Ok((sql.to_string(), Vec::new())); + } + + // Materialise the row source (VALUES / SELECT / WITH … SELECT) + // 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)?; + let mut rows: Vec> = Vec::new(); + { + let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?; + while let Some(r) = q.next().map_err(DbError::from_rusqlite)? { + let mut cells = Vec::with_capacity(listed_count); + for i in 0..listed_count { + cells.push( + r.get::<_, rusqlite::types::Value>(i) + .map_err(DbError::from_rusqlite)?, + ); + } + rows.push(cells); + } + } + let n = rows.len(); + if n == 0 { + // Nothing to insert — the verbatim statement inserts zero + // rows without touching the omitted shortid column. + return Ok((sql.to_string(), Vec::new())); + } + + // A fresh, distinct shortid per row for each omitted column, + // avoiding collision with values already stored in that column. + let mut id_batches: Vec> = + Vec::with_capacity(omitted_shortids.len()); + for col in &omitted_shortids { + let existing = existing_shortids(conn, target_table, col)?; + id_batches.push(generate_shortid_batch(n, &existing)?); + } + + // Reconstruct: listed columns followed by the omitted shortid + // columns; one parameterised tuple per materialised row. + let all_cols: Vec<&String> = + listed_columns.iter().chain(omitted_shortids.iter()).collect(); + let cols_csv = all_cols + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let per_tuple = all_cols.len(); + let mut params: Vec = Vec::with_capacity(n * per_tuple); + let mut tuples: Vec = Vec::with_capacity(n); + let mut ph = 1; + for (row_idx, row) in rows.into_iter().enumerate() { + for cell in row { + params.push(cell); + } + for batch in &id_batches { + params.push(batch[row_idx].clone()); + } + let placeholders = (0..per_tuple) + .map(|_| { + let s = format!("?{ph}"); + ph += 1; + s + }) + .collect::>() + .join(", "); + tuples.push(format!("({placeholders})")); + } + let exec_sql = format!( + "INSERT INTO {tbl} ({cols_csv}) VALUES {vals};", + tbl = quote_ident(target_table), + vals = tuples.join(", "), + ); + Ok((exec_sql, params)) +} + /// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1, /// sub-phase 3b). Mirrors `do_insert`'s persistence discipline: /// run the validated SQL inside a transaction, re-persist the @@ -5754,8 +5907,12 @@ fn do_run_select_request( /// `finalize_persistence` *before* `tx.commit()` (so a /// persistence failure rolls the insert back), then commit. /// -/// Grammar-as-text (ADR-0030 §4): the values are literals in -/// `sql`, so no parameters are bound. FK / UNIQUE / NOT NULL +/// Grammar-as-text (ADR-0030 §4): normally the values are literals +/// in `sql` and no parameters are bound. The sub-phase 3d shortid +/// auto-fill path is the exception — it reconstructs a +/// parameterised `INSERT` (see `plan_shortid_autofill`); either +/// way `history.log` records the original `source`, never the +/// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL /// engine errors surface enriched via `execute_with_fk_enrichment` /// + the friendly-error layer. /// @@ -5771,12 +5928,23 @@ fn do_sql_insert( source: Option<&str>, sql: &str, target_table: &str, + listed_columns: &[String], + row_source: &str, ) -> Result { debug!(sql = %sql, table = %target_table, "sql_insert"); + // Sub-phase 3d: when the user's column list omits one or more + // `shortid` columns, the worker materialises the row source, + // synthesises fresh distinct ids, and reinserts the augmented + // rows. Returns the executable SQL + bound params; an empty + // params vec with the original `sql` means "no auto-fill — + // execute verbatim" (the 3b path). + let (exec_sql, params) = + plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source)?; let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; - let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?; + let rows_affected = + execute_with_fk_enrichment(conn, target_table, &exec_sql, ¶ms)?; let last = conn.last_insert_rowid(); let rowids: Vec = if rows_affected == 0 { Vec::new() diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 6425550..101cf88 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -297,12 +297,20 @@ pub enum Command { /// the validated statement the worker executes verbatim; /// `target_table` is extracted from the parse so the worker can /// re-persist that table's CSV after a successful insert - /// (ADR-0030 §11) without re-parsing the SQL. `listed_columns` - /// (3d, `shortid` auto-fill) and `returning` (3g) are added by - /// the sub-phases that read them. + /// (ADR-0030 §11) without re-parsing the SQL. + /// + /// `listed_columns` is the user's explicit `(col, …)` list + /// (empty when the form omits it); `row_source` is the + /// `VALUES …` / `SELECT …` / `WITH … SELECT …` text. Both are + /// captured for sub-phase 3d's `shortid` auto-fill: when the + /// list omits a `shortid` column, the worker materialises the + /// row source, generates fresh ids, and reinserts. `returning` + /// (3g) is added by the sub-phase that reads it. SqlInsert { sql: String, target_table: String, + listed_columns: Vec, + row_source: String, }, /// App-lifecycle command (per ADR-0003). These work in both /// simple and advanced modes; the dispatcher branches on the diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 47567ed..9194e41 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -876,6 +876,38 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result None, }) .unwrap_or_default(); + // The user's explicit `(col, …)` list, in order (empty when the + // form omits it). Sub-phase 3d reads this to decide which + // `shortid` columns were left for the worker to auto-fill. + let listed_columns: Vec = path + .items + .iter() + .filter_map(|item| match item.kind { + MatchedKind::Ident { + role: "insert_column", + .. + } => Some(item.text.clone()), + _ => None, + }) + .collect(); + // The row source is everything from the `VALUES` / `SELECT` / + // `WITH` keyword onward. Located by the first matching *Word + // token* in the path (not a text scan), so a string literal + // like `values ('select')` can't be mistaken for the keyword. + let row_source = path + .items + .iter() + .find(|item| { + matches!(item.kind, MatchedKind::Word("values" | "select" | "with")) + }) + .map(|item| { + source[item.span.0..] + .trim() + .trim_end_matches(';') + .trim() + .to_string() + }) + .unwrap_or_default(); // Everything after the entry word is the `INTO …` tail; prefix // the real `insert` keyword for the engine. let tail = path @@ -883,7 +915,12 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result database - .run_sql_insert(sql, src, target_table) + Command::SqlInsert { + sql, + target_table, + listed_columns, + row_source, + } => database + .run_sql_insert(sql, src, target_table, listed_columns, row_source) .await .map(CommandOutcome::Insert), // `EXPLAIN QUERY PLAN` never executes the wrapped diff --git a/tests/sql_insert.rs b/tests/sql_insert.rs index 6aa0e6e..011bf26 100644 --- a/tests/sql_insert.rs +++ b/tests/sql_insert.rs @@ -20,7 +20,7 @@ //! worker-level tests call `db.run_sql_insert` directly with the //! real reconstructed SQL. -use rdbms_playground::db::Database; +use rdbms_playground::db::{Database, DbError, InsertResult}; use rdbms_playground::dsl::{ColumnSpec, Command, Type, parse_command}; use rdbms_playground::persistence::Persistence; use rdbms_playground::project; @@ -72,6 +72,8 @@ fn single_row_insert_persists_and_counts() { "insert into T (a, b) values (1, 'Ada')".to_string(), Some("insert into T (a, b) values (1, 'Ada')".to_string()), "T".to_string(), + Vec::new(), + String::new(), )) .expect("insert runs"); assert_eq!(result.rows_affected, 1, "one row inserted"); @@ -89,6 +91,8 @@ fn multi_row_insert_persists_both_rows() { "insert into T (a, b) values (1, 'first'), (2, 'second')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )) .expect("multi-row insert runs"); assert_eq!(result.rows_affected, 2, "two rows inserted"); @@ -109,6 +113,8 @@ fn no_column_list_full_arity_insert_persists() { "insert into T values (7, 'full-arity')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )) .expect("full-arity insert runs"); assert_eq!(result.rows_affected, 1); @@ -127,6 +133,8 @@ fn insert_appends_literal_line_to_history() { "insert into T (a, b) values (1, 'logged')".to_string(), Some(source.to_string()), "T".to_string(), + Vec::new(), + String::new(), )) .expect("insert runs"); let body = std::fs::read_to_string(project.path().join("history.log")) @@ -147,6 +155,8 @@ fn failed_insert_rolls_back_and_does_not_repersist() { "insert into T (a, b) values (1, 'kept')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )) .expect("first insert runs"); // Second insert violates the primary key — it must fail and @@ -156,6 +166,8 @@ fn failed_insert_rolls_back_and_does_not_repersist() { "insert into T (a, b) values (1, 'discarded')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )); assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}"); let csv = read_csv(&project, "T").expect("T.csv still present"); @@ -177,6 +189,8 @@ fn failed_multi_row_insert_is_atomic() { "insert into T (a, b) values (1, 'existing')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )) .expect("seed row"); // Row (2,…) is new but (1,…) collides on the PK — the whole @@ -185,6 +199,8 @@ fn failed_multi_row_insert_is_atomic() { "insert into T (a, b) values (2, 'fresh'), (1, 'collides')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )); assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}"); let csv = read_csv(&project, "T").expect("T.csv still present"); @@ -201,7 +217,7 @@ fn parse_path_lowers_sqlinsert_scaffold_to_command() { let command = parse_command("sqlinsert into Orders (id, total) values (1, 99.5)") .expect("sqlinsert parses in advanced mode"); match command { - Command::SqlInsert { sql, target_table } => { + Command::SqlInsert { sql, target_table, .. } => { assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)"); assert_eq!(target_table, "Orders"); } @@ -241,7 +257,7 @@ fn parse_path_lowers_insert_select_to_command() { let command = parse_command("sqlinsert into archive select * from source") .expect("INSERT … SELECT parses in advanced mode"); match command { - Command::SqlInsert { sql, target_table } => { + Command::SqlInsert { sql, target_table, .. } => { assert_eq!(sql, "insert into archive select * from source"); assert_eq!(target_table, "archive"); } @@ -257,7 +273,7 @@ fn parse_path_lowers_with_prefixed_insert_select() { ) .expect("WITH-prefixed INSERT … SELECT parses"); match command { - Command::SqlInsert { sql, target_table } => { + Command::SqlInsert { sql, target_table, .. } => { assert_eq!( sql, "insert into archive with t as (select * from orders) select * from t", @@ -278,6 +294,8 @@ fn insert_select_copies_rows_and_persists() { "insert into source (a, b) values (1, 'one'), (2, 'two')".to_string(), None, "source".to_string(), + Vec::new(), + String::new(), )) .expect("seed source"); let result = rt @@ -285,6 +303,8 @@ fn insert_select_copies_rows_and_persists() { "insert into archive select * from source".to_string(), Some("insert into archive select * from source".to_string()), "archive".to_string(), + Vec::new(), + String::new(), )) .expect("INSERT … SELECT runs"); assert_eq!(result.rows_affected, 2, "both source rows copied"); @@ -305,6 +325,8 @@ fn insert_select_with_column_list_and_projection_persists() { "insert into source (a, b) values (5, 'five')".to_string(), None, "source".to_string(), + Vec::new(), + String::new(), )) .expect("seed source"); let result = rt @@ -312,6 +334,8 @@ fn insert_select_with_column_list_and_projection_persists() { "insert into target (a, b) select a, b from source".to_string(), None, "target".to_string(), + Vec::new(), + String::new(), )) .expect("column-list + projection INSERT … SELECT runs"); assert_eq!(result.rows_affected, 1); @@ -330,6 +354,8 @@ fn with_prefixed_insert_select_runs_and_persists() { "insert into orders (a, b) values (1, 'a'), (2, 'b')".to_string(), None, "orders".to_string(), + Vec::new(), + String::new(), )) .expect("seed orders"); let result = rt @@ -337,6 +363,8 @@ fn with_prefixed_insert_select_runs_and_persists() { "insert into archive with t as (select * from orders) select * from t".to_string(), None, "archive".to_string(), + Vec::new(), + String::new(), )) .expect("WITH-prefixed INSERT … SELECT runs"); assert_eq!(result.rows_affected, 2); @@ -359,6 +387,8 @@ fn insert_select_from_self_runs_as_plain_insert() { "insert into T (a, b) values (1, 'x'), (2, 'y')".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )) .expect("seed"); let result = rt @@ -366,6 +396,8 @@ fn insert_select_from_self_runs_as_plain_insert() { "insert into T select a + 10, b from T".to_string(), None, "T".to_string(), + Vec::new(), + String::new(), )) .expect("self-sourced INSERT … SELECT runs"); assert_eq!(result.rows_affected, 2, "two rows copied with shifted PK"); @@ -375,3 +407,257 @@ fn insert_select_from_self_runs_as_plain_insert() { "the shifted-PK copies landed: {csv:?}", ); } + +// ================================================================= +// Sub-phase 3d — shortid auto-fill (worker) +// ================================================================= + +/// Full-stack: parse the dev `sqlinsert …` scaffold (so +/// `listed_columns` / `row_source` are extracted exactly as the +/// app does) and run it through the worker. +fn run_sqlinsert( + db: &Database, + rt: &tokio::runtime::Runtime, + input: &str, +) -> Result { + match parse_command(input).expect("parse sqlinsert") { + Command::SqlInsert { + sql, + target_table, + listed_columns, + row_source, + } => rt.block_on(db.run_sql_insert( + sql, + Some(input.to_string()), + target_table, + listed_columns, + row_source, + )), + other => panic!("expected Command::SqlInsert, got {other:?}"), + } +} + +fn create_cols( + db: &Database, + rt: &tokio::runtime::Runtime, + name: &str, + cols: &[(&str, Type)], + pk: &[&str], +) { + rt.block_on(db.create_table( + name.to_string(), + cols.iter().map(|(n, t)| ColumnSpec::new(*n, *t)).collect(), + pk.iter().map(|s| (*s).to_string()).collect(), + None, + )) + .unwrap_or_else(|e| panic!("create table {name}: {e:?}")); +} + +/// Data rows of a table's CSV (header skipped), each split on `,`. +/// The test data uses comma/quote-free values, so a plain split is +/// sufficient. +fn csv_rows(project: &project::Project, table: &str) -> Vec> { + read_csv(project, table) + .unwrap_or_default() + .lines() + .skip(1) + .filter(|l| !l.is_empty()) + .map(|l| l.split(',').map(str::to_string).collect()) + .collect() +} + +#[test] +fn values_autofills_omitted_shortid_pk() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x')") + .expect("auto-fill insert runs"); + assert_eq!(result.rows_affected, 1); + let rows = csv_rows(&project, "t"); + assert_eq!(rows.len(), 1, "one row: {rows:?}"); + assert!(!rows[0][0].is_empty(), "id auto-filled: {rows:?}"); + assert_eq!(rows[0][1], "x", "label preserved: {rows:?}"); +} + +#[test] +fn values_multirow_autofills_distinct_shortids() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + let result = run_sqlinsert( + &db, + &rt, + "sqlinsert into t (label) values ('a'), ('b'), ('c')", + ) + .expect("multi-row auto-fill runs"); + assert_eq!(result.rows_affected, 3); + let rows = csv_rows(&project, "t"); + let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect(); + assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}"); + assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}"); +} + +#[test] +fn explicit_shortid_value_is_respected() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + // The user provided `id` explicitly — it must be honoured + // verbatim (the override WARNING is sub-phase 3i). + run_sqlinsert( + &db, + &rt, + "sqlinsert into t (id, label) values ('hardcoded', 'x')", + ) + .expect("explicit-id insert runs"); + let rows = csv_rows(&project, "t"); + assert_eq!(rows[0][0], "hardcoded", "explicit id preserved: {rows:?}"); +} + +#[test] +fn insert_select_autofills_distinct_shortids() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "source", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols(&db, &rt, "target", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + run_sqlinsert(&db, &rt, "sqlinsert into source (label) values ('a'), ('b')") + .expect("seed source"); + let result = run_sqlinsert( + &db, + &rt, + "sqlinsert into target (label) select label from source", + ) + .expect("INSERT … SELECT auto-fill runs"); + assert_eq!(result.rows_affected, 2); + let rows = csv_rows(&project, "target"); + let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect(); + assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}"); + assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}"); +} + +#[test] +fn combined_serial_and_shortid_autofill() { + let (project, db, _dir) = open_project_db(); + let rt = rt(); + // id: serial PK (engine rowid), code: shortid (worker), name. + create_cols( + &db, + &rt, + "t", + &[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)], + &["id"], + ); + run_sqlinsert(&db, &rt, "sqlinsert into t (name) values ('x')") + .expect("combined auto-fill runs"); + let rows = csv_rows(&project, "t"); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}"); + assert!(!rows[0][1].is_empty(), "shortid worker-filled: {rows:?}"); + assert_eq!(rows[0][2], "x", "name preserved: {rows:?}"); +} + +#[test] +fn autofill_logs_original_source_not_rewritten_sql() { + // ADR-0030 §11: even though the worker rewrites the executed + // statement to bind synthesised shortids, history.log records + // the user's original line verbatim. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + let input = "sqlinsert into t (label) values ('x')"; + run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs"); + let body = std::fs::read_to_string(project.path().join("history.log")) + .expect("history.log present"); + assert!(body.contains(input), "original line logged: {body:?}"); + // The rewritten parameterised INSERT must not leak into history. + assert!( + !body.contains("INSERT INTO") && !body.contains("?1"), + "rewritten SQL must not be logged: {body:?}", + ); +} + +#[test] +fn shortid_autofill_respects_mixed_case_column_name() { + // ADR-0009 / 3d DA gate: identifiers are case-preserving. The + // omitted-shortid detection must match the case-preserved + // schema name `MyId`, not a lowercased form. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols(&db, &rt, "t", &[("MyId", Type::ShortId), ("label", Type::Text)], &["MyId"]); + run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x')") + .expect("mixed-case shortid auto-fill runs"); + let rows = csv_rows(&project, "t"); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert!(!rows[0][0].is_empty(), "MyId auto-filled: {rows:?}"); +} + +#[test] +fn two_shortids_pk_and_nonpk_both_autofill_distinctly() { + // Two shortid columns (one PK, one not), both omitted: each + // gets its own distinct-per-row batch. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)], + &["id"], + ); + let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x'), ('y')") + .expect("two-shortid auto-fill runs"); + assert_eq!(result.rows_affected, 2); + let rows = csv_rows(&project, "t"); + let ids: std::collections::HashSet<&String> = rows.iter().map(|x| &x[0]).collect(); + let codes: std::collections::HashSet<&String> = rows.iter().map(|x| &x[1]).collect(); + assert_eq!(ids.len(), 2, "distinct ids: {rows:?}"); + assert_eq!(codes.len(), 2, "distinct codes: {rows:?}"); + assert!( + rows.iter().all(|x| !x[0].is_empty() && !x[1].is_empty()), + "both shortid columns filled on every row: {rows:?}", + ); +} + +#[test] +fn two_shortids_one_provided_one_autofilled() { + // The user provides one shortid and omits the other; the + // provided value is honoured and only the omitted one fills. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)], + &["id"], + ); + run_sqlinsert(&db, &rt, "sqlinsert into t (id, label) values ('myid', 'x')") + .expect("partial-shortid insert runs"); + let rows = csv_rows(&project, "t"); + assert_eq!(rows[0][0], "myid", "provided id preserved: {rows:?}"); + assert!(!rows[0][1].is_empty(), "omitted code auto-filled: {rows:?}"); +} + +#[test] +fn compound_pk_with_shortid_member_autofills() { + // A shortid that is part of a compound PK still auto-fills when + // omitted (membership in the PK is irrelevant to the fill). + let (project, db, _dir) = open_project_db(); + let rt = rt(); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)], + &["id", "region"], + ); + run_sqlinsert(&db, &rt, "sqlinsert into t (region, label) values (1, 'x')") + .expect("compound-pk insert runs"); + let rows = csv_rows(&project, "t"); + assert!( + !rows[0][0].is_empty(), + "shortid PK member auto-filled: {rows:?}", + ); + assert_eq!(rows[0][1], "1", "{rows:?}"); +}