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,38 @@
1
+ from sqlalchemy import String
2
+
3
+ from async_orm_kit.models.fields.typed.base import BaseValidatedType, ValidatorType
4
+ from async_orm_kit.models.fields.validators import (
5
+ MaxLengthValidator,
6
+ MinLengthValidator,
7
+ )
8
+
9
+
10
+ class StringField(BaseValidatedType):
11
+ cache_ok = True
12
+ impl = String
13
+
14
+ def __init__(
15
+ self,
16
+ max_length: int | None = None,
17
+ min_length: int | None = None,
18
+ validators: list[ValidatorType] | None = None,
19
+ **kwargs,
20
+ ):
21
+ self.min_length = min_length
22
+ self.max_length = max_length
23
+
24
+ v: list[ValidatorType] = list(validators or [])
25
+
26
+ if max_length is not None:
27
+ v.append(MaxLengthValidator(max_length))
28
+
29
+ if min_length is not None:
30
+ v.append(MinLengthValidator(min_length))
31
+
32
+ super().__init__(validators=v, **kwargs)
33
+
34
+ def load_dialect_impl(self, dialect):
35
+ """Build the dialect string type with the configured maximum length."""
36
+ if self.max_length:
37
+ return dialect.type_descriptor(String(self.max_length))
38
+ return dialect.type_descriptor(String())
@@ -0,0 +1,110 @@
1
+ import re
2
+ from typing import Any
3
+
4
+ from async_orm_kit.models.fields.exceptions import FieldValidationError
5
+
6
+
7
+ class FieldValidator:
8
+ def __call__(self, value: Any) -> None:
9
+ """
10
+ Base validator interface.
11
+
12
+ Contract:
13
+ - Do nothing if `value` is valid.
14
+ - Raise FieldValidationError (or subclass) if invalid.
15
+ - Does NOT return or transform the value.
16
+ """
17
+ return None
18
+
19
+
20
+ class MaxLengthValidator(FieldValidator):
21
+ def __init__(self, limit: int):
22
+ self.limit = limit
23
+
24
+ def __call__(self, value) -> None:
25
+ if value is None:
26
+ return
27
+ try:
28
+ length = len(value)
29
+ except TypeError:
30
+ raise FieldValidationError(
31
+ f"Ensure this value has at most {self.limit} characters "
32
+ f"(it has a non-measurable length).",
33
+ code="max_length",
34
+ ) from None
35
+ if length > self.limit:
36
+ raise FieldValidationError(
37
+ f"Ensure this value has at most {self.limit} characters "
38
+ f"(it has {length}).",
39
+ code="max_length",
40
+ )
41
+
42
+
43
+ class MinLengthValidator(FieldValidator):
44
+ def __init__(self, limit: int):
45
+ self.limit = limit
46
+
47
+ def __call__(self, value) -> None:
48
+ if value is None:
49
+ return
50
+ try:
51
+ length = len(value)
52
+ except TypeError:
53
+ raise FieldValidationError(
54
+ f"Ensure this value has at least {self.limit} characters "
55
+ f"(it has a non-measurable length).",
56
+ code="min_length",
57
+ ) from None
58
+ if length < self.limit:
59
+ raise FieldValidationError(
60
+ f"Ensure this value has at least {self.limit} characters "
61
+ f"(it has {length}).",
62
+ code="min_length",
63
+ )
64
+
65
+
66
+ class RangeValidator(FieldValidator):
67
+ def __init__(self, *, min: int | None = None, max: int | None = None):
68
+ self.min = min
69
+ self.max = max
70
+
71
+ def __call__(self, value) -> None:
72
+ if value is None:
73
+ return
74
+
75
+ # Non-numeric or non-orderable values
76
+ try:
77
+ _ = value < value # just to trigger TypeError for non-comparables
78
+ except TypeError:
79
+ raise FieldValidationError(
80
+ "Ensure this value is a comparable numeric-type.",
81
+ code="invalid_type",
82
+ ) from None
83
+
84
+ if self.min is not None and value < self.min:
85
+ raise FieldValidationError(
86
+ f"Ensure this value is greater than or equal to {self.min} "
87
+ f"(it is {value}).",
88
+ code="min_value",
89
+ )
90
+ if self.max is not None and value > self.max:
91
+ raise FieldValidationError(
92
+ f"Ensure this value is less than or equal to {self.max} "
93
+ f"(it is {value}).",
94
+ code="max_value",
95
+ )
96
+
97
+
98
+ class Regex(FieldValidator):
99
+ def __init__(self, pattern: str | re.Pattern, message: str = "Invalid format"):
100
+ self.pattern = re.compile(pattern) if isinstance(pattern, str) else pattern
101
+ self.message = message
102
+
103
+ def __call__(self, value) -> None:
104
+ if value is None:
105
+ return
106
+ if not self.pattern.match(value):
107
+ raise FieldValidationError(
108
+ self.message,
109
+ code="regex",
110
+ )
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy.orm import DeclarativeBase
6
+
7
+ from async_orm_kit._logging import get_logger
8
+ from async_orm_kit.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
9
+ from async_orm_kit.manager import Manager
10
+ from async_orm_kit.models.fields.typed.base import BaseValidatedType
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class Base(DeclarativeBase):
16
+ """SQLAlchemy declarative base for all ORM models.
17
+ Provides the foundation for all model classes in the framework.
18
+ Models should inherit from ``Model`` rather than using this directly.
19
+ """
20
+
21
+ pass
22
+
23
+
24
+ class ModelMeta(type(Base)): # type: ignore[misc] # SQLAlchemy supplies the declarative metaclass dynamically.
25
+ """Metaclass for automatic manager registration and model initialization.
26
+
27
+ Handles the setup of managers with observers and model-specific exceptions
28
+ during class creation. Automatically discovers managers from the class
29
+ hierarchy, creates model-specific exception classes, and registers observers.
30
+
31
+ Responsibilities:
32
+ - Create model-specific ``DoesNotExist`` and ``MultipleObjectsReturned``
33
+ exception classes for each model
34
+ - Discover and register Manager instances with their observers from class and parent classes
35
+ - Add default ``objects`` manager if no managers are defined
36
+ - Collect and register model and queryset observers from class hierarchy
37
+ - Call ``contribute_to_class()`` on each manager to bind it to the model
38
+
39
+ Class Attributes Created:
40
+ DoesNotExist: Model-specific exception for missing objects.
41
+ MultipleObjectsReturned: Model-specific exception for ambiguous queries.
42
+ _managers: List of manager attribute names on this model.
43
+ _default_manager: The default manager instance (typically ``objects``).
44
+ _model_observers: List of registered model lifecycle observers.
45
+
46
+ Example:
47
+ >>> class User(Model): # Uses ModelMeta automatically
48
+ ... id: Mapped[int] = mapped_column(primary_key=True)
49
+ ... username: Mapped[str]
50
+ ...
51
+ ... # Custom manager
52
+ ... active = ActiveUserManager()
53
+ >>>
54
+ >>> # Model-specific exceptions created automatically
55
+ >>> try:
56
+ ... await User.objects.get(id=999)
57
+ ... except User.DoesNotExist:
58
+ ... print("User not found")
59
+ >>>
60
+ >>> # Managers registered and accessible
61
+ >>> User._managers
62
+ ['objects', 'active']
63
+ >>> User._default_manager
64
+ <Manager for User>
65
+
66
+ Note:
67
+ This metaclass is applied automatically when inheriting from ``Model``.
68
+ Abstract models (``__abstract__ = True``) skip manager registration.
69
+ """
70
+
71
+ def __new__(
72
+ mcs,
73
+ name: str,
74
+ bases: tuple[type, ...],
75
+ namespace: dict[str, Any],
76
+ **kwargs: Any,
77
+ ) -> type:
78
+ """Create and initialize a new model class.
79
+
80
+ Args:
81
+ name: Name of the class being created.
82
+ bases: Tuple of base classes.
83
+ namespace: Class namespace dictionary containing attributes and methods.
84
+ **kwargs: Additional keyword arguments passed to parent metaclass.
85
+
86
+ Returns:
87
+ Newly created and initialized model class with managers and
88
+ exceptions registered.
89
+ """
90
+ # Create the class
91
+ cls = super().__new__(mcs, name, bases, namespace, **kwargs)
92
+
93
+ # Skip for abstract base classes
94
+ if namespace.get("__abstract__", False):
95
+ return cls
96
+
97
+ # Create model-specific exceptions
98
+ cls.DoesNotExist = type(
99
+ f"{name}.DoesNotExist",
100
+ (ObjectDoesNotExist,),
101
+ {"__module__": cls.__module__},
102
+ )
103
+
104
+ cls.MultipleObjectsReturned = type(
105
+ f"{name}.MultipleObjectsReturned",
106
+ (MultipleObjectsReturned,),
107
+ {"__module__": cls.__module__},
108
+ )
109
+
110
+ cls._model_field_types = {} # {field_name: field_instance}
111
+ cls._file_fields = {}
112
+ if hasattr(cls, "__table__"):
113
+ for column in cls.__table__.columns:
114
+ if isinstance(column.type, BaseValidatedType):
115
+ attr_name = column.key
116
+ attr = getattr(cls, attr_name)
117
+ column.type.contribute_to_class(cls, attr_name, attr)
118
+
119
+ # Collect inherited managers and observers in MRO order. Managers must
120
+ # be cloned for each concrete model because they hold a model binding and
121
+ # mutable hooks/observer registries.
122
+ inherited_managers: dict[str, Manager] = {}
123
+ observers: list[Any] = []
124
+ queryset_observers: list[Any] = []
125
+
126
+ def append_unique(target: list[Any], values: Any) -> None:
127
+ for value in values or ():
128
+ if all(existing is not value for existing in target):
129
+ target.append(value)
130
+
131
+ # Resolve manager attributes using normal Python MRO precedence. A
132
+ # nearer non-manager attribute intentionally shadows a base manager.
133
+ seen_attributes: set[str] = set()
134
+ for base in cls.__mro__[1:]:
135
+ for attr_name, attr_value in base.__dict__.items():
136
+ if attr_name in seen_attributes:
137
+ continue
138
+ seen_attributes.add(attr_name)
139
+ if isinstance(attr_value, Manager):
140
+ inherited_managers[attr_name] = attr_value
141
+
142
+ # Observer registration order is base-to-derived and stable. Identity
143
+ # de-duplication avoids requiring observer instances to be hashable.
144
+ for base in reversed(cls.__mro__[1:]):
145
+ append_unique(observers, base.__dict__.get("model_observers", ()))
146
+ append_unique(
147
+ queryset_observers,
148
+ base.__dict__.get("queryset_observers", ()),
149
+ )
150
+
151
+ managers: dict[str, Manager] = {
152
+ name: manager.clone_for_model()
153
+ for name, manager in inherited_managers.items()
154
+ }
155
+
156
+ # Explicit managers on this class override inherited managers.
157
+ for attr_name, attr_value in namespace.items():
158
+ if isinstance(attr_value, Manager):
159
+ managers[attr_name] = attr_value
160
+
161
+ append_unique(observers, namespace.get("model_observers", ()))
162
+ append_unique(queryset_observers, namespace.get("queryset_observers", ()))
163
+
164
+ # Add a fresh default manager when no manager is inherited or declared.
165
+ if not managers:
166
+ managers["objects"] = Manager()
167
+
168
+ # Bind every manager to this model and give it an independent observer list.
169
+ for manager_name, manager in managers.items():
170
+ manager._observers = list(manager._observers)
171
+ append_unique(manager._observers, queryset_observers)
172
+ manager.contribute_to_class(cls, manager_name)
173
+
174
+ cls._managers = list(managers)
175
+ cls._default_manager = managers.get("objects") or next(
176
+ iter(managers.values()), None
177
+ )
178
+ cls._model_observers = observers
179
+ cls._queryset_observers = queryset_observers
180
+
181
+ return cls
@@ -0,0 +1,19 @@
1
+ from typing import ClassVar
2
+
3
+ from async_orm_kit.observers import BaseModelObserver, BaseQuerySetObserver
4
+ from async_orm_kit.observers.mixin import ObserverMixin
5
+
6
+
7
+ class ModelObserverMixin(ObserverMixin):
8
+ """Mixin to add observer support to Model"""
9
+
10
+ model_observers: ClassVar[list[BaseModelObserver]] = []
11
+ _model_observers: ClassVar[list[BaseModelObserver]] = []
12
+ queryset_observers: ClassVar[list[BaseQuerySetObserver]] = []
13
+ _queryset_observers: ClassVar[list[BaseQuerySetObserver]] = []
14
+
15
+ async def _trigger_model_observers(self, event: str, **kwargs) -> None:
16
+ """Trigger model-level observers"""
17
+ await self._trigger_observers(
18
+ self._model_observers, event, instance=self, **kwargs
19
+ )
@@ -0,0 +1,4 @@
1
+ from .model_observer import BaseModelObserver
2
+ from .queryset_observer import BaseQuerySetObserver
3
+
4
+ __all__ = ["BaseModelObserver", "BaseQuerySetObserver"]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Iterable
5
+ from typing import Any
6
+
7
+
8
+ class ObserverMixin:
9
+ """Shared, deterministic dispatcher for model and QuerySet observers."""
10
+
11
+ async def _trigger_observers(
12
+ self, observers: Iterable[Any], event: str, **kwargs: Any
13
+ ) -> None:
14
+ """Invoke observers in registration order.
15
+
16
+ Both synchronous and asynchronous handlers are supported. Exceptions are
17
+ intentionally propagated to the caller; observer failures must never be
18
+ hidden in untracked background tasks.
19
+ """
20
+ for observer in observers:
21
+ handler = getattr(observer, event, None)
22
+ if handler is None:
23
+ continue
24
+ result = handler(**kwargs)
25
+ if inspect.isawaitable(result):
26
+ await result
@@ -0,0 +1,20 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from async_orm_kit.models import Model
5
+
6
+
7
+ class BaseModelObserver:
8
+ """Base observer for single model instance lifecycle"""
9
+
10
+ async def pre_save(self, instance: "Model", **kwargs) -> None:
11
+ pass
12
+
13
+ async def post_save(self, instance: "Model", created: bool, **kwargs) -> None:
14
+ pass
15
+
16
+ async def pre_delete(self, instance: "Model", **kwargs) -> None:
17
+ pass
18
+
19
+ async def post_delete(self, instance: "Model", **kwargs) -> None:
20
+ pass
@@ -0,0 +1,38 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from async_orm_kit.queryset.queryset import QuerySet
5
+
6
+
7
+ class BaseQuerySetObserver:
8
+ """Base observer for queryset operations"""
9
+
10
+ async def pre_stream(self, queryset: "QuerySet", **kwargs) -> None:
11
+ pass
12
+
13
+ async def post_stream(self, queryset: "QuerySet", **kwargs) -> None:
14
+ pass
15
+
16
+ async def pre_execute(self, queryset: "QuerySet", **kwargs) -> None:
17
+ pass
18
+
19
+ async def post_execute(self, queryset: "QuerySet", **kwargs) -> None:
20
+ pass
21
+
22
+ async def pre_bulk_create(self, **kwargs) -> None:
23
+ pass
24
+
25
+ async def post_bulk_create(self, **kwargs) -> None:
26
+ pass
27
+
28
+ async def pre_bulk_update(self, queryset: "QuerySet", **kwargs) -> None:
29
+ pass
30
+
31
+ async def post_bulk_update(self, queryset: "QuerySet", **kwargs) -> None:
32
+ pass
33
+
34
+ async def pre_bulk_delete(self, queryset: "QuerySet", **kwargs) -> None:
35
+ pass
36
+
37
+ async def post_bulk_delete(self, queryset: "QuerySet", **kwargs) -> None:
38
+ pass
async_orm_kit/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,8 @@
1
+ from .avg import Avg
2
+ from .base import Aggregate, AggregateFunction
3
+ from .count import Count
4
+ from .max import Max
5
+ from .min import Min
6
+ from .sum import Sum
7
+
8
+ __all__ = ["Avg", "Aggregate", "AggregateFunction", "Count", "Max", "Min", "Sum"]
@@ -0,0 +1,71 @@
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 Avg(Aggregate):
15
+ """Calculate average (mean) of numeric field values across grouped rows.
16
+
17
+ Generates SQL AVG() aggregate function for computing arithmetic mean of field
18
+ values. Supports DISTINCT modifier to average only unique values. Commonly used
19
+ with QuerySet.annotate() for per-group averages or QuerySet.aggregate() for
20
+ overall dataset average.
21
+
22
+ Handles NULL values per SQL standard: NULLs are excluded from calculation, and
23
+ AVG of all-NULL column returns NULL. Works with numeric types (integers, decimals,
24
+ floats) and may return decimal results even for integer inputs depending on
25
+ database dialect.
26
+
27
+ Args:
28
+ field: Field to average. Accepts str column name, F() expression for computed
29
+ averages, Value() for literals, or Case() for conditional averaging.
30
+ distinct: Whether to average only distinct values. Useful for deduplicating
31
+ before averaging. Defaults to False.
32
+ **extra: Additional arguments like output_field for type coercion.
33
+
34
+ Returns:Decimal or float representing average value, or None if no non-NULL values.
35
+
36
+ Note:
37
+ Performance: AVG requires scanning all rows in group. On large datasets,
38
+ ensure grouped columns are indexed. DISTINCT adds deduplication overhead.
39
+
40
+ Database differences: PostgreSQL returns numeric type, MySQL returns double.
41
+ Use output_field parameter for consistent type handling across databases.
42
+
43
+ NULL handling: AVG(NULL, NULL, NULL) returns NULL, not 0. Use COALESCE or
44
+ Case() with default=Value(0) if you need zero for empty groups.
45
+ """
46
+
47
+ def resolve(
48
+ self,
49
+ field_resolver: FieldResolver,
50
+ context: ResolverContext = ResolverContext(),
51
+ ) -> ColumnElement:
52
+ """Resolve to SQLAlchemy AVG() function expression.
53
+
54
+ Args:
55
+ field_resolver: FieldResolver for column resolution and join tracking.
56
+ context: ResolverContext for OR context and filter marking propagation.
57
+
58
+ Returns:
59
+ SQLAlchemy func.avg() expression with optional DISTINCT modifier.
60
+
61
+ Example:
62
+ >>> # Generates: AVG(orders.amount)
63
+ >>> Avg("amount").resolve(field_resolver, context)
64
+ >>>
65
+ >>> # Generates: AVG(DISTINCT orders.amount)
66
+ >>> Avg("amount", distinct=True).resolve(field_resolver, context)
67
+ """
68
+ col = self._resolve_column(field_resolver=field_resolver, context=context)
69
+ return self._finish(
70
+ func.avg(self._prepare_column(col, field_resolver, context))
71
+ )