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.
Files changed (25) hide show
  1. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/PKG-INFO +1 -1
  2. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/pyproject.toml +1 -1
  3. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/__init__.py +8 -0
  4. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/_base.py +457 -1
  5. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/django.py +234 -0
  6. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/protocol.py +68 -0
  7. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/sqlalchemy.py +410 -0
  8. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/tortoise.py +219 -0
  9. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/core.py +265 -15
  10. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/filters.py +51 -0
  11. strawberry_orm-0.8.0/src/strawberry_orm/relay/connection.py +261 -0
  12. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/types.py +14 -0
  13. strawberry_orm-0.7.0/src/strawberry_orm/relay/connection.py +0 -32
  14. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/README.md +0 -0
  15. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/_async.py +0 -0
  16. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/backends/__init__.py +0 -0
  17. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/fields.py +0 -0
  18. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/mutations.py +0 -0
  19. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
  20. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/optimizer/extension.py +0 -0
  21. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/optimizer/store.py +0 -0
  22. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/policy.py +0 -0
  23. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/py.typed +0 -0
  24. {strawberry_orm-0.7.0 → strawberry_orm-0.8.0}/src/strawberry_orm/relay/__init__.py +0 -0
  25. {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.7.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "strawberry-orm"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Unified, backend-agnostic ORM abstraction for Strawberry GraphQL"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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(