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.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. 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
+ )