Files
rdbms-playground/tests/sql_insert.rs
T
claude@clouddev1 42306d33e3 fix: X4 — advanced-mode SQL INSERT auto-fills omitted non-PK serial (MAX+1)
A Form-A advanced-mode INSERT that omitted a non-PK serial column left it
silently NULL (the column is INTEGER UNIQUE, not NOT NULL, so SQLite
permits it), while simple-mode do_insert auto-fills it with MAX+1. That
violated ADR-0018 §1's "auto-generated on every path" contract and was the
unprincipled serial-vs-shortid asymmetry the ADR set out to remove
(advanced mode already auto-fills shortid).

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

Tests: 1949 passing (+1), 0 failed, 0 skipped, 1 ignored; clippy clean.
2026-05-27 11:18:57 +00:00

1406 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 insert_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
// ADR-0030 §11: the literal submitted line lands in history.log.
let source = "insert into T (a, b) values (1, 'logged')";
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'logged')".to_string(),
Some(source.to_string()),
"T".to_string(),
Vec::new(),
String::new(),
false,
))
.expect("insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present after an INSERT");
assert!(
body.contains(source),
"history.log records the literal INSERT line: {body:?}",
);
}
#[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 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 = "insert 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, "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:?}");
}
}