diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d9957975..ee4010e47 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,17 @@ Changelog 1.1 === +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. (#2139) + +Added +^^^^^ +- Tests for model validators. (#2137) + 1.1.8 ----- diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 8eaefb795..ba64cc28c 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -519,6 +519,38 @@ async def test_delete_limit_order_by(db, intfields_data): 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) + + 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() == 1 + + +@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) + + 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.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): counter = 0 @@ -1156,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 fe7d4a3f7..1d8cbce62 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -1349,12 +1349,37 @@ 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) 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.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: @@ -1437,6 +1462,22 @@ 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]) + + 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)) + self.query._delete_from = True return