grammar+db: 3h — UPSERT ON CONFLICT DO NOTHING / DO UPDATE (ADR-0033 §9)
on_conflict_clause on SQL_INSERT_SHAPE: optional (col,…) conflict target (distinct conflict_target_column role so it never enters listed_columns), DO NOTHING / DO UPDATE SET … [WHERE …]. `do` is factored out of the action Choice so nothing/update disambiguate without tripping the walk_seq/walk_choice shared-prefix trap (ADR-0033 Amendment 1). Worker runs the UPSERT verbatim (SQLite native); no new execution path. build_sql_insert: row_source now stops before the FIRST trailing clause — ON CONFLICT (3h) or RETURNING (3g) — and do_sql_insert's shortid auto-fill rewrite re-appends the whole trailing tail, so an auto-filled INSERT keeps its ON CONFLICT / RETURNING. excluded pseudo-table (§9): resolves to the target's columns inside the DO UPDATE action and completes at `excluded.|`, but stays flagged as unknown_qualifier in VALUES / RETURNING / non-upsert statements. Diagnostic pass scopes it by the DO UPDATE byte-range (update token → RETURNING/end); completion resolves it against the INSERT target's current_table_columns. NOTE: scoping uses byte-range rather than the plan's prescribed from_scope TableBinding push — same behaviour, no walker scope-frame change. Tests (+13): grammar accept/reject; DO NOTHING / DO UPDATE-excluded / no-target execution + persistence; auto-fill × ON CONFLICT with a REAL unique conflict (proves the clause survives the rewrite, not a no-op); excluded resolves in DO UPDATE SET + WHERE, flagged in VALUES (incl. same statement), unknown column under excluded; excluded.| completion; conflict-target not in listed_columns. 1576 pass / 0 fail / 1 ignored. Clippy clean. Dev sql_insert entry word still removed in 3j. Known follow-up (tracked for 3i): UPSERT DO UPDATE bare column refs (SET LHS / WHERE) are not schema-validated, unlike regular UPDATE — the INSERT target isn't a diagnostic binding. Fits 3i's cross-cut SET/WHERE validation scope.
This commit is contained in:
@@ -897,3 +897,139 @@ fn insert_select_returning_executes_and_returns_rows() {
|
||||
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("sqlinsert 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, "sqlinsert into t (code, label) values ('A', 'first')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert 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, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert 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, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert 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, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert 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,
|
||||
"sqlinsert 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user