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:
+167
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user