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:
@@ -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, ¶ms)?;
|
||||
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, ¶ms, 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, ¶ms)?;
|
||||
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)?;
|
||||
|
||||
Reference in New Issue
Block a user