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,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import ColumnElement
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
8
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
9
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Aggregate(CombinableMixin, OrderableMixin):
|
|
16
|
+
"""Base class for aggregate functions like Count, Sum, Avg, Min, Max.Provides common infrastructure for SQL aggregate functions that operate on
|
|
17
|
+
groups of rows to produce single values. Supports field resolution from strings,
|
|
18
|
+
F() expressions, Value() literals, and Case() conditionals. Handles DISTINCT
|
|
19
|
+
modifier for unique value aggregation. Subclasses implement specific aggregate
|
|
20
|
+
functions by overriding resolve() to generate appropriate SQLAlchemy expressions.
|
|
21
|
+
Aggregates are typically used with QuerySet.annotate() to add computed fields
|
|
22
|
+
to query results, often combined with GROUP BY through relationship traversal
|
|
23
|
+
or explicit grouping. They can also be used in QuerySet.aggregate() for
|
|
24
|
+
single-row summary statistics.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
field: Field specification to aggregate - can be column name string, F()
|
|
28
|
+
expression for field references, Value() for literals, or Case() for
|
|
29
|
+
conditional aggregation.
|
|
30
|
+
distinct: Whether to apply DISTINCT modifier to aggregate only unique values.
|
|
31
|
+
extra: Additional keyword arguments for subclass-specific configuration.
|
|
32
|
+
|
|
33
|
+
Note:
|
|
34
|
+
Performance: Aggregates trigger GROUP BY clauses which can be expensive on
|
|
35
|
+
large datasets. Ensure grouped columns are indexed. DISTINCT aggregates are
|
|
36
|
+
slower than non-distinct as they require deduplication.
|
|
37
|
+
This is an abstract base class - use concrete subclasses like Count, Sum,
|
|
38
|
+
Avg, Min, Max for actual aggregation operations. Subclasses must implement
|
|
39
|
+
resolve() to generate SQLAlchemy aggregate expressions.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, field: Any, distinct: bool = False, **extra):
|
|
43
|
+
"""Initialize aggregate with field specification and options.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
field: Field to aggregate. Accepts multiple types:
|
|
47
|
+
- str: Column name like "amount" or relationship path like "orders__amount"
|
|
48
|
+
- F(): Field expression for computed aggregation like F("quantity") * F("price")
|
|
49
|
+
- Value(): Literal value for conditional aggregation like Value(1)
|
|
50
|
+
- Case(): Conditional expression for filtered aggregationdistinct: Whether to aggregate only distinct values. Useful for countingunique values like Count("customer_id", distinct=True). Defaults to False.
|
|
51
|
+
**extra: Additional keyword arguments passed to subclass for specific
|
|
52
|
+
configuration (e.g., output_field for type coercion).
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> # String field name
|
|
56
|
+
>>> agg = Sum("amount")
|
|
57
|
+
>>>
|
|
58
|
+
>>> # DISTINCT modifier
|
|
59
|
+
>>> agg = Count("customer_id", distinct=True)
|
|
60
|
+
>>>
|
|
61
|
+
>>> # F() expression
|
|
62
|
+
>>> agg = Sum(F("quantity") * F("unit_price"))
|
|
63
|
+
>>>
|
|
64
|
+
>>> # Case() conditional
|
|
65
|
+
>>> agg = Sum(Case(When(status="completed", then=Value(1)), default=Value(0)))
|
|
66
|
+
>>>
|
|
67
|
+
>>> # Extra arguments for subclass
|
|
68
|
+
>>> agg = Avg("rating", output_field=DecimalField())
|
|
69
|
+
"""
|
|
70
|
+
unknown = set(extra) - {"filter", "default", "output_field"}
|
|
71
|
+
if unknown:
|
|
72
|
+
raise TypeError(f"Unsupported aggregate options: {sorted(unknown)}")
|
|
73
|
+
if "filter" in extra:
|
|
74
|
+
from async_orm_kit.query.q import Q
|
|
75
|
+
|
|
76
|
+
if not isinstance(extra["filter"], Q):
|
|
77
|
+
raise TypeError("Aggregate filter must be a Q expression")
|
|
78
|
+
self.field = field
|
|
79
|
+
self.distinct = distinct
|
|
80
|
+
self.extra = extra
|
|
81
|
+
|
|
82
|
+
def _prepare_column(self, col, field_resolver, context):
|
|
83
|
+
"""Apply a portable CASE expression for conditional aggregation."""
|
|
84
|
+
predicate = self.extra.get("filter")
|
|
85
|
+
if predicate is not None:
|
|
86
|
+
from sqlalchemy import case
|
|
87
|
+
|
|
88
|
+
resolved = predicate.resolve(field_resolver=field_resolver, context=context)
|
|
89
|
+
col = case((resolved, col), else_=None)
|
|
90
|
+
return self._maybe_distinct(col)
|
|
91
|
+
|
|
92
|
+
def _finish(self, aggregate):
|
|
93
|
+
"""Apply optional SQL result type and empty-group default."""
|
|
94
|
+
from sqlalchemy import cast, func
|
|
95
|
+
from sqlalchemy.types import TypeEngine
|
|
96
|
+
|
|
97
|
+
output = self.extra.get("output_field")
|
|
98
|
+
if output is not None:
|
|
99
|
+
if isinstance(output, type) and issubclass(output, TypeEngine):
|
|
100
|
+
output = output()
|
|
101
|
+
if not isinstance(output, TypeEngine):
|
|
102
|
+
raise TypeError("output_field must be a SQLAlchemy TypeEngine")
|
|
103
|
+
aggregate = cast(aggregate, output)
|
|
104
|
+
if "default" in self.extra:
|
|
105
|
+
aggregate = func.coalesce(aggregate, self.extra["default"]) # pylint: disable=assignment-from-no-return # SQLAlchemy constructs a SQL expression.
|
|
106
|
+
return aggregate
|
|
107
|
+
|
|
108
|
+
def _maybe_distinct(self, col):
|
|
109
|
+
"""Apply DISTINCT modifier to column if requested.Wraps SQLAlchemy column expression with .distinct() method when self.distinct
|
|
110
|
+
is True. Used by subclasses in resolve() to generate DISTINCT aggregates like
|
|
111
|
+
COUNT(DISTINCT customer_id) or SUM(DISTINCT amount).
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
col: SQLAlchemy column or expression to potentially wrap with DISTINCT.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Column with .distinct() applied if self.distinct is True, otherwise
|
|
118
|
+
returns column unchanged.
|
|
119
|
+
|
|
120
|
+
Note:
|
|
121
|
+
Performance: DISTINCT requires deduplication which can be expensive on
|
|
122
|
+
large result sets. Database must sort or hash values to identify duplicates.
|
|
123
|
+
Use only when necessary for correctness.
|
|
124
|
+
"""
|
|
125
|
+
return col.distinct() if self.distinct else col
|
|
126
|
+
|
|
127
|
+
def _resolve_column(
|
|
128
|
+
self, field_resolver: FieldResolver, context: ResolverContext
|
|
129
|
+
) -> ColumnElement:
|
|
130
|
+
"""Resolve self.field to SQLAlchemy column or expression for aggregation.
|
|
131
|
+
|
|
132
|
+
Handles multiple field types with priority-based resolution:
|
|
133
|
+
1. Case() - Delegates to Case.resolve() for conditional expressions
|
|
134
|
+
2. Value() - Delegates to Value.resolve() for literal wrapping
|
|
135
|
+
3. F() / FExpression() - Delegates to F.resolve() for field references with auto-join
|
|
136
|
+
4. str - Plain field name resolved via FieldResolver for column lookup and auto-join
|
|
137
|
+
|
|
138
|
+
Uses shared FieldResolver to maintain join state across entire query, ensuring
|
|
139
|
+
relationship traversal in aggregates (e.g., "orders__amount") properly registers
|
|
140
|
+
joins with QueryBuilder. Context propagation enables correct join strategy
|
|
141
|
+
(INNER vs LEFT) based on filter/annotation usage.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
field_resolver: FieldResolver instance from QueryBuilder maintaining join
|
|
145
|
+
state and column resolution. Must be provided in production
|
|
146
|
+
code paths for proper join tracking.
|
|
147
|
+
context: ResolverContext tracking OR context for join strategy and filter
|
|
148
|
+
marking for INNER join promotion. Propagated to nested expressions.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
SQLAlchemy ColumnElement representing resolved field for aggregation.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
TypeError: If field is not str, F(), Value(), or Case() - unsupported type
|
|
155
|
+
for aggregation.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> # Internal usage in subclass resolve()
|
|
159
|
+
>>> from async_orm_kit.query.aggregations import Sum
|
|
160
|
+
>>>
|
|
161
|
+
>>> # String field resolution
|
|
162
|
+
>>> agg = Sum("amount")
|
|
163
|
+
>>> col = agg._resolve_column(field_resolver, context)
|
|
164
|
+
>>> # Returns: Order.amount column
|
|
165
|
+
>>>
|
|
166
|
+
>>> # F() expression resolution
|
|
167
|
+
>>> agg = Sum(F("quantity") * F("unit_price"))
|
|
168
|
+
>>> col = agg._resolve_column(field_resolver, context)
|
|
169
|
+
>>> # Returns: Order.quantity * Order.unit_price expression
|
|
170
|
+
>>>
|
|
171
|
+
>>> # Case() conditional resolution
|
|
172
|
+
>>> agg = Sum(Case(When(status="completed", then=Value(1)), default=Value(0)))
|
|
173
|
+
>>> col = agg._resolve_column(field_resolver, context)
|
|
174
|
+
>>> # Returns: CASE WHEN status='completed' THEN 1 ELSE 0 END expression
|
|
175
|
+
>>>
|
|
176
|
+
>>> # Relationship traversal with auto-join
|
|
177
|
+
>>> agg = Sum("orders__amount")
|
|
178
|
+
>>> col = agg._resolve_column(field_resolver, context)
|
|
179
|
+
>>> # Returns: Order.amount column with JOIN registered in field_resolver
|
|
180
|
+
|
|
181
|
+
Note:
|
|
182
|
+
Performance: Resolution is O(1) for simple fields, O(n) for relationship
|
|
183
|
+
paths where n is traversal depth. F() expressions may resolve multiple
|
|
184
|
+
fields. Case() expressions resolve all When conditions.
|
|
185
|
+
Join tracking: All relationship traversal registers joins with field_resolver,
|
|
186
|
+
ensuring QueryBuilder generates proper JOIN clauses. Context propagation
|
|
187
|
+
ensures correct join strategy based on usage (filter vs annotation, OR context).
|
|
188
|
+
"""
|
|
189
|
+
# Structured expressions
|
|
190
|
+
if isinstance(self.field, CombinableMixin) and hasattr(self.field, "resolve"):
|
|
191
|
+
return self.field.resolve(field_resolver=field_resolver, context=context)
|
|
192
|
+
|
|
193
|
+
# Plain string field name
|
|
194
|
+
if isinstance(self.field, str):
|
|
195
|
+
# A relationship is not a scalar SQL column. Resolving a bare
|
|
196
|
+
# relation through SQLAlchemy normally produces a boolean join
|
|
197
|
+
# comparison; COUNT(relation) then counts the wrong rows and can
|
|
198
|
+
# silently introduce a Cartesian product.
|
|
199
|
+
from sqlalchemy import inspect as sa_inspect
|
|
200
|
+
|
|
201
|
+
parts = self.field.split("__")
|
|
202
|
+
current = field_resolver.model
|
|
203
|
+
terminal_relation = None
|
|
204
|
+
for part in parts:
|
|
205
|
+
inspected: Any = sa_inspect(current)
|
|
206
|
+
mapper = inspected.mapper if inspected.is_aliased_class else inspected
|
|
207
|
+
relation = mapper.relationships.get(part)
|
|
208
|
+
if relation is None:
|
|
209
|
+
terminal_relation = None
|
|
210
|
+
break
|
|
211
|
+
terminal_relation = relation
|
|
212
|
+
current = relation.mapper.class_
|
|
213
|
+
if terminal_relation is not None:
|
|
214
|
+
primary_key: Any = sa_inspect(current).primary_key
|
|
215
|
+
if len(primary_key) != 1:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"Aggregate over a composite-key relationship requires "
|
|
218
|
+
"an explicit related column"
|
|
219
|
+
)
|
|
220
|
+
key = f"{self.field}__{primary_key[0].key}"
|
|
221
|
+
else:
|
|
222
|
+
key = self.field
|
|
223
|
+
resolution = field_resolver.resolve_to_column(key=key, context=context)
|
|
224
|
+
return resolution.column
|
|
225
|
+
|
|
226
|
+
raise TypeError(
|
|
227
|
+
f"Aggregate field must be a str, F(), Value(), or Case(). "
|
|
228
|
+
f"Got: {type(self.field).__name__}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def resolve(
|
|
232
|
+
self,
|
|
233
|
+
field_resolver: FieldResolver,
|
|
234
|
+
context: ResolverContext = ResolverContext(),
|
|
235
|
+
) -> ColumnElement:
|
|
236
|
+
"""Resolve aggregate to SQLAlchemy expression for query compilation.
|
|
237
|
+
|
|
238
|
+
Abstract method that subclasses must implement to generate specific aggregate
|
|
239
|
+
functions like COUNT(), SUM(), AVG(), MIN(), MAX(). Should use _resolve_column()
|
|
240
|
+
to handle field resolution and _maybe_distinct() to apply DISTINCT modifier.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
field_resolver: FieldResolver instance for column resolution and join tracking.
|
|
244
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
SQLAlchemy ColumnElement representing aggregate function expression.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
NotImplementedError: Always raised - subclasses must override this method.
|
|
251
|
+
|
|
252
|
+
Example:
|
|
253
|
+
>>> # Subclass implementation example
|
|
254
|
+
>>> class Sum(Aggregate):
|
|
255
|
+
... def resolve(self, field_resolver, context=ResolverContext()):
|
|
256
|
+
... col = self._resolve_column(field_resolver, context)
|
|
257
|
+
... col = self._maybe_distinct(col)
|
|
258
|
+
... return func.sum(col)
|
|
259
|
+
|
|
260
|
+
Note:
|
|
261
|
+
Subclasses: Count, Sum, Avg, Min, Max all implement this method to generate
|
|
262
|
+
their respective SQL aggregate functions using SQLAlchemy's func namespace.
|
|
263
|
+
"""
|
|
264
|
+
raise NotImplementedError
|
|
265
|
+
|
|
266
|
+
def __repr__(self) -> str:
|
|
267
|
+
"""Return string representation showing aggregate type, field, and options.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
String in format "AggregateClass(field, distinct=True)" for debugging.
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
>>> Sum("amount")
|
|
274
|
+
"Sum('amount')"
|
|
275
|
+
>>>
|
|
276
|
+
>>> Count("customer_id", distinct=True)
|
|
277
|
+
"Count('customer_id', distinct=True)"
|
|
278
|
+
>>>
|
|
279
|
+
>>> Avg(F("quantity") * F("price"))
|
|
280
|
+
"Avg(F('quantity') * F('price'))"
|
|
281
|
+
"""
|
|
282
|
+
parts = [repr(self.field)]
|
|
283
|
+
if self.distinct:
|
|
284
|
+
parts.append("distinct=True")
|
|
285
|
+
return f"{self.__class__.__name__}({', '.join(parts)})"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class AggregateFunction(Aggregate):
|
|
289
|
+
"""Custom aggregate backed by a trusted SQLAlchemy expression factory.
|
|
290
|
+
|
|
291
|
+
``factory(resolved_column)`` must return a SQLAlchemy ColumnElement. This
|
|
292
|
+
allows dialect-specific aggregate functions without a global SQL namespace
|
|
293
|
+
registry, preserving bound values and expression-context analysis.
|
|
294
|
+
|
|
295
|
+
Example::
|
|
296
|
+
AggregateFunction("score", lambda col: func.group_concat(col))
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def __init__(self, field: Any, factory: Any, distinct: bool = False, **extra):
|
|
300
|
+
if not callable(factory):
|
|
301
|
+
raise TypeError("AggregateFunction factory must be callable")
|
|
302
|
+
super().__init__(field, distinct=distinct, **extra)
|
|
303
|
+
self.factory = factory
|
|
304
|
+
|
|
305
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
306
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
307
|
+
|
|
308
|
+
column = self._resolve_column(field_resolver, context)
|
|
309
|
+
result = self.factory(self._prepare_column(column, field_resolver, context))
|
|
310
|
+
if not isinstance(result, ColumnElement):
|
|
311
|
+
raise TypeError(
|
|
312
|
+
"AggregateFunction factory must return a SQLAlchemy ColumnElement"
|
|
313
|
+
)
|
|
314
|
+
# A custom dialect aggregate may not be named in the built-in function
|
|
315
|
+
# analyzer. Mark it explicitly so nested arithmetic and CASE grouping
|
|
316
|
+
# logic see the aggregate without parsing arbitrary SQL text.
|
|
317
|
+
return self._finish(result._annotate({"async_orm_kit_aggregate": True}))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import ColumnElement, func
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
8
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Count(Aggregate):
|
|
15
|
+
"""Count number of rows or non-NULL values in grouped result set.
|
|
16
|
+
|
|
17
|
+
Generates SQL COUNT() aggregate function for counting rows or field values.
|
|
18
|
+
Supports special "*" syntax for counting all rows regardless of NULL values,
|
|
19
|
+
and DISTINCT modifier for counting unique values. Most commonly used aggregate
|
|
20
|
+
for result set sizing and cardinality checks.
|
|
21
|
+
|
|
22
|
+
``COUNT(*)`` counts all rows including those with NULL values in counted field.
|
|
23
|
+
COUNT(field) counts only rows where field is non-NULL. COUNT(DISTINCT field)
|
|
24
|
+
counts unique non-NULL values.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
field: Field to count. Accepts "*" for row count (default), str column name,
|
|
28
|
+
F() expression, Value() literal, or Case() conditional. Defaults to "*".
|
|
29
|
+
distinct: Whether to count only distinct values. Cannot be used with "*".
|
|
30
|
+
Defaults to False.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
ColumnElement: SQL expression counting rows or non-NULL values.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: If distinct=True with field="*" (invalid SQL syntax).
|
|
37
|
+
|
|
38
|
+
Note:
|
|
39
|
+
Performance: ``COUNT(*)`` is typically fastest as it doesn't check NULL values.
|
|
40
|
+
COUNT(field) requires NULL checking. COUNT(DISTINCT field) adds deduplication
|
|
41
|
+
overhead and may require sorting or hashing.
|
|
42
|
+
|
|
43
|
+
NULL handling: COUNT(field) excludes NULL values. Use ``COUNT(*)`` to include
|
|
44
|
+
rows with NULL in the counted field.
|
|
45
|
+
DISTINCT validation: ``COUNT(DISTINCT *)`` is invalid SQL and raises ValueError
|
|
46
|
+
at resolution time to prevent database errors.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, field: Any = "*", distinct: bool = False, **extra) -> None:
|
|
50
|
+
"""Initialize Count aggregate with field and DISTINCT option.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
field: Field to count. Use "*" for all rows (default), or specify column
|
|
54
|
+
name, F() expression, Value(), or Case() for conditional counting.
|
|
55
|
+
distinct: Whether to count only unique values. Cannot be True when field="*".
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> # Count all rows
|
|
59
|
+
>>> Count()
|
|
60
|
+
>>> Count("*")
|
|
61
|
+
>>>
|
|
62
|
+
>>> # Count non-NULL values
|
|
63
|
+
>>> Count("customer_id")
|
|
64
|
+
>>>
|
|
65
|
+
>>> # Count unique values
|
|
66
|
+
>>> Count("customer_id", distinct=True)
|
|
67
|
+
>>>
|
|
68
|
+
>>> # Conditional count
|
|
69
|
+
>>> Count(Case(When(status="active", then=Value(1))))"""
|
|
70
|
+
super().__init__(field, distinct=distinct, **extra)
|
|
71
|
+
|
|
72
|
+
def resolve(
|
|
73
|
+
self,
|
|
74
|
+
field_resolver: FieldResolver,
|
|
75
|
+
context: ResolverContext = ResolverContext(),
|
|
76
|
+
) -> ColumnElement:
|
|
77
|
+
"""Resolve to SQLAlchemy COUNT() function expression.
|
|
78
|
+
Handles special "*" syntax for row counting and validates DISTINCT usage.
|
|
79
|
+
For non-"*" fields, delegates to _resolve_column() for field resolution.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
field_resolver: FieldResolver for column resolution and join tracking.
|
|
83
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
SQLAlchemy func.count() expression with optional DISTINCT modifier.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If distinct=True with field="*" (``COUNT(DISTINCT *)`` is invalid SQL).
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
>>> # Generates: ``COUNT(*)``
|
|
93
|
+
>>> Count().resolve(field_resolver, context)
|
|
94
|
+
>>>
|
|
95
|
+
>>> # Generates: COUNT(orders.customer_id)
|
|
96
|
+
>>> Count("customer_id").resolve(field_resolver, context)
|
|
97
|
+
>>>
|
|
98
|
+
>>> # Generates: COUNT(DISTINCT orders.customer_id)
|
|
99
|
+
>>> Count("customer_id", distinct=True).resolve(field_resolver, context)>>>
|
|
100
|
+
>>> # Raises ValueError
|
|
101
|
+
>>> Count("*", distinct=True).resolve(field_resolver, context)
|
|
102
|
+
"""
|
|
103
|
+
col: Any
|
|
104
|
+
if isinstance(self.field, str) and self.field == "*":
|
|
105
|
+
if self.distinct:
|
|
106
|
+
raise ValueError("``COUNT(DISTINCT *)`` is not valid SQL.")
|
|
107
|
+
if self.extra.get("filter") is None:
|
|
108
|
+
return self._finish(func.count())
|
|
109
|
+
from sqlalchemy import literal
|
|
110
|
+
|
|
111
|
+
col = literal(1)
|
|
112
|
+
else:
|
|
113
|
+
col = self._resolve_column(field_resolver=field_resolver, context=context)
|
|
114
|
+
col = self._prepare_column(col, field_resolver, context)
|
|
115
|
+
return self._finish(func.count(col))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import ColumnElement, func
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
8
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Max(Aggregate):
|
|
15
|
+
"""Find maximum value of field across grouped rows.
|
|
16
|
+
|
|
17
|
+
Generates SQL MAX() aggregate function for finding largest value in result set.
|
|
18
|
+
Supports DISTINCT modifier (though typically redundant for MAX). Works with
|
|
19
|
+
numeric, string, and date types using database-native comparison rules.
|
|
20
|
+
|
|
21
|
+
Handles NULL values per SQL standard: NULLs are excluded from comparison, and
|
|
22
|
+
MAX of all-NULL column returns NULL. For strings, uses lexicographic ordering.
|
|
23
|
+
For dates, returns most recent. For numbers, returns largest value.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
field: Field to find maximum of. Accepts str column name, F() expression,
|
|
27
|
+
Value() literal, or Case() for conditional maximum.
|
|
28
|
+
distinct: Whether to consider only distinct values before finding max.
|
|
29
|
+
Typically unnecessary as MAX(field) = MAX(DISTINCT field).
|
|
30
|
+
Defaults to False.
|
|
31
|
+
**extra: Additional arguments like output_field for type coercion.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Maximum value of appropriate type (int, Decimal, str, date, etc.), or None
|
|
35
|
+
if no non-NULL values exist.
|
|
36
|
+
|
|
37
|
+
Note:
|
|
38
|
+
Performance: MAX requires scanning all rows in group but is typically fast
|
|
39
|
+
as it only tracks single maximum value. Indexed columns improve performance.
|
|
40
|
+
|
|
41
|
+
DISTINCT redundancy: MAX(field) and MAX(DISTINCT field) produce identical
|
|
42
|
+
results since maximum is same whether duplicates exist or not. DISTINCT adds
|
|
43
|
+
unnecessary overhead.
|
|
44
|
+
|
|
45
|
+
NULL handling: MAX ignores NULL values. MAX(NULL, NULL, NULL) returns NULL.
|
|
46
|
+
Use COALESCE or Case() with default if you need fallback for empty groups.
|
|
47
|
+
|
|
48
|
+
Type compatibility: Ensure compared values are same type. Mixing types may
|
|
49
|
+
cause database errors or unexpected comparison behavior.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def resolve(
|
|
53
|
+
self,
|
|
54
|
+
field_resolver: FieldResolver,
|
|
55
|
+
context: ResolverContext = ResolverContext(),
|
|
56
|
+
) -> ColumnElement:
|
|
57
|
+
"""Resolve to SQLAlchemy MAX() function expression.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
field_resolver: FieldResolver for column resolution and join tracking.
|
|
61
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
SQLAlchemy func.max() expression with optional DISTINCT modifier.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> # Generates: MAX(orders.amount)
|
|
68
|
+
>>> Max("amount").resolve(field_resolver, context)
|
|
69
|
+
>>>
|
|
70
|
+
>>> # Generates: MAX(DISTINCT orders.amount) - redundant but valid
|
|
71
|
+
>>> Max("amount", distinct=True).resolve(field_resolver, context)
|
|
72
|
+
"""
|
|
73
|
+
col = self._resolve_column(field_resolver=field_resolver, context=context)
|
|
74
|
+
return self._finish(
|
|
75
|
+
func.max(self._prepare_column(col, field_resolver, context))
|
|
76
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import ColumnElement, func
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
8
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Min(Aggregate):
|
|
15
|
+
"""Find minimum value of field across grouped rows.
|
|
16
|
+
|
|
17
|
+
Generates SQL MIN() aggregate function for finding smallest value in result set.
|
|
18
|
+
Supports DISTINCT modifier (though typically redundant for MIN). Works with
|
|
19
|
+
numeric, string, and date types using database-native comparison rules.
|
|
20
|
+
|
|
21
|
+
Handles NULL values per SQL standard: NULLs are excluded from comparison, and
|
|
22
|
+
MIN of all-NULL column returns NULL. For strings, uses lexicographic ordering.
|
|
23
|
+
For dates, returns earliest. For numbers, returns smallest value.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
field: Field to find minimum of. Accepts str column name, F() expression,
|
|
27
|
+
Value() literal, or Case() for conditional minimum.
|
|
28
|
+
distinct: Whether to consider only distinct values before finding min.
|
|
29
|
+
Typically unnecessary as MIN(field) = MIN(DISTINCT field).
|
|
30
|
+
Defaults to False.
|
|
31
|
+
**extra: Additional arguments like output_field for type coercion.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Minimum value of appropriate type (int, Decimal, str, date, etc.), or None
|
|
35
|
+
if no non-NULL values exist.
|
|
36
|
+
|
|
37
|
+
Note:
|
|
38
|
+
Performance: MIN requires scanning all rows in group but is typically fast
|
|
39
|
+
as it only tracks single minimum value. Indexed columns improve performance.
|
|
40
|
+
|
|
41
|
+
DISTINCT redundancy: MIN(field) and MIN(DISTINCT field) produce identical
|
|
42
|
+
results since minimum is same whether duplicates exist or not. DISTINCT adds
|
|
43
|
+
unnecessary overhead.
|
|
44
|
+
|
|
45
|
+
NULL handling: MIN ignores NULL values. MIN(NULL, NULL, NULL) returns NULL.
|
|
46
|
+
Use COALESCE or Case() with default if you need fallback for empty groups.
|
|
47
|
+
|
|
48
|
+
Type compatibility: Ensure compared values are same type. Mixing types may
|
|
49
|
+
cause database errors or unexpected comparison behavior.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def resolve(
|
|
53
|
+
self,
|
|
54
|
+
field_resolver: FieldResolver,
|
|
55
|
+
context: ResolverContext = ResolverContext(),
|
|
56
|
+
) -> ColumnElement:
|
|
57
|
+
"""Resolve to SQLAlchemy MIN() function expression.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
field_resolver: FieldResolver for column resolution and join tracking.
|
|
61
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
SQLAlchemy func.min() expression with optional DISTINCT modifier.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> # Generates: MIN(orders.amount)
|
|
68
|
+
>>> Min("amount").resolve(field_resolver, context)
|
|
69
|
+
>>>
|
|
70
|
+
>>> # Generates: MIN(DISTINCT orders.amount) - redundant but valid
|
|
71
|
+
>>> Min("amount", distinct=True).resolve(field_resolver, context)
|
|
72
|
+
"""
|
|
73
|
+
col = self._resolve_column(field_resolver=field_resolver, context=context)
|
|
74
|
+
return self._finish(
|
|
75
|
+
func.min(self._prepare_column(col, field_resolver, context))
|
|
76
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import ColumnElement, func
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
8
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Sum(Aggregate):
|
|
15
|
+
"""Calculate sum (total) of numeric field values across grouped rows.
|
|
16
|
+
|
|
17
|
+
Generates SQL SUM() aggregate function for computing arithmetic sum of field
|
|
18
|
+
values. Supports DISTINCT modifier to sum only unique values. Commonly used
|
|
19
|
+
for totaling amounts, quantities, or other numeric metrics.
|
|
20
|
+
|
|
21
|
+
Handles NULL values per SQL standard: NULLs are excluded from calculation, and
|
|
22
|
+
SUM of all-NULL column returns NULL (not 0). Works with numeric types (integers,
|
|
23
|
+
decimals, floats) and preserves precision for decimal types.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
field: Field to sum. Accepts str column name, F() expression for computed
|
|
27
|
+
sums, Value() for literals, or Case() for conditional summation.
|
|
28
|
+
distinct: Whether to sum only distinct values. Useful for deduplicating
|
|
29
|
+
before summing. Defaults to False.
|
|
30
|
+
**extra: Additional arguments like output_field for type coercion.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Numeric value (int, Decimal, or float) representing sum, or None if no
|
|
34
|
+
non-NULL values exist.
|
|
35
|
+
|
|
36
|
+
Note:
|
|
37
|
+
Performance: SUM requires scanning all rows in group. On large datasets,
|
|
38
|
+
ensure grouped columns are indexed. DISTINCT adds deduplication overhead.
|
|
39
|
+
|
|
40
|
+
NULL handling: SUM(NULL, NULL, NULL) returns NULL, not 0. Use COALESCE or
|
|
41
|
+
Case() with default=Value(0) if you need zero for empty groups:
|
|
42
|
+
Sum(Case(When(condition, then=F("amount")), default=Value(0)))
|
|
43
|
+
Precision: For decimal fields, SUM preserves precision. For integer fields,
|
|
44
|
+
result may overflow on very large sums depending on database dialect.
|
|
45
|
+
DISTINCT usage: SUM(DISTINCT amount) sums unique amounts only, useful when
|
|
46
|
+
dealing with denormalized data where same value appears multiple times.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def resolve(
|
|
50
|
+
self,
|
|
51
|
+
field_resolver: FieldResolver,
|
|
52
|
+
context: ResolverContext = ResolverContext(),
|
|
53
|
+
) -> ColumnElement:
|
|
54
|
+
"""Resolve to SQLAlchemy SUM() function expression.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
field_resolver: FieldResolver for column resolution and join tracking.
|
|
58
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
SQLAlchemy func.sum() expression with optional DISTINCT modifier.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> # Generates: SUM(orders.amount)
|
|
65
|
+
>>> Sum("amount").resolve(field_resolver, context)
|
|
66
|
+
>>>
|
|
67
|
+
>>> # Generates: SUM(DISTINCT orders.amount)
|
|
68
|
+
>>> Sum("amount", distinct=True).resolve(field_resolver, context)
|
|
69
|
+
"""
|
|
70
|
+
col = self._resolve_column(field_resolver=field_resolver, context=context)
|
|
71
|
+
return self._finish(
|
|
72
|
+
func.sum(self._prepare_column(col, field_resolver, context))
|
|
73
|
+
)
|