db+grammar: 3d — shortid auto-fill for SQL INSERT (ADR-0033 §6)
When an INSERT's column list omits one or more shortid columns, the worker now fills them. Command::SqlInsert gains listed_columns and row_source, captured in build_sql_insert from the matched path (the row source is located by the first values/select/with Word token, so a string literal like 'select' can't be mistaken for the keyword). do_sql_insert calls plan_shortid_autofill, which — per the user-confirmed Option B — materialises the row source by running it as a query, generates a distinct shortid per row via the existing generate_shortid_batch (deduped against stored values), and reconstructs a parameterised multi-row INSERT over the listed columns plus the omitted shortid columns. Uniform for VALUES and INSERT…SELECT, and handles multiple omitted shortids in one row (each gets its own batch). No explicit list, no omitted shortid, or a zero-row source → execute verbatim (the 3b path). serial stays engine-filled via rowid. history.log keeps the original line, never the rewrite (§11). Tests: VALUES single/multi-row distinct; explicit override honoured; INSERT…SELECT distinct fills; combined serial(engine) + shortid(worker); two shortids (PK + non-PK) both fill; one provided + one omitted; compound-PK shortid member; mixed-case column name (ADR-0009 DA gate); original-source-in-history on the rewrite path. Still behind the dev `sqlinsert` entry word (3j). 1503 green, clippy clean.
This commit is contained in:
@@ -591,6 +591,8 @@ enum Request {
|
|||||||
sql: String,
|
sql: String,
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
target_table: String,
|
target_table: String,
|
||||||
|
listed_columns: Vec<String>,
|
||||||
|
row_source: String,
|
||||||
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
||||||
},
|
},
|
||||||
/// Capture the query plan for an explainable command via
|
/// Capture the query plan for an explainable command via
|
||||||
@@ -1060,12 +1062,16 @@ impl Database {
|
|||||||
sql: String,
|
sql: String,
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
target_table: String,
|
target_table: String,
|
||||||
|
listed_columns: Vec<String>,
|
||||||
|
row_source: String,
|
||||||
) -> Result<InsertResult, DbError> {
|
) -> Result<InsertResult, DbError> {
|
||||||
let (reply, recv) = oneshot::channel();
|
let (reply, recv) = oneshot::channel();
|
||||||
self.send(Request::RunSqlInsert {
|
self.send(Request::RunSqlInsert {
|
||||||
sql,
|
sql,
|
||||||
source,
|
source,
|
||||||
target_table,
|
target_table,
|
||||||
|
listed_columns,
|
||||||
|
row_source,
|
||||||
reply,
|
reply,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1521,6 +1527,8 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
sql,
|
sql,
|
||||||
source,
|
source,
|
||||||
target_table,
|
target_table,
|
||||||
|
listed_columns,
|
||||||
|
row_source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_sql_insert(
|
let _ = reply.send(do_sql_insert(
|
||||||
@@ -1529,6 +1537,8 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
&sql,
|
&sql,
|
||||||
&target_table,
|
&target_table,
|
||||||
|
&listed_columns,
|
||||||
|
&row_source,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Request::RebuildFromText {
|
Request::RebuildFromText {
|
||||||
@@ -5747,6 +5757,149 @@ fn do_run_select_request(
|
|||||||
Ok(data)
|
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<Vec<String>, 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<rusqlite::types::Value>), 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<String> =
|
||||||
|
listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect();
|
||||||
|
let omitted_shortids: Vec<String> = 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<rusqlite::types::Value>> = 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<rusqlite::types::Value>> =
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let per_tuple = all_cols.len();
|
||||||
|
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(n * per_tuple);
|
||||||
|
let mut tuples: Vec<String> = 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::<Vec<_>>()
|
||||||
|
.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,
|
/// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1,
|
||||||
/// sub-phase 3b). Mirrors `do_insert`'s persistence discipline:
|
/// sub-phase 3b). Mirrors `do_insert`'s persistence discipline:
|
||||||
/// run the validated SQL inside a transaction, re-persist the
|
/// 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
|
/// `finalize_persistence` *before* `tx.commit()` (so a
|
||||||
/// persistence failure rolls the insert back), then commit.
|
/// persistence failure rolls the insert back), then commit.
|
||||||
///
|
///
|
||||||
/// Grammar-as-text (ADR-0030 §4): the values are literals in
|
/// Grammar-as-text (ADR-0030 §4): normally the values are literals
|
||||||
/// `sql`, so no parameters are bound. FK / UNIQUE / NOT NULL
|
/// 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`
|
/// engine errors surface enriched via `execute_with_fk_enrichment`
|
||||||
/// + the friendly-error layer.
|
/// + the friendly-error layer.
|
||||||
///
|
///
|
||||||
@@ -5771,12 +5928,23 @@ fn do_sql_insert(
|
|||||||
source: Option<&str>,
|
source: Option<&str>,
|
||||||
sql: &str,
|
sql: &str,
|
||||||
target_table: &str,
|
target_table: &str,
|
||||||
|
listed_columns: &[String],
|
||||||
|
row_source: &str,
|
||||||
) -> Result<InsertResult, DbError> {
|
) -> Result<InsertResult, DbError> {
|
||||||
debug!(sql = %sql, table = %target_table, "sql_insert");
|
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
|
let tx = conn
|
||||||
.unchecked_transaction()
|
.unchecked_transaction()
|
||||||
.map_err(DbError::from_rusqlite)?;
|
.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 last = conn.last_insert_rowid();
|
||||||
let rowids: Vec<i64> = if rows_affected == 0 {
|
let rowids: Vec<i64> = if rows_affected == 0 {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|||||||
+11
-3
@@ -297,12 +297,20 @@ pub enum Command {
|
|||||||
/// the validated statement the worker executes verbatim;
|
/// the validated statement the worker executes verbatim;
|
||||||
/// `target_table` is extracted from the parse so the worker can
|
/// `target_table` is extracted from the parse so the worker can
|
||||||
/// re-persist that table's CSV after a successful insert
|
/// re-persist that table's CSV after a successful insert
|
||||||
/// (ADR-0030 §11) without re-parsing the SQL. `listed_columns`
|
/// (ADR-0030 §11) without re-parsing the SQL.
|
||||||
/// (3d, `shortid` auto-fill) and `returning` (3g) are added by
|
///
|
||||||
/// the sub-phases that read them.
|
/// `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 {
|
SqlInsert {
|
||||||
sql: String,
|
sql: String,
|
||||||
target_table: String,
|
target_table: String,
|
||||||
|
listed_columns: Vec<String>,
|
||||||
|
row_source: String,
|
||||||
},
|
},
|
||||||
/// App-lifecycle command (per ADR-0003). These work in both
|
/// App-lifecycle command (per ADR-0003). These work in both
|
||||||
/// simple and advanced modes; the dispatcher branches on the
|
/// simple and advanced modes; the dispatcher branches on the
|
||||||
|
|||||||
+38
-1
@@ -876,6 +876,38 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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<String> = 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
|
// Everything after the entry word is the `INTO …` tail; prefix
|
||||||
// the real `insert` keyword for the engine.
|
// the real `insert` keyword for the engine.
|
||||||
let tail = path
|
let tail = path
|
||||||
@@ -883,7 +915,12 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
|||||||
.first()
|
.first()
|
||||||
.map_or(source, |entry| &source[entry.span.1..]);
|
.map_or(source, |entry| &source[entry.span.1..]);
|
||||||
let sql = format!("insert {}", tail.trim());
|
let sql = format!("insert {}", tail.trim());
|
||||||
Ok(Command::SqlInsert { sql, target_table })
|
Ok(Command::SqlInsert {
|
||||||
|
sql,
|
||||||
|
target_table,
|
||||||
|
listed_columns,
|
||||||
|
row_source,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
+7
-2
@@ -1885,8 +1885,13 @@ async fn execute_command_typed(
|
|||||||
// text: the worker runs the validated `sql` and re-persists
|
// text: the worker runs the validated `sql` and re-persists
|
||||||
// the parsed `target_table`'s CSV. Reuses the DSL insert
|
// the parsed `target_table`'s CSV. Reuses the DSL insert
|
||||||
// outcome (affected-row count + auto-show).
|
// outcome (affected-row count + auto-show).
|
||||||
Command::SqlInsert { sql, target_table } => database
|
Command::SqlInsert {
|
||||||
.run_sql_insert(sql, src, target_table)
|
sql,
|
||||||
|
target_table,
|
||||||
|
listed_columns,
|
||||||
|
row_source,
|
||||||
|
} => database
|
||||||
|
.run_sql_insert(sql, src, target_table, listed_columns, row_source)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::Insert),
|
.map(CommandOutcome::Insert),
|
||||||
// `EXPLAIN QUERY PLAN` never executes the wrapped
|
// `EXPLAIN QUERY PLAN` never executes the wrapped
|
||||||
|
|||||||
+290
-4
@@ -20,7 +20,7 @@
|
|||||||
//! worker-level tests call `db.run_sql_insert` directly with the
|
//! worker-level tests call `db.run_sql_insert` directly with the
|
||||||
//! real reconstructed SQL.
|
//! 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::dsl::{ColumnSpec, Command, Type, parse_command};
|
||||||
use rdbms_playground::persistence::Persistence;
|
use rdbms_playground::persistence::Persistence;
|
||||||
use rdbms_playground::project;
|
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(),
|
"insert into T (a, b) values (1, 'Ada')".to_string(),
|
||||||
Some("insert into T (a, b) values (1, 'Ada')".to_string()),
|
Some("insert into T (a, b) values (1, 'Ada')".to_string()),
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("insert runs");
|
.expect("insert runs");
|
||||||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
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(),
|
"insert into T (a, b) values (1, 'first'), (2, 'second')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("multi-row insert runs");
|
.expect("multi-row insert runs");
|
||||||
assert_eq!(result.rows_affected, 2, "two rows inserted");
|
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(),
|
"insert into T values (7, 'full-arity')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("full-arity insert runs");
|
.expect("full-arity insert runs");
|
||||||
assert_eq!(result.rows_affected, 1);
|
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(),
|
"insert into T (a, b) values (1, 'logged')".to_string(),
|
||||||
Some(source.to_string()),
|
Some(source.to_string()),
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("insert runs");
|
.expect("insert runs");
|
||||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
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(),
|
"insert into T (a, b) values (1, 'kept')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("first insert runs");
|
.expect("first insert runs");
|
||||||
// Second insert violates the primary key — it must fail and
|
// 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(),
|
"insert into T (a, b) values (1, 'discarded')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
));
|
));
|
||||||
assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}");
|
assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}");
|
||||||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
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(),
|
"insert into T (a, b) values (1, 'existing')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("seed row");
|
.expect("seed row");
|
||||||
// Row (2,…) is new but (1,…) collides on the PK — the whole
|
// 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(),
|
"insert into T (a, b) values (2, 'fresh'), (1, 'collides')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
));
|
));
|
||||||
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}");
|
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}");
|
||||||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
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)")
|
let command = parse_command("sqlinsert into Orders (id, total) values (1, 99.5)")
|
||||||
.expect("sqlinsert parses in advanced mode");
|
.expect("sqlinsert parses in advanced mode");
|
||||||
match command {
|
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!(sql, "insert into Orders (id, total) values (1, 99.5)");
|
||||||
assert_eq!(target_table, "Orders");
|
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")
|
let command = parse_command("sqlinsert into archive select * from source")
|
||||||
.expect("INSERT … SELECT parses in advanced mode");
|
.expect("INSERT … SELECT parses in advanced mode");
|
||||||
match command {
|
match command {
|
||||||
Command::SqlInsert { sql, target_table } => {
|
Command::SqlInsert { sql, target_table, .. } => {
|
||||||
assert_eq!(sql, "insert into archive select * from source");
|
assert_eq!(sql, "insert into archive select * from source");
|
||||||
assert_eq!(target_table, "archive");
|
assert_eq!(target_table, "archive");
|
||||||
}
|
}
|
||||||
@@ -257,7 +273,7 @@ fn parse_path_lowers_with_prefixed_insert_select() {
|
|||||||
)
|
)
|
||||||
.expect("WITH-prefixed INSERT … SELECT parses");
|
.expect("WITH-prefixed INSERT … SELECT parses");
|
||||||
match command {
|
match command {
|
||||||
Command::SqlInsert { sql, target_table } => {
|
Command::SqlInsert { sql, target_table, .. } => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sql,
|
sql,
|
||||||
"insert into archive with t as (select * from orders) select * from t",
|
"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(),
|
"insert into source (a, b) values (1, 'one'), (2, 'two')".to_string(),
|
||||||
None,
|
None,
|
||||||
"source".to_string(),
|
"source".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("seed source");
|
.expect("seed source");
|
||||||
let result = rt
|
let result = rt
|
||||||
@@ -285,6 +303,8 @@ fn insert_select_copies_rows_and_persists() {
|
|||||||
"insert into archive select * from source".to_string(),
|
"insert into archive select * from source".to_string(),
|
||||||
Some("insert into archive select * from source".to_string()),
|
Some("insert into archive select * from source".to_string()),
|
||||||
"archive".to_string(),
|
"archive".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("INSERT … SELECT runs");
|
.expect("INSERT … SELECT runs");
|
||||||
assert_eq!(result.rows_affected, 2, "both source rows copied");
|
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(),
|
"insert into source (a, b) values (5, 'five')".to_string(),
|
||||||
None,
|
None,
|
||||||
"source".to_string(),
|
"source".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("seed source");
|
.expect("seed source");
|
||||||
let result = rt
|
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(),
|
"insert into target (a, b) select a, b from source".to_string(),
|
||||||
None,
|
None,
|
||||||
"target".to_string(),
|
"target".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("column-list + projection INSERT … SELECT runs");
|
.expect("column-list + projection INSERT … SELECT runs");
|
||||||
assert_eq!(result.rows_affected, 1);
|
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(),
|
"insert into orders (a, b) values (1, 'a'), (2, 'b')".to_string(),
|
||||||
None,
|
None,
|
||||||
"orders".to_string(),
|
"orders".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("seed orders");
|
.expect("seed orders");
|
||||||
let result = rt
|
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(),
|
"insert into archive with t as (select * from orders) select * from t".to_string(),
|
||||||
None,
|
None,
|
||||||
"archive".to_string(),
|
"archive".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("WITH-prefixed INSERT … SELECT runs");
|
.expect("WITH-prefixed INSERT … SELECT runs");
|
||||||
assert_eq!(result.rows_affected, 2);
|
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(),
|
"insert into T (a, b) values (1, 'x'), (2, 'y')".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("seed");
|
.expect("seed");
|
||||||
let result = rt
|
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(),
|
"insert into T select a + 10, b from T".to_string(),
|
||||||
None,
|
None,
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
String::new(),
|
||||||
))
|
))
|
||||||
.expect("self-sourced INSERT … SELECT runs");
|
.expect("self-sourced INSERT … SELECT runs");
|
||||||
assert_eq!(result.rows_affected, 2, "two rows copied with shifted PK");
|
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:?}",
|
"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<InsertResult, DbError> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user