From ee4ca6a2302e348ad5ad17b271f934b045edb804 Mon Sep 17 00:00:00 2001 From: seladb Date: Mon, 16 Mar 2026 22:54:27 -0700 Subject: [PATCH 01/20] Merge from tortoise/develop # Conflicts: # tortoise/queryset.py --- tortoise/backends/base/executor.py | 22 ++++ tortoise/queryset.py | 192 ++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/tortoise/backends/base/executor.py b/tortoise/backends/base/executor.py index a9471d75e..f22885d68 100644 --- a/tortoise/backends/base/executor.py +++ b/tortoise/backends/base/executor.py @@ -154,6 +154,28 @@ async def execute_select( await self._execute_prefetch_queries(instance_list) return instance_list + async def execute_union( + self, sql: str, app_field: str, model_field: str, models: set[type[Model]] + ) -> list: + _, raw_results = await self.db.execute_query(sql) + instance_list = [] + + for row_idx, row in enumerate(raw_results): + if row_idx != 0 and row_idx % CHUNK_SIZE == 0: + # Forcibly yield to the event loop to avoid blocking the event loop + # when selecting a large number of rows + await asyncio.sleep(0) + + for model in models: + if ( + model._meta.app == row[app_field] + and model._meta._model.__name__ == row[model_field] + ): + instance_list.append(model._init_from_db(**row)) + break + + return instance_list + def _prepare_insert_columns( self, include_generated: bool = False ) -> tuple[list[str], list[str]]: diff --git a/tortoise/queryset.py b/tortoise/queryset.py index ef4b5b568..23cce8598 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2,7 +2,7 @@ import types from collections import defaultdict -from collections.abc import AsyncIterator, Callable, Collection, Generator, Iterable +from collections.abc import AsyncIterator, Callable, Collection, Generator, Iterable, Sequence from copy import copy from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypeVar, cast, overload @@ -21,7 +21,7 @@ OperationalError, ParamsError, ) -from tortoise.expressions import Expression, Q, RawSQL, ResolveContext, ResolveResult +from tortoise.expressions import Expression, Q, RawSQL, ResolveContext, ResolveResult, Value from tortoise.fields.base import DatabaseDefault from tortoise.fields.relational import ( ForeignKeyFieldInstance, @@ -586,6 +586,17 @@ def distinct(self) -> QuerySet[MODEL]: queryset._distinct = True return queryset + def union( + self, *other_qs: QuerySet[Model] | UnionQuery[Model], all: bool = False + ) -> UnionQuery[MODEL]: + """ + Return the union of QuerySets. + + :param other_qs: Another QuerySet(s) to union with. + :return: A new UnionQuery representing the union of both QuerySets. + """ + return UnionQuery(self.model, self._db, self, *other_qs, all=all) + def select_for_update( self, nowait: bool = False, @@ -2223,3 +2234,180 @@ def sql(self, params_inline=False) -> str: return insert_sql return ";".join([insert_sql, insert_sql_all]) + + +class UnionQuery(AwaitableQuery[MODEL]): + __slots__ = ( + "model", + "models", + "union_query", + "_selects", + "_db", + "_qs", + "_all", + "_orderings", + "_limit", + ) + + TORTOISE_APP_FIELD = "tortoise_app" + TORTOISE_MODEL_FIELD = "tortoise_model" + + def __init__( + self, + model: type[MODEL], + db: BaseDBAsyncClient, + *querysets: QuerySet[Model] | UnionQuery[Model], + all: bool = False, + ): + super().__init__(model) + self.models = {model, *(qs.model for qs in querysets)} + self.union_query = None + self._selects = None + self._db = db + self._qs = querysets + self._all = all + self._orderings: list[tuple[str, Order]] | None = None + self._limit: int | None = None + + @classmethod + def _get_selects(cls, qs: QuerySet[Model] | UnionQuery[Model]) -> list[str]: + return [ + select.name + for select in qs.query._selects + if getattr(select, "alias") not in [cls.TORTOISE_APP_FIELD, cls.TORTOISE_MODEL_FIELD] + ] + + def _make_query(self) -> None: + for qs in self._qs: + model_annotations = { + self.TORTOISE_APP_FIELD: Value(qs.model._meta.app), + self.TORTOISE_MODEL_FIELD: Value(qs.model._meta._model.__name__), + } + qs = qs.annotate(**model_annotations) + qs._make_query() + qs.query.wrap_set_operation_queries = False + if not self.union_query: + self.union_query = qs.query + self._selects = self._get_selects(qs) + else: + if self._get_selects(qs) != self._selects: + raise ValueError("Union queries must have the same select fields") + self.union_query = ( + self.union_query.union_all(qs.query) + if self._all + else self.union_query.union(qs.query) + ) + + if self._orderings: + for field_name, order in self._orderings: + if field_name not in self._selects: + raise ParamsError("Order by field must be in the select list for union queries") + + self.union_query = self.union_query.orderby(field_name, order=order) + + if self._limit is not None: + self.union_query._limit = self.union_query._wrapper_cls(self._limit) + + def __await__(self) -> Generator[Any, None, Sequence[dict]]: + self._choose_db_if_not_chosen() + self._make_query() + return self._execute().__await__() + + async def __aiter__(self: UnionQuery[Any]) -> AsyncIterator[Any]: + for val in await self: + yield val + + async def _execute(self) -> Sequence[MODEL]: + sql = self.union_query.get_sql(self._qs[0].query.QUERY_CLS.SQL_CONTEXT) + print(sql) + instance_list = await self._db.executor_class( + model=self.model, + db=self._db, + ).execute_union(sql, self.TORTOISE_APP_FIELD, self.TORTOISE_MODEL_FIELD, self.models) + return instance_list + + def _clone(self) -> UnionQuery[MODEL]: + union = self.__class__.__new__(self.__class__) + union.model = self.model + union.models = self.models + union.union_query = self.union_query + union._selects = self._selects + union._db = self._db + union._qs = self._qs + union._all = self._all + union._orderings = self._orderings + union._limit = self._limit + return union + + @classmethod + def _parse_orderings(cls, orderings: tuple[str, ...]) -> list[tuple[str, Order]]: + """ + Convert ordering from strings to standard items for queryset. + + :param orderings: What columns/order to order by + :return: standard ordering for QuerySet. + """ + new_ordering = [] + for ordering in orderings: + new_ordering.append(QuerySet._resolve_ordering_string(ordering)) + return new_ordering + + def union( + self, *other_qs: QuerySet[Model] | UnionQuery[Model], all: bool = False + ) -> UnionQuery[MODEL]: + """ + Return the union of QuerySets. + + :param other_qs: Another QuerySet(s) to union with. + :return: A new UnionQuery representing the union of all QuerySets. + """ + union = self._clone() + union._qs = [*union._qs, *other_qs] + union._all = all + return union + + def order_by(self, *orderings: str) -> UnionQuery[MODEL]: + """ + Accept args to filter by in format like this: + + .. code-block:: python3 + + .order_by('name', '-id') + + Supports ordering by related models too. + A '-' before the name will result in descending sort order, default is ascending. + + :raises FieldError: If unknown field has been provided. + """ + union = self._clone() + union._orderings = self._parse_orderings(orderings) + return union + + def limit(self, limit: int) -> UnionQuery[MODEL]: + """ + Limits UnionQuery to given length. + + :raises ParamsError: Limit should be non-negative number. + """ + if limit < 0: + raise ParamsError("Limit should be non-negative number") + + union = self._clone() + union._limit = limit + return union + + def count(self) -> CountQuery: + """ + Return count of objects in queryset instead of objects. + """ + return CountQuery( + db=self._db, + model=self.model, + q_objects=[], + annotations={}, + custom_filters={}, + limit=self._limit, + offset=None, + force_indexes=set(), + use_indexes=set(), + ) From 483f720c2046aaff3b22ecda56dc6c4acfe09914 Mon Sep 17 00:00:00 2001 From: seladb Date: Mon, 16 Mar 2026 01:10:31 -0700 Subject: [PATCH 02/20] Add test and some fixes --- tests/test_queryset.py | 143 +++++++++++++++++++++++++++++++++++++++++ tortoise/queryset.py | 38 +++++------ 2 files changed, 159 insertions(+), 22 deletions(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 513979035..094190aa6 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -901,3 +901,146 @@ def test_multiple_objects_returned(): exp_cls: type[NotExistOrMultiple] = MultipleObjectsReturned assert str(exp_cls("old format")) == "old format" assert str(exp_cls(Tournament)) == exp_cls.TEMPLATE.format(Tournament.__name__) + + +@pytest.mark.asyncio +async def test_union_basic(db): + t1 = await Tournament.create(name="T1") + t2 = await Tournament.create(name="T2") + t3 = await Tournament.create(name="T3") + await Tournament.create(name="T4") + + qs1 = Tournament.filter(name__in=["T1", "T2"]) + qs2 = Tournament.filter(name="T3") + + result = await qs1.union(qs2) + assert set(result) == {t1, t2, t3} + + +@pytest.mark.asyncio +async def test_union_all(db): + t1 = await Tournament.create(name="T1") + await Tournament.create(name="T2") + + qs1 = Tournament.filter(name="T1") + qs2 = Tournament.filter(name="T1") + + result = await qs1.union(qs2, all=True) + assert list(result) == [t1, t1] + + +@pytest.mark.asyncio +async def test_union_mixed_models(db): + r1 = await Reporter.create(name="R1") + r2 = await Reporter.create(name="R2") + await Reporter.create(name="R3") + t1 = await Tournament.create(name="T1") + await Tournament.create(name="T2") + + qs1 = Tournament.filter(name="T1").only("id", "name") + qs2 = Reporter.filter(name__in=["R1", "R2"]).only("id", "name") + + result = await qs1.union(qs2) + assert set(result) == {t1, r1, r2} + + +@pytest.mark.parametrize( + "orderings,expected", + [ + ("id", [1, 3, 6]), + ("-id", [6, 3, 1]), + ("name,-id", [3, 1, 6]), + ], +) +@pytest.mark.asyncio +async def test_union_order_by(db, orderings, expected): + await Tournament.create(id=1, name="C") + await Reporter.create(id=2, name="A") + await Tournament.create(id=3, name="B") + await Reporter.create(id=4, name="D") + await Tournament.create(id=5, name="E") + await Reporter.create(id=6, name="F") + + qs1 = Tournament.filter(id__in=[1, 3]).only("id", "name") + qs2 = Reporter.filter(id=6).only("id", "name") + + result = await qs1.union(qs2).order_by(*orderings.split(",")) + actual = [r.id for r in result] + assert actual == expected + + +@pytest.mark.asyncio +async def test_union_limit(db): + r1 = await Reporter.create(name="B") + t1 = await Tournament.create(name="A") + await Reporter.create(name="D") + await Tournament.create(name="C") + + qs1 = Tournament.all().only("id", "name") + qs2 = Reporter.all().only("id", "name") + + result = await qs1.union(qs2).order_by("name").limit(2) + assert list(result) == [t1, r1] + + +@pytest.mark.asyncio +async def test_union_chained(db): + t1 = await Tournament.create(name="T1") + t2 = await Tournament.create(name="T2") + await Tournament.create(name="T3") + r1 = await Reporter.create(name="R1") + await Reporter.create(name="R2") + + qs1 = Tournament.filter(name="T1").only("id", "name") + qs2 = Tournament.filter(name="T2").only("id", "name") + qs3 = Reporter.filter(name="R1").only("id", "name") + + result = await qs1.union(qs2).union(qs3) + assert set(result) == {t1, t2, r1} + + +@pytest.mark.asyncio +async def test_union_count(db): + await Tournament.create(name="T1") + await Tournament.create(name="T2") + await Tournament.create(name="T3") + + qs1 = Tournament.filter(name="T1") + qs2 = Tournament.filter(name="T2") + + assert await qs1.union(qs2).count() == 2 + + +@pytest.mark.asyncio +async def test_union_different_select_fields_raises(db): + await Tournament.create(name="T1") + + qs1 = Tournament.filter(name="T1").only("name") + qs2 = Tournament.filter(name="T1").only("desc") + + with pytest.raises(ValueError, match="Union queries must have the same select fields"): + await qs1.union(qs2) + + +@pytest.mark.asyncio +async def test_union_different_fields__in_different_models_raises(db): + await Tournament.create(name="T1") + await Reporter.create(name="R1") + + qs1 = Tournament.all() + qs2 = Reporter.all() + + with pytest.raises(ValueError, match="Union queries must have the same select fields"): + await qs1.union(qs2) + + +@pytest.mark.asyncio +async def test_union_order_by_field_not_in_select_raises(db): + await Tournament.create(name="T1") + + qs1 = Tournament.filter(name="T1").only("id", "name") + qs2 = Tournament.filter(name="T1").only("id", "name") + + qs = qs1.union(qs2) + with pytest.raises(ParamsError, match="Order by field must be in the select list"): + await qs.order_by("desc") diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 23cce8598..daa244263 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2239,8 +2239,8 @@ def sql(self, params_inline=False) -> str: class UnionQuery(AwaitableQuery[MODEL]): __slots__ = ( "model", - "models", - "union_query", + "_models", + "_union_query", "_selects", "_db", "_qs", @@ -2260,8 +2260,8 @@ def __init__( all: bool = False, ): super().__init__(model) - self.models = {model, *(qs.model for qs in querysets)} - self.union_query = None + self._models = {model, *(qs.model for qs in querysets)} + self._union_query = None self._selects = None self._db = db self._qs = querysets @@ -2286,16 +2286,16 @@ def _make_query(self) -> None: qs = qs.annotate(**model_annotations) qs._make_query() qs.query.wrap_set_operation_queries = False - if not self.union_query: - self.union_query = qs.query + if not self._union_query: + self._union_query = qs.query self._selects = self._get_selects(qs) else: if self._get_selects(qs) != self._selects: raise ValueError("Union queries must have the same select fields") - self.union_query = ( - self.union_query.union_all(qs.query) + self._union_query = ( + self._union_query.union_all(qs.query) if self._all - else self.union_query.union(qs.query) + else self._union_query.union(qs.query) ) if self._orderings: @@ -2303,10 +2303,10 @@ def _make_query(self) -> None: if field_name not in self._selects: raise ParamsError("Order by field must be in the select list for union queries") - self.union_query = self.union_query.orderby(field_name, order=order) + self._union_query = self._union_query.orderby(field_name, order=order) if self._limit is not None: - self.union_query._limit = self.union_query._wrapper_cls(self._limit) + self._union_query._limit = self._union_query._wrapper_cls(self._limit) def __await__(self) -> Generator[Any, None, Sequence[dict]]: self._choose_db_if_not_chosen() @@ -2318,19 +2318,18 @@ async def __aiter__(self: UnionQuery[Any]) -> AsyncIterator[Any]: yield val async def _execute(self) -> Sequence[MODEL]: - sql = self.union_query.get_sql(self._qs[0].query.QUERY_CLS.SQL_CONTEXT) - print(sql) + sql = self._union_query.get_sql(self._qs[0].query.QUERY_CLS.SQL_CONTEXT) instance_list = await self._db.executor_class( model=self.model, db=self._db, - ).execute_union(sql, self.TORTOISE_APP_FIELD, self.TORTOISE_MODEL_FIELD, self.models) + ).execute_union(sql, self.TORTOISE_APP_FIELD, self.TORTOISE_MODEL_FIELD, self._models) return instance_list def _clone(self) -> UnionQuery[MODEL]: union = self.__class__.__new__(self.__class__) union.model = self.model - union.models = self.models - union.union_query = self.union_query + union._models = self._models + union._union_query = self._union_query union._selects = self._selects union._db = self._db union._qs = self._qs @@ -2341,12 +2340,6 @@ def _clone(self) -> UnionQuery[MODEL]: @classmethod def _parse_orderings(cls, orderings: tuple[str, ...]) -> list[tuple[str, Order]]: - """ - Convert ordering from strings to standard items for queryset. - - :param orderings: What columns/order to order by - :return: standard ordering for QuerySet. - """ new_ordering = [] for ordering in orderings: new_ordering.append(QuerySet._resolve_ordering_string(ordering)) @@ -2362,6 +2355,7 @@ def union( :return: A new UnionQuery representing the union of all QuerySets. """ union = self._clone() + union._models = {*union._models, *(qs.model for qs in other_qs)} union._qs = [*union._qs, *other_qs] union._all = all return union From 7d761b8c26058292d89787f0b428f121f6b4d4a6 Mon Sep 17 00:00:00 2001 From: seladb Date: Mon, 16 Mar 2026 01:41:36 -0700 Subject: [PATCH 03/20] Fix union count --- tests/test_queryset.py | 7 +++--- tortoise/queryset.py | 50 ++++++++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 094190aa6..f6aa7e88e 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -1002,11 +1002,12 @@ async def test_union_chained(db): @pytest.mark.asyncio async def test_union_count(db): await Tournament.create(name="T1") + await Reporter.create(name="R1") await Tournament.create(name="T2") - await Tournament.create(name="T3") + await Reporter.create(name="R2") - qs1 = Tournament.filter(name="T1") - qs2 = Tournament.filter(name="T2") + qs1 = Tournament.filter(name="T1").only("id") + qs2 = Reporter.filter(name="R1").only("id") assert await qs1.union(qs2).count() == 2 diff --git a/tortoise/queryset.py b/tortoise/queryset.py index daa244263..84d0c3aa0 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2236,6 +2236,36 @@ def sql(self, params_inline=False) -> str: return ";".join([insert_sql, insert_sql_all]) +class UnionCountQuery(AwaitableQuery): + __slots__ = ("_union_query", "_db", "_query_cls") + + def __init__( + self, + model: type[MODEL], + db: BaseDBAsyncClient, + union_query: QueryBuilder, + query_cls: type, + ) -> None: + super().__init__(model) + self._union_query = union_query + self._db = db + self._query_cls = query_cls + + def _make_query(self) -> None: + self.query = self._query_cls.from_(self._union_query).select(Count(Star())) + + def __await__(self) -> Generator[Any, None, int]: + self._choose_db_if_not_chosen() + self._make_query() + return self._execute().__await__() + + async def _execute(self) -> int: + _, result = await self._db.execute_query(*self.query.get_parameterized_sql()) + if not result: + return 0 + return list(dict(result[0]).values())[0] + + class UnionQuery(AwaitableQuery[MODEL]): __slots__ = ( "model", @@ -2390,18 +2420,16 @@ def limit(self, limit: int) -> UnionQuery[MODEL]: union._limit = limit return union - def count(self) -> CountQuery: + def count(self) -> UnionCountQuery: """ - Return count of objects in queryset instead of objects. + Return count of objects in union query. """ - return CountQuery( - db=self._db, + self._choose_db_if_not_chosen() + self._make_query() + query_cls = self._qs[0].query.QUERY_CLS + return UnionCountQuery( model=self.model, - q_objects=[], - annotations={}, - custom_filters={}, - limit=self._limit, - offset=None, - force_indexes=set(), - use_indexes=set(), + db=self._db, + union_query=self._union_query, + query_cls=query_cls, ) From 1484a9b599ea0684506d84f9807f2d6ffe944183 Mon Sep 17 00:00:00 2001 From: seladb Date: Mon, 16 Mar 2026 23:10:56 -0700 Subject: [PATCH 04/20] Add documentation and changelog --- CHANGELOG.rst | 7 +++++++ docs/query.rst | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5c448bdd0..a4ed46db2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,13 @@ Changelog 1.1 === +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()``. + 1.1.6 ----- diff --git a/docs/query.rst b/docs/query.rst index 75b9dee6f..7c6350bc7 100644 --- a/docs/query.rst +++ b/docs/query.rst @@ -349,3 +349,23 @@ You can view full example here: :ref:`example_prefetching` .. autoclass:: tortoise.query_utils.Prefetch :members: + +.. _union: + +Union +===== + +Tortoise ORM supports SQL ``UNION`` queries to combine results from multiple QuerySets. + +Example usage: + +.. code-block:: python3 + + qs1 = Tournament.filter(name__in=["T1", "T2"]).only("id", "name") + qs2 = Reporter.filter(name__in=["R1", "R2"]).only("id", "name") + + result = await qs1.union(qs2) + +.. autoclass:: tortoise.queryset.UnionQuery + :members: + :inherited-members: From 808c3029adff4b7187d3190d91be8d383dd01722 Mon Sep 17 00:00:00 2001 From: seladb Date: Mon, 16 Mar 2026 23:51:39 -0700 Subject: [PATCH 05/20] Fix checks --- tortoise/queryset.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 84d0c3aa0..132aef831 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -9,7 +9,7 @@ from pypika_tortoise import JoinType, Order, Table from pypika_tortoise.analytics import Count from pypika_tortoise.functions import Cast -from pypika_tortoise.queries import QueryBuilder +from pypika_tortoise.queries import QueryBuilder, _SetOperation from pypika_tortoise.terms import Case, Field, Star, Term, ValueWrapper from tortoise.backends.base.client import BaseDBAsyncClient, Capabilities @@ -586,16 +586,14 @@ def distinct(self) -> QuerySet[MODEL]: queryset._distinct = True return queryset - def union( - self, *other_qs: QuerySet[Model] | UnionQuery[Model], all: bool = False - ) -> UnionQuery[MODEL]: + def union(self, *other_qs: QuerySet[Model], all: bool = False) -> UnionQuery[MODEL]: """ Return the union of QuerySets. :param other_qs: Another QuerySet(s) to union with. :return: A new UnionQuery representing the union of both QuerySets. """ - return UnionQuery(self.model, self._db, self, *other_qs, all=all) + return UnionQuery(self.model, self._db, self, *other_qs, all=all) # type: ignore[arg-type] def select_for_update( self, @@ -2243,7 +2241,7 @@ def __init__( self, model: type[MODEL], db: BaseDBAsyncClient, - union_query: QueryBuilder, + union_query: QueryBuilder | _SetOperation, query_cls: type, ) -> None: super().__init__(model) @@ -2252,7 +2250,7 @@ def __init__( self._query_cls = query_cls def _make_query(self) -> None: - self.query = self._query_cls.from_(self._union_query).select(Count(Star())) + self.query = self._query_cls.from_(self._union_query).select(Count(Star())) # type: ignore[attr-defined] def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() @@ -2286,13 +2284,13 @@ def __init__( self, model: type[MODEL], db: BaseDBAsyncClient, - *querysets: QuerySet[Model] | UnionQuery[Model], + *querysets: QuerySet[Model], all: bool = False, ): super().__init__(model) - self._models = {model, *(qs.model for qs in querysets)} - self._union_query = None - self._selects = None + self._models: set[type[Model]] = {model, *(qs.model for qs in querysets)} + self._union_query: QueryBuilder | _SetOperation | None = None + self._selects: list[str] = [] self._db = db self._qs = querysets self._all = all @@ -2328,6 +2326,9 @@ def _make_query(self) -> None: else self._union_query.union(qs.query) ) + if self._union_query is None: + return + if self._orderings: for field_name, order in self._orderings: if field_name not in self._selects: @@ -2338,7 +2339,7 @@ def _make_query(self) -> None: if self._limit is not None: self._union_query._limit = self._union_query._wrapper_cls(self._limit) - def __await__(self) -> Generator[Any, None, Sequence[dict]]: + def __await__(self) -> Generator[Any, None, Sequence[MODEL]]: self._choose_db_if_not_chosen() self._make_query() return self._execute().__await__() @@ -2348,6 +2349,9 @@ async def __aiter__(self: UnionQuery[Any]) -> AsyncIterator[Any]: yield val async def _execute(self) -> Sequence[MODEL]: + if self._union_query is None: + return [] + sql = self._union_query.get_sql(self._qs[0].query.QUERY_CLS.SQL_CONTEXT) instance_list = await self._db.executor_class( model=self.model, @@ -2375,9 +2379,7 @@ def _parse_orderings(cls, orderings: tuple[str, ...]) -> list[tuple[str, Order]] new_ordering.append(QuerySet._resolve_ordering_string(ordering)) return new_ordering - def union( - self, *other_qs: QuerySet[Model] | UnionQuery[Model], all: bool = False - ) -> UnionQuery[MODEL]: + def union(self, *other_qs: QuerySet[Model], all: bool = False) -> UnionQuery[MODEL]: """ Return the union of QuerySets. @@ -2386,7 +2388,7 @@ def union( """ union = self._clone() union._models = {*union._models, *(qs.model for qs in other_qs)} - union._qs = [*union._qs, *other_qs] + union._qs = union._qs + other_qs union._all = all return union @@ -2426,6 +2428,10 @@ def count(self) -> UnionCountQuery: """ self._choose_db_if_not_chosen() self._make_query() + + if self._union_query is None: + raise RuntimeError("Couldn't generate union query") + query_cls = self._qs[0].query.QUERY_CLS return UnionCountQuery( model=self.model, From 3e4dee724debdb2dbf3b2bbc65fd71b4c97391bf Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 17 Mar 2026 01:44:49 -0700 Subject: [PATCH 06/20] Fix count query --- tortoise/queryset.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 132aef831..96361ebbb 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2235,7 +2235,7 @@ def sql(self, params_inline=False) -> str: class UnionCountQuery(AwaitableQuery): - __slots__ = ("_union_query", "_db", "_query_cls") + __slots__ = ("_union_query", "_db", "_query_cls", "_query_string") def __init__( self, @@ -2250,7 +2250,8 @@ def __init__( self._query_cls = query_cls def _make_query(self) -> None: - self.query = self._query_cls.from_(self._union_query).select(Count(Star())) # type: ignore[attr-defined] + ctx = self.query.QUERY_CLS.SQL_CONTEXT + self._query_string = f"SELECT COUNT(*) FROM ({self._union_query.get_sql(ctx)})" # nosec: B608 def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() @@ -2258,7 +2259,7 @@ def __await__(self) -> Generator[Any, None, int]: return self._execute().__await__() async def _execute(self) -> int: - _, result = await self._db.execute_query(*self.query.get_parameterized_sql()) + _, result = await self._db.execute_query(self._query_string) if not result: return 0 return list(dict(result[0]).values())[0] From 9080bfb65f50523022d69b6d4c617e82e6ce8a30 Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 17 Mar 2026 02:02:29 -0700 Subject: [PATCH 07/20] Another fix for count query --- tortoise/queryset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 96361ebbb..8c38df964 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2251,7 +2251,7 @@ def __init__( def _make_query(self) -> None: ctx = self.query.QUERY_CLS.SQL_CONTEXT - self._query_string = f"SELECT COUNT(*) FROM ({self._union_query.get_sql(ctx)})" # nosec: B608 + self._query_string = f"SELECT COUNT(*) FROM ({self._union_query.get_sql(ctx)} AS sq1)" # nosec: B608 def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() From 18dfd147567baec6a2ee6f282cc3db7f8c0346e5 Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 17 Mar 2026 02:08:31 -0700 Subject: [PATCH 08/20] Small fix --- tortoise/queryset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 8c38df964..3f35b3f80 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2251,7 +2251,7 @@ def __init__( def _make_query(self) -> None: ctx = self.query.QUERY_CLS.SQL_CONTEXT - self._query_string = f"SELECT COUNT(*) FROM ({self._union_query.get_sql(ctx)} AS sq1)" # nosec: B608 + self._query_string = f"SELECT COUNT(*) FROM ({self._union_query.get_sql(ctx)}) AS sq1" # nosec: B608 def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() From 852faa0ff106c8bac9a236b051abeb3d88a34e38 Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 17 Mar 2026 23:34:45 -0700 Subject: [PATCH 09/20] Add debug SQL --- tortoise/queryset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 3f35b3f80..cb690831c 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2354,6 +2354,7 @@ async def _execute(self) -> Sequence[MODEL]: return [] sql = self._union_query.get_sql(self._qs[0].query.QUERY_CLS.SQL_CONTEXT) + print("DEBUG SQL:", sql) instance_list = await self._db.executor_class( model=self.model, db=self._db, From da59410534773570028480b78fcf1e0712bb476f Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 18 Mar 2026 01:43:44 -0700 Subject: [PATCH 10/20] Fixed test for MSSql --- tests/test_queryset.py | 57 +++++++++++++++++++++++++++++++----------- tortoise/queryset.py | 3 +-- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index f6aa7e88e..46fd8c521 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -945,30 +945,57 @@ async def test_union_mixed_models(db): @pytest.mark.parametrize( - "orderings,expected", + "orderings,expected_instances", [ - ("id", [1, 3, 6]), - ("-id", [6, 3, 1]), - ("name,-id", [3, 1, 6]), + ("name", ["t2", "t1", "r1"]), + ("-name", ["r1", "t1", "t2"]), ], ) @pytest.mark.asyncio -async def test_union_order_by(db, orderings, expected): - await Tournament.create(id=1, name="C") - await Reporter.create(id=2, name="A") - await Tournament.create(id=3, name="B") - await Reporter.create(id=4, name="D") - await Tournament.create(id=5, name="E") - await Reporter.create(id=6, name="F") +async def test_union_order_by(db, orderings, expected_instances): + t1 = await Tournament.create(name="C") + await Reporter.create(name="A") + t2 = await Tournament.create(name="B") + await Reporter.create(name="D") + await Tournament.create(name="E") + r1 = await Reporter.create(name="F") - qs1 = Tournament.filter(id__in=[1, 3]).only("id", "name") - qs2 = Reporter.filter(id=6).only("id", "name") + qs1 = Tournament.filter(id__in=[t1.id, t2.id]).only("id", "name") + qs2 = Reporter.filter(id=r1.id).only("id", "name") result = await qs1.union(qs2).order_by(*orderings.split(",")) - actual = [r.id for r in result] - assert actual == expected + instance_map = {"t1": t1, "t2": t2, "r1": r1} + expected = [instance_map[k] for k in expected_instances] + + assert result == expected + + +@pytest.mark.asyncio +async def test_union_order_by_multiple_fields(db): + t1 = await Tournament.create(name="C") + t2 = await Tournament.create(name="B") + r1 = await Reporter.create(name="C") + await Tournament.create(name="Z") + await Reporter.create(name="Z") + + qs1 = Tournament.filter(id__in=[t1.id, t2.id]).only("id", "name") + qs2 = Reporter.filter(id=r1.id).only("id", "name") + result = await qs1.union(qs2).order_by("name", "id") + + if r1.id == t1.id: + return + + if r1.id > t1.id: + expected = [t2, t1, r1] + else: + expected = [t2, r1, t1] + + assert result == expected + + +@requireCapability(dialect=NotEQ("mssql")) @pytest.mark.asyncio async def test_union_limit(db): r1 = await Reporter.create(name="B") diff --git a/tortoise/queryset.py b/tortoise/queryset.py index cb690831c..6b87c998b 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2338,7 +2338,7 @@ def _make_query(self) -> None: self._union_query = self._union_query.orderby(field_name, order=order) if self._limit is not None: - self._union_query._limit = self._union_query._wrapper_cls(self._limit) + self._union_query = self._union_query.limit(self._limit) def __await__(self) -> Generator[Any, None, Sequence[MODEL]]: self._choose_db_if_not_chosen() @@ -2354,7 +2354,6 @@ async def _execute(self) -> Sequence[MODEL]: return [] sql = self._union_query.get_sql(self._qs[0].query.QUERY_CLS.SQL_CONTEXT) - print("DEBUG SQL:", sql) instance_list = await self._db.executor_class( model=self.model, db=self._db, From e96065b73a7cadae8fd68ccc53b3b78ab3f253f1 Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 18 Mar 2026 02:31:05 -0700 Subject: [PATCH 11/20] Fix count query --- tortoise/queryset.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 6b87c998b..915a2510d 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2250,8 +2250,7 @@ def __init__( self._query_cls = query_cls def _make_query(self) -> None: - ctx = self.query.QUERY_CLS.SQL_CONTEXT - self._query_string = f"SELECT COUNT(*) FROM ({self._union_query.get_sql(ctx)}) AS sq1" # nosec: B608 + self.query = self.query.QUERY_CLS.from_(self._union_query).select(Count(Star())) def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() @@ -2259,7 +2258,7 @@ def __await__(self) -> Generator[Any, None, int]: return self._execute().__await__() async def _execute(self) -> int: - _, result = await self._db.execute_query(self._query_string) + _, result = await self._db.execute_query(self.query.get_sql()) if not result: return 0 return list(dict(result[0]).values())[0] From 761848174843a0122c74d26f8ade5a3626fb778c Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 18 Mar 2026 02:32:55 -0700 Subject: [PATCH 12/20] Cleanup --- tortoise/queryset.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 915a2510d..0bde784cb 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2235,19 +2235,17 @@ def sql(self, params_inline=False) -> str: class UnionCountQuery(AwaitableQuery): - __slots__ = ("_union_query", "_db", "_query_cls", "_query_string") + __slots__ = ("_union_query", "_db") def __init__( self, model: type[MODEL], db: BaseDBAsyncClient, union_query: QueryBuilder | _SetOperation, - query_cls: type, ) -> None: super().__init__(model) self._union_query = union_query self._db = db - self._query_cls = query_cls def _make_query(self) -> None: self.query = self.query.QUERY_CLS.from_(self._union_query).select(Count(Star())) @@ -2437,5 +2435,4 @@ def count(self) -> UnionCountQuery: model=self.model, db=self._db, union_query=self._union_query, - query_cls=query_cls, ) From ba5973e170c6611e4f398d20944600b7ead9eee5 Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 18 Mar 2026 02:41:20 -0700 Subject: [PATCH 13/20] Remove unused variable --- tortoise/queryset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 0bde784cb..b05f77a42 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2430,7 +2430,6 @@ def count(self) -> UnionCountQuery: if self._union_query is None: raise RuntimeError("Couldn't generate union query") - query_cls = self._qs[0].query.QUERY_CLS return UnionCountQuery( model=self.model, db=self._db, From 8f6a10477df22ece4b73dd879f4ddabab1da0914 Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 24 Mar 2026 22:21:48 -0700 Subject: [PATCH 14/20] Address PR comments --- tests/test_queryset.py | 4 ++-- tortoise/queryset.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 46fd8c521..4a61165d5 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -1046,7 +1046,7 @@ async def test_union_different_select_fields_raises(db): qs1 = Tournament.filter(name="T1").only("name") qs2 = Tournament.filter(name="T1").only("desc") - with pytest.raises(ValueError, match="Union queries must have the same select fields"): + with pytest.raises(ParamsError, match="Union queries must have the same select fields"): await qs1.union(qs2) @@ -1058,7 +1058,7 @@ async def test_union_different_fields__in_different_models_raises(db): qs1 = Tournament.all() qs2 = Reporter.all() - with pytest.raises(ValueError, match="Union queries must have the same select fields"): + with pytest.raises(ParamsError, match="Union queries must have the same select fields"): await qs1.union(qs2) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index b05f77a42..f78a2bf57 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2317,7 +2317,7 @@ def _make_query(self) -> None: self._selects = self._get_selects(qs) else: if self._get_selects(qs) != self._selects: - raise ValueError("Union queries must have the same select fields") + raise ParamsError("Union queries must have the same select fields") self._union_query = ( self._union_query.union_all(qs.query) if self._all @@ -2387,7 +2387,7 @@ def union(self, *other_qs: QuerySet[Model], all: bool = False) -> UnionQuery[MOD union = self._clone() union._models = {*union._models, *(qs.model for qs in other_qs)} union._qs = union._qs + other_qs - union._all = all + union._all = union._all or all return union def order_by(self, *orderings: str) -> UnionQuery[MODEL]: @@ -2398,7 +2398,6 @@ def order_by(self, *orderings: str) -> UnionQuery[MODEL]: .order_by('name', '-id') - Supports ordering by related models too. A '-' before the name will result in descending sort order, default is ascending. :raises FieldError: If unknown field has been provided. From 709ddfa72a904032b88139062daa50f44d61706e Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 24 Mar 2026 23:33:49 -0700 Subject: [PATCH 15/20] Raise a proper exception when qs uses annotations --- tests/test_queryset.py | 22 +++++++++++++++++++++- tortoise/queryset.py | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 4a61165d5..9ef7b892b 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -24,7 +24,7 @@ NotExistOrMultiple, ParamsError, ) -from tortoise.expressions import F, RawSQL, Subquery +from tortoise.expressions import F, RawSQL, Subquery, Value from tortoise.functions import Avg # TODO: Test the many exceptions in QuerySet @@ -1072,3 +1072,23 @@ async def test_union_order_by_field_not_in_select_raises(db): qs = qs1.union(qs2) with pytest.raises(ParamsError, match="Order by field must be in the select list"): await qs.order_by("desc") + + +@pytest.mark.asyncio +async def test_union_with_annotate_raises(db): + await Tournament.create(name="T1") + await Reporter.create(name="R1") + + qs1 = ( + Tournament.filter(name="T1") + .annotate(annotated_value=Value(1)) + .only("id", "name", "annotated_value") + ) + qs2 = ( + Reporter.filter(name="R1") + .annotate(annotated_value=Value(1)) + .only("id", "name", "annotated_value") + ) + + with pytest.raises(ParamsError, match="Union queries do not support annotations"): + await qs1.union(qs2) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index f78a2bf57..6526be671 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2305,6 +2305,8 @@ def _get_selects(cls, qs: QuerySet[Model] | UnionQuery[Model]) -> list[str]: def _make_query(self) -> None: for qs in self._qs: + if qs._annotations: + raise ParamsError("Union queries do not support annotations") model_annotations = { self.TORTOISE_APP_FIELD: Value(qs.model._meta.app), self.TORTOISE_MODEL_FIELD: Value(qs.model._meta._model.__name__), From dbbed50c8416730239ca14d989bbee3dbd86280b Mon Sep 17 00:00:00 2001 From: seladb Date: Tue, 24 Mar 2026 23:57:49 -0700 Subject: [PATCH 16/20] Add offset support --- tests/test_queryset.py | 23 +++++++++++++++++++++++ tortoise/queryset.py | 21 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 9ef7b892b..c7973c120 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -1010,6 +1010,29 @@ async def test_union_limit(db): assert list(result) == [t1, r1] +@pytest.mark.asyncio +async def test_union_offset(db): + await Tournament.create(name="T1") + await Tournament.create(name="T2") + t3 = await Tournament.create(name="T3") + t4 = await Tournament.create(name="T4") + + qs1 = Tournament.filter(name__in=["T1", "T2"]).only("id", "name") + qs2 = Tournament.filter(name__in=["T3", "T4"]).only("id", "name") + + result = await qs1.union(qs2).order_by("name").limit(4).offset(2) + assert list(result) == [t3, t4] + + +@pytest.mark.asyncio +async def test_union_offset_negative_raises(db): + qs1 = Tournament.all().only("id", "name") + qs2 = Tournament.all().only("id", "name") + + with pytest.raises(ParamsError, match="Offset should be non-negative number"): + await qs1.union(qs2).offset(-1) + + @pytest.mark.asyncio async def test_union_chained(db): t1 = await Tournament.create(name="T1") diff --git a/tortoise/queryset.py b/tortoise/queryset.py index 6526be671..d2ed5b7c9 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2273,6 +2273,7 @@ class UnionQuery(AwaitableQuery[MODEL]): "_all", "_orderings", "_limit", + "_offset", ) TORTOISE_APP_FIELD = "tortoise_app" @@ -2294,6 +2295,7 @@ def __init__( self._all = all self._orderings: list[tuple[str, Order]] | None = None self._limit: int | None = None + self._offset: int | None = None @classmethod def _get_selects(cls, qs: QuerySet[Model] | UnionQuery[Model]) -> list[str]: @@ -2339,6 +2341,9 @@ def _make_query(self) -> None: if self._limit is not None: self._union_query = self._union_query.limit(self._limit) + if self._offset is not None: + self._union_query = self._union_query.offset(self._offset) + def __await__(self) -> Generator[Any, None, Sequence[MODEL]]: self._choose_db_if_not_chosen() self._make_query() @@ -2363,13 +2368,14 @@ def _clone(self) -> UnionQuery[MODEL]: union = self.__class__.__new__(self.__class__) union.model = self.model union._models = self._models - union._union_query = self._union_query + union._union_query = None union._selects = self._selects union._db = self._db union._qs = self._qs union._all = self._all union._orderings = self._orderings union._limit = self._limit + union._offset = self._offset return union @classmethod @@ -2421,6 +2427,19 @@ def limit(self, limit: int) -> UnionQuery[MODEL]: union._limit = limit return union + def offset(self, offset: int) -> UnionQuery[MODEL]: + """ + Query offset for UnionQuery. + + :raises ParamsError: Offset should be non-negative number. + """ + if offset < 0: + raise ParamsError("Offset should be non-negative number") + + union = self._clone() + union._offset = offset + return union + def count(self) -> UnionCountQuery: """ Return count of objects in union query. From a1accfdfdc3f52fc2cb23222f8ca77f2e685ce51 Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 25 Mar 2026 00:03:38 -0700 Subject: [PATCH 17/20] Exclude MS-SQL from offset tests --- tests/test_queryset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index c7973c120..166db0b8e 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -1010,6 +1010,7 @@ async def test_union_limit(db): assert list(result) == [t1, r1] +@requireCapability(dialect=NotEQ("mssql")) @pytest.mark.asyncio async def test_union_offset(db): await Tournament.create(name="T1") From 211bbfe5fd39b26ba45cf7c76c180e69d65c5e64 Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 25 Mar 2026 00:15:08 -0700 Subject: [PATCH 18/20] Fix UnionCountQuery to properly build union query before counting --- tortoise/queryset.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index d2ed5b7c9..e716e4e22 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2241,14 +2241,15 @@ def __init__( self, model: type[MODEL], db: BaseDBAsyncClient, - union_query: QueryBuilder | _SetOperation, + union_query: UnionQuery[MODEL], ) -> None: super().__init__(model) self._union_query = union_query self._db = db def _make_query(self) -> None: - self.query = self.query.QUERY_CLS.from_(self._union_query).select(Count(Star())) + self._union_query._make_query() + self.query = self.query.QUERY_CLS.from_(self._union_query._union_query).select(Count(Star())) def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() @@ -2445,13 +2446,10 @@ def count(self) -> UnionCountQuery: Return count of objects in union query. """ self._choose_db_if_not_chosen() - self._make_query() - - if self._union_query is None: - raise RuntimeError("Couldn't generate union query") + union_query_clone = self._clone() return UnionCountQuery( model=self.model, db=self._db, - union_query=self._union_query, + union_query=union_query_clone, ) From 7c1225a7de2a1545719e7f53d6b7473ab8b96105 Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 25 Mar 2026 00:20:28 -0700 Subject: [PATCH 19/20] Fix style --- tortoise/queryset.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index e716e4e22..cb34d7150 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -2249,7 +2249,9 @@ def __init__( def _make_query(self) -> None: self._union_query._make_query() - self.query = self.query.QUERY_CLS.from_(self._union_query._union_query).select(Count(Star())) + self.query = self.query.QUERY_CLS.from_(self._union_query._union_query).select( # type:ignore[arg-type] + Count(Star()) + ) def __await__(self) -> Generator[Any, None, int]: self._choose_db_if_not_chosen() From f8dd7c8227c19e09420dd93b67c72b52b37afb2f Mon Sep 17 00:00:00 2001 From: seladb Date: Wed, 25 Mar 2026 00:28:52 -0700 Subject: [PATCH 20/20] Trigger CI