[1d] Add returning() to queryset update() and delete()#85
Open
davegaeddert wants to merge 4 commits into
Open
Conversation
Chaining returning() before a queryset update() or delete() emits a Postgres RETURNING clause and hands back the affected rows instead of an int rowcount. No-arg returning() hydrates full model instances (RETURNING every concrete column); returning(*names) returns a list of dicts of just those columns. Without returning(), update()/delete() still return an int. returning() returns a ReturningQuerySet flavor whose update()/delete() are typed as returning a list, so static checkers see honest return types while the plain QuerySet keeps int.
Cover instances vs. dicts, unchanged int behavior, JSON converter application, bad-field validation, the returning()-before-filter chain, and that a cascade delete's child rows never appear in RETURNING.
- Move DELETE RETURNING handling into SQLDeleteCompiler.execute_sql, symmetric to the UPDATE compiler; QuerySet._raw_delete and DeleteQuery.do_query now just delegate to it - Extract SQLCompiler._returning_sql(), shared by the UPDATE and DELETE compilers instead of duplicating the emission block - Rename return_insert_columns to returning_columns and return just the SQL string (the params tuple was always empty) - Reuse convert_returning_rows in the insert execute path instead of its own inline cols/converters copy - Resolve returning fields once at returning() time, stored on the clone as _returning_fields, instead of re-resolving at execute - Inline _execute_update/_execute_delete back into update()/delete(); ReturningQuerySet delegates through super() - Drop the duplicate cascade fixture; the cascade-exclusion test now reuses DeleteParent/ChildCascade
davegaeddert
marked this pull request as ready for review
July 23, 2026 16:13
|
Next steps:
|
QuerySet.returning(*fields) now accepts Field references (Model.field) instead of column-name strings. The with-fields overload is validated at the returning() call: each ref must be a concrete Field belonging to the queryset's model, and a string raises a clear TypeError pointing at Model.field. Return shape is unchanged (no-arg hydrates instances, with-fields returns dicts keyed by field name).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a queryset-chain API for Postgres
RETURNINGon set-based writes:returning()is a queryset clone method set before the write, so the SQL is fully known before execution.Semantics
returning()->RETURNINGall concrete columns -> list of model instances (converters applied). Forupdate()these reflect post-update values; fordelete(), the rows as they were.returning(*fields: Field[Any])->RETURNINGthose columns -> list of dicts (not partial instances), keyed by field name. Fields are passed as typed field references (Event.id, not"id"); a string argument raisesTypeErrorpointing at theModel.fieldform, and a field belonging to a different model raisesFieldError. Validation happens in_resolve_returning_fields()at thereturning()call, not when the write runs.returning()->update()/delete()return anintexactly as before.RETURNINGonly reports the statement's own target-table rows; cascade-deleted children never appear (documented in the delete docstring and README)..values()before a returning write raisesTypeErrorrather than misbehaving silently.Typing
returning()returns aReturningQuerySet[T, R]flavor (a two-type-parameter subclass) whoseupdate()/delete()are typed-> R. Two overloads pinR: no-arg ->list[T], field refs ->list[dict[str, Any]]. The plainQuerySetkeepsupdate/delete->int. Verified withassert_typethat the four shapes resolve honestly (int / list[Model] / list[dict]).Implementation
UpdateQuery/DeleteQuery; the clause is emitted bySQLUpdateCompiler.as_sqlandSQLDeleteCompiler._as_sql(reusing the insert path'sreturning_columnsandconvert_returning_rowshelpers). The JOIN ->WHERE id IN (subquery)rewrite stays a single statement, so RETURNING works there too.Tests
New public tests in
plain-postgres/tests/public/test_returning.py: instances reflect new values, dicts for named delete, unchanged int behavior, JSON converter application, bad-field validation (string arg, wrong-model field),returning()-before-filter(), empty result sets, and a FK-cascade case proving only target-table rows are returned../scripts/fix,./scripts/test plain-postgres, full./scripts/test, and./scripts/type-check plain-postgresall pass.