grammar+db: 3g — RETURNING on INSERT/UPDATE/DELETE (ADR-0033 §5)

Shared RETURNING_CLAUSE (reuses Phase-2 PROJECTION_LIST, now
pub(crate)) as an optional tail on all three SQL DML shapes.
`returning: bool` on the Command variants, set by the ast-builders
and threaded to the worker. run_returning collects the returned rows
as a DataResult (RETURNING mutates + yields in one pass), reusing
resolve_select_column_types for bare-column type recovery; computed
projections stay typeless. DeleteResult gains a `data` field rendered
alongside the cascade summary.

Follow-set fix: `returning` is added to the table-source and
projection bare-alias follow-sets so an INSERT … SELECT row source
stops before RETURNING instead of reading it as a table alias.

Auto-fill × RETURNING: build_sql_insert stops row_source before the
RETURNING token (keeping it preparable for shortid materialisation),
and plan_shortid_autofill re-appends the RETURNING tail so generated
shortids surface in RETURNING *.

Tests (+17): grammar accept on all three; INSERT/UPDATE/DELETE
RETURNING incl. *, aliases, multi-row, type recovery + computed-
typeless; auto-fill × RETURNING (single + multi-row distinct ids);
INSERT…SELECT…RETURNING execution; UPDATE…RETURNING zero-match;
DELETE…RETURNING cascade+rows; app-level render of both. Dev
sql_insert/sql_update/sql_delete entry words still removed in 3j.
1562 pass / 0 fail / 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-22 20:44:55 +00:00
parent b935090d7b
commit fd8b74ba5e
12 changed files with 637 additions and 46 deletions
+37 -7
View File
@@ -890,10 +890,19 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
_ => None,
})
.collect();
// The row source is everything from the `VALUES` / `SELECT` /
// `WITH` keyword onward. Located by the first matching *Word
// token* in the path (not a text scan), so a string literal
// like `values ('select')` can't be mistaken for the keyword.
// 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).
let returning_start = path
.items
.iter()
.find(|item| matches!(item.kind, MatchedKind::Word("returning")))
.map(|item| item.span.0);
let row_source = path
.items
.iter()
@@ -901,7 +910,8 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.map(|item| {
source[item.span.0..]
let end = returning_start.unwrap_or(source.len());
source[item.span.0..end]
.trim()
.trim_end_matches(';')
.trim()
@@ -920,9 +930,21 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
target_table,
listed_columns,
row_source,
returning: path_has_returning(path),
})
}
/// Whether the matched path contains a `RETURNING` clause
/// (ADR-0033 §5, sub-phase 3g). Located by the `returning` *Word
/// token* in the path — path-based, so a string literal can't be
/// mistaken for the keyword (mirrors `build_sql_insert`'s
/// row-source detection).
fn path_has_returning(path: &MatchedPath) -> bool {
path.items
.iter()
.any(|item| matches!(item.kind, MatchedKind::Word("returning")))
}
/// Build `Command::SqlUpdate` from a validated SQL `UPDATE`
/// (ADR-0033 §2, sub-phase 3e). Extracts the target table from the
/// matched path so the worker re-persists the right CSV.
@@ -949,7 +971,11 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
.first()
.map_or(source, |entry| &source[entry.span.1..]);
let sql = format!("update {}", tail.trim());
Ok(Command::SqlUpdate { sql, target_table })
Ok(Command::SqlUpdate {
sql,
target_table,
returning: path_has_returning(path),
})
}
/// Build `Command::SqlDelete` from a validated SQL `DELETE`
@@ -982,7 +1008,11 @@ fn build_sql_delete(path: &MatchedPath, source: &str) -> Result<Command, Validat
.first()
.map_or(source, |entry| &source[entry.span.1..]);
let sql = format!("delete {}", tail.trim());
Ok(Command::SqlDelete { sql, target_table })
Ok(Command::SqlDelete {
sql,
target_table,
returning: path_has_returning(path),
})
}
// =================================================================