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:
claude@clouddev1
2026-05-22 21:28:24 +00:00
parent fd8b74ba5e
commit 6b8888f105
6 changed files with 529 additions and 52 deletions
+38 -6
View File
@@ -374,12 +374,26 @@ pub fn candidates_at_cursor_with_in_mode(
let qualified_columns: Option<Vec<String>> = prefix_qualifier
.as_ref()
.map(|q| {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let expected = if probe.expected.is_empty() {
@@ -2119,6 +2133,24 @@ mod tests {
);
}
#[test]
fn excluded_prefix_completes_to_target_columns() {
// ADR-0033 §9: `excluded.|` inside a DO UPDATE action
// completes to the INSERT target table's columns.
let cache = two_table_schema();
let input = "sqlinsert into a (id, name) values (1, 'x') \
on conflict (id) do update set name = excluded.";
let cs = cands_with(input, input.len(), &cache);
assert!(
cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()),
"excluded.| should offer the target table's columns; got {cs:?}",
);
assert!(
!cs.contains(&"total".to_string()),
"a column from an unrelated table must not appear; got {cs:?}",
);
}
#[test]
fn qualified_prefix_with_partial_filters_prefix() {
// `select a.na` — only `name` (starts with `na`).