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.
- async_orm_kit/__init__.py +151 -0
- async_orm_kit/_logging.py +58 -0
- async_orm_kit/_version.py +1 -0
- async_orm_kit/config.py +31 -0
- async_orm_kit/exceptions.py +353 -0
- async_orm_kit/integrations/__init__.py +15 -0
- async_orm_kit/integrations/alembic.py +56 -0
- async_orm_kit/integrations/fastapi.py +134 -0
- async_orm_kit/integrations/storage.py +213 -0
- async_orm_kit/manager/__init__.py +3 -0
- async_orm_kit/manager/manager.py +568 -0
- async_orm_kit/models/__init__.py +3 -0
- async_orm_kit/models/base.py +568 -0
- async_orm_kit/models/fields/__init__.py +23 -0
- async_orm_kit/models/fields/exceptions.py +10 -0
- async_orm_kit/models/fields/typed/__init__.py +11 -0
- async_orm_kit/models/fields/typed/base.py +155 -0
- async_orm_kit/models/fields/typed/file.py +697 -0
- async_orm_kit/models/fields/typed/string.py +38 -0
- async_orm_kit/models/fields/validators.py +110 -0
- async_orm_kit/models/meta.py +181 -0
- async_orm_kit/models/mixins.py +19 -0
- async_orm_kit/observers/__init__.py +4 -0
- async_orm_kit/observers/mixin.py +26 -0
- async_orm_kit/observers/model_observer.py +20 -0
- async_orm_kit/observers/queryset_observer.py +38 -0
- async_orm_kit/py.typed +0 -0
- async_orm_kit/query/__init__.py +0 -0
- async_orm_kit/query/aggregations/__init__.py +8 -0
- async_orm_kit/query/aggregations/avg.py +71 -0
- async_orm_kit/query/aggregations/base.py +317 -0
- async_orm_kit/query/aggregations/count.py +115 -0
- async_orm_kit/query/aggregations/max.py +76 -0
- async_orm_kit/query/aggregations/min.py +76 -0
- async_orm_kit/query/aggregations/sum.py +73 -0
- async_orm_kit/query/compiler.py +574 -0
- async_orm_kit/query/debugger.py +269 -0
- async_orm_kit/query/expressions/__init__.py +62 -0
- async_orm_kit/query/expressions/analysis.py +56 -0
- async_orm_kit/query/expressions/case.py +321 -0
- async_orm_kit/query/expressions/collections.py +251 -0
- async_orm_kit/query/expressions/f.py +362 -0
- async_orm_kit/query/expressions/functions.py +202 -0
- async_orm_kit/query/expressions/subquery.py +206 -0
- async_orm_kit/query/expressions/value.py +101 -0
- async_orm_kit/query/expressions/window.py +95 -0
- async_orm_kit/query/lookups/__init__.py +121 -0
- async_orm_kit/query/lookups/conditions.py +315 -0
- async_orm_kit/query/lookups/correlated.py +86 -0
- async_orm_kit/query/lookups/json_paths.py +167 -0
- async_orm_kit/query/lookups/parser.py +276 -0
- async_orm_kit/query/lookups/resolver/__init__.py +5 -0
- async_orm_kit/query/lookups/resolver/context.py +150 -0
- async_orm_kit/query/lookups/resolver/resolver.py +572 -0
- async_orm_kit/query/lookups/resolver/types.py +150 -0
- async_orm_kit/query/mixins/__init__.py +0 -0
- async_orm_kit/query/mixins/combinable.py +376 -0
- async_orm_kit/query/mixins/orderable.py +208 -0
- async_orm_kit/query/observability.py +346 -0
- async_orm_kit/query/planning/__init__.py +0 -0
- async_orm_kit/query/planning/directives.py +117 -0
- async_orm_kit/query/planning/factory.py +40 -0
- async_orm_kit/query/planning/passes/__init__.py +15 -0
- async_orm_kit/query/planning/passes/base.py +47 -0
- async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
- async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
- async_orm_kit/query/planning/passes/join_promotion.py +81 -0
- async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
- async_orm_kit/query/planning/passes/redundant_path.py +67 -0
- async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
- async_orm_kit/query/planning/planner.py +160 -0
- async_orm_kit/query/planning/protocols.py +78 -0
- async_orm_kit/query/postgres/__init__.py +0 -0
- async_orm_kit/query/postgres/optimizer.py +189 -0
- async_orm_kit/query/q.py +596 -0
- async_orm_kit/query/relations/__init__.py +0 -0
- async_orm_kit/query/relations/enums.py +16 -0
- async_orm_kit/query/relations/graph.py +908 -0
- async_orm_kit/query/relations/node.py +421 -0
- async_orm_kit/queryset/__init__.py +4 -0
- async_orm_kit/queryset/builder.py +1259 -0
- async_orm_kit/queryset/compound.py +379 -0
- async_orm_kit/queryset/executor.py +718 -0
- async_orm_kit/queryset/queryset.py +1726 -0
- async_orm_kit/queryset/state.py +164 -0
- async_orm_kit/queryset/validators.py +361 -0
- async_orm_kit/queryset/writer.py +755 -0
- async_orm_kit/types.py +23 -0
- async_orm_kit-1.0.0.dist-info/METADATA +318 -0
- async_orm_kit-1.0.0.dist-info/RECORD +92 -0
- async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
- 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()
|