async-orm-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_orm_kit/__init__.py +151 -0
- async_orm_kit/_logging.py +58 -0
- async_orm_kit/_version.py +1 -0
- async_orm_kit/config.py +31 -0
- async_orm_kit/exceptions.py +353 -0
- async_orm_kit/integrations/__init__.py +15 -0
- async_orm_kit/integrations/alembic.py +56 -0
- async_orm_kit/integrations/fastapi.py +134 -0
- async_orm_kit/integrations/storage.py +213 -0
- async_orm_kit/manager/__init__.py +3 -0
- async_orm_kit/manager/manager.py +568 -0
- async_orm_kit/models/__init__.py +3 -0
- async_orm_kit/models/base.py +568 -0
- async_orm_kit/models/fields/__init__.py +23 -0
- async_orm_kit/models/fields/exceptions.py +10 -0
- async_orm_kit/models/fields/typed/__init__.py +11 -0
- async_orm_kit/models/fields/typed/base.py +155 -0
- async_orm_kit/models/fields/typed/file.py +697 -0
- async_orm_kit/models/fields/typed/string.py +38 -0
- async_orm_kit/models/fields/validators.py +110 -0
- async_orm_kit/models/meta.py +181 -0
- async_orm_kit/models/mixins.py +19 -0
- async_orm_kit/observers/__init__.py +4 -0
- async_orm_kit/observers/mixin.py +26 -0
- async_orm_kit/observers/model_observer.py +20 -0
- async_orm_kit/observers/queryset_observer.py +38 -0
- async_orm_kit/py.typed +0 -0
- async_orm_kit/query/__init__.py +0 -0
- async_orm_kit/query/aggregations/__init__.py +8 -0
- async_orm_kit/query/aggregations/avg.py +71 -0
- async_orm_kit/query/aggregations/base.py +317 -0
- async_orm_kit/query/aggregations/count.py +115 -0
- async_orm_kit/query/aggregations/max.py +76 -0
- async_orm_kit/query/aggregations/min.py +76 -0
- async_orm_kit/query/aggregations/sum.py +73 -0
- async_orm_kit/query/compiler.py +574 -0
- async_orm_kit/query/debugger.py +269 -0
- async_orm_kit/query/expressions/__init__.py +62 -0
- async_orm_kit/query/expressions/analysis.py +56 -0
- async_orm_kit/query/expressions/case.py +321 -0
- async_orm_kit/query/expressions/collections.py +251 -0
- async_orm_kit/query/expressions/f.py +362 -0
- async_orm_kit/query/expressions/functions.py +202 -0
- async_orm_kit/query/expressions/subquery.py +206 -0
- async_orm_kit/query/expressions/value.py +101 -0
- async_orm_kit/query/expressions/window.py +95 -0
- async_orm_kit/query/lookups/__init__.py +121 -0
- async_orm_kit/query/lookups/conditions.py +315 -0
- async_orm_kit/query/lookups/correlated.py +86 -0
- async_orm_kit/query/lookups/json_paths.py +167 -0
- async_orm_kit/query/lookups/parser.py +276 -0
- async_orm_kit/query/lookups/resolver/__init__.py +5 -0
- async_orm_kit/query/lookups/resolver/context.py +150 -0
- async_orm_kit/query/lookups/resolver/resolver.py +572 -0
- async_orm_kit/query/lookups/resolver/types.py +150 -0
- async_orm_kit/query/mixins/__init__.py +0 -0
- async_orm_kit/query/mixins/combinable.py +376 -0
- async_orm_kit/query/mixins/orderable.py +208 -0
- async_orm_kit/query/observability.py +346 -0
- async_orm_kit/query/planning/__init__.py +0 -0
- async_orm_kit/query/planning/directives.py +117 -0
- async_orm_kit/query/planning/factory.py +40 -0
- async_orm_kit/query/planning/passes/__init__.py +15 -0
- async_orm_kit/query/planning/passes/base.py +47 -0
- async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
- async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
- async_orm_kit/query/planning/passes/join_promotion.py +81 -0
- async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
- async_orm_kit/query/planning/passes/redundant_path.py +67 -0
- async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
- async_orm_kit/query/planning/planner.py +160 -0
- async_orm_kit/query/planning/protocols.py +78 -0
- async_orm_kit/query/postgres/__init__.py +0 -0
- async_orm_kit/query/postgres/optimizer.py +189 -0
- async_orm_kit/query/q.py +596 -0
- async_orm_kit/query/relations/__init__.py +0 -0
- async_orm_kit/query/relations/enums.py +16 -0
- async_orm_kit/query/relations/graph.py +908 -0
- async_orm_kit/query/relations/node.py +421 -0
- async_orm_kit/queryset/__init__.py +4 -0
- async_orm_kit/queryset/builder.py +1259 -0
- async_orm_kit/queryset/compound.py +379 -0
- async_orm_kit/queryset/executor.py +718 -0
- async_orm_kit/queryset/queryset.py +1726 -0
- async_orm_kit/queryset/state.py +164 -0
- async_orm_kit/queryset/validators.py +361 -0
- async_orm_kit/queryset/writer.py +755 -0
- async_orm_kit/types.py +23 -0
- async_orm_kit-1.0.0.dist-info/METADATA +318 -0
- async_orm_kit-1.0.0.dist-info/RECORD +92 -0
- async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
- async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Lazy, immutable SQL set operations over compatible QuerySets.
|
|
2
|
+
|
|
3
|
+
UNION, UNION ALL, INTERSECT and EXCEPT are SQL set operations: unlike joining
|
|
4
|
+
related objects, they combine equal-shaped rows. The wrapper deliberately
|
|
5
|
+
limits operations to compatible QuerySets bound to the SAME session and leaves
|
|
6
|
+
SQLAlchemy in charge of SQL rendering. SQL query values remain bound parameters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import (
|
|
15
|
+
column,
|
|
16
|
+
except_,
|
|
17
|
+
func,
|
|
18
|
+
inspect as sa_inspect,
|
|
19
|
+
intersect,
|
|
20
|
+
select,
|
|
21
|
+
union,
|
|
22
|
+
union_all,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
25
|
+
|
|
26
|
+
from async_orm_kit.exceptions import DatabaseError, QueryStateError
|
|
27
|
+
|
|
28
|
+
_OPERATIONS = {
|
|
29
|
+
"union": union,
|
|
30
|
+
"union_all": union_all,
|
|
31
|
+
"intersect": intersect,
|
|
32
|
+
"except": except_,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _python_result_type(column) -> type | None:
|
|
37
|
+
"""Best-effort SQLAlchemy type check, without evaluating bound SQL values.
|
|
38
|
+
|
|
39
|
+
Dialect-specific/custom types may not expose ``python_type``. In that case
|
|
40
|
+
the database remains the source of truth for compound-column coercion.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return column.type.python_type
|
|
44
|
+
except (AttributeError, NotImplementedError, TypeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _query_shape(queryset):
|
|
49
|
+
state = queryset._state
|
|
50
|
+
if state.for_update is not None:
|
|
51
|
+
raise QueryStateError("Set operations do not support row locking")
|
|
52
|
+
if state.select_related or state.prefetch_related:
|
|
53
|
+
raise QueryStateError("Set operations do not support eager-loading directives")
|
|
54
|
+
if state.order_by or state.limit_val is not None or state.offset_val is not None:
|
|
55
|
+
raise QueryStateError(
|
|
56
|
+
"Set-operation branches cannot have ORDER BY, LIMIT or OFFSET; "
|
|
57
|
+
"apply them to the combined result instead"
|
|
58
|
+
)
|
|
59
|
+
if state.distinct_on:
|
|
60
|
+
raise QueryStateError("Set operations do not support DISTINCT ON branches")
|
|
61
|
+
|
|
62
|
+
if state.values_select is not None:
|
|
63
|
+
keys = tuple(name for name, _ in state.values_select)
|
|
64
|
+
return "values", keys + tuple(
|
|
65
|
+
key for key in state.annotations if key not in keys
|
|
66
|
+
)
|
|
67
|
+
if state.values_list_select is not None:
|
|
68
|
+
keys = tuple(name for name, _ in state.values_list_select)
|
|
69
|
+
return ("flat" if state.values_list_flat else "tuples"), (
|
|
70
|
+
keys + tuple(key for key in state.annotations if key not in keys)
|
|
71
|
+
)
|
|
72
|
+
if state.annotations or state.only_fields is not None:
|
|
73
|
+
raise QueryStateError(
|
|
74
|
+
"Model set operations do not support annotations or deferred fields; "
|
|
75
|
+
"use matching values() or values_list() projections"
|
|
76
|
+
)
|
|
77
|
+
mapper = sa_inspect(queryset.model)
|
|
78
|
+
mapper = mapper.mapper if mapper.is_aliased_class else mapper
|
|
79
|
+
return "model", tuple(attr.key for attr in mapper.column_attrs)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CompoundQuerySet:
|
|
83
|
+
"""Result of combining compatible QuerySets with SQL set operators.
|
|
84
|
+
|
|
85
|
+
The result is awaitable and supports ``.order_by()``, ``.limit()``,
|
|
86
|
+
``.offset()``, ``.count()``, ``.exists()``, ``.sql()`` and streaming.
|
|
87
|
+
``filter()`` and ``exclude()`` apply to the compound result after the set
|
|
88
|
+
operation, before ordering and pagination. Only projected names are valid;
|
|
89
|
+
this does not implicitly join related tables.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
__slots__ = (
|
|
93
|
+
"model",
|
|
94
|
+
"session",
|
|
95
|
+
"_mode",
|
|
96
|
+
"_fields",
|
|
97
|
+
"_statement",
|
|
98
|
+
"_cache",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def __init__(self, querysets, operation: str):
|
|
102
|
+
from async_orm_kit.queryset.queryset import QuerySet
|
|
103
|
+
|
|
104
|
+
if operation not in _OPERATIONS:
|
|
105
|
+
raise ValueError(f"Unsupported set operation: {operation}")
|
|
106
|
+
if len(querysets) < 2 or not all(isinstance(qs, QuerySet) for qs in querysets):
|
|
107
|
+
raise TypeError("Set operations require at least two QuerySets")
|
|
108
|
+
first = querysets[0]
|
|
109
|
+
shape = _query_shape(first)
|
|
110
|
+
for other in querysets[1:]:
|
|
111
|
+
if other.session is not first.session:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"Set-operation branches must use the same AsyncSession"
|
|
114
|
+
)
|
|
115
|
+
if other.model is not first.model:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"Set-operation branches must query the same mapped model"
|
|
118
|
+
)
|
|
119
|
+
if _query_shape(other) != shape:
|
|
120
|
+
raise QueryStateError(
|
|
121
|
+
"Set-operation branches must have matching projections"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self.model = first.model
|
|
125
|
+
self.session = first.session
|
|
126
|
+
self._mode, self._fields = shape
|
|
127
|
+
statements = [qs._compiler.compile(qs.state) for qs in querysets]
|
|
128
|
+
widths = [len(stmt.selected_columns) for stmt in statements]
|
|
129
|
+
if len(set(widths)) != 1:
|
|
130
|
+
raise QueryStateError(
|
|
131
|
+
"Set-operation branches must return an identical number of SQL columns"
|
|
132
|
+
)
|
|
133
|
+
# Equal labels and column counts are insufficient for compound SELECTs.
|
|
134
|
+
# For example, UNION of an integer-valued annotation with a text-valued
|
|
135
|
+
# annotation can return rows with inconsistent Python value types (or
|
|
136
|
+
# produce a dialect-specific coercion error). Reject known incompatible
|
|
137
|
+
# Python result types before execution; leave custom/unknown SQLAlchemy
|
|
138
|
+
# types to the database rather than guessing about their compatibility.
|
|
139
|
+
expected_types = tuple(
|
|
140
|
+
_python_result_type(column) for column in statements[0].selected_columns
|
|
141
|
+
)
|
|
142
|
+
for statement in statements[1:]:
|
|
143
|
+
for index, (expected, selected_column) in enumerate(
|
|
144
|
+
zip(expected_types, statement.selected_columns, strict=False), start=1
|
|
145
|
+
):
|
|
146
|
+
actual = _python_result_type(selected_column)
|
|
147
|
+
if (
|
|
148
|
+
expected is not None
|
|
149
|
+
and actual is not None
|
|
150
|
+
and expected is not actual
|
|
151
|
+
):
|
|
152
|
+
raise QueryStateError(
|
|
153
|
+
f"Set-operation column {index} has incompatible result "
|
|
154
|
+
f"types: {expected.__name__} versus {actual.__name__}; "
|
|
155
|
+
"cast projections explicitly to a common type"
|
|
156
|
+
)
|
|
157
|
+
self._statement = _OPERATIONS[operation](*statements)
|
|
158
|
+
self._cache = None
|
|
159
|
+
|
|
160
|
+
def _clone(self):
|
|
161
|
+
clone = object.__new__(type(self))
|
|
162
|
+
clone.model = self.model
|
|
163
|
+
clone.session = self.session
|
|
164
|
+
clone._mode = self._mode
|
|
165
|
+
clone._fields = self._fields
|
|
166
|
+
clone._statement = self._statement
|
|
167
|
+
clone._cache = None
|
|
168
|
+
return clone
|
|
169
|
+
|
|
170
|
+
def _filter_result(self, predicate, *, negate: bool):
|
|
171
|
+
from sqlalchemy import and_, or_
|
|
172
|
+
|
|
173
|
+
from async_orm_kit.query.lookups import LOOKUP_OPERATORS, STANDARD_LOOKUPS
|
|
174
|
+
from async_orm_kit.query.lookups.conditions import ConditionBuilder
|
|
175
|
+
from async_orm_kit.query.lookups.json_paths import json_lookup
|
|
176
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
177
|
+
from async_orm_kit.query.q import Q
|
|
178
|
+
|
|
179
|
+
statement = self._statement
|
|
180
|
+
if (
|
|
181
|
+
statement._limit_clause is not None
|
|
182
|
+
or statement._offset_clause is not None
|
|
183
|
+
or tuple(statement._order_by_clauses)
|
|
184
|
+
):
|
|
185
|
+
raise QueryStateError(
|
|
186
|
+
"Apply filter()/exclude() before order_by(), limit() and offset() "
|
|
187
|
+
"on a set-operation result"
|
|
188
|
+
)
|
|
189
|
+
source = statement.subquery()
|
|
190
|
+
|
|
191
|
+
class _Resolver:
|
|
192
|
+
query_state = type("_State", (), {"custom_lookups": {}})()
|
|
193
|
+
|
|
194
|
+
builder = ConditionBuilder(_Resolver())
|
|
195
|
+
|
|
196
|
+
def leaf(key, value):
|
|
197
|
+
if not isinstance(key, str) or not key:
|
|
198
|
+
raise TypeError("Compound filters require nonempty field lookup names")
|
|
199
|
+
name, _, lookup = key.partition("__")
|
|
200
|
+
if name not in self._fields or name not in source.c:
|
|
201
|
+
raise QueryStateError(
|
|
202
|
+
f"Field {name!r} is not projected by the set result"
|
|
203
|
+
)
|
|
204
|
+
field = source.c[name]
|
|
205
|
+
lookup = lookup or "exact"
|
|
206
|
+
if getattr(value, "resolve", None) is not None:
|
|
207
|
+
raise QueryStateError("Compound filters require literal lookup values")
|
|
208
|
+
resolved = json_lookup(field, lookup, value)
|
|
209
|
+
if isinstance(resolved, ColumnElement):
|
|
210
|
+
return resolved
|
|
211
|
+
if resolved is not None:
|
|
212
|
+
field, lookup = resolved
|
|
213
|
+
if lookup not in LOOKUP_OPERATORS and lookup not in STANDARD_LOOKUPS:
|
|
214
|
+
raise QueryStateError(f"Unsupported compound result lookup: {lookup!r}")
|
|
215
|
+
return builder.build(field, lookup, value, context=ResolverContext())
|
|
216
|
+
|
|
217
|
+
def resolve(q):
|
|
218
|
+
clauses = []
|
|
219
|
+
for child in q.children:
|
|
220
|
+
if isinstance(child, Q):
|
|
221
|
+
clauses.append(resolve(child))
|
|
222
|
+
else:
|
|
223
|
+
_, key, value = child
|
|
224
|
+
clauses.append(leaf(key, value))
|
|
225
|
+
if not clauses:
|
|
226
|
+
return None
|
|
227
|
+
result = or_(*clauses) if q.connector == Q.OR else and_(*clauses)
|
|
228
|
+
return result.is_not(True) if q.negated else result
|
|
229
|
+
|
|
230
|
+
expression = resolve(predicate)
|
|
231
|
+
if expression is None:
|
|
232
|
+
return self._clone()
|
|
233
|
+
if negate:
|
|
234
|
+
expression = expression.is_not(True)
|
|
235
|
+
clone = self._clone()
|
|
236
|
+
clone._statement = select(*(source.c[key] for key in self._fields)).where(
|
|
237
|
+
expression
|
|
238
|
+
)
|
|
239
|
+
return clone
|
|
240
|
+
|
|
241
|
+
def filter(self, *conditions, **lookups):
|
|
242
|
+
"""Filter the combined result by projected fields before pagination."""
|
|
243
|
+
from async_orm_kit.query.q import Q
|
|
244
|
+
|
|
245
|
+
if any(not isinstance(arg, Q) for arg in conditions):
|
|
246
|
+
raise TypeError("Compound filter() accepts only positional Q objects")
|
|
247
|
+
q = Q(*conditions, **lookups)
|
|
248
|
+
return self._filter_result(q, negate=False)
|
|
249
|
+
|
|
250
|
+
def exclude(self, *conditions, **lookups):
|
|
251
|
+
"""Exclude matching combined rows, retaining nullable comparisons."""
|
|
252
|
+
from async_orm_kit.query.q import Q
|
|
253
|
+
|
|
254
|
+
if any(not isinstance(arg, Q) for arg in conditions):
|
|
255
|
+
raise TypeError("Compound exclude() accepts only positional Q objects")
|
|
256
|
+
q = Q(*conditions, **lookups)
|
|
257
|
+
return self._filter_result(q, negate=True)
|
|
258
|
+
|
|
259
|
+
def order_by(self, *fields: str):
|
|
260
|
+
"""Order by a projected column name; ``-field`` means descending."""
|
|
261
|
+
clone = self._clone()
|
|
262
|
+
clauses = []
|
|
263
|
+
for field in fields:
|
|
264
|
+
if not isinstance(field, str) or not field:
|
|
265
|
+
raise TypeError(
|
|
266
|
+
"Set operation order_by fields must be non-empty strings"
|
|
267
|
+
)
|
|
268
|
+
key = field[1:] if field.startswith("-") else field
|
|
269
|
+
if not key or key not in self._fields:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"Set operation cannot order by unknown column {field!r}"
|
|
272
|
+
)
|
|
273
|
+
expr: Any = column(key)
|
|
274
|
+
clauses.append(expr.desc() if field.startswith("-") else expr.asc())
|
|
275
|
+
clone._statement = clone._statement.order_by(None).order_by(*clauses)
|
|
276
|
+
return clone
|
|
277
|
+
|
|
278
|
+
def limit(self, value: int):
|
|
279
|
+
if type(value) is not int or value < 0:
|
|
280
|
+
raise ValueError("limit must be a nonnegative integer")
|
|
281
|
+
clone = self._clone()
|
|
282
|
+
clone._statement = clone._statement.limit(value)
|
|
283
|
+
return clone
|
|
284
|
+
|
|
285
|
+
def offset(self, value: int):
|
|
286
|
+
if type(value) is not int or value < 0:
|
|
287
|
+
raise ValueError("offset must be a nonnegative integer")
|
|
288
|
+
clone = self._clone()
|
|
289
|
+
clone._statement = clone._statement.offset(value)
|
|
290
|
+
return clone
|
|
291
|
+
|
|
292
|
+
def sql(self, dialect=None) -> str:
|
|
293
|
+
"""Compile parameterized SQL without interpolating user values."""
|
|
294
|
+
if dialect is None:
|
|
295
|
+
bind = getattr(self.session, "bind", None)
|
|
296
|
+
if bind is not None:
|
|
297
|
+
dialect = bind.dialect
|
|
298
|
+
return str(self._statement.compile(dialect=dialect))
|
|
299
|
+
|
|
300
|
+
def cte(self, name: str | None = None):
|
|
301
|
+
"""Expose the resulting set operation as a SQLAlchemy CTE."""
|
|
302
|
+
return self._statement.cte(name=name)
|
|
303
|
+
|
|
304
|
+
def subquery(self, name: str | None = None):
|
|
305
|
+
"""Expose the resulting set operation as a SQLAlchemy subquery."""
|
|
306
|
+
return self._statement.subquery(name=name)
|
|
307
|
+
|
|
308
|
+
def _execution_statement(self):
|
|
309
|
+
if self._mode == "model":
|
|
310
|
+
# A set operation selects mapped table columns, not ORM entities.
|
|
311
|
+
# from_statement() hydrates them through SQLAlchemy's identity map.
|
|
312
|
+
return select(self.model).from_statement(self._statement)
|
|
313
|
+
return self._statement
|
|
314
|
+
|
|
315
|
+
def _convert(self, row):
|
|
316
|
+
if self._mode == "model":
|
|
317
|
+
return row[0]
|
|
318
|
+
if self._mode == "values":
|
|
319
|
+
return dict(zip(self._fields, row, strict=False))
|
|
320
|
+
if self._mode == "flat":
|
|
321
|
+
return row[0]
|
|
322
|
+
return tuple(row)
|
|
323
|
+
|
|
324
|
+
async def _fetch(self):
|
|
325
|
+
if self._cache is not None:
|
|
326
|
+
return self._cache
|
|
327
|
+
try:
|
|
328
|
+
result = await self.session.execute(self._execution_statement())
|
|
329
|
+
rows = result.unique().all() if self._mode == "model" else result.all()
|
|
330
|
+
self._cache = [self._convert(row) for row in rows]
|
|
331
|
+
return self._cache
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
raise DatabaseError("Error executing SQL set operation", cause=exc) from exc
|
|
334
|
+
|
|
335
|
+
def __await__(self):
|
|
336
|
+
return self._fetch().__await__()
|
|
337
|
+
|
|
338
|
+
async def first(self):
|
|
339
|
+
rows = await self.limit(1)
|
|
340
|
+
return rows[0] if rows else None
|
|
341
|
+
|
|
342
|
+
async def count(self) -> int:
|
|
343
|
+
"""Count the rows returned by the set operation including its pagination."""
|
|
344
|
+
stmt = select(func.count()).select_from(self._statement.subquery())
|
|
345
|
+
try:
|
|
346
|
+
return (await self.session.execute(stmt)).scalar_one()
|
|
347
|
+
except Exception as exc:
|
|
348
|
+
raise DatabaseError("Error counting SQL set operation", cause=exc) from exc
|
|
349
|
+
|
|
350
|
+
async def exists(self) -> bool:
|
|
351
|
+
stmt = select(self._statement.exists())
|
|
352
|
+
try:
|
|
353
|
+
return bool((await self.session.execute(stmt)).scalar_one())
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
raise DatabaseError("Error checking SQL set operation", cause=exc) from exc
|
|
356
|
+
|
|
357
|
+
async def stream(self) -> AsyncIterator[Any]:
|
|
358
|
+
"""Stream set-operation results and close the SQLAlchemy cursor."""
|
|
359
|
+
try:
|
|
360
|
+
result = await self.session.stream(self._execution_statement())
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
raise DatabaseError(
|
|
363
|
+
"Error opening SQL set-operation stream", cause=exc
|
|
364
|
+
) from exc
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
iterator = result.unique() if self._mode == "model" else result
|
|
368
|
+
async for row in iterator:
|
|
369
|
+
yield self._convert(row)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
raise DatabaseError("Error streaming SQL set operation", cause=exc) from exc
|
|
372
|
+
finally:
|
|
373
|
+
await result.close()
|
|
374
|
+
|
|
375
|
+
def __aiter__(self):
|
|
376
|
+
return self.stream()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
__all__ = ["CompoundQuerySet"]
|