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
+167 -16
View File
@@ -593,6 +593,47 @@ fn schema_existence_diagnostics(
}
}
// ADR-0033 §9: the `excluded` pseudo-table. In an
// `INSERT … ON CONFLICT … DO UPDATE`, a `excluded.col` reference
// resolves to the target table's columns — but ONLY within the
// DO UPDATE action's byte range. Outside it (a VALUES tuple, a
// trailing RETURNING, or any non-upsert statement) `excluded`
// has no meaning and must stay unresolved so the existing
// `unknown_qualifier` diagnostic fires (the leak guard). The
// range runs from the `update` keyword (the one after
// `conflict`) to the `RETURNING` boundary (or end).
let upsert_excluded: Option<(String, usize, usize)> = {
let target = path.items.iter().find_map(|it| match it.kind {
MatchedKind::Ident {
source: IdentSource::Tables,
role: "insert_target_table",
} => Some(it.text.clone()),
_ => None,
});
let conflict_pos = path
.items
.iter()
.find(|it| matches!(it.kind, MatchedKind::Word("conflict")))
.map(|it| it.span.0);
let do_update_pos = conflict_pos.and_then(|cp| {
path.items
.iter()
.find(|it| matches!(it.kind, MatchedKind::Word("update")) && it.span.0 > cp)
.map(|it| it.span.0)
});
let returning_pos = path
.items
.iter()
.find(|it| matches!(it.kind, MatchedKind::Word("returning")))
.map(|it| it.span.0);
match (target, do_update_pos) {
(Some(t), Some(start)) if schema_has_table(schema, &t) => {
Some((t, start, returning_pos.unwrap_or(usize::MAX)))
}
_ => None,
}
};
// Track which CTE names have already been seen, for
// duplicate detection (a separate single-pass walk; emits
// the diagnostic on the second occurrence).
@@ -724,22 +765,56 @@ fn schema_existence_diagnostics(
}
}
None => {
// Qualifier didn't resolve — emit
// unknown_qualifier on the
// qualifier span, not on the
// column, so the learner sees
// the root cause.
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: qual_span,
message: crate::friendly::translate(
"diagnostic.unknown_qualifier",
&[(
"qualifier",
&qual as &dyn std::fmt::Display,
)],
),
});
// ADR-0033 §9: `excluded.col` inside
// a DO UPDATE action resolves to the
// target table's columns. Scoped by
// byte-range so it stays unknown (and
// flagged below) in VALUES / RETURNING
// / non-upsert statements (leak guard).
let in_excluded_scope = upsert_excluded
.as_ref()
.is_some_and(|(_, start, end)| {
qual.eq_ignore_ascii_case("excluded")
&& qual_span.0 >= *start
&& qual_span.0 < *end
});
if let Some((target, _, _)) =
upsert_excluded.as_ref().filter(|_| in_excluded_scope)
{
// Validate the column against the
// target's columns (excluded mirrors
// the target row's shape).
if !schema_has_column(schema, target, &item.text) {
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: item.span,
message: crate::friendly::translate(
"diagnostic.unknown_column",
&[
("name", &item.text as &dyn std::fmt::Display),
("table", target as &dyn std::fmt::Display),
],
),
});
}
} else {
// Qualifier didn't resolve — emit
// unknown_qualifier on the
// qualifier span, not on the
// column, so the learner sees
// the root cause.
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: qual_span,
message: crate::friendly::translate(
"diagnostic.unknown_qualifier",
&[(
"qualifier",
&qual as &dyn std::fmt::Display,
)],
),
});
}
}
}
}
@@ -3915,6 +3990,82 @@ mod tests {
);
}
#[test]
fn upsert_excluded_resolves_in_do_update() {
// ADR-0033 §9: inside a DO UPDATE action, `excluded.col`
// resolves to the target table's columns — no diagnostic.
let schema = two_table_schema();
let diags = diag_keys(
"sqlinsert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
&schema,
);
assert!(diags.is_empty(), "excluded.name should resolve in DO UPDATE; got {diags:?}");
}
#[test]
fn upsert_excluded_resolves_in_do_update_where() {
// The DO UPDATE WHERE is part of the action's byte range, so
// `excluded` resolves there too (not just in the SET RHS).
let schema = two_table_schema();
let diags = diag_keys(
"sqlinsert into a (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id = excluded.id",
&schema,
);
assert!(diags.is_empty(), "excluded in DO UPDATE WHERE should resolve; got {diags:?}");
}
#[test]
fn upsert_excluded_unknown_column_is_flagged() {
// `excluded` mirrors the target's shape, so an unknown column
// under it is still an unknown_column error.
let schema = two_table_schema();
let diags = diag_keys(
"sqlinsert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.nosuch",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such column")),
"excluded.nosuch should be unknown_column; got {diags:?}",
);
}
#[test]
fn excluded_outside_do_update_is_unknown_qualifier() {
// The leak guard: `excluded` in a VALUES tuple (no DO UPDATE
// in scope) has no meaning and must be flagged.
let schema = two_table_schema();
let diags = diag_keys(
"sqlinsert into a (id, name) values (excluded.name, 'x')",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such table or alias")),
"excluded in VALUES should be unknown_qualifier; got {diags:?}",
);
}
#[test]
fn excluded_in_values_flagged_even_when_do_update_present() {
// Strongest leak case: same statement uses `excluded` BOTH in
// VALUES (out of scope → flagged) and in DO UPDATE (in scope →
// resolves). The byte-range scoping must distinguish them.
let schema = two_table_schema();
let diags = diag_keys(
"sqlinsert into a (id, name) values (excluded.id, 'x') on conflict (id) do update set name = excluded.name",
&schema,
);
// Exactly the VALUES-side `excluded.id` is flagged; the
// DO UPDATE-side `excluded.name` resolves cleanly.
assert!(
diags.iter().any(|d| d.contains("no such table or alias")),
"VALUES-side excluded must still leak-flag; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("no such column")),
"DO UPDATE-side excluded.name must resolve (no unknown_column); got {diags:?}",
);
}
#[test]
fn insert_select_unknown_projection_column_is_error() {
// ADR-0033 sub-phase 3c cross-cut: the Phase-2