strawberry-orm 0.7.0__tar.gz → 0.8.0__tar.gz
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.
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/PKG-INFO +1 -1
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/pyproject.toml +1 -1
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/__init__.py +8 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/_base.py +457 -1
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/django.py +234 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/protocol.py +68 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/sqlalchemy.py +410 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/tortoise.py +219 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/core.py +265 -15
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/filters.py +51 -0
- strawberry_orm-0.8.0/src/strawberry_orm/relay/connection.py +261 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/types.py +14 -0
- strawberry_orm-0.7.0/src/strawberry_orm/relay/connection.py +0 -32
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/README.md +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/_async.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/__init__.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/fields.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/mutations.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/optimizer/extension.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/optimizer/store.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/policy.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/py.typed +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/relay/__init__.py +0 -0
- {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/repo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: strawberry-orm
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Unified, backend-agnostic ORM abstraction for Strawberry GraphQL
|
|
5
5
|
Author: James Davidson, Patrick Arminio
|
|
6
6
|
Author-email: James Davidson <jamie.t.davidson@gmail.com>, Patrick Arminio <patrick.arminio@gmail.com>
|
|
@@ -12,7 +12,9 @@ from strawberry_orm.filters import (
|
|
|
12
12
|
StringLookup,
|
|
13
13
|
StringLookupNoRegex,
|
|
14
14
|
TimeComparisonLookup,
|
|
15
|
+
aggregate_field,
|
|
15
16
|
filter_field,
|
|
17
|
+
group_field,
|
|
16
18
|
order_field,
|
|
17
19
|
)
|
|
18
20
|
from strawberry_orm.mutations import make_ref_type
|
|
@@ -21,6 +23,8 @@ from strawberry_orm.policy import MutationPolicy
|
|
|
21
23
|
from strawberry_orm.repo import AbstractRepo
|
|
22
24
|
from strawberry_orm.types import (
|
|
23
25
|
UNSET,
|
|
26
|
+
DateGroupByInterval,
|
|
27
|
+
DateGroupByOption,
|
|
24
28
|
FieldDefinition,
|
|
25
29
|
OperationInfo,
|
|
26
30
|
OperationMessage,
|
|
@@ -32,6 +36,8 @@ __all__ = [
|
|
|
32
36
|
"AbstractRepo",
|
|
33
37
|
"BooleanLookup",
|
|
34
38
|
"DateComparisonLookup",
|
|
39
|
+
"DateGroupByInterval",
|
|
40
|
+
"DateGroupByOption",
|
|
35
41
|
"DateTimeComparisonLookup",
|
|
36
42
|
"FieldDefinition",
|
|
37
43
|
"FieldHints",
|
|
@@ -49,8 +55,10 @@ __all__ = [
|
|
|
49
55
|
"StringLookupNoRegex",
|
|
50
56
|
"TimeComparisonLookup",
|
|
51
57
|
"UNSET",
|
|
58
|
+
"aggregate_field",
|
|
52
59
|
"auto",
|
|
53
60
|
"filter_field",
|
|
61
|
+
"group_field",
|
|
54
62
|
"make_field",
|
|
55
63
|
"make_ref_type",
|
|
56
64
|
"order_field",
|
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import datetime
|
|
5
6
|
import hashlib
|
|
6
7
|
import inspect
|
|
7
8
|
import re
|
|
8
9
|
import typing
|
|
9
10
|
import warnings
|
|
10
11
|
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from dataclasses import field as dc_field
|
|
11
14
|
from typing import Any, Optional
|
|
12
15
|
|
|
13
16
|
import strawberry
|
|
14
17
|
|
|
15
18
|
from strawberry_orm.filters import (
|
|
19
|
+
_CUSTOM_AGGREGATE_ATTR,
|
|
16
20
|
_CUSTOM_FILTER_ATTR,
|
|
21
|
+
_CUSTOM_GROUP_ATTR,
|
|
17
22
|
_CUSTOM_ORDER_ATTR,
|
|
18
23
|
TYPE_TO_LOOKUP,
|
|
19
24
|
StringLookup,
|
|
@@ -21,7 +26,7 @@ from strawberry_orm.filters import (
|
|
|
21
26
|
)
|
|
22
27
|
from strawberry_orm.mutations import make_ref_type
|
|
23
28
|
from strawberry_orm.optimizer import OptimizerStore
|
|
24
|
-
from strawberry_orm.types import Ordering
|
|
29
|
+
from strawberry_orm.types import DateGroupByOption, Ordering
|
|
25
30
|
|
|
26
31
|
FieldMeta = tuple[str, type, bool, type | None]
|
|
27
32
|
|
|
@@ -37,6 +42,129 @@ _KNOWN_FILTER_KEYS = frozenset({"field", "object", "all", "any", "not_", "one_of
|
|
|
37
42
|
_KNOWN_ORDER_KEYS = frozenset({"field", "object"})
|
|
38
43
|
|
|
39
44
|
|
|
45
|
+
@dataclass
|
|
46
|
+
class AggregateMeta:
|
|
47
|
+
"""Holds the auto-generated aggregate output types and field metadata."""
|
|
48
|
+
|
|
49
|
+
model: type
|
|
50
|
+
aggregates_type: type
|
|
51
|
+
group_key_type: type
|
|
52
|
+
sum_type: type | None = None
|
|
53
|
+
avg_type: type | None = None
|
|
54
|
+
min_type: type | None = None
|
|
55
|
+
max_type: type | None = None
|
|
56
|
+
numeric_fields: list[tuple[str, type]] = dc_field(default_factory=list)
|
|
57
|
+
comparable_fields: list[tuple[str, type]] = dc_field(default_factory=list)
|
|
58
|
+
groupable_fields: list[tuple[str, type]] = dc_field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
group_key_fields: list[str] = dc_field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
custom_fields: list[tuple[str, Callable[..., Any], type]] = dc_field(
|
|
63
|
+
default_factory=list,
|
|
64
|
+
)
|
|
65
|
+
"""Each entry is ``(field_name, handler_callable, python_return_type)``."""
|
|
66
|
+
|
|
67
|
+
def build_aggregates(self, row: Any, requested: dict[str, Any]) -> Any:
|
|
68
|
+
"""Construct an ``Aggregates`` instance from a SQL result *row*."""
|
|
69
|
+
kwargs: dict[str, Any] = {}
|
|
70
|
+
kwargs["count"] = getattr(row, "_count", 0) if requested.get("count") else 0
|
|
71
|
+
|
|
72
|
+
for func_name, SubType in [
|
|
73
|
+
("sum", self.sum_type),
|
|
74
|
+
("avg", self.avg_type),
|
|
75
|
+
("min", self.min_type),
|
|
76
|
+
("max", self.max_type),
|
|
77
|
+
]:
|
|
78
|
+
col_names = requested.get(func_name, [])
|
|
79
|
+
if SubType is not None and col_names:
|
|
80
|
+
sub_kwargs = {}
|
|
81
|
+
for col in col_names:
|
|
82
|
+
sub_kwargs[col] = getattr(row, f"_{func_name}_{col}", None)
|
|
83
|
+
kwargs[func_name] = SubType(**sub_kwargs)
|
|
84
|
+
|
|
85
|
+
for field_name, _handler, _rtype in self.custom_fields:
|
|
86
|
+
val = getattr(row, f"_custom_{field_name}", None)
|
|
87
|
+
if val is not None:
|
|
88
|
+
kwargs[field_name] = val
|
|
89
|
+
|
|
90
|
+
return self.aggregates_type(**kwargs)
|
|
91
|
+
|
|
92
|
+
def build_group_key(self, row: Any, key_fields: list[str]) -> Any:
|
|
93
|
+
"""Construct a ``GroupKey`` instance from a SQL result *row*."""
|
|
94
|
+
kwargs: dict[str, Any] = {}
|
|
95
|
+
for fname in key_fields:
|
|
96
|
+
val = getattr(row, fname, None)
|
|
97
|
+
kwargs[fname] = str(val) if val is not None else None
|
|
98
|
+
return self.group_key_type(**kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _find_selection(info: Any, field_path: str) -> Any:
|
|
102
|
+
"""Walk ``info.selected_fields`` to find a nested selection by dot-path.
|
|
103
|
+
|
|
104
|
+
Strawberry wraps the current field in ``info.selected_fields``, so
|
|
105
|
+
the first entry is the field being resolved. We unwrap that
|
|
106
|
+
automatically when the first path component does not match the
|
|
107
|
+
top-level field name.
|
|
108
|
+
"""
|
|
109
|
+
parts = field_path.split(".")
|
|
110
|
+
selections = info.selected_fields
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
selections
|
|
114
|
+
and len(parts) > 0
|
|
115
|
+
and parts[0] != getattr(selections[0], "name", None)
|
|
116
|
+
):
|
|
117
|
+
inner: list[Any] = []
|
|
118
|
+
for sel in selections:
|
|
119
|
+
inner.extend(getattr(sel, "selections", []))
|
|
120
|
+
if inner:
|
|
121
|
+
selections = inner
|
|
122
|
+
|
|
123
|
+
for part in parts:
|
|
124
|
+
found = None
|
|
125
|
+
for sel in selections:
|
|
126
|
+
if sel.name == part:
|
|
127
|
+
found = sel
|
|
128
|
+
break
|
|
129
|
+
if found is None:
|
|
130
|
+
return None
|
|
131
|
+
selections = found.selections if hasattr(found, "selections") else []
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _selection_requests(info: Any, *path: str) -> bool:
|
|
136
|
+
"""Return ``True`` if the dot-separated *path* is in the selection set."""
|
|
137
|
+
return _find_selection(info, ".".join(path)) is not None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def requested_aggregates(
|
|
141
|
+
info: Any, field_path: str = "aggregates"
|
|
142
|
+
) -> dict[str, Any] | None:
|
|
143
|
+
"""Parse the selection set to determine which aggregates are needed.
|
|
144
|
+
|
|
145
|
+
Returns a dict like::
|
|
146
|
+
|
|
147
|
+
{'count': True, 'sum': ['amount'], 'avg': [], 'min': [], 'max': []}
|
|
148
|
+
|
|
149
|
+
or ``None`` if the field is not in the selection set at all.
|
|
150
|
+
"""
|
|
151
|
+
agg_selection = _find_selection(info, field_path)
|
|
152
|
+
if agg_selection is None:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
result: dict[str, Any] = {}
|
|
156
|
+
for fld in agg_selection.selections:
|
|
157
|
+
name = fld.name
|
|
158
|
+
if name == "count":
|
|
159
|
+
result["count"] = True
|
|
160
|
+
elif name in ("sum", "avg", "min", "max"):
|
|
161
|
+
sub_fields = (
|
|
162
|
+
[sf.name for sf in fld.selections] if hasattr(fld, "selections") else []
|
|
163
|
+
)
|
|
164
|
+
result[name] = sub_fields
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
40
168
|
def invoke_custom_callback(
|
|
41
169
|
callback: Callable[..., Any],
|
|
42
170
|
instance: Any,
|
|
@@ -95,6 +223,8 @@ class BaseBackend:
|
|
|
95
223
|
self._filter_registry: dict[type, type] = {}
|
|
96
224
|
self._projected_filter_cache: dict[tuple[type, Any], type] = {}
|
|
97
225
|
self._order_registry: dict[type, type] = {}
|
|
226
|
+
self._group_registry: dict[type, type] = {}
|
|
227
|
+
self._aggregate_type_cache: dict[type, AggregateMeta] = {}
|
|
98
228
|
self._type_querysets: dict[type, Any] = {}
|
|
99
229
|
self._warn_sensitive: bool = kwargs.get("warn_sensitive", True)
|
|
100
230
|
self._exclude_sensitive_fields: bool = kwargs.get(
|
|
@@ -691,6 +821,324 @@ class BaseBackend:
|
|
|
691
821
|
self._order_registry[model] = OrderType
|
|
692
822
|
return OrderType
|
|
693
823
|
|
|
824
|
+
# -- Group-by type generation --------------------------------------------
|
|
825
|
+
|
|
826
|
+
_NUMERIC_TYPES: tuple[type, ...] = (int, float)
|
|
827
|
+
_COMPARABLE_TYPES: tuple[type, ...] = (
|
|
828
|
+
int,
|
|
829
|
+
float,
|
|
830
|
+
datetime.date,
|
|
831
|
+
datetime.time,
|
|
832
|
+
datetime.datetime,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
def group(self, model_or_type: type, **kwargs: Any) -> Any:
|
|
836
|
+
"""Generate a ``@oneOf`` group-by input for *model*.
|
|
837
|
+
|
|
838
|
+
Boolean fields use ``Boolean`` (set to ``true`` to group by that
|
|
839
|
+
column); date/datetime fields use ``DateGroupByOption`` so the
|
|
840
|
+
caller can choose a truncation interval.
|
|
841
|
+
"""
|
|
842
|
+
model = model_or_type
|
|
843
|
+
include = kwargs.get("include")
|
|
844
|
+
exclude = kwargs.get("exclude")
|
|
845
|
+
|
|
846
|
+
fields_meta = self._introspect_model(model)
|
|
847
|
+
|
|
848
|
+
field_annotations: dict[str, Any] = {}
|
|
849
|
+
field_defaults: dict[str, Any] = {}
|
|
850
|
+
|
|
851
|
+
for fname, ftype, is_relation, _rel in fields_meta:
|
|
852
|
+
if is_relation:
|
|
853
|
+
continue
|
|
854
|
+
if include and fname not in include:
|
|
855
|
+
continue
|
|
856
|
+
if exclude and fname in exclude:
|
|
857
|
+
continue
|
|
858
|
+
if self._exclude_generated_sensitive_field(fname, include):
|
|
859
|
+
continue
|
|
860
|
+
if ftype in (datetime.date, datetime.datetime):
|
|
861
|
+
field_annotations[fname] = Optional[DateGroupByOption]
|
|
862
|
+
else:
|
|
863
|
+
field_annotations[fname] = Optional[bool]
|
|
864
|
+
field_defaults[fname] = strawberry.UNSET
|
|
865
|
+
|
|
866
|
+
field_type_name = f"{model.__name__}GroupByField"
|
|
867
|
+
field_ns: dict[str, Any] = {
|
|
868
|
+
"__annotations__": field_annotations,
|
|
869
|
+
**field_defaults,
|
|
870
|
+
}
|
|
871
|
+
field_cls = type(field_type_name, (), field_ns)
|
|
872
|
+
GroupByFieldType = strawberry.input(field_cls, one_of=True)
|
|
873
|
+
|
|
874
|
+
group_type_name = f"{model.__name__}GroupBy"
|
|
875
|
+
group_ns: dict[str, Any] = {
|
|
876
|
+
"__annotations__": {"field": Optional[GroupByFieldType]},
|
|
877
|
+
"field": strawberry.UNSET,
|
|
878
|
+
}
|
|
879
|
+
GroupByCls = type(group_type_name, (), group_ns)
|
|
880
|
+
GroupByType = strawberry.input(GroupByCls, one_of=True)
|
|
881
|
+
GroupByType._field_type = GroupByFieldType # type: ignore[attr-defined]
|
|
882
|
+
GroupByType.__orm_model__ = model # type: ignore[attr-defined]
|
|
883
|
+
|
|
884
|
+
self._group_registry[model] = GroupByType
|
|
885
|
+
return GroupByType
|
|
886
|
+
|
|
887
|
+
def group_type(
|
|
888
|
+
self,
|
|
889
|
+
model: type,
|
|
890
|
+
*,
|
|
891
|
+
include: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
892
|
+
exclude: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
893
|
+
) -> Callable[[type], type]:
|
|
894
|
+
"""Decorator that builds a ``@oneOf`` group-by input from a user class.
|
|
895
|
+
|
|
896
|
+
``auto`` annotations are expanded to ``Boolean`` or
|
|
897
|
+
``DateGroupByOption``. Methods decorated with ``@group_field``
|
|
898
|
+
become additional top-level keys on the group-by input.
|
|
899
|
+
"""
|
|
900
|
+
|
|
901
|
+
def decorator(cls: type) -> type:
|
|
902
|
+
return self._build_custom_group_type(
|
|
903
|
+
cls, model, include=include, exclude=exclude
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
return decorator
|
|
907
|
+
|
|
908
|
+
def _build_custom_group_type(
|
|
909
|
+
self,
|
|
910
|
+
cls: type,
|
|
911
|
+
model: type,
|
|
912
|
+
*,
|
|
913
|
+
include: Any = None,
|
|
914
|
+
exclude: Any = None,
|
|
915
|
+
) -> type:
|
|
916
|
+
fields_meta = self._introspect_model(model)
|
|
917
|
+
col_types = {
|
|
918
|
+
fname: ftype for fname, ftype, is_rel, _ in fields_meta if not is_rel
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
user_annotations = typing.get_type_hints(cls, include_extras=True)
|
|
922
|
+
|
|
923
|
+
field_annotations: dict[str, Any] = {}
|
|
924
|
+
field_defaults: dict[str, Any] = {}
|
|
925
|
+
custom_group_annotations: dict[str, Any] = {}
|
|
926
|
+
custom_group_defaults: dict[str, Any] = {}
|
|
927
|
+
custom_groups: dict[str, Callable[..., Any]] = {}
|
|
928
|
+
|
|
929
|
+
for fname, ann in user_annotations.items():
|
|
930
|
+
if include and fname not in include:
|
|
931
|
+
continue
|
|
932
|
+
if exclude and fname in exclude:
|
|
933
|
+
continue
|
|
934
|
+
if ann is strawberry.auto:
|
|
935
|
+
if fname in col_types:
|
|
936
|
+
ftype = col_types[fname]
|
|
937
|
+
if ftype in (datetime.date, datetime.datetime):
|
|
938
|
+
field_annotations[fname] = Optional[DateGroupByOption]
|
|
939
|
+
else:
|
|
940
|
+
field_annotations[fname] = Optional[bool]
|
|
941
|
+
field_defaults[fname] = strawberry.UNSET
|
|
942
|
+
|
|
943
|
+
for attr_name in list(vars(cls)):
|
|
944
|
+
method = getattr(cls, attr_name, None)
|
|
945
|
+
if callable(method) and getattr(method, _CUSTOM_GROUP_ATTR, False):
|
|
946
|
+
custom_group_annotations[attr_name] = Optional[bool]
|
|
947
|
+
custom_group_defaults[attr_name] = strawberry.UNSET
|
|
948
|
+
custom_groups[attr_name] = method
|
|
949
|
+
|
|
950
|
+
field_type_name = f"{model.__name__}GroupByField"
|
|
951
|
+
if field_annotations:
|
|
952
|
+
field_ns: dict[str, Any] = {
|
|
953
|
+
"__annotations__": field_annotations,
|
|
954
|
+
**field_defaults,
|
|
955
|
+
}
|
|
956
|
+
field_cls = type(field_type_name, (), field_ns)
|
|
957
|
+
GroupByFieldType = strawberry.input(field_cls, one_of=True)
|
|
958
|
+
else:
|
|
959
|
+
GroupByFieldType = None
|
|
960
|
+
|
|
961
|
+
group_type_name = f"{model.__name__}GroupBy"
|
|
962
|
+
group_ns: dict[str, Any] = {"__annotations__": {}}
|
|
963
|
+
if GroupByFieldType is not None:
|
|
964
|
+
group_ns["__annotations__"]["field"] = Optional[GroupByFieldType]
|
|
965
|
+
group_ns["field"] = strawberry.UNSET
|
|
966
|
+
|
|
967
|
+
for cname, cann in custom_group_annotations.items():
|
|
968
|
+
group_ns["__annotations__"][cname] = cann
|
|
969
|
+
group_ns[cname] = custom_group_defaults[cname]
|
|
970
|
+
|
|
971
|
+
GroupByCls = type(group_type_name, (), group_ns)
|
|
972
|
+
GroupByType = strawberry.input(GroupByCls, one_of=True)
|
|
973
|
+
GroupByType.__orm_model__ = model # type: ignore[attr-defined]
|
|
974
|
+
if GroupByFieldType is not None:
|
|
975
|
+
GroupByType._field_type = GroupByFieldType # type: ignore[attr-defined]
|
|
976
|
+
if custom_groups:
|
|
977
|
+
GroupByType._custom_groups = custom_groups # type: ignore[attr-defined]
|
|
978
|
+
|
|
979
|
+
self._group_registry[model] = GroupByType
|
|
980
|
+
return GroupByType
|
|
981
|
+
|
|
982
|
+
# -- Aggregate type registration -----------------------------------------
|
|
983
|
+
|
|
984
|
+
def aggregate(self, model_or_type: type, **kwargs: Any) -> Any:
|
|
985
|
+
"""Return a marker that stores the aggregate class for *model*.
|
|
986
|
+
|
|
987
|
+
With no custom class, aggregation uses all introspected fields.
|
|
988
|
+
"""
|
|
989
|
+
return None
|
|
990
|
+
|
|
991
|
+
def aggregate_type(
|
|
992
|
+
self,
|
|
993
|
+
model: type,
|
|
994
|
+
*,
|
|
995
|
+
include: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
996
|
+
exclude: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
997
|
+
) -> Callable[[type], type]:
|
|
998
|
+
"""Decorator that registers a user-defined aggregate class.
|
|
999
|
+
|
|
1000
|
+
``auto`` annotations select which fields get standard sum/avg/min/max.
|
|
1001
|
+
Methods decorated with ``@aggregate_field`` add custom computed
|
|
1002
|
+
aggregate fields.
|
|
1003
|
+
|
|
1004
|
+
Usage::
|
|
1005
|
+
|
|
1006
|
+
@orm.aggregate_type(Order)
|
|
1007
|
+
class OrderAggregation:
|
|
1008
|
+
amount: auto
|
|
1009
|
+
quantity: auto
|
|
1010
|
+
|
|
1011
|
+
@aggregate_field
|
|
1012
|
+
def total_revenue(self, columns) -> float:
|
|
1013
|
+
from sqlalchemy import func
|
|
1014
|
+
return func.sum(columns.amount * columns.quantity)
|
|
1015
|
+
"""
|
|
1016
|
+
|
|
1017
|
+
def decorator(cls: type) -> type:
|
|
1018
|
+
cls.__orm_aggregate_model__ = model # type: ignore[attr-defined]
|
|
1019
|
+
return cls
|
|
1020
|
+
|
|
1021
|
+
return decorator
|
|
1022
|
+
|
|
1023
|
+
# -- Aggregate output type generation ------------------------------------
|
|
1024
|
+
|
|
1025
|
+
def _build_aggregate_types(
|
|
1026
|
+
self, model: type, aggregate_cls: type | None = None
|
|
1027
|
+
) -> AggregateMeta:
|
|
1028
|
+
"""Build the aggregate output types and return an ``AggregateMeta``.
|
|
1029
|
+
|
|
1030
|
+
When *aggregate_cls* is provided (from ``@orm.aggregate_type``),
|
|
1031
|
+
only fields annotated with ``auto`` on that class are included in
|
|
1032
|
+
the standard sub-aggregates (sum/avg/min/max), and methods
|
|
1033
|
+
decorated with ``@aggregate_field`` become additional top-level
|
|
1034
|
+
fields on the aggregates type.
|
|
1035
|
+
|
|
1036
|
+
Cached per ``(model, aggregate_cls)`` so repeated calls return
|
|
1037
|
+
the same types.
|
|
1038
|
+
"""
|
|
1039
|
+
import datetime as _dt
|
|
1040
|
+
|
|
1041
|
+
cache_key = (model, aggregate_cls)
|
|
1042
|
+
cached = self._aggregate_type_cache.get(cache_key)
|
|
1043
|
+
if cached is not None:
|
|
1044
|
+
return cached
|
|
1045
|
+
|
|
1046
|
+
fields_meta = self._introspect_model(model)
|
|
1047
|
+
model_name = model.__name__
|
|
1048
|
+
|
|
1049
|
+
include_fields: set[str] | None = None
|
|
1050
|
+
custom_agg_handlers: list[tuple[str, Callable[..., Any], type]] = []
|
|
1051
|
+
|
|
1052
|
+
if aggregate_cls is not None:
|
|
1053
|
+
user_hints = typing.get_type_hints(aggregate_cls, include_extras=True)
|
|
1054
|
+
include_fields = {k for k, v in user_hints.items() if v is strawberry.auto}
|
|
1055
|
+
for attr_name in list(vars(aggregate_cls)):
|
|
1056
|
+
handler = getattr(aggregate_cls, attr_name, None)
|
|
1057
|
+
if callable(handler) and getattr(
|
|
1058
|
+
handler, _CUSTOM_AGGREGATE_ATTR, False
|
|
1059
|
+
):
|
|
1060
|
+
ret_type = typing.get_type_hints(handler).get("return", float)
|
|
1061
|
+
custom_agg_handlers.append((attr_name, handler, ret_type))
|
|
1062
|
+
|
|
1063
|
+
numeric_fields: list[tuple[str, type]] = []
|
|
1064
|
+
comparable_fields: list[tuple[str, type]] = []
|
|
1065
|
+
groupable_fields: list[tuple[str, type]] = []
|
|
1066
|
+
|
|
1067
|
+
for fname, ftype, is_relation, _rel in fields_meta:
|
|
1068
|
+
if is_relation:
|
|
1069
|
+
continue
|
|
1070
|
+
if include_fields is not None and fname not in include_fields:
|
|
1071
|
+
groupable_fields.append((fname, ftype))
|
|
1072
|
+
continue
|
|
1073
|
+
if ftype in self._NUMERIC_TYPES:
|
|
1074
|
+
numeric_fields.append((fname, ftype))
|
|
1075
|
+
comparable_fields.append((fname, ftype))
|
|
1076
|
+
elif ftype in (_dt.date, _dt.datetime, _dt.time):
|
|
1077
|
+
comparable_fields.append((fname, ftype))
|
|
1078
|
+
groupable_fields.append((fname, ftype))
|
|
1079
|
+
|
|
1080
|
+
def _make_sub_agg(prefix: str, fields: list[tuple[str, type]]) -> type | None:
|
|
1081
|
+
if not fields:
|
|
1082
|
+
return None
|
|
1083
|
+
ann: dict[str, Any] = {}
|
|
1084
|
+
defs: dict[str, Any] = {}
|
|
1085
|
+
for fname, _ in fields:
|
|
1086
|
+
ann[fname] = Optional[float]
|
|
1087
|
+
defs[fname] = None
|
|
1088
|
+
cls_name = f"{model_name}{prefix}Aggregates"
|
|
1089
|
+
ns = {"__annotations__": ann, **defs}
|
|
1090
|
+
return strawberry.type(type(cls_name, (), ns))
|
|
1091
|
+
|
|
1092
|
+
SumType = _make_sub_agg("Sum", numeric_fields)
|
|
1093
|
+
AvgType = _make_sub_agg("Avg", numeric_fields)
|
|
1094
|
+
MinType = _make_sub_agg("Min", comparable_fields)
|
|
1095
|
+
MaxType = _make_sub_agg("Max", comparable_fields)
|
|
1096
|
+
|
|
1097
|
+
agg_ann: dict[str, Any] = {"count": int}
|
|
1098
|
+
agg_defs: dict[str, Any] = {"count": 0}
|
|
1099
|
+
for label, sub_type in [
|
|
1100
|
+
("sum", SumType),
|
|
1101
|
+
("avg", AvgType),
|
|
1102
|
+
("min", MinType),
|
|
1103
|
+
("max", MaxType),
|
|
1104
|
+
]:
|
|
1105
|
+
if sub_type is not None:
|
|
1106
|
+
agg_ann[label] = Optional[sub_type]
|
|
1107
|
+
agg_defs[label] = None
|
|
1108
|
+
|
|
1109
|
+
for field_name, _handler, ret_type in custom_agg_handlers:
|
|
1110
|
+
agg_ann[field_name] = Optional[ret_type]
|
|
1111
|
+
agg_defs[field_name] = None
|
|
1112
|
+
|
|
1113
|
+
agg_cls_name = f"{model_name}Aggregates"
|
|
1114
|
+
agg_ns = {"__annotations__": agg_ann, **agg_defs}
|
|
1115
|
+
AggregatesType = strawberry.type(type(agg_cls_name, (), agg_ns))
|
|
1116
|
+
|
|
1117
|
+
key_ann: dict[str, Any] = {}
|
|
1118
|
+
key_defs: dict[str, Any] = {}
|
|
1119
|
+
for fname, ftype in groupable_fields:
|
|
1120
|
+
key_ann[fname] = Optional[str]
|
|
1121
|
+
key_defs[fname] = None
|
|
1122
|
+
GroupKeyType = strawberry.type(
|
|
1123
|
+
type(f"{model_name}GroupKey", (), {"__annotations__": key_ann, **key_defs})
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
meta = AggregateMeta(
|
|
1127
|
+
model=model,
|
|
1128
|
+
aggregates_type=AggregatesType,
|
|
1129
|
+
group_key_type=GroupKeyType,
|
|
1130
|
+
sum_type=SumType,
|
|
1131
|
+
avg_type=AvgType,
|
|
1132
|
+
min_type=MinType,
|
|
1133
|
+
max_type=MaxType,
|
|
1134
|
+
numeric_fields=numeric_fields,
|
|
1135
|
+
comparable_fields=comparable_fields,
|
|
1136
|
+
groupable_fields=groupable_fields,
|
|
1137
|
+
custom_fields=custom_agg_handlers,
|
|
1138
|
+
)
|
|
1139
|
+
self._aggregate_type_cache[cache_key] = meta
|
|
1140
|
+
return meta
|
|
1141
|
+
|
|
694
1142
|
# -- Fields --------------------------------------------------------------
|
|
695
1143
|
|
|
696
1144
|
def field(self, **kwargs: Any) -> Any:
|
|
@@ -763,6 +1211,8 @@ class BaseBackend:
|
|
|
763
1211
|
name: str | None = None,
|
|
764
1212
|
filters: Any = None,
|
|
765
1213
|
order: Any = None,
|
|
1214
|
+
group: Any = None,
|
|
1215
|
+
aggregate: Any = None,
|
|
766
1216
|
) -> str:
|
|
767
1217
|
"""Shared annotation processing for ``type()`` decorators.
|
|
768
1218
|
|
|
@@ -803,6 +1253,12 @@ class BaseBackend:
|
|
|
803
1253
|
if order is not None:
|
|
804
1254
|
cls.__orm_order__ = order # type: ignore[attr-defined]
|
|
805
1255
|
|
|
1256
|
+
if group is not None:
|
|
1257
|
+
cls.__orm_group__ = group # type: ignore[attr-defined]
|
|
1258
|
+
|
|
1259
|
+
if aggregate is not None:
|
|
1260
|
+
cls.__orm_aggregate__ = aggregate # type: ignore[attr-defined]
|
|
1261
|
+
|
|
806
1262
|
type_name = name or cls.__name__
|
|
807
1263
|
|
|
808
1264
|
if hasattr(cls, "get_queryset") and isinstance(
|