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:
@@ -5945,7 +5945,7 @@ fn plan_shortid_autofill(
|
||||
sql: &str,
|
||||
listed_columns: &[String],
|
||||
row_source: &str,
|
||||
returning_tail: &str,
|
||||
trailing_tail: &str,
|
||||
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
|
||||
if listed_columns.is_empty() {
|
||||
return Ok((sql.to_string(), Vec::new()));
|
||||
@@ -6040,18 +6040,18 @@ fn plan_shortid_autofill(
|
||||
.join(", ");
|
||||
tuples.push(format!("({placeholders})"));
|
||||
}
|
||||
// Preserve any RETURNING tail (3g) — the reconstruction would
|
||||
// otherwise drop it, so `INSERT … RETURNING *` on an auto-filled
|
||||
// shortid table would return no rows (and the worker would read
|
||||
// a zero affected-row count). `returning_tail` is "" on the
|
||||
// non-RETURNING path.
|
||||
let returning_suffix = if returning_tail.is_empty() {
|
||||
// Preserve any trailing clause — `ON CONFLICT …` (3h) and/or
|
||||
// `RETURNING …` (3g). The reconstruction rebuilds only INSERT …
|
||||
// VALUES …, so without this the UPSERT action would be lost and
|
||||
// `RETURNING *` would yield no rows. `trailing_tail` is "" when
|
||||
// the statement has neither.
|
||||
let trailing_suffix = if trailing_tail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {returning_tail}")
|
||||
format!(" {trailing_tail}")
|
||||
};
|
||||
let exec_sql = format!(
|
||||
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals}{returning_suffix};",
|
||||
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals}{trailing_suffix};",
|
||||
tbl = quote_ident(target_table),
|
||||
vals = tuples.join(", "),
|
||||
);
|
||||
@@ -6092,20 +6092,21 @@ fn do_sql_insert(
|
||||
returning: bool,
|
||||
) -> Result<InsertResult, DbError> {
|
||||
debug!(sql = %sql, table = %target_table, returning, "sql_insert");
|
||||
// RETURNING (3g): the `shortid` auto-fill rewrite reconstructs
|
||||
// only `INSERT … VALUES …` and would drop the RETURNING tail, so
|
||||
// extract it here to re-append. `row_source` is the clean
|
||||
// VALUES/SELECT text (no RETURNING — `build_sql_insert` stops the
|
||||
// slice at the RETURNING token), so whatever follows it in the
|
||||
// full `sql` is the RETURNING clause. On the verbatim (no
|
||||
// auto-fill) path the original `sql` already carries RETURNING,
|
||||
// so the tail is only consumed by the rewrite.
|
||||
let returning_tail: String = if returning && !row_source.is_empty() {
|
||||
// The `shortid` auto-fill rewrite reconstructs only `INSERT …
|
||||
// VALUES …` and would drop any trailing clause — `ON CONFLICT …`
|
||||
// (3h) and/or `RETURNING …` (3g). `row_source` is the clean
|
||||
// VALUES/SELECT text (`build_sql_insert` stops the slice at the
|
||||
// first trailing clause), so whatever follows it in the full
|
||||
// `sql` is exactly that tail; extract it here so the rewrite can
|
||||
// re-append it verbatim. On the verbatim (no auto-fill) path the
|
||||
// original `sql` already carries the tail, so it is consumed only
|
||||
// by the rewrite.
|
||||
let trailing_tail: String = if row_source.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
sql.find(row_source)
|
||||
.map(|i| sql[i + row_source.len()..].trim().trim_end_matches(';').trim().to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Sub-phase 3d: when the user's column list omits one or more
|
||||
// `shortid` columns, the worker materialises the row source,
|
||||
@@ -6114,7 +6115,7 @@ fn do_sql_insert(
|
||||
// params vec with the original `sql` means "no auto-fill —
|
||||
// execute verbatim" (the 3b path).
|
||||
let (exec_sql, params) =
|
||||
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source, &returning_tail)?;
|
||||
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source, &trailing_tail)?;
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
|
||||
Reference in New Issue
Block a user