Skip to content

fix(queryset): support DELETE/UPDATE with filters on related fields - #2269

Merged
waketzheng merged 1 commit into
tortoise:developfrom
mmdverse:fix-283-related-field-delete-update
Sep 1, 2026
Merged

fix(queryset): support DELETE/UPDATE with filters on related fields#2269
waketzheng merged 1 commit into
tortoise:developfrom
mmdverse:fix-283-related-field-delete-update

Conversation

@mmdverse

Copy link
Copy Markdown
Contributor

Description

QuerySet.delete() / QuerySet.update() on a filter that touches a related field produce an invalid statement: the JOIN added by resolve_filters() is left in place when the query is turned into a DELETE/UPDATE.

On SQLite:

>>> await LogMessage.filter(device__name="test").delete()
DELETE FROM "logmessage" LEFT OUTER JOIN "device" "logmessage__device" ON ...
sqlite3.OperationalError: near "LEFT": syntax error

The UPDATE path is also broken (separately): for a filter on a related field it renders an aliased FROM "book" "book_" whose alias does not exist, e.g. no such column: book.author_id.

Motivation and Context

Fixes #283. This is a normal, documented usage pattern (filter across a foreign key followed by delete() / update()), and it has been broken on the default SQLite backend since the DELETE/UPDATE rewrite (see the discussion in #283).

Solution

When the resolved filters add joins to the query, the target rows are selected by primary key through a subquery instead:

DELETE FROM "logmessage" WHERE "id" IN (
    SELECT "_t"."id" FROM (
        SELECT "logmessage"."id" FROM "logmessage"
        LEFT OUTER JOIN "device" "logmessage__device" ON ...
        WHERE "logmessage__device"."name" = ?
    ) AS "_t"
)

The derived-table wrapper keeps MySQL happy (ER_UPDATE_TABLE_USED), and the non-JOIN path is left untouched, so simple delete() / update() queries are unchanged.

How Has This Been Tested?

  • Two new regression tests in tests/test_queryset.py (test_delete_filter_by_related_field, test_update_filter_by_related_field) — they fail with OperationalError before the fix and pass after it.
  • Full test suite locally on SQLite and PostgreSQL (asyncpg); outstanding failures are identical to the clean tree (environmental: zoneinfo tzdata / jsonb serialization).
  • Verified behaviorally on MariaDB/MySQL (including backward-relation filters, a custom source_field PK column, and limit/order_by on MySQL): DELETE and UPDATE both execute and only the matching rows are affected.

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added the changelog accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

Filters on related fields add JOINs to the query, but JOINs are not valid in
DELETE/UPDATE statements on most backends, so e.g. on SQLite

    LogMessage.filter(device__name="test").delete()

produces

    DELETE FROM "logmessage" LEFT OUTER JOIN "device" ...
    sqlite3.OperationalError: near "LEFT": syntax error

and the UPDATE path renders a broken aliased FROM clause on top of that.

When filters add joins, target the primary key through a subquery instead:

    DELETE FROM "logmessage" WHERE "id" IN (
        SELECT "_t"."id" FROM (
            SELECT "logmessage"."id" FROM "logmessage" LEFT OUTER JOIN ...
        ) AS "_t"
    )

The derived-table wrapper keeps MySQL (ER_UPDATE_TABLE_USED) happy, and the
non-JOIN path stays untouched.

Fixes tortoise#283
@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks


Comparing mmdverse:fix-283-related-field-delete-update (b578bdd) with develop (a324edc)

Open in CodSpeed

@waketzheng
waketzheng merged commit ffc946e into tortoise:develop Sep 1, 2026
25 checks passed
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.

Delete with related field query fails due to invalid SQL generation

2 participants