From 80c15af9b37b557b2a2330f8eadb66107375db4d Mon Sep 17 00:00:00 2001 From: solvin-ai-teammate Date: Wed, 11 Mar 2026 12:15:32 +0200 Subject: [PATCH 1/7] fix(queryset): use subquery for DELETE/UPDATE filtering by related fields --- tests/test_queryset.py | 22 ++++++++++++++++++++++ tortoise/queryset.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 513979035..6478c5282 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -478,6 +478,28 @@ async def test_delete_limit_order_by(db, intfields_data): with pytest.raises(DoesNotExist): await IntFields.get(intnum=97) +@pytest.mark.asyncio +async def test_delete_filter_with_foreign_key(db): + author = await Author.create(name="test") + await Book.create(name="book1", author=author, rating=5.0) + await Book.create(name="book2", author=author, rating=4.0) + + # This is the failing query + await Book.filter(author__name="test").delete() + + assert await Book.all().count() == 0 + + +@pytest.mark.asyncio +async def test_update_filter_with_foreign_key(db): + author = await Author.create(name="test") + await Book.create(name="book1", author=author, rating=5.0) + + await Book.filter(author__name="test").update(rating=1.0) + + book = await Book.first() + assert book.rating == 1.0 + @pytest.mark.asyncio async def test_async_iter(db, intfields_data): diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 0cfd2fc52..cb0bade7a 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -1301,6 +1301,26 @@ def _make_query(self) -> None: self.resolve_ordering(self.model, table, self._orderings, self._annotations) self.resolve_filters() + if self._joined_tables: + # If we have joins, we must use a subquery for update + # because standard UPDATE does not support JOINs on many DBs. + pk_column = self.model._meta.db_pk_column + subquery = self._db.query_class.from_(table).select(table[pk_column]) + subquery._wheres = self.query._wheres + subquery._havings = self.query._havings + subquery._joins = self.query._joins + if hasattr(self.query, "_limit"): + subquery._limit = self.query._limit + if hasattr(self.query, "_orderbys"): + subquery._orderbys = self.query._orderbys + + # To avoid MySQL Error 1093, we wrap the subquery in another SELECT + # To avoid MySQL Error 1235, the outer SELECT shouldn't have LIMIT + wrapper = self._db.query_class.from_(subquery.as_("_t")).select(Table("_t")[pk_column]) + + self.query = self._db.query_class.update(table) + self.query = self.query.where(table[pk_column].isin(wrapper)) + for key, value in self.update_kwargs.items(): field_object = self.model._meta.fields_map.get(key) if not field_object: @@ -1383,6 +1403,21 @@ def _make_query(self) -> None: annotations=self._annotations, ) self.resolve_filters() + if self._joined_tables: + # If we have joins, we must use a subquery for deletion + # because standard DELETE FROM does not support JOINs. + pk_column = self.model._meta.db_pk_column + subquery = self.query.select(self.model._meta.basetable[pk_column]) + + # To avoid MySQL Error 1093, we wrap the subquery in another SELECT + # To avoid MySQL Error 1235, the outer SELECT shouldn't have LIMIT + # We use the connection's query class directly to avoid carrying over + # the base table into the FROM clause. + wrapper = self._db.query_class.from_(subquery.as_("_t")).select(Table("_t")[pk_column]) + + self.query = copy(self.model._meta.basequery) + self.query = self.query.where(self.model._meta.basetable[pk_column].isin(wrapper)) + self.query._delete_from = True return From fc545b7ca0f21c3bb6f3aabcdf135d7718add28a Mon Sep 17 00:00:00 2001 From: NoySolvin Date: Wed, 11 Mar 2026 12:40:48 +0200 Subject: [PATCH 2/7] update changelog --- CHANGELOG.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5c448bdd0..f2aff4cc6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,13 @@ Changelog 1.1 === +1.1.7 +----- + +Fixed +^^^^^ +- Fixed DELETE and UPDATE queries failing when filtering by related fields (foreign keys). Using a subquery pattern instead of JOIN for compatibility with MySQL and SQLite. (#283) + 1.1.6 ----- From b45306503ff65f89d8e8dc845765f6183d5b2a85 Mon Sep 17 00:00:00 2001 From: NoySolvin Date: Thu, 12 Mar 2026 11:39:38 +0200 Subject: [PATCH 3/7] style: fix formatting --- tests/test_queryset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 6478c5282..744a40139 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -478,6 +478,7 @@ async def test_delete_limit_order_by(db, intfields_data): with pytest.raises(DoesNotExist): await IntFields.get(intnum=97) + @pytest.mark.asyncio async def test_delete_filter_with_foreign_key(db): author = await Author.create(name="test") From 677962d42395a88dbe1eea063a1709433eec7c37 Mon Sep 17 00:00:00 2001 From: NoySolvin Date: Tue, 16 Jun 2026 13:43:11 +0300 Subject: [PATCH 4/7] test(queryset): add negative controls in foreign key delete and update filters --- CHANGELOG.rst | 60 ++++++++++++++++++++++++++++++++++-------- tests/test_queryset.py | 13 +++++++-- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f2aff4cc6..bc8a80ec6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,13 +8,52 @@ Changelog 1.1 === -1.1.7 +1.1.9 ----- Fixed ^^^^^ - Fixed DELETE and UPDATE queries failing when filtering by related fields (foreign keys). Using a subquery pattern instead of JOIN for compatibility with MySQL and SQLite. (#283) +Added +^^^^^ +- Tests for model validators. (#2137) + +1.1.8 +----- + +Added +^^^^^ +- ``QuerySet.union()`` — SQL UNION query support for combining results from multiple QuerySets, including support for union across different models, ``union(all=True)`` for duplicates, ``order_by()``, ``limit()``, and ``count()``. +- ``QuerySet.contains()`` method to check if an object exists in a queryset. +- Added comprehensive EXPLAIN support for MySQL and PostgreSQL. +- Built-in ``DomainNameValidator``, ``URLValidator``, and ``EmailValidator`` classes for common validation patterns. (#2162) + +Fixed +^^^^^ +- ``MigrationRecorder`` now uses parameterized queries; fixes MariaDB/MySQL rejecting ISO-8601 ``applied_at`` values. (#2132) +- ``QuerySet.count()`` now matches the limited query result for the LIMIT/OFFSET edge cases: it returns ``0`` (instead of a negative number) when ``offset()`` exceeds the total row count, and ``0`` (instead of the total) for ``limit(0)``. (#2208) +- Field declarations on models now resolve to their concrete type (e.g. ``CharField[str]``) in Pyright/Pylance instead of ``Field[Unknown]``; the ``Field.__new__`` type-check stub now returns ``Self``. (#2216) + +1.1.7 +----- + +Added +^^^^^ +- ``QuerySet.union()`` — SQL UNION query support for combining results from multiple QuerySets, including support for union across different models, ``union(all=True)`` for duplicates, ``order_by()``, ``limit()``, and ``count()``. +- Tests for model validators. (#2137) + +Fixed +^^^^^ +- Reorder delete model operations in migrations to avoid foreign key constraint errors. (#2145) +- Return value generated by ``db_default`` on create instead of ``None``. (#2143) +- Column comment alteration now works correctly for MySQL and PostgreSQL; fixed ``db_default`` handling for MySQL. (#2142) +- Fix docstrings for a few classes. (#2135) + +Changed +^^^^^^^ +- Improved Pydantic JSON dump performance. (#2130) + 1.1.6 ----- @@ -1038,7 +1077,7 @@ Removals: 0.15.15 ------- -- Add ability to suppply a ``to_field=`` parameter for FK/O2O to a non-PK but still uniquely indexed remote field. (#287) +- Add ability to supply a ``to_field=`` parameter for FK/O2O to a non-PK but still uniquely indexed remote field. (#287) 0.15.14 ------- @@ -1268,7 +1307,7 @@ Breaking Changes: 0.13.6 ------ - Fix minor bug in ``Model.__init__`` where we raise the wrong error on setting RFK/M2M values directly. -- Fields in ``Queryset.values_list()`` is now in the defined Model order. +- Fields in ``Queryset.values_list()`` isow in the defined Model order. - Fields in ``Queryset.values()`` is now in the defined Model order. 0.13.5 @@ -1291,7 +1330,7 @@ Breaking Changes: - ``fetch_related(…)`` now correctly encodes non-integer keys. - ``ForeignKey`` fields of type ``UUIDField`` are now escaped consistently. - Pre-generated ForeignKey fields (e.g. UUIDField) is now checked for persistence correctly. -- Duplicate M2M ``.add(…)`` now checks using consistent field encoding. +- Duplicate M2M ``.add(…)`` now checks using consiste encoding. - ``source_field`` Fields are now handled correctly for ordering. - ``source_field`` Fields are now handled correctly for updating. @@ -1307,7 +1346,7 @@ Breaking Changes: - Partial update is now ~3× faster - Delete is now ~2.7x faster -- Fix generated Schema Primary Key for ``BigIntField`` for MySQL and PostgreSQL. +- Fix generated Schema Primary Key for ``BigId`` for MySQL and PostgreSQL. - Added support for using a ``SmallIntField`` as a auto-gen Primary Key. - Ensure that default PK is added to the top of the attrs. @@ -1446,7 +1485,7 @@ Docs/examples: If you don't define a primary key, we will create a primary key of type ``IntField`` with name of ``id`` for you. - Any of these are valid primary key definitions in a Model: + Any of thesere valid primary key definitions in a Model: .. code-block:: python3 @@ -1492,8 +1531,7 @@ Docs/examples: 0.11.7 ------ - Fixed ``unique_together`` for foreign keys (#114) -- Fixed Field.to_db_value method to handle Enum (#113 #115 #116) - +- Fixed Field.to_db_value method to handle Enum (#113 #115 #116 0.11.6 ------ - Added ability to use ``unique_together`` meta Model option @@ -1589,7 +1627,7 @@ Docs/examples: 0.10.9 ------ -- Uses macros on SQLite driver to minimise syncronisation. ``aiosqlite>=0.7.0`` +- Uses macros on SQLite driver to minimise synchronisation. ``aiosqlite>=0.7.0`` - Uses prepared statements for insert, large insert performance increase. - Pre-generate base pypika query object per model, providing general purpose speedup. @@ -1653,7 +1691,7 @@ Docs/examples: # also specify the app name of "models" # which contain models from "app.models" await Tortoise.init( - db_url='sqlite://db.sqlite3', + db_url='sqlit//db.sqlite3', modules={'models': ['app.models']} ) # Generate the schema @@ -1709,7 +1747,7 @@ Docs/examples: - Fixed ``DatetimeField`` and ``DateField`` to work as expected on SQLite. - Added ``PyLint`` plugin. -- Added test class to mange DB state for testing isolation. +- Added test class to manage DB state for testing isolation. 0.8.0 ----- diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 744a40139..9a2e0ee26 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -485,10 +485,13 @@ async def test_delete_filter_with_foreign_key(db): await Book.create(name="book1", author=author, rating=5.0) await Book.create(name="book2", author=author, rating=4.0) + author2 = await Author.create(name="test2") + await Book.create(name="book3", author=author2, rating=5.0) + # This is the failing query await Book.filter(author__name="test").delete() - assert await Book.all().count() == 0 + assert await Book.all().count() == 1 @pytest.mark.asyncio @@ -496,11 +499,17 @@ async def test_update_filter_with_foreign_key(db): author = await Author.create(name="test") await Book.create(name="book1", author=author, rating=5.0) + author2 = await Author.create(name="test2") + await Book.create(name="book2", author=author2, rating=5.0) + await Book.filter(author__name="test").update(rating=1.0) - book = await Book.first() + book = await Book.get(name="book1") assert book.rating == 1.0 + book2 = await Book.get(name="book2") + assert book2.rating == 5.0 + @pytest.mark.asyncio async def test_async_iter(db, intfields_data): From debd9f18b79bdeae3876eee5700ca9eae73ecc13 Mon Sep 17 00:00:00 2001 From: noy-solvin Date: Tue, 16 Jun 2026 14:02:48 +0300 Subject: [PATCH 5/7] Update CHANGELOG.rst --- CHANGELOG.rst | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 77d4ba8b8..43b421275 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -43,11 +43,6 @@ Added - ``QuerySet.union()`` — SQL UNION query support for combining results from multiple QuerySets, including support for union across different models, ``union(all=True)`` for duplicates, ``order_by()``, ``limit()``, and ``count()``. - Tests for model validators. (#2137) -======= -1.1.7 ------ - ->>>>>>> bad19073a48af627c0e7e6475faf41c159e5f99e Fixed ^^^^^ - Reorder delete model operations in migrations to avoid foreign key constraint errors. (#2145) @@ -1312,7 +1307,7 @@ Breaking Changes: 0.13.6 ------ - Fix minor bug in ``Model.__init__`` where we raise the wrong error on setting RFK/M2M values directly. -- Fields in ``Queryset.values_list()`` isow in the defined Model order. +- Fields in ``Queryset.values_list()`` is now in the defined Model order. - Fields in ``Queryset.values()`` is now in the defined Model order. 0.13.5 @@ -1335,7 +1330,7 @@ Breaking Changes: - ``fetch_related(…)`` now correctly encodes non-integer keys. - ``ForeignKey`` fields of type ``UUIDField`` are now escaped consistently. - Pre-generated ForeignKey fields (e.g. UUIDField) is now checked for persistence correctly. -- Duplicate M2M ``.add(…)`` now checks using consiste encoding. +- Duplicate M2M ``.add(…)`` now checks using consistent field encoding. - ``source_field`` Fields are now handled correctly for ordering. - ``source_field`` Fields are now handled correctly for updating. @@ -1351,7 +1346,7 @@ Breaking Changes: - Partial update is now ~3× faster - Delete is now ~2.7x faster -- Fix generated Schema Primary Key for ``BigId`` for MySQL and PostgreSQL. +- Fix generated Schema Primary Key for ``BigIntField`` for MySQL and PostgreSQL. - Added support for using a ``SmallIntField`` as a auto-gen Primary Key. - Ensure that default PK is added to the top of the attrs. @@ -1490,7 +1485,7 @@ Docs/examples: If you don't define a primary key, we will create a primary key of type ``IntField`` with name of ``id`` for you. - Any of thesere valid primary key definitions in a Model: + Any of these are valid primary key definitions in a Model: .. code-block:: python3 @@ -1536,7 +1531,8 @@ Docs/examples: 0.11.7 ------ - Fixed ``unique_together`` for foreign keys (#114) -- Fixed Field.to_db_value method to handle Enum (#113 #115 #116 +- Fixed Field.to_db_value method to handle Enum (#113 #115 #116) + 0.11.6 ------ - Added ability to use ``unique_together`` meta Model option @@ -1696,7 +1692,7 @@ Docs/examples: # also specify the app name of "models" # which contain models from "app.models" await Tortoise.init( - db_url='sqlit//db.sqlite3', + db_url='sqlite://db.sqlite3', modules={'models': ['app.models']} ) # Generate the schema From f1e644240c232067d328c2bc1693820fd6361e99 Mon Sep 17 00:00:00 2001 From: noy-solvin Date: Thu, 25 Jun 2026 09:53:47 +0300 Subject: [PATCH 6/7] Update CHANGELOG.rst --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 43b421275..363629f2d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,7 +13,7 @@ Changelog Fixed ^^^^^ -- Fixed DELETE and UPDATE queries failing when filtering by related fields (foreign keys). Using a subquery pattern instead of JOIN for compatibility with MySQL and SQLite. (#283) +- Fixed DELETE and UPDATE queries failing when filtering by related fields (foreign keys). Using a subquery pattern instead of JOIN for compatibility with MySQL and SQLite. (#2139) Added ^^^^^ From 4727f84d8f1953c3b4eb644430ca29ac2a05b687 Mon Sep 17 00:00:00 2001 From: noy-solvin Date: Tue, 11 Aug 2026 13:13:53 +0300 Subject: [PATCH 7/7] fixed abondar's comments --- tests/test_queryset.py | 69 ++++++++++++++++++++++++++++++++++++++++++ tortoise/queryset.py | 42 ++++++++++++++----------- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 1e8cb31ae..ba64cc28c 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -1188,3 +1188,72 @@ async def test_union_with_annotate_raises(db): with pytest.raises(ParamsError, match="Union queries do not support annotations"): await qs1.union(qs2) + + +@pytest.mark.asyncio +async def test_update_limit_order_by_with_join(db): + old_cap_val = Event._meta.db.capabilities.support_update_limit_order_by + object.__setattr__(Event._meta.db.capabilities, "_mutable", True) + Event._meta.db.capabilities.support_update_limit_order_by = True + try: + t1 = await Tournament.create(name="T1") + e1 = await Event.create(name="E1", tournament=t1) + e2 = await Event.create(name="E2", tournament=t1) + + updated = ( + await Event.filter(tournament__name="T1") + .order_by("event_id") + .limit(1) + .update(name="E1_updated") + ) + assert updated == 1 + + await e1.refresh_from_db() + await e2.refresh_from_db() + assert e1.name == "E1_updated" + assert e2.name == "E2" + finally: + Event._meta.db.capabilities.support_update_limit_order_by = old_cap_val + object.__setattr__(Event._meta.db.capabilities, "_mutable", False) + + +@pytest.mark.asyncio +async def test_delete_limit_order_by_with_join(db): + old_cap_val = Event._meta.db.capabilities.support_update_limit_order_by + object.__setattr__(Event._meta.db.capabilities, "_mutable", True) + Event._meta.db.capabilities.support_update_limit_order_by = True + try: + t1 = await Tournament.create(name="T1") + await Event.create(name="E1", tournament=t1) + await Event.create(name="E2", tournament=t1) + + deleted = await Event.filter(tournament__name="T1").order_by("event_id").limit(1).delete() + assert deleted == 1 + + count = await Event.all().count() + assert count == 1 + finally: + Event._meta.db.capabilities.support_update_limit_order_by = old_cap_val + object.__setattr__(Event._meta.db.capabilities, "_mutable", False) + + +def test_update_query_postgres_dialect_coverage(db): + from tortoise.backends.base.client import Capabilities + + q = Event.filter(tournament__name="T1").limit(1).update(name="E1_updated") + q.capabilities = Capabilities("postgres", support_update_limit_order_by=False) + sql = q.sql() + assert "IN (SELECT " in sql + assert '"_t"' not in sql + assert "`_t`" not in sql + + +def test_delete_query_postgres_dialect_coverage(db): + from tortoise.backends.base.client import Capabilities + + q = Event.filter(tournament__name="T1").delete() + q.capabilities = Capabilities("postgres", support_update_limit_order_by=False) + sql = q.sql() + assert "IN (SELECT " in sql + assert '"_t"' not in sql + assert "`_t`" not in sql diff --git a/tortoise/queryset.py b/tortoise/queryset.py index a6cf92f1a..1d8cbce62 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -1349,7 +1349,7 @@ def __init__( def _make_query(self) -> None: table = self.model._meta.basetable - self.query = self._db.query_class.update(table) + self.query = copy(self.model._meta.basequery) if self.capabilities.support_update_limit_order_by and self._limit: self.query._limit = self.query._wrapper_cls(self._limit) self.resolve_ordering(self.model, table, self._orderings, self._annotations) @@ -1359,22 +1359,27 @@ def _make_query(self) -> None: # If we have joins, we must use a subquery for update # because standard UPDATE does not support JOINs on many DBs. pk_column = self.model._meta.db_pk_column - subquery = self._db.query_class.from_(table).select(table[pk_column]) - subquery._wheres = self.query._wheres - subquery._havings = self.query._havings - subquery._joins = self.query._joins - if hasattr(self.query, "_limit"): - subquery._limit = self.query._limit - if hasattr(self.query, "_orderbys"): - subquery._orderbys = self.query._orderbys - - # To avoid MySQL Error 1093, we wrap the subquery in another SELECT - # To avoid MySQL Error 1235, the outer SELECT shouldn't have LIMIT - wrapper = self._db.query_class.from_(subquery.as_("_t")).select(Table("_t")[pk_column]) + subquery = self.query.select(table[pk_column]) + + if self.capabilities.dialect == "mysql": + # To avoid MySQL Error 1093, we wrap the subquery in another SELECT + # To avoid MySQL Error 1235, the outer SELECT shouldn't have LIMIT + wrapper = self._db.query_class.from_(subquery.as_("_t")).select( + Table("_t")[pk_column] + ) + else: + wrapper = subquery self.query = self._db.query_class.update(table) self.query = self.query.where(table[pk_column].isin(wrapper)) + else: + update_query = self._db.query_class.update(table) + update_query._wheres = self.query._wheres + update_query._limit = self.query._limit + update_query._orderbys = self.query._orderbys + self.query = update_query + for key, value in self.update_kwargs.items(): field_object = self.model._meta.fields_map.get(key) if not field_object: @@ -1463,11 +1468,12 @@ def _make_query(self) -> None: pk_column = self.model._meta.db_pk_column subquery = self.query.select(self.model._meta.basetable[pk_column]) - # To avoid MySQL Error 1093, we wrap the subquery in another SELECT - # To avoid MySQL Error 1235, the outer SELECT shouldn't have LIMIT - # We use the connection's query class directly to avoid carrying over - # the base table into the FROM clause. - wrapper = self._db.query_class.from_(subquery.as_("_t")).select(Table("_t")[pk_column]) + if self.capabilities.dialect == "mysql": + wrapper = self._db.query_class.from_(subquery.as_("_t")).select( + Table("_t")[pk_column] + ) + else: + wrapper = subquery self.query = copy(self.model._meta.basequery) self.query = self.query.where(self.model._meta.basetable[pk_column].isin(wrapper))