Skip to content

sql: Add DELETE FROM t ROWS (<query>) - #40052

Open
antiguru wants to merge 2 commits into
mainfrom
delete-rows
Open

antiguru wants to merge 2 commits into
mainfrom
delete-rows

Conversation

@antiguru

@antiguru antiguru commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Adds DELETE FROM t ROWS (<query>), which removes the rows a query returns, with multiplicity. DELETE ... WHERE only takes per-row predicates, and WHERE (...) IN (subquery) removes every copy of a duplicated row instead of the excess count.

The statement deletes t INTERSECT ALL q, so it removes at most as many copies of a row as the table holds and cannot drive multiplicities negative. The query's columns take implicit casts only, the ones INTERSECT ALL applies, because assignment casts round and would match rows the query does not contain. Planning reuses the INTERSECT ALL lowering, and execution reuses the read-then-write path. The form is gated behind enable_delete_rows, which is off by default and on in CI. rows still parses as a table alias unless ROWS ( is followed by the start of a query.

Standard SQL has no multiset delete. PostgreSQL expresses one through physical row identity, joining numbered copies on ctid and deleting WHERE ctid IN (...), with CTEs only organizing the query. Materialize tables have no row identity, so identical copies are indistinguishable and any content predicate removes all of them or none. Giving WHERE (...) IN (q) count semantics would change the meaning of an existing standard query, hence a separate clause.

🤖 Generated with Claude Code

`DELETE FROM t ROWS (q)` deletes `t INTERSECT ALL q`: each row of `q`
removes at most one copy of a matching table row, and rows of `q` that
the table does not hold are ignored. This lets users delete the excess
copies of duplicated rows, which `WHERE (...) IN (subquery)` cannot
express because it deletes every copy.

The query is planned like the source of `INSERT ... SELECT`, including
assignment casts to the table's column types, and the INTERSECT ALL
lowering moves into a reusable `HirRelationExpr::intersect_all`. The
syntax is gated behind the `enable_delete_rows` feature flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antiguru
antiguru added this pull request to stack #40054 October 21, 2026 08:23
@antiguru antiguru changed the title delete rows sql: Add DELETE FROM t ROWS (<query>) Oct 21, 2026
@antiguru
antiguru marked this pull request as ready for review October 21, 2026 08:46
@antiguru
antiguru requested a review from a team as a code owner October 21, 2026 08:46
@def-

def- commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Assignment casts let ROWS delete table rows the query does not return

src/sql/src/plan/query.rs:834

DELETE ... ROWS casts the query's columns to the table's column types in assignment context before intersecting, and assignment casts round or truncate (numeric and float to integer, numeric to a narrower scale, timestamp precision, timestamptz to date or timestamp). A query row that equals no table row can round onto one and delete it, so the statement does not delete t INTERSECT ALL q as documented, and the extra deletions are silent.

Details
CREATE TABLE t (a int);
INSERT INTO t VALUES (1);
SELECT a FROM t INTERSECT ALL SELECT 1.4;  -- 0 rows
DELETE FROM t ROWS (SELECT 1.4);           -- DELETE 1

cast_to_table_columns uses CastContext::Assignment (src/sql/src/plan/query.rs:263). In that context numeric -> int4 is CastNumericToInt32, which rounds (src/expr/src/scalar/func/impls/numeric.rs:173), and float8 -> int, numeric -> numeric(p,s), timestamptz -> date and timestamptz -> timestamp are assignment casts too (src/sql/src/plan/typeconv.rs:635, :691, :692, :1096). The VALUES special case (plan_values_insert) applies the same cast, so ROWS (VALUES (1.4)) deletes as well. A realistic case is a numeric(10,2) column where the query supplies 10.004 from an unconstrained numeric column: it deletes the stored 10.00. For INSERT the cast only decides what gets stored. Here it decides which existing rows are destroyed, while INTERSECT ALL and WHERE (...) IN (...) compare in the unified type and match nothing.

Narrowing casts that can fail have the converse effect. A query row the table cannot hold, such as 3000000000::bigint against an int column or an over-long string against varchar(3), aborts the whole statement instead of being ignored as the commit message promises.

The cast tests in delete_rows.slt only cover lossless conversions (2.25::float8 into numeric(10,2)), so they pass either way. One fix is to plan the match like plan_set_expr plans INTERSECT ALL: unify both sides to a common type, intersect, then cast the surviving rows, which are rows of t, back to the table's types. A simpler fix is to cast in implicit context, which rejects lossy conversions at plan time.

Assignment casts round or truncate, so a query row that equals no table
row could round onto one and delete it. Implicit casts are the ones
INTERSECT ALL applies when unifying the query with the table's types, so
the statement deletes exactly t INTERSECT ALL q whenever it plans, and
lossy or narrowing conversions are rejected at plan time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antiguru

Copy link
Copy Markdown
Member Author

Fixed in b9245bc: the query's columns now take implicit casts only, the casts INTERSECT ALL applies when unifying, so the statement deletes exactly t INTERSECT ALL q whenever it plans and rejects lossy or narrowing conversions at plan time. I didn't cast matched rows back to the table's types, because that round trip isn't exact (timestamp through timestamptz across a DST gap).

Posted by Claude Code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants