Skip to content

[1d] Add returning() to queryset update() and delete()#85

Open
davegaeddert wants to merge 4 commits into
masterfrom
returning
Open

[1d] Add returning() to queryset update() and delete()#85
davegaeddert wants to merge 4 commits into
masterfrom
returning

Conversation

@davegaeddert

@davegaeddert davegaeddert commented Jul 23, 2026

Copy link
Copy Markdown
Member

What

Adds a queryset-chain API for Postgres RETURNING on set-based writes:

# No args -> RETURNING every concrete column -> list of hydrated model instances
running = Job.query.filter(status="pending").returning().update(status="running")

# Field refs -> RETURNING those columns -> list of dicts
deleted = Event.query.filter(created_at__lt=cutoff).returning(Event.id, Event.payload).delete()

# Unchanged: int rowcount when returning() is not used
n = Job.query.filter(...).update(status="x")

returning() is a queryset clone method set before the write, so the SQL is fully known before execution.

Semantics

  • No-arg returning() -> RETURNING all concrete columns -> list of model instances (converters applied). For update() these reflect post-update values; for delete(), the rows as they were.
  • returning(*fields: Field[Any]) -> RETURNING those 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 raises TypeError pointing at the Model.field form, and a field belonging to a different model raises FieldError. Validation happens in _resolve_returning_fields() at the returning() call, not when the write runs.
  • Without returning() -> update()/delete() return an int exactly as before.
  • RETURNING only 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 raises TypeError rather than misbehaving silently.

Typing

returning() returns a ReturningQuerySet[T, R] flavor (a two-type-parameter subclass) whose update()/delete() are typed -> R. Two overloads pin R: no-arg -> list[T], field refs -> list[dict[str, Any]]. The plain QuerySet keeps update/delete -> int. Verified with assert_type that the four shapes resolve honestly (int / list[Model] / list[dict]).

Implementation

  • RETURNING selection stored on UpdateQuery/DeleteQuery; the clause is emitted by SQLUpdateCompiler.as_sql and SQLDeleteCompiler._as_sql (reusing the insert path's returning_columns and convert_returning_rows helpers). The JOIN -> WHERE id IN (subquery) rewrite stays a single statement, so RETURNING works there too.
  • No changes to insert / bulk_create / upsert / bulk_update paths.

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-postgres all pass.

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
davegaeddert marked this pull request as ready for review July 23, 2026 16:13
@pullapprove5

pullapprove5 Bot commented Jul 23, 2026

Copy link
Copy Markdown
PASS: 1 review scope passed
Scope Progress
all 0/0

View in PullApprove

Next steps:

@davegaeddert davegaeddert changed the title Add returning() to queryset update() and delete() [merge 4] Add returning() to queryset update() and delete() Jul 23, 2026
@davegaeddert davegaeddert changed the title [merge 4] Add returning() to queryset update() and delete() [1d] Add returning() to queryset update() and delete() Jul 23, 2026
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).
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.

1 participant