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,164 @@
1
+ import copy
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class QueryState:
8
+ """Immutable snapshot of QuerySet configuration state.
9
+
10
+ Encapsulates all query parameters and transformations applied to a QuerySet.
11
+ Designed for efficient cloning to support QuerySet's immutable chain pattern.
12
+ Each QuerySet operation clones the state, modifies the clone, and returns a
13
+ new QuerySet with the updated state.
14
+
15
+ This separation of state from behavior enables:
16
+ - QuerySet immutability (operations return new instances)
17
+ - Efficient query building without side effects
18
+ - Easy state inspection and debugging
19
+ - Clean separation between query construction and execution
20
+
21
+ Attributes:
22
+ model: The SQLAlchemy model class this query operates on.
23
+ filters: List of filter conditions (WHERE clauses).
24
+ order_by: List of ordering expressions (ORDER BY clauses).
25
+ limit_val: Maximum number of rows to return (LIMIT).
26
+ offset_val: Number of rows to skip (OFFSET).
27
+ is_distinct: Whether to apply DISTINCT to remove duplicates.
28
+ distinct_on: Fields for DISTINCT ON (PostgreSQL only).
29
+ group_by_fields: Fields for GROUP BY aggregation.
30
+ having_conditions: Conditions for HAVING clause (post-aggregation filters).
31
+ annotations: Computed fields added via annotate() (e.g., aggregates, expressions).
32
+ annotation_paths: Relationship paths for each annotation field.
33
+ has_aggregates: Whether query contains aggregate functions (Count, Sum, etc.).
34
+ select_related: Relationships to JOIN and load eagerly (1-to-1, many-to-1).
35
+ prefetch_related: Relationships to load in separate queries (1-to-many, many-to-many).
36
+ values_select: Fields to project as dicts (values() mode).
37
+ values_list_select: Fields to project as tuples (values_list() mode).
38
+ values_list_flat: Whether to flatten single-field values_list() results.
39
+ only_fields: Fields to load immediately (deferred loading mode).
40
+ join_graph: Internal join planning structure for relationship traversal.
41
+
42
+ Example:
43
+ >>> # QueryState is internal - users interact via QuerySet
44
+ >>> from your_app.models import DBUser
45
+ >>> qs = DBUser.objects.filter(active=True).order_by("-created_at")
46
+ >>>
47
+ >>> # Inspect internal state (debugging)
48
+ >>> state = qs._state
49
+ >>> print(state.model) # <class 'User'>
50
+ >>> print(state.filters) # [BinaryExpression(...)]
51
+ >>> print(state.order_by) # [UnaryExpression(...)]
52
+ >>>
53
+ >>> # State is cloned on each operation
54
+ >>> qs2 = qs.limit(10)
55
+ >>> assert qs._state is not qs2._state # Different objects
56
+ >>> assert qs._state.limit_val is None # Original unchanged
57
+ >>> assert qs2._state.limit_val == 10 # Clone modified
58
+ Note:
59
+ Performance: Uses __slots__ for memory efficiency. Cloning is O(n) where
60
+ n is the number of filters/annotations, but typically very fast since
61
+ most lists are small. Deep copying is avoided - lists are shallow copied
62
+ and their contents (SQLAlchemy expressions) are reused.
63
+ """
64
+
65
+ model: type
66
+ filters: list[Any] = field(default_factory=list)
67
+ order_by: list[Any] = field(default_factory=list)
68
+ limit_val: int | None = None
69
+ offset_val: int | None = None
70
+ is_distinct: bool = False
71
+ distinct_on: list[Any] = field(default_factory=list)
72
+ group_by_fields: list[Any] = field(default_factory=list)
73
+ having_conditions: list[Any] = field(default_factory=list)
74
+ annotations: dict[str, Any] = field(default_factory=dict)
75
+ annotation_paths: dict[str, list[str]] = field(default_factory=dict)
76
+ has_aggregates: bool = False
77
+ for_update: dict[str, Any] | None = None
78
+ select_related: list[str] = field(default_factory=list)
79
+ prefetch_related: list[str] = field(default_factory=list)
80
+ values_select: list[tuple[str, Any]] | None = None
81
+ values_list_select: list[tuple[str, Any]] | None = None
82
+ values_list_flat: bool = False
83
+ only_fields: list[Any] | None = None
84
+ deferred_fields: list[str] = field(default_factory=list)
85
+ custom_lookups: dict[str, Any] = field(default_factory=dict)
86
+ ordering_fields: list[str] = field(default_factory=list)
87
+ seek_active: bool = False
88
+ join_graph: Any = field(init=False)
89
+
90
+ def __post_init__(self) -> None:
91
+ """Create the empty join graph required by every compiler execution path."""
92
+ from async_orm_kit.query.relations.graph import JoinGraph
93
+
94
+ self.join_graph = JoinGraph(self.model)
95
+
96
+ def clone(self) -> "QueryState":
97
+ """Create a shallow copy of this state for immutable query chaining.
98
+
99
+ Clones the QueryState to enable QuerySet's immutable pattern where each
100
+ operation returns a new QuerySet without modifying the original. Uses
101
+ shallow copying for efficiency - list contents (SQLAlchemy expressions)
102
+ are reused, not deep copied.
103
+
104
+ Returns:
105
+ New QueryState instance with copied lists and same scalar values.
106
+
107
+ Example:
108
+ >>> state = QueryState(model=User)
109
+ >>> state.filters.append(some_condition)
110
+ >>>
111
+ >>> cloned = state.clone()
112
+ >>> cloned.filters.append(another_condition)
113
+ >>>
114
+ >>> len(state.filters) # 1 - original unchanged
115
+ >>> len(cloned.filters) # 2 - clone modified
116
+ >>>
117
+ >>> # Scalar values are copied
118
+ >>> cloned.limit_val = 10
119
+ >>> assert state.limit_val is None
120
+
121
+ Note:
122
+ Performance: O(n) where n is total items across all lists. Typically
123
+ very fast since most queries have few filters/annotations. Uses
124
+ shallow copy - SQLAlchemy expression objects are reused, not cloned.
125
+ JoinGraph is deep cloned if present to maintain isolation.
126
+ """
127
+ new_state = copy.copy(self)
128
+ new_state.filters = self.filters.copy()
129
+ new_state.order_by = self.order_by.copy()
130
+ new_state.is_distinct = self.is_distinct
131
+ new_state.distinct_on = self.distinct_on.copy()
132
+ new_state.annotations = self.annotations.copy()
133
+ new_state.annotation_paths = {
134
+ name: paths.copy() for name, paths in self.annotation_paths.items()
135
+ }
136
+ new_state.has_aggregates = self.has_aggregates
137
+ new_state.for_update = (
138
+ self.for_update.copy() if self.for_update is not None else None
139
+ )
140
+ new_state.select_related = self.select_related.copy()
141
+ new_state.prefetch_related = self.prefetch_related.copy()
142
+ new_state.group_by_fields = self.group_by_fields.copy()
143
+ new_state.having_conditions = self.having_conditions.copy()
144
+ new_state.only_fields = (
145
+ self.only_fields.copy() if self.only_fields is not None else None
146
+ )
147
+ new_state.values_select = (
148
+ self.values_select.copy() if self.values_select is not None else None
149
+ )
150
+ new_state.values_list_select = (
151
+ self.values_list_select.copy()
152
+ if self.values_list_select is not None
153
+ else None
154
+ )
155
+ new_state.values_list_flat = self.values_list_flat
156
+ new_state.deferred_fields = self.deferred_fields.copy()
157
+ new_state.custom_lookups = self.custom_lookups.copy()
158
+ new_state.ordering_fields = self.ordering_fields.copy()
159
+ new_state.seek_active = self.seek_active
160
+
161
+ # Clone join graph and planner
162
+ new_state.join_graph = self.join_graph.clone()
163
+
164
+ return new_state
@@ -0,0 +1,361 @@
1
+ from async_orm_kit.exceptions import QueryStateError
2
+ from async_orm_kit.queryset.state import QueryState
3
+
4
+
5
+ class QuerySetValidator:
6
+ """Validates QuerySet method calls and state transitions for correctness.
7
+
8
+ Enforces Django-style ORM constraints by validating that QuerySet methods are
9
+ called in compatible combinations. Prevents invalid state transitions like calling
10
+ select_related() after values() (which returns dicts, not model instances), or
11
+ using HAVING without aggregates. Follows Single Responsibility Principle by
12
+ separating validation logic from QuerySet's query building responsibilities.
13
+
14
+ Attributes:
15
+ _state: QueryState instance containing current query configuration to validate.
16
+ Example:
17
+ >>> # QuerySetValidator is internal - users see validation errors via QuerySet
18
+ >>> from your_app.models import DBUser
19
+ >>>
20
+ >>> # Invalid: select_related after values
21
+ >>> try:
22
+ ... DBUser.objects.values("username").select_related("profile")
23
+ ... except QueryStateError as e:
24
+ ... print(e) # "Cannot call select_related() after values()..."
25
+ >>>
26
+ >>> # Invalid: order_by field not in values selection
27
+ >>> try:
28
+ ... User.objects.values("username").order_by("email")
29
+ ... except QueryStateError as e:
30
+ ... print(e) # "Cannot order by 'email' - not in values() selection..."
31
+ >>>
32
+ >>> # Invalid: having without aggregates
33
+ >>> try:
34
+ ... User.objects.having(count__gte=5)
35
+ ... except QueryStateError as e:
36
+ ... print(e) # "having() requires at least one aggregate annotation..."
37
+ Note:
38
+ Performance: All validations are O(1) or O(n) where n is number of fields,
39
+ executed in-memory before database queries. Validation overhead is negligible
40
+ compared to query execution time.
41
+ """
42
+
43
+ __slots__ = ("_state",)
44
+
45
+ def __init__(self, state: QueryState):
46
+ """Initialize validator with QueryState to validate.
47
+
48
+ Args:
49
+ state: QueryState instance containing query configuration to validate.
50
+ """
51
+ self._state = state
52
+
53
+ def validate_requires_model_instances(self, method_name: str) -> None:
54
+ """Validate that QuerySet returns model instances, not dicts or tuples.
55
+
56
+ Methods like select_related(), prefetch_related(), and only() operate on
57
+ model instances by loading related objects or deferring fields. These methods
58
+ are incompatible with values() and values_list() which return dictionaries
59
+ and tuples respectively. This validation prevents nonsensical combinations
60
+ like trying to eager-load relationships on dictionary results.
61
+
62
+ Args:
63
+ method_name: Name of method being called (for error message).
64
+
65
+ Raises:
66
+ QueryStateError: If values() or values_list() was called before this method,
67
+ making the operation invalid.
68
+
69
+ Example:
70
+ >>> # Valid: select_related on model instances
71
+ >>> User.objects.select_related("profile").all()
72
+ >>>
73
+ >>> # Invalid: select_related after values
74
+ >>> User.objects.values("username").select_related("profile")
75
+ >>> # Raises: QueryStateError("Cannot call select_related() after values()...")
76
+ >>>
77
+ >>> # Invalid: only after values_list
78
+ >>> User.objects.values_list("id").only("username")
79
+ >>> # Raises: QueryStateError("Cannot call only() after values_list()...")
80
+ Note:
81
+ Performance: O(1) - simple attribute checks. Called before query building,
82
+ preventing invalid queries from reaching the database.
83
+ """
84
+ if self._state.values_select is not None:
85
+ raise QueryStateError(
86
+ f"Cannot call {method_name}() after values(). "
87
+ f"values() returns dictionaries, not model instances."
88
+ )
89
+ if self._state.values_list_select is not None:
90
+ raise QueryStateError(
91
+ f"Cannot call {method_name}() after values_list(). "
92
+ f"values_list() returns tuples, not model instances."
93
+ )
94
+
95
+ def validate_not_deferred_loading(self, method_name: str) -> None:
96
+ """Validate that deferred loading (only) is not active.
97
+
98
+ The only() method implements deferred loading by selecting specific fields
99
+ and deferring others. Methods like values() and values_list() have their own
100
+ field selection semantics that conflict with only()'s approach. This validation
101
+ prevents combining these incompatible field selection strategies.
102
+
103
+ Args:
104
+ method_name: Name of method being called (for error message).
105
+
106
+ Raises:
107
+ QueryStateError: If only() was called before this method, creating
108
+ conflicting field selection semantics.
109
+
110
+ Example:
111
+ >>> # Valid: values without only
112
+ >>> User.objects.values("username", "email")
113
+ >>>
114
+ >>> # Invalid: values after only
115
+ >>> User.objects.only("username").values("email")
116
+ >>> # Raises: QueryStateError("Cannot call values() after only()...")
117
+ >>>
118
+ >>> # Invalid: values_list after only
119
+ >>> User.objects.only("id", "username").values_list("email")
120
+ >>> # Raises: QueryStateError("Cannot call values_list() after only()...")
121
+
122
+ Note:
123
+ Performance: O(1) - simple boolean check on only_fields set. Prevents
124
+ ambiguous field selection before query compilation.
125
+ """
126
+ if self._state.only_fields or self._state.deferred_fields:
127
+ method = "only" if self._state.only_fields else "defer"
128
+ raise QueryStateError(
129
+ f"Cannot call {method_name}() after {method}(). "
130
+ "These methods have conflicting field selection semantics."
131
+ )
132
+
133
+ def validate_order_by_fields(self, fields: tuple) -> None:
134
+ """Validate that order_by fields exist in current selection mode.
135
+
136
+ When using values() or values_list() to project specific fields, ORDER BY
137
+ can only reference fields that are in the SELECT clause or are annotations.
138
+ This is a SQL requirement - you cannot order by fields that aren't selected.
139
+ When returning model instances (no values/values_list), all model fields are
140
+ available for ordering.
141
+
142
+ Args:
143
+ fields: Tuple of field names to order by. May include "-" prefix for
144
+ descending order (e.g., "-created_at").
145
+
146
+ Raises:
147
+ QueryStateError: If any order_by field is not in the values/values_list
148
+ selection or annotations, making the SQL query invalid.
149
+
150
+ Example:
151
+ >>> # Valid: order by selected field
152
+ >>> User.objects.values("username", "email").order_by("username")
153
+ >>>
154
+ >>> # Valid: order by annotation
155
+ >>> User.objects.values("username").annotate(
156
+ ... post_count=Count("posts")
157
+ ... ).order_by("post_count")
158
+ >>>
159
+ >>> # Invalid: order by non-selected field
160
+ >>> User.objects.values("username").order_by("email")
161
+ >>> # Raises: QueryStateError("Cannot order by 'email' - not in values() selection...")
162
+ >>>
163
+ >>> # Valid: descending order with prefix
164
+ >>> User.objects.values("username", "created_at").order_by("-created_at")
165
+
166
+ Note:
167
+ Performance: O(n*m) where n is number of order_by fields and m is number
168
+ of selected fields. Typically small numbers, negligible overhead. Strips
169
+ "-" prefix for descending order before validation.
170
+ """
171
+ if self._state.values_select is None and self._state.values_list_select is None:
172
+ return # No restriction when returning model instances
173
+
174
+ selected_fields = self._get_selected_fields()
175
+ annotation_names = set(self._state.annotations.keys())
176
+ allowed_fields = selected_fields | annotation_names
177
+
178
+ for field in fields:
179
+ if not isinstance(field, str):
180
+ raise QueryStateError(
181
+ "Expression-based ordering is only supported when returning "
182
+ "model instances; annotate the expression and select its alias "
183
+ "when using values() or values_list()."
184
+ )
185
+ if not field or field == "-" or field.startswith("--"):
186
+ raise QueryStateError(f"Invalid order_by field {field!r}.")
187
+ field_name = field[1:] if field.startswith("-") else field
188
+ if field_name not in allowed_fields:
189
+ mode = (
190
+ "values()"
191
+ if self._state.values_select is not None
192
+ else "values_list()"
193
+ )
194
+ raise QueryStateError(
195
+ f"Cannot order by '{field_name}' - not in {mode} selection. "
196
+ f"Available fields: {sorted(allowed_fields)}"
197
+ )
198
+
199
+ def validate_distinct_fields(self, fields: tuple) -> None:
200
+ """Validate that DISTINCT ON fields exist in current selection mode.
201
+
202
+ PostgreSQL's DISTINCT ON requires that distinct fields appear in the SELECT
203
+ clause. When using values() or values_list() to project specific fields,
204
+ DISTINCT ON can only reference those selected fields. Plain DISTINCT (without
205
+ fields) is always valid. When returning model instances, all fields are available.
206
+
207
+ Args:
208
+ fields: Tuple of field names for DISTINCT ON. Empty tuple means plain
209
+ DISTINCT (no field-specific deduplication).
210
+
211
+ Raises:
212
+ QueryStateError: If any DISTINCT ON field is not in the values/values_list
213
+ selection, making the PostgreSQL query invalid.
214
+
215
+ Example:
216
+ >>> # Valid: plain DISTINCT
217
+ >>> User.objects.values("username").distinct()
218
+ >>>
219
+ >>> # Valid: DISTINCT ON selected field
220
+ >>> User.objects.values("username", "email").distinct("username")
221
+ >>>
222
+ >>> # Invalid: DISTINCT ON non-selected field
223
+ >>> User.objects.values("username").distinct("email")
224
+ >>> # Raises: QueryStateError("distinct() field 'email' must be in values() selection...")
225
+ >>>
226
+ >>> # Valid: DISTINCT ON with model instances
227
+ >>> User.objects.distinct("username") # All fields available
228
+
229
+ Note:
230
+ Performance: O(n*m) where n is number of distinct fields and m is number
231
+ of selected fields. Empty fields tuple (plain DISTINCT) returns immediately.
232
+ PostgreSQL-specific feature - other databases may not support DISTINCT ON.
233
+ """
234
+ if not fields:
235
+ return # DISTINCT without fields is always valid
236
+
237
+ if self._state.values_select is None and self._state.values_list_select is None:
238
+ return # No restriction when returning model instances
239
+
240
+ selected_fields = self._get_selected_fields()
241
+
242
+ for field in fields:
243
+ if field not in selected_fields:
244
+ mode = "values()" if self._state.values_select else "values_list()"
245
+ raise QueryStateError(
246
+ f"distinct() field '{field}' must be in {mode} selection. "
247
+ f"Available: {sorted(selected_fields)}"
248
+ )
249
+
250
+ def validate_values_list_flat(self, fields: tuple, flat: bool) -> None:
251
+ """Validate that flat parameter is used correctly with values_list.
252
+
253
+ The flat=True parameter flattens values_list results from list of tuples to
254
+ list of scalar values. This only makes sense when selecting exactly one field,
255
+ as multiple fields cannot be flattened to scalars. This validation prevents
256
+ ambiguous flattening like trying to flatten (id, username) tuples.
257
+
258
+ Args:
259
+ fields: Tuple of field names being selected.
260
+ flat: Whether to flatten results to scalar values.
261
+
262
+ Raises:
263
+ QueryStateError: If flat=True with multiple fields, creating ambiguous
264
+ flattening behavior.
265
+
266
+ Example:
267
+ >>> # Valid: flat with single field
268
+ >>> ids = User.objects.values_list("id", flat=True)
269
+ >>> # Returns: [1, 2, 3] instead of [(1,), (2,), (3,)]
270
+ >>>
271
+ >>> # Valid: flat=False with multiple fields
272
+ >>> pairs = User.objects.values_list("id", "username", flat=False)
273
+ >>> # Returns: [(1, "alice"), (2, "bob")]
274
+ >>>
275
+ >>> # Invalid: flat with multiple fields
276
+ >>> User.objects.values_list("id", "username", flat=True)
277
+ >>> # Raises: QueryStateError("values_list() with flat=True requires exactly one field...")
278
+
279
+ Note:
280
+ Performance: O(1) - simple length check. Called before query execution
281
+ to prevent invalid result transformation.
282
+ """
283
+ if type(flat) is not bool:
284
+ raise TypeError("flat must be a bool.")
285
+ if flat and len(fields) != 1:
286
+ raise QueryStateError(
287
+ "values_list() with flat=True requires exactly one field, "
288
+ f"got {len(fields)} fields"
289
+ )
290
+
291
+ def validate_having_requires_aggregates(self) -> None:
292
+ """Validate that HAVING clause is used with aggregate functions.
293
+
294
+ SQL's HAVING clause filters grouped results after aggregation, requiring a
295
+ GROUP BY clause (created by aggregates). Using HAVING without aggregates is
296
+ semantically incorrect - use WHERE/filter() instead for row-level filtering.
297
+ This validation enforces Django-style ORM behavior where HAVING only makes
298
+ sense with annotate() containing aggregate functions like Count, Sum, Avg.
299
+
300
+ Raises:
301
+ QueryStateError: If having() is called without any aggregate annotations,
302
+ making the HAVING clause semantically invalid.
303
+
304
+ Example:
305
+ >>> # Valid: having with aggregates
306
+ >>> User.objects.annotate(
307
+ ... post_count=Count("posts")
308
+ ... ).having(post_count__gte=5)
309
+ >>>
310
+ >>> # Valid: multiple aggregates
311
+ >>> User.objects.annotate(
312
+ ... total_views=Sum("posts__views"),
313
+ ... avg_rating=Avg("posts__rating")
314
+ ... ).having(total_views__gte=1000, avg_rating__gte=4.0)
315
+ >>>
316
+ >>> # Invalid: having without aggregates
317
+ >>> User.objects.having(username__startswith="a")
318
+ >>> # Raises: QueryStateError("having() requires at least one aggregate annotation...")
319
+ >>>
320
+ >>> # Invalid: having with non-aggregate annotation
321
+ >>> User.objects.annotate(
322
+ ... full_name=Concat("first_name", Value(" "), "last_name")
323
+ ... ).having(full_name__icontains="smith")
324
+ >>> # Raises: QueryStateError (no aggregates, so no GROUP BY)
325
+
326
+ Note:
327
+ Performance: O(1) - simple boolean check on has_aggregates flag. Prevents
328
+ invalid SQL generation before query compilation. Use filter() instead of
329
+ having() for row-level filtering without aggregates.
330
+ """
331
+ if not self._state.has_aggregates:
332
+ raise QueryStateError(
333
+ "having() requires at least one aggregate annotation. "
334
+ "Call .annotate(total=Sum('amount')) before .having(total__gte=100). "
335
+ f"Current annotations: {list(self._state.annotations.keys()) or 'none'}"
336
+ )
337
+
338
+ def _get_selected_fields(self) -> set[str]:
339
+ """Extract field names from current values/values_list selection.
340
+
341
+ Helper method that unpacks field names from the (field_name, column) tuples
342
+ stored in values_select or values_list_select. Returns empty set if neither
343
+ projection mode is active (returning model instances).
344
+
345
+ Returns:
346
+ Set of field names currently selected, or empty set if no projection.
347
+
348
+ Example:
349
+ >>> # Internal usage in validation methods
350
+ >>> validator = QuerySetValidator(state)
351
+ >>> fields = validator._get_selected_fields()
352
+ >>> # Returns: {"username", "email"} if values("username", "email")
353
+ >>> # Returns: set() if no values/values_list
354
+ """
355
+ if self._state.values_select is not None:
356
+ # Unpack field names from (field_name, column) tuples
357
+ return {field_name for field_name, col in self._state.values_select}
358
+ if self._state.values_list_select is not None:
359
+ # Unpack field names from (field_name, column) tuples
360
+ return {field_name for field_name, col in self._state.values_list_select}
361
+ return set()