oxyde-postgres 0.1.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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
oxyde/queries/q.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Q-expressions for complex boolean filter logic (AND/OR/NOT).
|
|
2
|
+
|
|
3
|
+
Q objects allow building complex WHERE clauses that cannot be expressed
|
|
4
|
+
with simple keyword arguments. They support boolean operators &, |, ~.
|
|
5
|
+
|
|
6
|
+
Basic Usage:
|
|
7
|
+
Q(age__gte=18) → age >= 18
|
|
8
|
+
Q(name="Alice") → name = 'Alice'
|
|
9
|
+
Q(status__in=["a","b"]) → status IN ('a', 'b')
|
|
10
|
+
|
|
11
|
+
FK Traversal:
|
|
12
|
+
Q(user__age__gte=18) → JOIN user, WHERE user.age >= 18
|
|
13
|
+
Q(author__name="Bob") → JOIN author, WHERE author.name = 'Bob'
|
|
14
|
+
|
|
15
|
+
Combining with AND:
|
|
16
|
+
Q(age__gte=18) & Q(status="active")
|
|
17
|
+
→ age >= 18 AND status = 'active'
|
|
18
|
+
|
|
19
|
+
Combining with OR:
|
|
20
|
+
Q(role="admin") | Q(role="moderator")
|
|
21
|
+
→ role = 'admin' OR role = 'moderator'
|
|
22
|
+
|
|
23
|
+
Negation with NOT:
|
|
24
|
+
~Q(status="banned")
|
|
25
|
+
→ NOT (status = 'banned')
|
|
26
|
+
|
|
27
|
+
Complex Nesting:
|
|
28
|
+
Q(age__gte=18) & (Q(status="active") | Q(premium=True))
|
|
29
|
+
→ age >= 18 AND (status = 'active' OR premium = true)
|
|
30
|
+
|
|
31
|
+
Implementation:
|
|
32
|
+
Q stores either:
|
|
33
|
+
- _kwargs: dict of field lookups (leaf node)
|
|
34
|
+
- _op + _children: boolean operation with child Q objects
|
|
35
|
+
|
|
36
|
+
to_filter_node(model_class, query) recursively converts Q tree to FilterNode
|
|
37
|
+
IR format that Rust understands. If query is provided and FK paths are used,
|
|
38
|
+
JOINs are automatically added to the query.
|
|
39
|
+
|
|
40
|
+
Usage in Queries:
|
|
41
|
+
User.objects.filter(Q(age__gte=18) | Q(premium=True))
|
|
42
|
+
User.objects.filter(Q(status="active"), name__startswith="A") # mixed
|
|
43
|
+
User.objects.exclude(Q(role="bot") | Q(status="banned"))
|
|
44
|
+
Post.objects.filter(user__age__gte=18) # FK traversal with auto-JOIN
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from typing import TYPE_CHECKING, Any
|
|
50
|
+
|
|
51
|
+
from oxyde.core.ir import FilterNode, filter_and, filter_not, filter_or
|
|
52
|
+
from oxyde.models.lookups import _build_lookup_conditions, _resolve_lookup
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from oxyde.models.base import Model
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Q:
|
|
59
|
+
"""
|
|
60
|
+
Q-expression for building complex filter conditions with AND/OR/NOT logic.
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
Q(age__gte=18) & Q(status="active")
|
|
64
|
+
Q(age__gte=18) | Q(premium=True)
|
|
65
|
+
~Q(status="banned")
|
|
66
|
+
Q(age__gte=18) & (Q(status="active") | Q(status="premium"))
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
_node: FilterNode | None
|
|
70
|
+
_kwargs: dict[str, Any]
|
|
71
|
+
_op: str | None
|
|
72
|
+
_children: list[Q]
|
|
73
|
+
|
|
74
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
75
|
+
"""
|
|
76
|
+
Create a Q expression from conditions.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
**kwargs: Field lookups (e.g., age__gte=18, name__contains="John")
|
|
80
|
+
"""
|
|
81
|
+
self._op = None
|
|
82
|
+
self._children = []
|
|
83
|
+
|
|
84
|
+
if args and not kwargs:
|
|
85
|
+
# Q(condition_node) - wrap existing FilterNode
|
|
86
|
+
if len(args) == 1 and isinstance(args[0], dict):
|
|
87
|
+
self._node = args[0]
|
|
88
|
+
self._kwargs = {}
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"Q() with positional args must receive a single FilterNode dict"
|
|
92
|
+
)
|
|
93
|
+
elif not args and kwargs:
|
|
94
|
+
# Q(age=18, status="active") - build from kwargs
|
|
95
|
+
self._node = None
|
|
96
|
+
self._kwargs = kwargs
|
|
97
|
+
elif not args and not kwargs:
|
|
98
|
+
# Q() - empty, will be filtered out
|
|
99
|
+
self._node = None
|
|
100
|
+
self._kwargs = {}
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Q() accepts either positional FilterNode or keyword lookups, not both"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _ensure_node(
|
|
107
|
+
self,
|
|
108
|
+
model_class: type[Model] | None = None,
|
|
109
|
+
query: Any = None,
|
|
110
|
+
) -> FilterNode | None:
|
|
111
|
+
"""Convert kwargs to FilterNode if needed.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
model_class: Model class for field resolution
|
|
115
|
+
query: Optional query object for adding JOINs when FK paths are used
|
|
116
|
+
"""
|
|
117
|
+
if self._node is not None:
|
|
118
|
+
return self._node
|
|
119
|
+
if not self._kwargs:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
if model_class is None:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"Q expression with kwargs requires model_class to resolve fields"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
conditions: list[FilterNode] = []
|
|
128
|
+
|
|
129
|
+
for key, value in self._kwargs.items():
|
|
130
|
+
# e.g. "name__icontains", or "user__age__gte" through FK relations
|
|
131
|
+
resolved, lookup = _resolve_lookup(model_class, key)
|
|
132
|
+
column_meta = resolved.column_meta
|
|
133
|
+
|
|
134
|
+
qualified_column = None
|
|
135
|
+
if resolved.joins:
|
|
136
|
+
join_path = "__".join(name for name, _ in resolved.joins)
|
|
137
|
+
# Add JOINs to query if provided
|
|
138
|
+
if query is not None:
|
|
139
|
+
query._add_join_path(join_path)
|
|
140
|
+
# Qualified column name "alias.column": the alias is the join
|
|
141
|
+
# path with __ replaced by _
|
|
142
|
+
alias = "_".join(name for name, _ in resolved.joins)
|
|
143
|
+
qualified_column = f"{alias}.{column_meta.db_column}"
|
|
144
|
+
|
|
145
|
+
field_conditions = _build_lookup_conditions(
|
|
146
|
+
resolved.final_model,
|
|
147
|
+
resolved.final_field,
|
|
148
|
+
lookup,
|
|
149
|
+
value,
|
|
150
|
+
column_meta,
|
|
151
|
+
)
|
|
152
|
+
if qualified_column is not None:
|
|
153
|
+
for cond in field_conditions:
|
|
154
|
+
cond.column = qualified_column
|
|
155
|
+
conditions.extend([c.to_ir() for c in field_conditions])
|
|
156
|
+
|
|
157
|
+
if not conditions:
|
|
158
|
+
return None
|
|
159
|
+
if len(conditions) == 1:
|
|
160
|
+
return conditions[0]
|
|
161
|
+
return filter_and(*conditions)
|
|
162
|
+
|
|
163
|
+
def __and__(self, other: Q) -> Q:
|
|
164
|
+
"""Combine with AND logic: Q(...) & Q(...)"""
|
|
165
|
+
if not isinstance(other, Q):
|
|
166
|
+
raise TypeError(f"Cannot AND Q with {type(other)}")
|
|
167
|
+
result = Q()
|
|
168
|
+
result._op = "and"
|
|
169
|
+
result._children = [self, other]
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def __or__(self, other: Q) -> Q:
|
|
173
|
+
"""Combine with OR logic: Q(...) | Q(...)"""
|
|
174
|
+
if not isinstance(other, Q):
|
|
175
|
+
raise TypeError(f"Cannot OR Q with {type(other)}")
|
|
176
|
+
result = Q()
|
|
177
|
+
result._op = "or"
|
|
178
|
+
result._children = [self, other]
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
def __invert__(self) -> Q:
|
|
182
|
+
"""Negate with NOT logic: ~Q(...)"""
|
|
183
|
+
result = Q()
|
|
184
|
+
result._op = "not"
|
|
185
|
+
result._children = [self]
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def to_filter_node(
|
|
189
|
+
self,
|
|
190
|
+
model_class: type[Model],
|
|
191
|
+
query: Any = None,
|
|
192
|
+
) -> FilterNode | None:
|
|
193
|
+
"""
|
|
194
|
+
Convert Q expression to FilterNode for IR.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
model_class: Model class for field resolution
|
|
198
|
+
query: Optional query object for adding JOINs when FK paths are used
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
FilterNode dict or None if empty
|
|
202
|
+
"""
|
|
203
|
+
# Check if this is a combined Q with operations
|
|
204
|
+
op = getattr(self, "_op", None)
|
|
205
|
+
children = getattr(self, "_children", None)
|
|
206
|
+
|
|
207
|
+
if op and children:
|
|
208
|
+
# Recursively resolve children
|
|
209
|
+
child_nodes: list[FilterNode] = []
|
|
210
|
+
for child in children:
|
|
211
|
+
node = child.to_filter_node(model_class, query)
|
|
212
|
+
if node is not None:
|
|
213
|
+
child_nodes.append(node)
|
|
214
|
+
|
|
215
|
+
if not child_nodes:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
if op == "and":
|
|
219
|
+
if len(child_nodes) == 1:
|
|
220
|
+
return child_nodes[0]
|
|
221
|
+
return filter_and(*child_nodes)
|
|
222
|
+
elif op == "or":
|
|
223
|
+
if len(child_nodes) < 2:
|
|
224
|
+
# OR requires at least 2 conditions, return single or None
|
|
225
|
+
return child_nodes[0] if child_nodes else None
|
|
226
|
+
return filter_or(*child_nodes)
|
|
227
|
+
elif op == "not":
|
|
228
|
+
if not child_nodes:
|
|
229
|
+
return None
|
|
230
|
+
return filter_not(child_nodes[0])
|
|
231
|
+
|
|
232
|
+
# Leaf Q expression
|
|
233
|
+
return self._ensure_node(model_class, query)
|
|
234
|
+
|
|
235
|
+
def __repr__(self) -> str:
|
|
236
|
+
op = getattr(self, "_op", None)
|
|
237
|
+
if op:
|
|
238
|
+
children = getattr(self, "_children", [])
|
|
239
|
+
if op == "not":
|
|
240
|
+
return f"~{children[0]!r}"
|
|
241
|
+
else:
|
|
242
|
+
sep = f" {op.upper()} "
|
|
243
|
+
return f"({sep.join(repr(c) for c in children)})"
|
|
244
|
+
elif self._node:
|
|
245
|
+
return f"Q({self._node})"
|
|
246
|
+
else:
|
|
247
|
+
return f"Q({', '.join(f'{k}={v!r}' for k, v in self._kwargs.items())})"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
__all__ = ["Q"]
|
oxyde/queries/raw.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Raw SQL execution with automatic connection resolution.
|
|
2
|
+
|
|
3
|
+
This module provides execute_raw() - a function for executing arbitrary SQL
|
|
4
|
+
while respecting the same connection resolution logic as Model.objects.
|
|
5
|
+
|
|
6
|
+
Connection Resolution:
|
|
7
|
+
1. Explicit client parameter (AsyncDatabase or AsyncTransaction)
|
|
8
|
+
2. Active transaction (if inside atomic())
|
|
9
|
+
3. Named connection (using="alias")
|
|
10
|
+
4. Default connection ("default")
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
from oxyde import execute_raw
|
|
14
|
+
|
|
15
|
+
# Simple query (uses default connection)
|
|
16
|
+
users = await execute_raw("SELECT * FROM users WHERE age > $1", [18])
|
|
17
|
+
|
|
18
|
+
# Inside transaction - automatically uses same transaction
|
|
19
|
+
async with transaction.atomic():
|
|
20
|
+
await User.objects.create(name="Alice")
|
|
21
|
+
await execute_raw("INSERT INTO audit_log VALUES ($1, $2)", [1, "created"])
|
|
22
|
+
|
|
23
|
+
# Specific connection
|
|
24
|
+
stats = await execute_raw("SELECT ...", using="analytics")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from oxyde._msgpack import msgpack
|
|
32
|
+
from oxyde.queries.base import _resolve_execution_client
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from oxyde.queries.base import SupportsExecute
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def execute_raw(
|
|
39
|
+
sql: str,
|
|
40
|
+
params: list[Any] | None = None,
|
|
41
|
+
*,
|
|
42
|
+
using: str | None = None,
|
|
43
|
+
client: SupportsExecute | None = None,
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
"""
|
|
46
|
+
Execute raw SQL query.
|
|
47
|
+
|
|
48
|
+
Uses the same connection resolution as Model.objects:
|
|
49
|
+
- Active transaction (if inside atomic())
|
|
50
|
+
- Named connection (using="alias")
|
|
51
|
+
- Default connection
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
sql: SQL with PostgreSQL placeholders ($1, $2, ...)
|
|
55
|
+
params: Query parameters (use these to prevent SQL injection!)
|
|
56
|
+
using: Connection alias (default: "default")
|
|
57
|
+
client: Explicit client (AsyncDatabase or AsyncTransaction)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of dicts for SELECT queries.
|
|
61
|
+
Empty list for INSERT/UPDATE/DELETE without RETURNING.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
RuntimeError: If no connection is available.
|
|
65
|
+
ManagerError: If both 'using' and 'client' are provided.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
# Simple SELECT
|
|
69
|
+
users = await execute_raw("SELECT * FROM users WHERE age > $1", [18])
|
|
70
|
+
|
|
71
|
+
# Inside transaction (automatically uses same tx)
|
|
72
|
+
async with transaction.atomic():
|
|
73
|
+
await User.objects.create(name="Alice")
|
|
74
|
+
await execute_raw(
|
|
75
|
+
"INSERT INTO audit_log (user_id, action) VALUES ($1, $2)",
|
|
76
|
+
[1, "created"]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# PostgreSQL-specific features
|
|
80
|
+
results = await execute_raw(
|
|
81
|
+
"SELECT * FROM products WHERE metadata @> $1::jsonb",
|
|
82
|
+
['{"featured": true}']
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Different connection
|
|
86
|
+
stats = await execute_raw("SELECT ...", using="analytics")
|
|
87
|
+
|
|
88
|
+
Warning:
|
|
89
|
+
Always use parameterized queries! Never interpolate user input:
|
|
90
|
+
|
|
91
|
+
# GOOD
|
|
92
|
+
await execute_raw("SELECT * FROM users WHERE email = $1", [email])
|
|
93
|
+
|
|
94
|
+
# BAD - SQL injection risk!
|
|
95
|
+
await execute_raw(f"SELECT * FROM users WHERE email = '{email}'")
|
|
96
|
+
"""
|
|
97
|
+
execution_client = await _resolve_execution_client(using, client)
|
|
98
|
+
|
|
99
|
+
ir = {
|
|
100
|
+
"proto": 1,
|
|
101
|
+
"op": "raw",
|
|
102
|
+
"table": "",
|
|
103
|
+
"sql": sql,
|
|
104
|
+
"params": params or [],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
result_bytes = await execution_client.execute(ir)
|
|
108
|
+
|
|
109
|
+
# Columnar format from Rust: (columns, rows) -> list[dict]
|
|
110
|
+
columns, rows = msgpack.unpackb(result_bytes, raw=False)
|
|
111
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["execute_raw"]
|
oxyde/queries/select.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""SELECT query builder with Django-style fluent interface.
|
|
2
|
+
|
|
3
|
+
This module provides the Query class - the primary way to build SELECT queries
|
|
4
|
+
in Oxyde. Query objects are immutable: each method returns a new Query instance.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
Query inherits from multiple mixins that provide different capabilities:
|
|
8
|
+
FilteringMixin: filter(), exclude()
|
|
9
|
+
PaginationMixin: limit(), offset(), order_by(), distinct(), values()
|
|
10
|
+
JoiningMixin: join(), prefetch()
|
|
11
|
+
AggregationMixin: annotate(), group_by(), having(), count(), sum()
|
|
12
|
+
ExecutionMixin: fetch_all(), fetch_models(), exists(), all()
|
|
13
|
+
MutationMixin: update(), delete(), increment()
|
|
14
|
+
DebugMixin: sql(), explain(), union()
|
|
15
|
+
|
|
16
|
+
Immutability:
|
|
17
|
+
All methods return new Query instances via _clone(). The original is unchanged.
|
|
18
|
+
|
|
19
|
+
base = User.objects.filter(active=True)
|
|
20
|
+
admins = base.filter(role="admin") # base is unchanged
|
|
21
|
+
users = base.filter(role="user") # base is unchanged
|
|
22
|
+
|
|
23
|
+
State Attributes:
|
|
24
|
+
_filter_tree: FilterNode tree for WHERE clause
|
|
25
|
+
_limit_value: LIMIT value
|
|
26
|
+
_offset_value: OFFSET value
|
|
27
|
+
_order_by_fields: List of (field, direction) tuples
|
|
28
|
+
_selected_fields: Explicit field list or None for all
|
|
29
|
+
_distinct: DISTINCT flag
|
|
30
|
+
_join_specs: JOIN descriptors
|
|
31
|
+
_annotations: Aggregate aliases {alias: Aggregate}
|
|
32
|
+
_group_by_fields: GROUP BY columns
|
|
33
|
+
_lock_type: "update" | "share" | None for row locking
|
|
34
|
+
|
|
35
|
+
Execution:
|
|
36
|
+
to_ir() converts Query state to dict (Intermediate Representation).
|
|
37
|
+
IR is serialized to MessagePack and sent to Rust for SQL generation.
|
|
38
|
+
|
|
39
|
+
The IR includes column_types (ColumnTypeSpec dicts per column)
|
|
40
|
+
which enables type-aware decoding in Rust without expensive type_info() calls.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
query = (
|
|
44
|
+
User.objects
|
|
45
|
+
.filter(status="active")
|
|
46
|
+
.exclude(role="bot")
|
|
47
|
+
.order_by("-created_at")
|
|
48
|
+
.limit(10)
|
|
49
|
+
)
|
|
50
|
+
ir = query.to_ir()
|
|
51
|
+
# {"table": "user", "columns": [...], "col_types": {"id": "int", ...}, ...}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
57
|
+
|
|
58
|
+
from typing_extensions import Self
|
|
59
|
+
|
|
60
|
+
from oxyde.core import ir
|
|
61
|
+
from oxyde.models.utils import _unwrap_optional
|
|
62
|
+
from oxyde.queries.base import _model_key
|
|
63
|
+
from oxyde.queries.joins import _JoinDescriptor
|
|
64
|
+
from oxyde.queries.mixins import (
|
|
65
|
+
AggregationMixin,
|
|
66
|
+
DebugMixin,
|
|
67
|
+
ExecutionMixin,
|
|
68
|
+
FilteringMixin,
|
|
69
|
+
JoiningMixin,
|
|
70
|
+
MutationMixin,
|
|
71
|
+
PaginationMixin,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if TYPE_CHECKING:
|
|
75
|
+
from oxyde.models.base import Model
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Query(
|
|
82
|
+
FilteringMixin[TModel],
|
|
83
|
+
PaginationMixin[TModel],
|
|
84
|
+
JoiningMixin[TModel],
|
|
85
|
+
AggregationMixin[TModel],
|
|
86
|
+
ExecutionMixin[TModel],
|
|
87
|
+
MutationMixin[TModel],
|
|
88
|
+
DebugMixin[TModel],
|
|
89
|
+
Generic[TModel],
|
|
90
|
+
):
|
|
91
|
+
"""
|
|
92
|
+
Query builder for SELECT operations.
|
|
93
|
+
|
|
94
|
+
Combines all query capabilities through mixins:
|
|
95
|
+
- FilteringMixin: filter, exclude
|
|
96
|
+
- PaginationMixin: limit, offset, order_by, distinct, values, values_list
|
|
97
|
+
- JoiningMixin: join, prefetch
|
|
98
|
+
- AggregationMixin: annotate, group_by, having, count, sum, avg, max, min
|
|
99
|
+
- ExecutionMixin: fetch_all, fetch_one, fetch_models, exists, all
|
|
100
|
+
- MutationMixin: update, delete, increment
|
|
101
|
+
- DebugMixin: sql, query, explain, union, union_all
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, model_class: type[TModel]):
|
|
105
|
+
self.model_class = model_class
|
|
106
|
+
# Filtering state
|
|
107
|
+
self._filter_tree: ir.FilterNode | None = None
|
|
108
|
+
# Pagination state
|
|
109
|
+
self._limit_value: int | None = None
|
|
110
|
+
self._offset_value: int | None = None
|
|
111
|
+
self._order_by_fields: list[tuple[str, str]] = []
|
|
112
|
+
self._selected_fields: list[str] | None = None
|
|
113
|
+
self._distinct: bool = False
|
|
114
|
+
self._result_mode: str | None = None
|
|
115
|
+
self._values_flat: bool = False
|
|
116
|
+
# Joining state
|
|
117
|
+
self._join_specs: list[_JoinDescriptor] = []
|
|
118
|
+
self._prefetch_paths: list[str] = []
|
|
119
|
+
# Aggregation state
|
|
120
|
+
self._annotations: dict[str, Any] = {}
|
|
121
|
+
self._group_by_fields: list[str] = []
|
|
122
|
+
self._having: ir.FilterNode | None = None
|
|
123
|
+
# Union state
|
|
124
|
+
self._union_query: Query | None = None
|
|
125
|
+
self._union_all: bool = False
|
|
126
|
+
# Locking state (FOR UPDATE / FOR SHARE)
|
|
127
|
+
self._lock_type: str | None = None
|
|
128
|
+
# Shortcut query flags (count / exists)
|
|
129
|
+
self._count: bool = False
|
|
130
|
+
self._exists: bool = False
|
|
131
|
+
|
|
132
|
+
def _clone(self) -> Self:
|
|
133
|
+
"""Create a copy of this query."""
|
|
134
|
+
clone = self.__class__(self.model_class)
|
|
135
|
+
# Filtering
|
|
136
|
+
clone._filter_tree = self._filter_tree
|
|
137
|
+
# Pagination
|
|
138
|
+
clone._limit_value = self._limit_value
|
|
139
|
+
clone._offset_value = self._offset_value
|
|
140
|
+
clone._order_by_fields = list(self._order_by_fields)
|
|
141
|
+
clone._selected_fields = (
|
|
142
|
+
None if self._selected_fields is None else list(self._selected_fields)
|
|
143
|
+
)
|
|
144
|
+
clone._distinct = self._distinct
|
|
145
|
+
clone._result_mode = self._result_mode
|
|
146
|
+
clone._values_flat = self._values_flat
|
|
147
|
+
# Joining
|
|
148
|
+
clone._join_specs = list(self._join_specs)
|
|
149
|
+
clone._prefetch_paths = list(self._prefetch_paths)
|
|
150
|
+
# Aggregation
|
|
151
|
+
clone._annotations = dict(self._annotations)
|
|
152
|
+
clone._group_by_fields = list(self._group_by_fields)
|
|
153
|
+
clone._having = self._having
|
|
154
|
+
# Union
|
|
155
|
+
clone._union_query = self._union_query
|
|
156
|
+
clone._union_all = self._union_all
|
|
157
|
+
# Locking
|
|
158
|
+
clone._lock_type = self._lock_type
|
|
159
|
+
# Shortcut flags
|
|
160
|
+
clone._count = self._count
|
|
161
|
+
clone._exists = self._exists
|
|
162
|
+
return clone
|
|
163
|
+
|
|
164
|
+
def select(self, *fields: str) -> Self:
|
|
165
|
+
"""Specify fields to select."""
|
|
166
|
+
if not fields:
|
|
167
|
+
raise ValueError("select() requires at least one column name")
|
|
168
|
+
clone = self._clone()
|
|
169
|
+
clone._selected_fields = list(fields)
|
|
170
|
+
return clone
|
|
171
|
+
|
|
172
|
+
def for_update(self) -> Self:
|
|
173
|
+
"""Add FOR UPDATE lock to query.
|
|
174
|
+
|
|
175
|
+
Locks selected rows for update, preventing other transactions
|
|
176
|
+
from modifying them until the current transaction commits.
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Query with FOR UPDATE lock
|
|
181
|
+
|
|
182
|
+
Examples:
|
|
183
|
+
async with atomic():
|
|
184
|
+
user = await User.objects.filter(id=1).for_update().first()
|
|
185
|
+
user.balance -= 100
|
|
186
|
+
await user.save()
|
|
187
|
+
"""
|
|
188
|
+
clone = self._clone()
|
|
189
|
+
clone._lock_type = "update"
|
|
190
|
+
return clone
|
|
191
|
+
|
|
192
|
+
def for_share(self) -> Self:
|
|
193
|
+
"""Add FOR SHARE lock to query.
|
|
194
|
+
|
|
195
|
+
Locks selected rows for reading, preventing other transactions
|
|
196
|
+
from modifying them (but allowing reads) until the current
|
|
197
|
+
transaction commits.
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Query with FOR SHARE lock
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
async with atomic():
|
|
205
|
+
users = await User.objects.filter(status="active").for_share().all()
|
|
206
|
+
# Other transactions can read but not modify these rows
|
|
207
|
+
"""
|
|
208
|
+
clone = self._clone()
|
|
209
|
+
clone._lock_type = "share"
|
|
210
|
+
return clone
|
|
211
|
+
|
|
212
|
+
def _column_types_for_query(self) -> dict[str, dict] | None:
|
|
213
|
+
"""Return base and joined column type specs keyed by SQL/result column names."""
|
|
214
|
+
base_types = self.model_class._db_meta.column_types or {}
|
|
215
|
+
column_types: dict[str, dict] = dict(base_types)
|
|
216
|
+
|
|
217
|
+
for descriptor in self._join_specs:
|
|
218
|
+
target_types = descriptor.target_model._db_meta.column_types or {}
|
|
219
|
+
for field, column in descriptor.columns:
|
|
220
|
+
spec = target_types.get(column)
|
|
221
|
+
if spec is None:
|
|
222
|
+
continue
|
|
223
|
+
# Qualified column reference (filters on the joined table)
|
|
224
|
+
column_types[f"{descriptor.alias}.{column}"] = spec
|
|
225
|
+
# Result column: the SQL aliases joined columns by *field*
|
|
226
|
+
# name, which differs from the column when db_column is set
|
|
227
|
+
column_types[f"{descriptor.result_prefix}__{field}"] = spec
|
|
228
|
+
|
|
229
|
+
return column_types or None
|
|
230
|
+
|
|
231
|
+
def _column_db_types_for_query(self) -> dict[str, str] | None:
|
|
232
|
+
"""Return base and joined db_type overrides keyed by SQL column
|
|
233
|
+
reference ("alias.column" for joined columns, as filters name them)."""
|
|
234
|
+
column_db_types = dict(self.model_class._db_meta.column_db_types or {})
|
|
235
|
+
|
|
236
|
+
for descriptor in self._join_specs:
|
|
237
|
+
target_db_types = descriptor.target_model._db_meta.column_db_types or {}
|
|
238
|
+
for _field, column in descriptor.columns:
|
|
239
|
+
db_type = target_db_types.get(column)
|
|
240
|
+
if db_type is not None:
|
|
241
|
+
column_db_types[f"{descriptor.alias}.{column}"] = db_type
|
|
242
|
+
|
|
243
|
+
return column_db_types or None
|
|
244
|
+
|
|
245
|
+
def to_ir(self) -> dict[str, Any]:
|
|
246
|
+
"""Convert query to IR format for Rust execution."""
|
|
247
|
+
from oxyde.models.base import Model
|
|
248
|
+
|
|
249
|
+
table_name = self.model_class.get_table_name()
|
|
250
|
+
|
|
251
|
+
# Get fields to select (exclude virtual relation fields and FK model fields)
|
|
252
|
+
if self._selected_fields is None:
|
|
253
|
+
fields = []
|
|
254
|
+
for field_name, field_info in self.model_class.model_fields.items():
|
|
255
|
+
# Skip virtual relation fields (db_reverse_fk, db_m2m)
|
|
256
|
+
if getattr(field_info, "db_reverse_fk", None) or getattr(
|
|
257
|
+
field_info, "db_m2m", False
|
|
258
|
+
):
|
|
259
|
+
continue
|
|
260
|
+
# Skip FK model fields (user: User) - we select user_id instead
|
|
261
|
+
annotation = field_info.annotation
|
|
262
|
+
if annotation is not None:
|
|
263
|
+
inner_type, _ = _unwrap_optional(annotation)
|
|
264
|
+
if isinstance(inner_type, type) and issubclass(inner_type, Model):
|
|
265
|
+
continue
|
|
266
|
+
fields.append(field_name)
|
|
267
|
+
else:
|
|
268
|
+
if not self._selected_fields:
|
|
269
|
+
raise ValueError("SELECT query must include at least one column")
|
|
270
|
+
fields = self._selected_fields
|
|
271
|
+
|
|
272
|
+
# Convert field names to db_columns for Rust (Rust operates on columns only)
|
|
273
|
+
db_columns = [self._column_for_field(f) for f in fields]
|
|
274
|
+
order_by = [
|
|
275
|
+
(field if field == "?" else self._column_for_field(field), direction)
|
|
276
|
+
for field, direction in self._order_by_fields
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
# Build filter_tree
|
|
280
|
+
final_filter_tree = self._build_filter_tree()
|
|
281
|
+
|
|
282
|
+
# Convert annotations to aggregates IR format (supports multiple aggregates)
|
|
283
|
+
aggregates_ir = None
|
|
284
|
+
if self._annotations:
|
|
285
|
+
aggregates_ir = []
|
|
286
|
+
for alias, agg_obj in self._annotations.items():
|
|
287
|
+
if not hasattr(agg_obj, "to_ir"):
|
|
288
|
+
raise TypeError(f"annotate() received non-expression: {agg_obj!r}")
|
|
289
|
+
agg_spec = agg_obj.to_ir()
|
|
290
|
+
func_name = agg_spec.get("func", "").lower()
|
|
291
|
+
agg_ir: dict[str, Any] = {
|
|
292
|
+
"op": func_name,
|
|
293
|
+
"field": agg_spec.get("field"),
|
|
294
|
+
"alias": alias,
|
|
295
|
+
}
|
|
296
|
+
if agg_spec.get("distinct"):
|
|
297
|
+
agg_ir["distinct"] = True
|
|
298
|
+
aggregates_ir.append(agg_ir)
|
|
299
|
+
|
|
300
|
+
# Convert group_by fields to column names
|
|
301
|
+
group_by_columns = None
|
|
302
|
+
if self._group_by_fields:
|
|
303
|
+
group_by_columns = [
|
|
304
|
+
self._column_for_field(field) for field in self._group_by_fields
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
# Use cached col_types from model metadata plus joined column aliases.
|
|
308
|
+
column_types = self._column_types_for_query()
|
|
309
|
+
|
|
310
|
+
# Pass pk_column only for JOIN queries (needed for deduplication)
|
|
311
|
+
pk_column = None
|
|
312
|
+
if self._join_specs:
|
|
313
|
+
pk_column = self.model_class._db_meta.pk_column
|
|
314
|
+
|
|
315
|
+
# Build union IR recursively
|
|
316
|
+
union_ir = None
|
|
317
|
+
if self._union_query is not None:
|
|
318
|
+
union_ir = self._union_query.to_ir()
|
|
319
|
+
|
|
320
|
+
return ir.build_select_ir(
|
|
321
|
+
table=table_name,
|
|
322
|
+
columns=db_columns,
|
|
323
|
+
column_types=column_types,
|
|
324
|
+
column_db_types=self._column_db_types_for_query(),
|
|
325
|
+
model=_model_key(self.model_class),
|
|
326
|
+
column_mappings=None,
|
|
327
|
+
filter_tree=final_filter_tree,
|
|
328
|
+
distinct=self._distinct or None,
|
|
329
|
+
limit=self._limit_value,
|
|
330
|
+
offset=self._offset_value,
|
|
331
|
+
order_by=order_by or None,
|
|
332
|
+
joins=self._join_specs_to_ir() or None,
|
|
333
|
+
group_by=group_by_columns,
|
|
334
|
+
having=self._having,
|
|
335
|
+
aggregates=aggregates_ir,
|
|
336
|
+
lock=self._lock_type,
|
|
337
|
+
pk_column=pk_column,
|
|
338
|
+
union_query=union_ir,
|
|
339
|
+
union_all=self._union_all or None,
|
|
340
|
+
count=self._count or None,
|
|
341
|
+
exists=self._exists or None,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
__all__ = ["Query"]
|