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:
+28
-8
@@ -891,18 +891,38 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
})
|
||||
.collect();
|
||||
// The row source is the `VALUES` / `SELECT` / `WITH` clause —
|
||||
// from that keyword up to (but not including) any `RETURNING`
|
||||
// tail (3g) or trailing `;`. Both boundaries are located by
|
||||
// *Word token* in the path (not a text scan), so a string
|
||||
// literal like `values ('select')` / `values ('returning')`
|
||||
// can't be mistaken for a keyword. Excluding RETURNING keeps the
|
||||
// row source independently preparable for `shortid` auto-fill
|
||||
// (`VALUES … RETURNING …` is not a valid standalone statement).
|
||||
// from that keyword up to (but not including) any trailing
|
||||
// clause: `ON CONFLICT …` (3h) or `RETURNING …` (3g), whichever
|
||||
// comes first, else the trailing `;` / end. Boundaries are
|
||||
// located by *Word token* in the path (not a text scan), so a
|
||||
// string literal like `values ('select')` can't be mistaken for
|
||||
// a keyword. Excluding the trailing clauses keeps the row source
|
||||
// independently preparable for `shortid` auto-fill (`VALUES …
|
||||
// ON CONFLICT …` / `VALUES … RETURNING …` are not valid
|
||||
// standalone statements), and the auto-fill rewrite re-appends
|
||||
// the trailing tail verbatim (see `do_sql_insert`).
|
||||
//
|
||||
// `ON CONFLICT`'s `on` is located via the unambiguous `conflict`
|
||||
// keyword that immediately follows it — a JOIN's `on` inside a
|
||||
// SELECT row source has no following `conflict`, so it is not
|
||||
// mistaken for a clause boundary.
|
||||
let on_conflict_start = path
|
||||
.items
|
||||
.windows(2)
|
||||
.find(|w| {
|
||||
matches!(w[0].kind, MatchedKind::Word("on"))
|
||||
&& matches!(w[1].kind, MatchedKind::Word("conflict"))
|
||||
})
|
||||
.map(|w| w[0].span.0);
|
||||
let returning_start = path
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| matches!(item.kind, MatchedKind::Word("returning")))
|
||||
.map(|item| item.span.0);
|
||||
let tail_start = [on_conflict_start, returning_start]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
let row_source = path
|
||||
.items
|
||||
.iter()
|
||||
@@ -910,7 +930,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
|
||||
})
|
||||
.map(|item| {
|
||||
let end = returning_start.unwrap_or(source.len());
|
||||
let end = tail_start.unwrap_or(source.len());
|
||||
source[item.span.0..end]
|
||||
.trim()
|
||||
.trim_end_matches(';')
|
||||
|
||||
Reference in New Issue
Block a user