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
+60
View File
@@ -1355,6 +1355,15 @@ impl App {
for effect in &result.cascade {
self.note_system(render_cascade_effect(effect));
}
// A `RETURNING` clause (ADR-0033 §5, 3g) carries the deleted
// rows; the cascade summary above surfaces alongside them. A
// column-less result (the DSL `delete` and SQL `DELETE`
// without RETURNING) is skipped, exactly as for UPDATE.
if !result.data.columns.is_empty() {
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
}
}
fn handle_dsl_failure(
@@ -3365,6 +3374,7 @@ mod tests {
command: Command::SqlUpdate {
sql: "update t set v = 1".to_string(),
target_table: "t".to_string(),
returning: false,
},
result: crate::db::UpdateResult {
rows_affected: 2,
@@ -3396,6 +3406,7 @@ mod tests {
command: Command::SqlUpdate {
sql: "update t set v = 1".to_string(),
target_table: "t".to_string(),
returning: false,
},
result: crate::db::UpdateResult {
rows_affected: 1,
@@ -3429,6 +3440,7 @@ mod tests {
command: Command::SqlDelete {
sql: "delete from Customers where id = 1".to_string(),
target_table: "Customers".to_string(),
returning: false,
},
result: crate::db::DeleteResult {
rows_affected: 1,
@@ -3438,6 +3450,12 @@ mod tests {
rows_changed: 2,
action: ReferentialAction::Cascade,
}],
data: crate::db::DataResult {
table_name: "Customers".to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
@@ -3455,4 +3473,46 @@ mod tests {
"per-relationship cascade summary surfaced: {texts:?}",
);
}
#[test]
fn sql_delete_returning_renders_cascade_and_result_table() {
// ADR-0033 3g: a DELETE … RETURNING surfaces BOTH the cascade
// summary AND the returned-rows table. Pins the render branch
// that tabulates `result.data` when RETURNING populated it
// (the column-less non-RETURNING path is skipped — see the
// sibling test above).
use crate::dsl::ReferentialAction;
let mut app = App::new();
app.update(AppEvent::DslDeleteSucceeded {
command: Command::SqlDelete {
sql: "delete from Customers where id = 1 returning *".to_string(),
target_table: "Customers".to_string(),
returning: true,
},
result: crate::db::DeleteResult {
rows_affected: 1,
cascade: vec![crate::db::CascadeEffect {
relationship_name: "places".to_string(),
child_table: "Orders".to_string(),
rows_changed: 2,
action: ReferentialAction::Cascade,
}],
data: crate::db::DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string()],
column_types: vec![Some(Type::Int), Some(Type::Text)],
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
},
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
"cascade summary still surfaces alongside RETURNING: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("Name")) && texts.iter().any(|t| t.contains("Alice")),
"the returned (deleted) row is tabulated: {texts:?}",
);
}
}
+179 -24
View File
@@ -351,6 +351,13 @@ pub struct UpdateResult {
pub struct DeleteResult {
pub rows_affected: usize,
pub cascade: Vec<CascadeEffect>,
/// Rows produced by a `RETURNING` clause (ADR-0033 §5, 3g).
/// Empty (no columns, no rows) when the DELETE had no
/// `RETURNING` — the renderer skips a column-less result, so the
/// non-RETURNING path is unaffected. For SQL `DELETE … RETURNING`
/// these are the rows as they were *before* deletion; the
/// cascade summary surfaces alongside.
pub data: DataResult,
}
/// One observed change in a child table caused by referential
@@ -593,6 +600,7 @@ enum Request {
target_table: String,
listed_columns: Vec<String>,
row_source: String,
returning: bool,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
/// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The
@@ -603,6 +611,7 @@ enum Request {
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
/// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
@@ -615,6 +624,7 @@ enum Request {
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
},
/// Capture the query plan for an explainable command via
@@ -1086,6 +1096,7 @@ impl Database {
target_table: String,
listed_columns: Vec<String>,
row_source: String,
returning: bool,
) -> Result<InsertResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlInsert {
@@ -1094,6 +1105,7 @@ impl Database {
target_table,
listed_columns,
row_source,
returning,
reply,
})
.await?;
@@ -1110,12 +1122,14 @@ impl Database {
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
) -> Result<UpdateResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlUpdate {
sql,
source,
target_table,
returning,
reply,
})
.await?;
@@ -1133,12 +1147,14 @@ impl Database {
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
) -> Result<DeleteResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlDelete {
sql,
source,
target_table,
returning,
reply,
})
.await?;
@@ -1596,6 +1612,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
target_table,
listed_columns,
row_source,
returning,
reply,
} => {
let _ = reply.send(do_sql_insert(
@@ -1606,12 +1623,14 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
&target_table,
&listed_columns,
&row_source,
returning,
));
}
Request::RunSqlUpdate {
sql,
source,
target_table,
returning,
reply,
} => {
let _ = reply.send(do_sql_update(
@@ -1620,12 +1639,14 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
source.as_deref(),
&sql,
&target_table,
returning,
));
}
Request::RunSqlDelete {
sql,
source,
target_table,
returning,
reply,
} => {
let _ = reply.send(do_sql_delete(
@@ -1634,6 +1655,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
source.as_deref(),
&sql,
&target_table,
returning,
));
}
Request::RebuildFromText {
@@ -5826,6 +5848,14 @@ fn do_delete(
Ok(DeleteResult {
rows_affected,
cascade,
// The DSL `delete` has no RETURNING (a SQL-only clause); the
// empty result is skipped by the renderer (ADR-0033 §5, 3g).
data: DataResult {
table_name: table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
})
}
@@ -5915,6 +5945,7 @@ fn plan_shortid_autofill(
sql: &str,
listed_columns: &[String],
row_source: &str,
returning_tail: &str,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
if listed_columns.is_empty() {
return Ok((sql.to_string(), Vec::new()));
@@ -6009,8 +6040,18 @@ fn plan_shortid_autofill(
.join(", ");
tuples.push(format!("({placeholders})"));
}
// Preserve any RETURNING tail (3g) — the reconstruction would
// otherwise drop it, so `INSERT … RETURNING *` on an auto-filled
// shortid table would return no rows (and the worker would read
// a zero affected-row count). `returning_tail` is "" on the
// non-RETURNING path.
let returning_suffix = if returning_tail.is_empty() {
String::new()
} else {
format!(" {returning_tail}")
};
let exec_sql = format!(
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals};",
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals}{returning_suffix};",
tbl = quote_ident(target_table),
vals = tuples.join(", "),
);
@@ -6039,6 +6080,7 @@ fn plan_shortid_autofill(
/// exactly the inserted rows; an INSERT that sets explicit
/// non-contiguous rowid/INTEGER-PK values may surface a partial
/// view. `RETURNING` (sub-phase 3g) is the precise tool.
#[allow(clippy::too_many_arguments)]
fn do_sql_insert(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -6047,8 +6089,24 @@ fn do_sql_insert(
target_table: &str,
listed_columns: &[String],
row_source: &str,
returning: bool,
) -> Result<InsertResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_insert");
debug!(sql = %sql, table = %target_table, returning, "sql_insert");
// RETURNING (3g): the `shortid` auto-fill rewrite reconstructs
// only `INSERT … VALUES …` and would drop the RETURNING tail, so
// extract it here to re-append. `row_source` is the clean
// VALUES/SELECT text (no RETURNING — `build_sql_insert` stops the
// slice at the RETURNING token), so whatever follows it in the
// full `sql` is the RETURNING clause. On the verbatim (no
// auto-fill) path the original `sql` already carries RETURNING,
// so the tail is only consumed by the rewrite.
let returning_tail: String = if returning && !row_source.is_empty() {
sql.find(row_source)
.map(|i| sql[i + row_source.len()..].trim().trim_end_matches(';').trim().to_string())
.unwrap_or_default()
} else {
String::new()
};
// Sub-phase 3d: when the user's column list omits one or more
// `shortid` columns, the worker materialises the row source,
// synthesises fresh distinct ids, and reinserts the augmented
@@ -6056,20 +6114,29 @@ fn do_sql_insert(
// params vec with the original `sql` means "no auto-fill —
// execute verbatim" (the 3b path).
let (exec_sql, params) =
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source)?;
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source, &returning_tail)?;
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected =
execute_with_fk_enrichment(conn, target_table, &exec_sql, &params)?;
let last = conn.last_insert_rowid();
let rowids: Vec<i64> = if rows_affected == 0 {
Vec::new()
// RETURNING (3g): one pass inserts and yields the inserted rows
// (incl. any auto-filled shortid), so the returned set is the
// precise auto-show and rows_affected is its length. Without
// RETURNING, fall back to the best-effort rowid auto-show.
let (rows_affected, data) = if returning {
let data = run_returning(conn, &exec_sql, &params, target_table)?;
(data.rows.len(), data)
} else {
let n = rows_affected as i64;
((last - n + 1)..=last).collect()
let n = execute_with_fk_enrichment(conn, target_table, &exec_sql, &params)?;
let last = conn.last_insert_rowid();
let rowids: Vec<i64> = if n == 0 {
Vec::new()
} else {
let count = n as i64;
((last - count + 1)..=last).collect()
};
let data = query_rows_by_rowid(conn, target_table, &rowids)?;
(n, data)
};
let data = query_rows_by_rowid(conn, target_table, &rowids)?;
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
@@ -6107,12 +6174,31 @@ fn do_sql_update(
source: Option<&str>,
sql: &str,
target_table: &str,
returning: bool,
) -> Result<UpdateResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_update");
debug!(sql = %sql, table = %target_table, returning, "sql_update");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
// RETURNING (3g): one pass performs the update and yields the
// modified rows; rows_affected is the row count. Without
// RETURNING the affected-row count surfaces and the (column-less)
// DataResult is skipped by the renderer (3e behaviour).
let (rows_affected, data) = if returning {
let data = run_returning(conn, sql, &[], target_table)?;
(data.rows.len(), data)
} else {
let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
(
n,
DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
)
};
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
@@ -6120,15 +6206,7 @@ fn do_sql_update(
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(UpdateResult {
rows_affected,
data: DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
})
Ok(UpdateResult { rows_affected, data })
}
/// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7,
@@ -6167,8 +6245,9 @@ fn do_sql_delete(
source: Option<&str>,
sql: &str,
target_table: &str,
returning: bool,
) -> Result<DeleteResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_delete");
debug!(sql = %sql, table = %target_table, returning, "sql_delete");
// Snapshot child-table row counts before the delete so cascade
// effects can be detected by diffing afterwards (Amendment 2;
@@ -6183,7 +6262,27 @@ fn do_sql_delete(
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
// RETURNING (3g): one pass deletes and yields the rows as they
// were *before* deletion. `rows_affected` is the count of
// directly-deleted rows either way (RETURNING does not yield
// cascade-deleted child rows, so data.rows.len() == direct
// deletes), which keeps the self-ref cascade correction below
// valid. The cascade pre-count was already captured above.
let (rows_affected, data) = if returning {
let data = run_returning(conn, sql, &[], target_table)?;
(data.rows.len(), data)
} else {
let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
(
n,
DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
)
};
// Compare child-table counts after the delete; positive diffs
// are cascade effects. Collect the cascaded tables so the
@@ -6228,6 +6327,7 @@ fn do_sql_delete(
Ok(DeleteResult {
rows_affected,
cascade,
data,
})
}
@@ -6245,6 +6345,61 @@ fn do_sql_delete(
/// `None`. The renderer (ADR-0016) handles typed columns
/// (bool → true/false, etc.) and falls back to neutral
/// alignment for `None`.
/// Execute a grammar-validated SQL DML statement carrying a
/// `RETURNING` clause and collect its returned rows into a
/// [`DataResult`] (ADR-0033 §5, sub-phase 3g).
///
/// A `RETURNING` DML, when stepped, *both* performs the mutation
/// *and* yields one result row per affected row — so `query_map`
/// does the write and the read in one pass. Result-column
/// playground types are recovered via the same column-origin path
/// SELECT uses (`resolve_select_column_types`), so a bare-column
/// `RETURNING` ref renders with its playground type; computed
/// projections stay typeless. `params` carries the bound values for
/// the `shortid` auto-fill rewrite (empty on the verbatim path).
///
/// `table_name` labels the result for the renderer; the columns are
/// the RETURNING projection, which may not be the table's columns
/// (aliases, expressions), exactly as for a SELECT.
fn run_returning(
conn: &Connection,
sql: &str,
params: &[rusqlite::types::Value],
table_name: &str,
) -> Result<DataResult, DbError> {
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
let column_names: Vec<String> =
stmt.column_names().into_iter().map(String::from).collect();
let col_count = column_names.len();
let column_types = resolve_select_column_types(conn, &stmt);
let rows_iter = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
for i in 0..col_count {
cells.push(row.get(i)?);
}
Ok(cells)
})
.map_err(DbError::from_rusqlite)?;
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
rows.push(
cells
.into_iter()
.enumerate()
.map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
.collect(),
);
}
Ok(DataResult {
table_name: table_name.to_string(),
columns: column_names,
column_types,
rows,
})
}
fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
debug!(sql = %sql, "run_select");
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
+12
View File
@@ -311,6 +311,11 @@ pub enum Command {
target_table: String,
listed_columns: Vec<String>,
row_source: String,
/// Whether a `RETURNING` clause matched (ADR-0033 §5,
/// sub-phase 3g). The worker collects the returned rows as a
/// `DataResult` when true; otherwise it surfaces the
/// affected-row count (+ auto-show) as before.
returning: bool,
},
/// A SQL `UPDATE` validated by the walker (ADR-0033 §2,
/// advanced mode). Grammar-as-text: the worker executes `sql`
@@ -319,6 +324,9 @@ pub enum Command {
SqlUpdate {
sql: String,
target_table: String,
/// Whether a `RETURNING` clause matched (ADR-0033 §5,
/// sub-phase 3g).
returning: bool,
},
/// A SQL `DELETE` validated by the walker (ADR-0033 §1/§7,
/// advanced mode). Grammar-as-text: the worker executes `sql`,
@@ -331,6 +339,10 @@ pub enum Command {
SqlDelete {
sql: String,
target_table: String,
/// Whether a `RETURNING` clause matched (ADR-0033 §5,
/// sub-phase 3g). The cascade summary surfaces alongside the
/// returned rows when true.
returning: bool,
},
/// App-lifecycle command (per ADR-0003). These work in both
/// simple and advanced modes; the dispatcher branches on the
+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),
})
}
// =================================================================
+10 -1
View File
@@ -17,7 +17,7 @@
//! later. The worker never inspects the WHERE clause (Amendment 2),
//! so no predicate-byte extraction is needed.
use crate::dsl::grammar::sql_select::{WHERE_CLAUSE, reject_internal_table};
use crate::dsl::grammar::sql_select::{RETURNING_CLAUSE, WHERE_CLAUSE, reject_internal_table};
use crate::dsl::grammar::{IdentSource, Node, Word};
/// The `DELETE` target table. `__rdbms_*` rejected (ADR-0030 §6 /
@@ -48,6 +48,7 @@ static SQL_DELETE_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("from")),
TARGET_TABLE,
Node::Optional(&WHERE_CLAUSE),
Node::Optional(&RETURNING_CLAUSE),
Node::Optional(&Node::Punct(';')),
];
@@ -123,6 +124,14 @@ mod tests {
good("from orders where customer_id in (select id from customers where country = 'DE')");
}
#[test]
fn returning_tail_admitted() {
// 3g: optional RETURNING projection_list tail.
good("from orders where id = 1 returning *");
good("from orders returning id, total");
good("from orders where id = 1 returning id as gone;");
}
#[test]
fn internal_target_table_rejected() {
bad("from __rdbms_playground_columns");
+13 -1
View File
@@ -14,7 +14,7 @@
//! sub-phases.
use crate::dsl::grammar::sql_expr;
use crate::dsl::grammar::sql_select::{SQL_SELECT_COMPOUND, reject_internal_table};
use crate::dsl::grammar::sql_select::{RETURNING_CLAUSE, SQL_SELECT_COMPOUND, reject_internal_table};
use crate::dsl::grammar::{IdentSource, Node, Word};
static COMMA: Node = Node::Punct(',');
@@ -110,6 +110,7 @@ static SQL_INSERT_TAIL_NODES: &[Node] = &[
TARGET_TABLE,
OPTIONAL_COLUMN_LIST,
ROW_SOURCE,
Node::Optional(&RETURNING_CLAUSE),
Node::Optional(&Node::Punct(';')),
];
@@ -184,6 +185,17 @@ mod tests {
good("into t values (case when 1 > 0 then 'y' else 'n' end)");
}
#[test]
fn returning_tail_admitted() {
// 3g: optional RETURNING projection_list tail, on both row
// sources.
good("into orders values (1, 2.0) returning *");
good("into orders (id, total) values (1, 2.0) returning id");
good("into orders values (1, 'a'), (2, 'b') returning id, total");
good("into archive select * from orders returning *");
good("into orders values (1) returning id as new_id;");
}
#[test]
fn internal_target_table_rejected() {
bad("into __rdbms_playground_columns values (1)");
+24 -1
View File
@@ -143,6 +143,11 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit",
"union", "intersect", "except",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source
// (`select id returning *`) stops before it.
"returning",
];
/// Continuation keywords that may legitimately follow a table
@@ -156,6 +161,10 @@ const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit",
"union", "intersect", "except",
"inner", "left", "right", "full", "cross", "join", "on",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias.
"returning",
];
fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
@@ -325,12 +334,26 @@ fn projection_item_factory(
static PROJECTION_ITEM: Node = Node::Lookahead(projection_item_factory);
static PROJECTION_LIST: Node = Node::Repeated {
pub(crate) static PROJECTION_LIST: Node = Node::Repeated {
inner: &PROJECTION_ITEM,
separator: Some(&COMMA),
min: 1,
};
/// `RETURNING projection_list` — the optional tail shared by the
/// SQL DML statements (ADR-0033 §5, sub-phase 3g). Reuses the
/// Phase-2 projection list unchanged (`*`, bare/qualified column
/// refs, `expr AS alias`, computed expressions), so a RETURNING
/// projection is parsed, completed and highlighted exactly as a
/// SELECT projection. The worker collects the returned rows as a
/// `DataResult`; result-column playground types are recovered via
/// the same column-origin path SELECT uses (ADR-0032 §12).
pub(crate) static RETURNING_CLAUSE: Node = Node::Seq(RETURNING_CLAUSE_NODES);
static RETURNING_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("returning")),
Node::Subgrammar(&PROJECTION_LIST),
];
// =================================================================
// DISTINCT / ALL prefix
// =================================================================
+10 -1
View File
@@ -15,7 +15,7 @@
//! written (ADR-0030 §12). `RETURNING` (3g) lands later.
use crate::dsl::grammar::sql_expr;
use crate::dsl::grammar::sql_select::{WHERE_CLAUSE, reject_internal_table};
use crate::dsl::grammar::sql_select::{RETURNING_CLAUSE, WHERE_CLAUSE, reject_internal_table};
use crate::dsl::grammar::{IdentSource, Node, Word};
static COMMA: Node = Node::Punct(',');
@@ -79,6 +79,7 @@ static SQL_UPDATE_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("set")),
ASSIGNMENT_LIST,
Node::Optional(&WHERE_CLAUSE),
Node::Optional(&RETURNING_CLAUSE),
Node::Optional(&Node::Punct(';')),
];
@@ -153,6 +154,14 @@ mod tests {
good("t set v = (select max(other) from other_table) where id = 1");
}
#[test]
fn returning_tail_admitted() {
// 3g: optional RETURNING projection_list tail.
good("t set v = 1 where id = 1 returning *");
good("t set v = 1 returning id, v");
good("t set v = 1 where id = 1 returning v as new_v;");
}
#[test]
fn internal_target_table_rejected() {
bad("__rdbms_playground_columns set a = 1");
+14 -5
View File
@@ -1890,16 +1890,21 @@ async fn execute_command_typed(
target_table,
listed_columns,
row_source,
returning,
} => database
.run_sql_insert(sql, src, target_table, listed_columns, row_source)
.run_sql_insert(sql, src, target_table, listed_columns, row_source, returning)
.await
.map(CommandOutcome::Insert),
// A SQL `UPDATE` (advanced mode; ADR-0033 §2). Grammar-as-
// text: the worker runs the validated `sql` and re-persists
// the parsed `target_table`'s CSV. Reuses the DSL update
// outcome (affected-row count).
Command::SqlUpdate { sql, target_table } => database
.run_sql_update(sql, src, target_table)
Command::SqlUpdate {
sql,
target_table,
returning,
} => database
.run_sql_update(sql, src, target_table, returning)
.await
.map(CommandOutcome::Update),
// A SQL `DELETE` (advanced mode; ADR-0033 §1/§7). Grammar-
@@ -1908,8 +1913,12 @@ async fn execute_command_typed(
// plus every cascade-affected child. Reuses the DSL delete
// outcome (affected-row count + per-relationship cascade
// summary).
Command::SqlDelete { sql, target_table } => database
.run_sql_delete(sql, src, target_table)
Command::SqlDelete {
sql,
target_table,
returning,
} => database
.run_sql_delete(sql, src, target_table, returning)
.await
.map(CommandOutcome::Delete),
// `EXPLAIN QUERY PLAN` never executes the wrapped