4aeea55984
Record the submission mode per history entry so advanced commands are reusable in simple mode, and fix the bug where a ':'-one-shot command lost its ':' across sessions (ADR-0052, closing #30). Format: the history.log status token gains an optional ':adv' suffix (ok / ok:adv / err / err:adv); 'source' stays last and canonical, so replay is unaffected. The in-memory ring (still Vec<String>) stores advanced entries ': '-prefixed; recall strips the ':' in advanced mode and keeps it in simple; hydration reconstructs the prefix from the tag. Journaling moved from the worker to the dispatch layer (spawn_dsl_- dispatch / run_replay / app-command sites), where the mode is in scope with no worker plumbing; finalize_persistence writes only yaml/csv (commit-db-last still atomic for state). The journal write is now best-effort (command already committed), consistent with the failure path. App commands journal simple, so they recall bare. Journaling is now uniform (every successful command, per ADR-0034) — closing a gap where show tables/relationships/explain didn't journal. Amends ADR-0034 (status tag + journaling location), ADR-0015 §6 (history.log out of the worker tx), ADR-0040 (journal-write best-effort). 15 worker-level journaling tests retired, re-covered at the new layer (history.rs format, app.rs recall matrix, iteration6 cross-session regression, replay). 2471 pass / 0 fail / 0 skip, clippy clean.
1362 lines
52 KiB
Rust
1362 lines
52 KiB
Rust
//! Sub-phase 3b integration tests for the advanced-mode SQL
|
||
//! `INSERT` surface (ADR-0033 §1).
|
||
//!
|
||
//! Covers:
|
||
//! - Worker round-trip: a validated `INSERT` runs against the
|
||
//! database, returns the affected-row count, and re-persists the
|
||
//! target table's CSV (ADR-0030 §11).
|
||
//! - Single-row, multi-row, explicit-column-list, and full-arity
|
||
//! (no column list) forms.
|
||
//! - `history.log` records the literal submitted line.
|
||
//! - A failing INSERT (PK conflict) rolls back and does NOT
|
||
//! re-persist the CSV.
|
||
//! - Parse path: `parse_command` (advanced mode) lowers the dev
|
||
//! `sqlinsert` scaffold to `Command::SqlInsert`, reconstructing
|
||
//! valid `insert …` SQL and extracting the target table.
|
||
//! - `__rdbms_*` target tables are rejected at the grammar layer.
|
||
//!
|
||
//! The dev `sqlinsert` entry word keeps the SQL INSERT path
|
||
//! isolated from the DSL `insert` word until sub-phase 3j; the
|
||
//! worker-level tests call `db.run_sql_insert` directly with the
|
||
//! real reconstructed SQL.
|
||
|
||
use rdbms_playground::completion::{SchemaCache, TableColumn};
|
||
use rdbms_playground::db::{Database, DbError, InsertResult};
|
||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||
use rdbms_playground::event::AppEvent;
|
||
use rdbms_playground::input_render::{
|
||
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
|
||
};
|
||
use rdbms_playground::mode::Mode;
|
||
use rdbms_playground::persistence::Persistence;
|
||
use rdbms_playground::project;
|
||
use rdbms_playground::runtime::run_replay;
|
||
|
||
fn rt() -> tokio::runtime::Runtime {
|
||
tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("tokio rt")
|
||
}
|
||
|
||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||
let dir = tempfile::tempdir().expect("create tempdir");
|
||
let project =
|
||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||
let persistence = Persistence::new(project.path().to_path_buf());
|
||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||
.expect("open db with persistence");
|
||
(project, db, dir)
|
||
}
|
||
|
||
fn read_csv(project: &project::Project, table: &str) -> Option<String> {
|
||
std::fs::read_to_string(project.path().join("data").join(format!("{table}.csv"))).ok()
|
||
}
|
||
|
||
/// Create a two-column table `T(a int pk, b text)` — no
|
||
/// auto-generated columns, so 3b's explicit-values requirement is
|
||
/// satisfied by every INSERT below.
|
||
fn create_t(db: &Database, rt: &tokio::runtime::Runtime) {
|
||
rt.block_on(db.create_table(
|
||
"T".to_string(),
|
||
vec![
|
||
ColumnSpec::new("a", Type::Int),
|
||
ColumnSpec::new("b", Type::Text),
|
||
],
|
||
vec!["a".to_string()],
|
||
None,
|
||
))
|
||
.expect("create table T");
|
||
}
|
||
|
||
#[test]
|
||
fn single_row_insert_persists_and_counts() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_t(&db, &rt);
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"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(),
|
||
false,
|
||
))
|
||
.expect("insert runs");
|
||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||
let csv = read_csv(&project, "T").expect("T.csv written after insert");
|
||
assert!(csv.contains("Ada"), "CSV reflects the inserted row: {csv:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn multi_row_insert_persists_both_rows() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_t(&db, &rt);
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"insert into T (a, b) values (1, 'first'), (2, 'second')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("multi-row insert runs");
|
||
assert_eq!(result.rows_affected, 2, "two rows inserted");
|
||
let csv = read_csv(&project, "T").expect("T.csv written");
|
||
assert!(
|
||
csv.contains("first") && csv.contains("second"),
|
||
"CSV reflects both inserted rows: {csv:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn no_column_list_full_arity_insert_persists() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_t(&db, &rt);
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"insert into T values (7, 'full-arity')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("full-arity insert runs");
|
||
assert_eq!(result.rows_affected, 1);
|
||
let csv = read_csv(&project, "T").expect("T.csv written");
|
||
assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn failed_insert_rolls_back_and_does_not_repersist() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_t(&db, &rt);
|
||
// First insert succeeds and persists.
|
||
rt.block_on(db.run_sql_insert(
|
||
"insert into T (a, b) values (1, 'kept')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("first insert runs");
|
||
// Second insert violates the primary key — it must fail and
|
||
// leave persistence untouched (the transaction rolls back
|
||
// before finalize_persistence).
|
||
let outcome = rt.block_on(db.run_sql_insert(
|
||
"insert into T (a, b) values (1, 'discarded')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
));
|
||
assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}");
|
||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
||
assert!(
|
||
csv.contains("kept") && !csv.contains("discarded"),
|
||
"failed insert must not be persisted: {csv:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_multi_row_insert_is_atomic() {
|
||
// ADR-0033 §3b DA gate: a multi-row INSERT whose later tuple
|
||
// violates a constraint fails as one statement — no partial
|
||
// rows land, and the CSV is not rewritten.
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_t(&db, &rt);
|
||
rt.block_on(db.run_sql_insert(
|
||
"insert into T (a, b) values (1, 'existing')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("seed row");
|
||
// Row (2,…) is new but (1,…) collides on the PK — the whole
|
||
// statement must fail with neither tuple applied.
|
||
let outcome = rt.block_on(db.run_sql_insert(
|
||
"insert into T (a, b) values (2, 'fresh'), (1, 'collides')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
));
|
||
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}");
|
||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
||
assert!(
|
||
csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"),
|
||
"no tuple from the failed multi-row insert may land: {csv:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_path_lowers_sqlinsert_scaffold_to_command() {
|
||
// Advanced-mode parse of the dev scaffold reconstructs valid
|
||
// `insert …` SQL and extracts the target table.
|
||
let command = parse_command("insert into Orders (id, total) values (1, 99.5)")
|
||
.expect("insert parses in advanced mode");
|
||
match command {
|
||
Command::SqlInsert { sql, target_table, .. } => {
|
||
assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
|
||
assert_eq!(target_table, "Orders");
|
||
}
|
||
other => panic!("expected Command::SqlInsert, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_path_rejects_internal_target_table() {
|
||
let result = parse_command("insert into __rdbms_playground_columns values (1)");
|
||
assert!(
|
||
result.is_err(),
|
||
"an internal `__rdbms_*` target must be rejected: {result:?}",
|
||
);
|
||
}
|
||
|
||
// =================================================================
|
||
// Sub-phase 3c — INSERT … SELECT
|
||
// =================================================================
|
||
|
||
/// Create a two-column table `name(a int pk, b text)`.
|
||
fn create_named(db: &Database, rt: &tokio::runtime::Runtime, name: &str) {
|
||
rt.block_on(db.create_table(
|
||
name.to_string(),
|
||
vec![
|
||
ColumnSpec::new("a", Type::Int),
|
||
ColumnSpec::new("b", Type::Text),
|
||
],
|
||
vec!["a".to_string()],
|
||
None,
|
||
))
|
||
.unwrap_or_else(|e| panic!("create table {name}: {e:?}"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_path_lowers_insert_select_to_command() {
|
||
let command = parse_command("insert into archive select * from source")
|
||
.expect("INSERT … SELECT parses in advanced mode");
|
||
match command {
|
||
Command::SqlInsert { sql, target_table, .. } => {
|
||
assert_eq!(sql, "insert into archive select * from source");
|
||
assert_eq!(target_table, "archive");
|
||
}
|
||
other => panic!("expected Command::SqlInsert, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_path_lowers_with_prefixed_insert_select() {
|
||
// R4: a WITH-prefixed SELECT row source lowers verbatim.
|
||
let command = parse_command(
|
||
"insert into archive with t as (select * from orders) select * from t",
|
||
)
|
||
.expect("WITH-prefixed INSERT … SELECT parses");
|
||
match command {
|
||
Command::SqlInsert { sql, target_table, .. } => {
|
||
assert_eq!(
|
||
sql,
|
||
"insert into archive with t as (select * from orders) select * from t",
|
||
);
|
||
assert_eq!(target_table, "archive");
|
||
}
|
||
other => panic!("expected Command::SqlInsert, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn insert_select_copies_rows_and_persists() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_named(&db, &rt, "source");
|
||
create_named(&db, &rt, "archive");
|
||
rt.block_on(db.run_sql_insert(
|
||
"insert into source (a, b) values (1, 'one'), (2, 'two')".to_string(),
|
||
None,
|
||
"source".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("seed source");
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"insert into archive select * from source".to_string(),
|
||
Some("insert into archive select * from source".to_string()),
|
||
"archive".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("INSERT … SELECT runs");
|
||
assert_eq!(result.rows_affected, 2, "both source rows copied");
|
||
let csv = read_csv(&project, "archive").expect("archive.csv written");
|
||
assert!(
|
||
csv.contains("one") && csv.contains("two"),
|
||
"archive CSV reflects the copied rows: {csv:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_select_with_column_list_and_projection_persists() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_named(&db, &rt, "source");
|
||
create_named(&db, &rt, "target");
|
||
rt.block_on(db.run_sql_insert(
|
||
"insert into source (a, b) values (5, 'five')".to_string(),
|
||
None,
|
||
"source".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("seed source");
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"insert into target (a, b) select a, b from source".to_string(),
|
||
None,
|
||
"target".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("column-list + projection INSERT … SELECT runs");
|
||
assert_eq!(result.rows_affected, 1);
|
||
let csv = read_csv(&project, "target").expect("target.csv written");
|
||
assert!(csv.contains("five"), "target CSV reflects the row: {csv:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn with_prefixed_insert_select_runs_and_persists() {
|
||
// R4 end-to-end: the CTE row source executes and lands rows.
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_named(&db, &rt, "orders");
|
||
create_named(&db, &rt, "archive");
|
||
rt.block_on(db.run_sql_insert(
|
||
"insert into orders (a, b) values (1, 'a'), (2, 'b')".to_string(),
|
||
None,
|
||
"orders".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("seed orders");
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"insert into archive with t as (select * from orders) select * from t".to_string(),
|
||
None,
|
||
"archive".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("WITH-prefixed INSERT … SELECT runs");
|
||
assert_eq!(result.rows_affected, 2);
|
||
let csv = read_csv(&project, "archive").expect("archive.csv written");
|
||
assert!(
|
||
csv.contains('a') && csv.contains('b'),
|
||
"archive CSV reflects the CTE-sourced rows: {csv:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_select_from_self_runs_as_plain_insert() {
|
||
// DA gate: INSERT … SELECT where the source is the target
|
||
// executes as a plain insert (InsertResult — no cascade
|
||
// summary; cascade output is a DELETE-only concept, 3f).
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_named(&db, &rt, "T");
|
||
rt.block_on(db.run_sql_insert(
|
||
"insert into T (a, b) values (1, 'x'), (2, 'y')".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("seed");
|
||
let result = rt
|
||
.block_on(db.run_sql_insert(
|
||
"insert into T select a + 10, b from T".to_string(),
|
||
None,
|
||
"T".to_string(),
|
||
Vec::new(),
|
||
String::new(),
|
||
false,
|
||
))
|
||
.expect("self-sourced INSERT … SELECT runs");
|
||
assert_eq!(result.rows_affected, 2, "two rows copied with shifted PK");
|
||
let csv = read_csv(&project, "T").expect("T.csv written");
|
||
assert!(
|
||
csv.contains("11") && csv.contains("12"),
|
||
"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 insert") {
|
||
Command::SqlInsert {
|
||
sql,
|
||
target_table,
|
||
listed_columns,
|
||
row_source,
|
||
returning,
|
||
..
|
||
} => rt.block_on(db.run_sql_insert(
|
||
sql,
|
||
Some(input.to_string()),
|
||
target_table,
|
||
listed_columns,
|
||
row_source,
|
||
returning,
|
||
)),
|
||
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, "insert 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,
|
||
"insert 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,
|
||
"insert 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, "insert into source (label) values ('a'), ('b')")
|
||
.expect("seed source");
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert 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, "insert 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 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, "insert 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, "insert 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, "insert 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, "insert 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:?}");
|
||
}
|
||
|
||
#[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, "insert 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 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]
|
||
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, "insert into src (a, b) values ('p', 'q')").expect("seed");
|
||
let outcome = run_sqlinsert(&db, &rt, "insert 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, "insert into src (a) values ('p')").expect("seed");
|
||
let outcome = run_sqlinsert(&db, &rt, "insert 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");
|
||
}
|
||
|
||
// =================================================================
|
||
// Sub-phase 3g — RETURNING (ADR-0033 §5)
|
||
// =================================================================
|
||
|
||
#[test]
|
||
fn insert_returning_star_returns_inserted_row() {
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||
let result = run_sqlinsert(&db, &rt, "insert into t (id, b) values (1, 'Ada') returning *")
|
||
.expect("INSERT … RETURNING * runs");
|
||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row");
|
||
assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]);
|
||
assert_eq!(result.data.rows[0][1], Some("Ada".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn insert_multirow_returning_id_yields_distinct_rows() {
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
|
||
)
|
||
.expect("multi-row INSERT … RETURNING id runs");
|
||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||
assert_eq!(result.data.columns, vec!["id".to_string()]);
|
||
let ids: std::collections::BTreeSet<_> =
|
||
result.data.rows.iter().map(|r| r[0].clone()).collect();
|
||
assert_eq!(ids.len(), 3, "three distinct ids returned: {:?}", result.data.rows);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_returning_autofills_shortid_and_returns_it() {
|
||
// The auto-fill × RETURNING interaction (3d × 3g): the worker
|
||
// rewrites the INSERT to add the generated shortid, and the
|
||
// rewrite must PRESERVE the RETURNING tail so the generated id
|
||
// surfaces in the returned row.
|
||
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, "insert into t (label) values ('x') returning *")
|
||
.expect("auto-fill INSERT … RETURNING * runs");
|
||
assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)");
|
||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
|
||
// `id` is the auto-filled shortid column; it must be non-empty in
|
||
// the returned row (proving the rewrite kept RETURNING).
|
||
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column");
|
||
let id_val = result.data.rows[0][id_idx].clone();
|
||
assert!(id_val.is_some_and(|s| !s.is_empty()), "generated shortid surfaced via RETURNING");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_returning_recovers_bare_column_type() {
|
||
// 3g type recovery: a bare-column RETURNING ref recovers its
|
||
// playground type via the column-origin path (a `bool` column
|
||
// renders as the word, not 0/1).
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||
let result = run_sqlinsert(&db, &rt, "insert into t (id, active) values (1, true) returning active")
|
||
.expect("INSERT … RETURNING active runs");
|
||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||
assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_returning_computed_expression_is_typeless() {
|
||
// 3g: a computed RETURNING projection has no base-table origin,
|
||
// so its recovered type is None (renders with neutral alignment).
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]);
|
||
let result = run_sqlinsert(&db, &rt, "insert into t (id, n) values (1, 5) returning n + 1")
|
||
.expect("INSERT … RETURNING <expr> runs");
|
||
assert_eq!(result.data.column_types, vec![None], "computed projection is typeless");
|
||
assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_returning_recovers_multiple_bare_column_types() {
|
||
// 3g type recovery spans the playground vocabulary. RETURNING
|
||
// reuses the SELECT column-origin path (`resolve_select_column_
|
||
// types`), exhaustively type-tested on the SELECT side; this
|
||
// pins a representative spread reached via the RETURNING tail.
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(
|
||
&db,
|
||
&rt,
|
||
"t",
|
||
&[
|
||
("id", Type::Int),
|
||
("txt", Type::Text),
|
||
("amount", Type::Decimal),
|
||
("ratio", Type::Real),
|
||
("flag", Type::Bool),
|
||
],
|
||
&["id"],
|
||
);
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
|
||
)
|
||
.expect("INSERT … RETURNING <cols> runs");
|
||
assert_eq!(
|
||
result.data.column_types,
|
||
vec![
|
||
Some(Type::Int),
|
||
Some(Type::Text),
|
||
Some(Type::Decimal),
|
||
Some(Type::Real),
|
||
Some(Type::Bool),
|
||
],
|
||
"each bare-column RETURNING ref recovered its playground type",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn multirow_autofill_returning_yields_distinct_generated_ids() {
|
||
// DA gate (3d × 3g): multi-row INSERT with an omitted shortid PK
|
||
// AND RETURNING — the auto-fill rewrite produces N tuples with N
|
||
// distinct generated ids, and RETURNING * must surface all N
|
||
// rows each carrying its own generated id.
|
||
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,
|
||
"insert into t (label) values ('a'), ('b'), ('c') returning *",
|
||
)
|
||
.expect("multi-row auto-fill INSERT … RETURNING * runs");
|
||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||
assert_eq!(result.data.rows.len(), 3, "three rows returned");
|
||
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column");
|
||
let ids: std::collections::BTreeSet<_> =
|
||
result.data.rows.iter().map(|r| r[id_idx].clone()).collect();
|
||
assert_eq!(ids.len(), 3, "three DISTINCT generated ids via RETURNING: {:?}", result.data.rows);
|
||
assert!(ids.iter().all(|v| v.as_ref().is_some_and(|s| !s.is_empty())), "all ids non-empty");
|
||
}
|
||
|
||
#[test]
|
||
fn insert_select_returning_executes_and_returns_rows() {
|
||
// DA gate: the grammar accepts INSERT … SELECT … RETURNING; this
|
||
// pins that it also EXECUTES through run_returning (the SELECT row
|
||
// source feeds the insert, and RETURNING yields the inserted rows).
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||
create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||
run_sqlinsert(&db, &rt, "insert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
|
||
let result = run_sqlinsert(&db, &rt, "insert into dst select * from src returning id, b")
|
||
.expect("INSERT … SELECT … RETURNING runs");
|
||
assert_eq!(result.rows_affected, 2, "two rows copied");
|
||
assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows");
|
||
let bs: std::collections::BTreeSet<_> =
|
||
result.data.rows.iter().map(|r| r[1].clone()).collect();
|
||
assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string())));
|
||
}
|
||
|
||
// =================================================================
|
||
// Sub-phase 3h — UPSERT (ON CONFLICT … DO NOTHING / DO UPDATE)
|
||
// =================================================================
|
||
|
||
#[test]
|
||
fn conflict_target_columns_excluded_from_listed_columns() {
|
||
// DA gate: the ON CONFLICT (col, …) target uses a DISTINCT role
|
||
// from the inserted column list, so build_sql_insert's
|
||
// listed_columns (which drives shortid auto-fill) must NOT pick
|
||
// up the conflict-target columns. If it did, an omitted shortid
|
||
// would look "listed" and auto-fill would wrongly skip.
|
||
match parse_command("insert into t (name) values ('x') on conflict (id) do nothing")
|
||
.expect("parse upsert")
|
||
{
|
||
Command::SqlInsert { listed_columns, .. } => {
|
||
assert_eq!(
|
||
listed_columns,
|
||
vec!["name".to_string()],
|
||
"only the inserted column list, not the conflict target",
|
||
);
|
||
}
|
||
other => panic!("expected SqlInsert, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn autofill_upsert_real_conflict_preserves_clause_and_excluded() {
|
||
// DA gate (stronger than autofill_preserves_on_conflict_clause,
|
||
// which can't tell a preserved clause from a dropped one because
|
||
// the generated id never conflicts). Here the table has a shortid
|
||
// PK (auto-filled) AND a UNIQUE `code`. The second insert reuses
|
||
// code 'A', so it hits a REAL conflict: with the ON CONFLICT
|
||
// clause preserved through the auto-fill rewrite it DO-UPDATEs via
|
||
// excluded; if the rewrite had dropped the clause it would raise a
|
||
// UNIQUE violation instead (the `.expect` would panic).
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
rt.block_on(db.create_table(
|
||
"t".to_string(),
|
||
vec![
|
||
ColumnSpec::new("id", Type::ShortId),
|
||
ColumnSpec { unique: true, ..ColumnSpec::new("code", Type::Text) },
|
||
ColumnSpec::new("label", Type::Text),
|
||
],
|
||
vec!["id".to_string()],
|
||
None,
|
||
))
|
||
.expect("create table with shortid pk + unique code");
|
||
run_sqlinsert(&db, &rt, "insert into t (code, label) values ('A', 'first')").expect("seed");
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
|
||
)
|
||
.expect("auto-filled UPSERT with a real conflict (clause preserved)");
|
||
assert_eq!(result.rows_affected, 1, "the conflicting row was updated, not inserted");
|
||
let rows = csv_rows(&project, "t");
|
||
assert_eq!(rows.len(), 1, "still one row (DO UPDATE, not a second insert)");
|
||
assert!(rows[0].iter().any(|c| c == "second"), "label updated via excluded: {rows:?}");
|
||
assert!(!rows[0].iter().any(|c| c == "first"), "old label replaced: {rows:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn on_conflict_do_nothing_keeps_existing_row() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert into t (id, name) values (1, 'new') on conflict (id) do nothing",
|
||
)
|
||
.expect("ON CONFLICT DO NOTHING runs");
|
||
assert_eq!(result.rows_affected, 0, "conflicting row left untouched");
|
||
let rows = csv_rows(&project, "t");
|
||
assert_eq!(rows.len(), 1, "still one row");
|
||
assert!(rows[0].iter().any(|c| c == "orig"), "original value kept: {rows:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn on_conflict_do_update_applies_excluded() {
|
||
let (project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert into t (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
|
||
)
|
||
.expect("ON CONFLICT DO UPDATE runs");
|
||
assert_eq!(result.rows_affected, 1, "the conflicting row was updated");
|
||
let rows = csv_rows(&project, "t");
|
||
assert_eq!(rows.len(), 1, "still one row (updated, not inserted)");
|
||
assert!(rows[0].iter().any(|c| c == "new"), "row updated to excluded.name: {rows:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn on_conflict_do_nothing_without_target() {
|
||
let (_project, db, _dir) = open_project_db();
|
||
let rt = rt();
|
||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||
let result = run_sqlinsert(
|
||
&db,
|
||
&rt,
|
||
"insert into t (id, name) values (1, 'x') on conflict do nothing",
|
||
)
|
||
.expect("ON CONFLICT (no target) DO NOTHING runs");
|
||
assert_eq!(result.rows_affected, 0, "any-conflict do-nothing absorbed the duplicate");
|
||
}
|
||
|
||
#[test]
|
||
fn autofill_preserves_on_conflict_clause() {
|
||
// DA gate / landmine: an INSERT with an omitted shortid PK AND an
|
||
// ON CONFLICT tail. The auto-fill rewrite reconstructs INSERT …
|
||
// VALUES …; row_source must stop before ON CONFLICT (so the
|
||
// materialisation prepare doesn't choke) and the rewrite must
|
||
// re-append the clause (so it isn't silently dropped). The fresh
|
||
// generated id won't conflict, so the row inserts — the point is
|
||
// the rewrite doesn't prepare-fail and the clause survives.
|
||
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,
|
||
"insert into t (label) values ('x') on conflict (id) do nothing",
|
||
)
|
||
.expect("auto-fill INSERT with ON CONFLICT runs (clause preserved)");
|
||
assert_eq!(result.rows_affected, 1, "row inserted with a generated id");
|
||
let rows = csv_rows(&project, "t");
|
||
assert_eq!(rows.len(), 1, "one row landed");
|
||
}
|
||
|
||
#[test]
|
||
fn sql_dml_validates_literal_values_like_the_dsl() {
|
||
// ADR-0036 Phase 1: advanced-mode SQL `INSERT` now validates each
|
||
// literal value against its column type before the (still verbatim)
|
||
// insert runs, sharing the DSL's per-type validators. `2025/01/15` is
|
||
// a malformed date (slashes, not dashes) — the DSL rejects it at bind
|
||
// time, and advanced-mode SQL now refuses it too (it used to splice the
|
||
// literal into text and let a STRICT TEXT column accept anything).
|
||
let (project, db, _d) = open_project_db();
|
||
let r = rt();
|
||
r.block_on(db.create_table(
|
||
"T".to_string(),
|
||
vec![
|
||
ColumnSpec::new("id", Type::Int),
|
||
ColumnSpec::new("d", Type::Date),
|
||
],
|
||
vec!["id".to_string()],
|
||
Some("create table T with pk id(int)".to_string()),
|
||
))
|
||
.expect("create T(id int pk, d date)");
|
||
|
||
// DSL path — validates the `date` and rejects the malformed value.
|
||
let dsl = r.block_on(db.insert(
|
||
"T".to_string(),
|
||
Some(vec!["id".to_string(), "d".to_string()]),
|
||
vec![Value::Number("1".to_string()), Value::Text("2025/01/15".to_string())],
|
||
Some("insert".to_string()),
|
||
));
|
||
assert!(
|
||
dsl.is_err(),
|
||
"the DSL insert path validates `date` and rejects 2025/01/15; got {dsl:?}"
|
||
);
|
||
|
||
// SQL path (advanced mode, full pipeline) — now REJECTS it too.
|
||
std::fs::write(
|
||
project.path().join("ins.commands"),
|
||
"insert into T (id, d) values (2, '2025/01/15')\n",
|
||
)
|
||
.expect("write script");
|
||
let events = r.block_on(run_replay(&db, project.path(), "ins.commands"));
|
||
assert!(
|
||
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
|
||
"advanced-mode SQL validates the `date` literal and refuses \
|
||
2025/01/15 (ADR-0036 Phase 1); events: {events:?}"
|
||
);
|
||
// A valid date still inserts (the bound/verbatim path is unaffected).
|
||
std::fs::write(
|
||
project.path().join("ok.commands"),
|
||
"insert into T (id, d) values (3, '2025-01-15')\n",
|
||
)
|
||
.expect("write script");
|
||
let ok = r.block_on(run_replay(&db, project.path(), "ok.commands"));
|
||
assert!(
|
||
matches!(ok.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
|
||
"a well-formed date still inserts; events: {ok:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sql_insert_expression_value_is_not_validated_and_runs() {
|
||
// An expression position (not a bare literal) is left to the engine —
|
||
// ADR-0036 Phase 1 has nothing static to validate, so `1 + 2` into an
|
||
// int column computes 3 and inserts; it must not be mis-classified as
|
||
// a literal or rejected.
|
||
let (project, db, _d) = open_project_db();
|
||
let r = rt();
|
||
r.block_on(db.create_table(
|
||
"T".to_string(),
|
||
vec![
|
||
ColumnSpec::new("id", Type::Int),
|
||
ColumnSpec::new("n", Type::Int),
|
||
],
|
||
vec!["id".to_string()],
|
||
Some("create table T with pk id(int)".to_string()),
|
||
))
|
||
.expect("create T");
|
||
std::fs::write(
|
||
project.path().join("e.commands"),
|
||
"insert into T (id, n) values (1, 1 + 2)\n",
|
||
)
|
||
.expect("write script");
|
||
let events = r.block_on(run_replay(&db, project.path(), "e.commands"));
|
||
assert!(
|
||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
|
||
"the expression value executes (engine computes it); events: {events:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sql_insert_multi_row_validates_each_literal() {
|
||
// Validation applies to every literal row; a malformed `date` in the
|
||
// second tuple is caught (ADR-0036 Phase 1 — execution is verbatim, so
|
||
// multi-row comes for free).
|
||
let (project, db, _d) = open_project_db();
|
||
let r = rt();
|
||
r.block_on(db.create_table(
|
||
"T".to_string(),
|
||
vec![
|
||
ColumnSpec::new("id", Type::Int),
|
||
ColumnSpec::new("d", Type::Date),
|
||
],
|
||
vec!["id".to_string()],
|
||
Some("create table T with pk id(int)".to_string()),
|
||
))
|
||
.expect("create T");
|
||
std::fs::write(
|
||
project.path().join("m.commands"),
|
||
"insert into T (id, d) values (1, '2025-01-15'), (2, '2025/02/20')\n",
|
||
)
|
||
.expect("write script");
|
||
let events = r.block_on(run_replay(&db, project.path(), "m.commands"));
|
||
assert!(
|
||
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
|
||
"the malformed date in the second row is caught; events: {events:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sql_insert_natural_order_validates_against_schema_columns() {
|
||
// With no explicit column list, positions map to the schema's columns
|
||
// in definition order (engine semantics) — validation must use that
|
||
// mapping, not an explicit list (ADR-0036 Phase 1).
|
||
let (project, db, _d) = open_project_db();
|
||
let r = rt();
|
||
r.block_on(db.create_table(
|
||
"T".to_string(),
|
||
vec![
|
||
ColumnSpec::new("id", Type::Int),
|
||
ColumnSpec::new("d", Type::Date),
|
||
],
|
||
vec!["id".to_string()],
|
||
Some("create table T with pk id(int)".to_string()),
|
||
))
|
||
.expect("create T");
|
||
std::fs::write(
|
||
project.path().join("nat.commands"),
|
||
"insert into T values (1, '2025/02/20')\n",
|
||
)
|
||
.expect("write script");
|
||
let events = r.block_on(run_replay(&db, project.path(), "nat.commands"));
|
||
assert!(
|
||
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
|
||
"natural-order insert validates the date against column `d`; events: {events:?}"
|
||
);
|
||
}
|
||
|
||
// =================================================================
|
||
// ADR-0036 Phase 3a — live typed-slot hint for the UPSERT
|
||
// `ON CONFLICT … DO UPDATE SET col = <rhs>` value position.
|
||
// =================================================================
|
||
|
||
#[test]
|
||
fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
|
||
// ADR-0036 Phase 3a: the `DO UPDATE SET col = ` value position
|
||
// shares the SQL UPDATE `SET` treatment, so it drives the same
|
||
// column-typed slot hint (boundary-aware lookahead → typed slot).
|
||
let mut cache = SchemaCache::default();
|
||
let cols = vec![
|
||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
||
TableColumn { name: "Name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
|
||
];
|
||
cache.tables.push("Customers".to_string());
|
||
cache.columns.push("id".to_string());
|
||
cache.columns.push("Name".to_string());
|
||
cache.table_columns.insert("Customers".to_string(), cols);
|
||
|
||
let input = "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name=";
|
||
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
|
||
let Some(AmbientHint::Prose(prose)) = hint else {
|
||
panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}");
|
||
};
|
||
assert!(prose.contains("Name"), "hint names the column `Name`: {prose:?}");
|
||
assert!(
|
||
prose.contains("quoted string"),
|
||
"text-column hint says `quoted string`: {prose:?}"
|
||
);
|
||
}
|
||
|
||
// =================================================================
|
||
// ADR-0036 Phase 3b — live typed-slot hints + highlighting for the
|
||
// INSERT `VALUES (…)` positions (per-position column mapping via the
|
||
// `Node::SetColumn` primitive; boundary-aware lookahead per position).
|
||
// =================================================================
|
||
|
||
/// Build a `SchemaCache` for the advanced-mode typing-surface tests.
|
||
fn vschema(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
|
||
let mut cache = SchemaCache::default();
|
||
for (table, cols) in tables {
|
||
let table_cols: Vec<TableColumn> = cols
|
||
.iter()
|
||
.map(|(n, t)| TableColumn {
|
||
name: (*n).to_string(),
|
||
user_type: *t,
|
||
not_null: false,
|
||
has_default: false,
|
||
})
|
||
.collect();
|
||
cache.tables.push((*table).to_string());
|
||
for c in &table_cols {
|
||
if !cache.columns.contains(&c.name) {
|
||
cache.columns.push(c.name.clone());
|
||
}
|
||
}
|
||
cache.table_columns.insert((*table).to_string(), table_cols);
|
||
}
|
||
cache
|
||
}
|
||
|
||
fn prose_at(input: &str, schema: &SchemaCache) -> String {
|
||
let hint = ambient_hint_in_mode(input, input.len(), None, schema, Mode::Advanced);
|
||
match hint {
|
||
Some(AmbientHint::Prose(p)) => p,
|
||
other => panic!("expected a Prose hint for {input:?}, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_form_a_value_offers_typed_slot_hint() {
|
||
// Form A (explicit column list): the value position maps to the
|
||
// user-listed column, so the hint is that column's typed prose.
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
let prose = prose_at("insert into Things (note) values (", &schema);
|
||
assert!(prose.contains("note"), "names listed column `note`: {prose:?}");
|
||
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_form_b_value_maps_first_column() {
|
||
// Form B (no column list): positions map to ALL columns in
|
||
// declaration order, so the first position is the first column.
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
let prose = prose_at("insert into Things values (", &schema);
|
||
assert!(prose.contains("k"), "names first column `k`: {prose:?}");
|
||
assert!(prose.contains("integer"), "int-column prose: {prose:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_second_position_hints_second_column() {
|
||
// Per-position mapping advances: after the first value + comma, the
|
||
// hint is the SECOND column's typed prose.
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
let prose = prose_at("insert into Things (k, note) values (5, ", &schema);
|
||
assert!(prose.contains("note"), "second position names `note`: {prose:?}");
|
||
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_value_int_mismatch_is_caught_live() {
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
let bad = classify_input_with_schema_in_mode(
|
||
"insert into Things (k) values (3.14)",
|
||
&schema,
|
||
Mode::Advanced,
|
||
);
|
||
assert!(!matches!(bad, InputState::Valid), "decimal into int rejected live: {bad:?}");
|
||
let ok = classify_input_with_schema_in_mode(
|
||
"insert into Things (k) values (5)",
|
||
&schema,
|
||
Mode::Advanced,
|
||
);
|
||
assert!(matches!(ok, InputState::Valid), "valid int literal parses: {ok:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_string_into_int_is_caught_live() {
|
||
// The Option-A win over the structural fallback: a wrong-KIND lone
|
||
// literal (a string into an int column) is rejected WHILE TYPING,
|
||
// not only at execution.
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
let bad = classify_input_with_schema_in_mode(
|
||
"insert into Things (k) values ('text')",
|
||
&schema,
|
||
Mode::Advanced,
|
||
);
|
||
assert!(!matches!(bad, InputState::Valid), "string into int rejected live: {bad:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_multi_row_typed_and_mismatch_caught() {
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
let ok = classify_input_with_schema_in_mode(
|
||
"insert into Things (k, note) values (1, 'a'), (2, 'b')",
|
||
&schema,
|
||
Mode::Advanced,
|
||
);
|
||
assert!(matches!(ok, InputState::Valid), "well-formed multi-row parses: {ok:?}");
|
||
let bad = classify_input_with_schema_in_mode(
|
||
"insert into Things (k, note) values (1, 'a'), (3.14, 'b')",
|
||
&schema,
|
||
Mode::Advanced,
|
||
);
|
||
assert!(
|
||
!matches!(bad, InputState::Valid),
|
||
"a mismatch in the second row is caught: {bad:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_form_b_maps_all_columns_including_serial() {
|
||
// SQL Form B supplies a value for EVERY column (no auto-fill), so
|
||
// the position count = all columns, and a serial column's position
|
||
// takes an int literal (unlike the DSL, which omits auto-gen cols).
|
||
let schema = vschema(&[(
|
||
"Customers",
|
||
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||
)]);
|
||
let state = classify_input_with_schema_in_mode(
|
||
"insert into Customers values (1, 'Bob', 'b@c')",
|
||
&schema,
|
||
Mode::Advanced,
|
||
);
|
||
assert!(matches!(state, InputState::Valid), "Form B maps all 3 columns: {state:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_insert_value_expressions_still_parse_via_sql_expr() {
|
||
// Regression guard: a non-lone-literal value position (arithmetic,
|
||
// literal-prefixed, function call, signed number) falls through to
|
||
// sql_expr unchanged — the typed slot must not steal it.
|
||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||
for input in [
|
||
"insert into Things (k) values (1 + 2)",
|
||
"insert into Things (k, note) values (5, upper(note))",
|
||
"insert into Things (k) values (-5)",
|
||
"insert into Things (k) values ((select 1))",
|
||
] {
|
||
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
|
||
assert!(matches!(state, InputState::Valid), "{input:?} must parse: {state:?}");
|
||
}
|
||
}
|