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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Aggregation mixin for query building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Container
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from oxyde._msgpack import msgpack
|
|
12
|
+
from oxyde.core import ir
|
|
13
|
+
from oxyde.queries.aggregates import Avg, Max, Min, Sum
|
|
14
|
+
from oxyde.queries.base import SupportsExecute, _resolve_execution_client
|
|
15
|
+
from oxyde.queries.q import Q
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from oxyde.models.base import Model
|
|
19
|
+
|
|
20
|
+
# Lookup → SQL operator mapping for annotation fields in HAVING
|
|
21
|
+
_HAVING_LOOKUP_OPS: dict[str, str] = {
|
|
22
|
+
"exact": "=",
|
|
23
|
+
"gt": ">",
|
|
24
|
+
"gte": ">=",
|
|
25
|
+
"lt": "<",
|
|
26
|
+
"lte": "<=",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AggregationMixin(Generic[TModel]):
|
|
34
|
+
"""Mixin providing aggregation capabilities."""
|
|
35
|
+
|
|
36
|
+
# These attributes are defined in the base Query class
|
|
37
|
+
model_class: type[TModel]
|
|
38
|
+
_annotations: dict[str, Any]
|
|
39
|
+
_group_by_fields: list[str]
|
|
40
|
+
_having: ir.FilterNode | None
|
|
41
|
+
_limit_value: int | None
|
|
42
|
+
_offset_value: int | None
|
|
43
|
+
_order_by_fields: list[tuple[str, str]]
|
|
44
|
+
_count: bool
|
|
45
|
+
|
|
46
|
+
def _clone(self) -> Self:
|
|
47
|
+
"""Must be implemented by the main Query class."""
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def to_ir(self) -> dict[str, Any]:
|
|
51
|
+
"""Must be implemented by the main Query class."""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def _build_filter_tree(self) -> ir.FilterNode | None:
|
|
55
|
+
"""Must be implemented by FilteringMixin."""
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
def _join_specs_to_ir(self) -> list[dict[str, Any]]:
|
|
59
|
+
"""Must be implemented by JoiningMixin."""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def annotate(self, **annotations: Any) -> Self:
|
|
63
|
+
"""
|
|
64
|
+
Add computed fields using aggregate functions.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
**annotations: Named aggregate expressions
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
from oxyde.queries.aggregates import Count, Avg
|
|
71
|
+
User.objects.annotate(post_count=Count("posts"), avg_age=Avg("age"))
|
|
72
|
+
"""
|
|
73
|
+
clone = self._clone()
|
|
74
|
+
clone._annotations.update(annotations)
|
|
75
|
+
return clone
|
|
76
|
+
|
|
77
|
+
def group_by(self, *fields: str) -> Self:
|
|
78
|
+
"""
|
|
79
|
+
Add GROUP BY clause.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
*fields: Field names to group by
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
User.objects.group_by("status", "country")
|
|
86
|
+
"""
|
|
87
|
+
clone = self._clone()
|
|
88
|
+
clone._group_by_fields.extend(fields)
|
|
89
|
+
return clone
|
|
90
|
+
|
|
91
|
+
def having(self, *q_exprs: Q | Any, **kwargs: Any) -> Self:
|
|
92
|
+
"""
|
|
93
|
+
Add HAVING clause for filtering grouped results.
|
|
94
|
+
|
|
95
|
+
Supports both annotation aliases and model fields:
|
|
96
|
+
.having(total__gt=100) # 'total' from annotate(total=Sum(...))
|
|
97
|
+
.having(Q(total__gt=100) | Q(total__lt=10))
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
*q_exprs: Q expressions
|
|
101
|
+
**kwargs: Field lookups
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
Post.objects.annotate(total=Sum("views")).group_by("author_id").having(total__gt=100)
|
|
105
|
+
"""
|
|
106
|
+
clone = self._clone()
|
|
107
|
+
conditions_to_add: list[ir.FilterNode] = []
|
|
108
|
+
|
|
109
|
+
for q_expr in q_exprs:
|
|
110
|
+
if isinstance(q_expr, Q):
|
|
111
|
+
node = q_expr.to_filter_node(self.model_class)
|
|
112
|
+
if node:
|
|
113
|
+
conditions_to_add.append(node)
|
|
114
|
+
|
|
115
|
+
if kwargs:
|
|
116
|
+
annotation_kwargs = {}
|
|
117
|
+
model_kwargs = {}
|
|
118
|
+
for key, value in kwargs.items():
|
|
119
|
+
field_name, _ = _split_having_key(key, self._annotations)
|
|
120
|
+
if field_name in self._annotations:
|
|
121
|
+
annotation_kwargs[key] = value
|
|
122
|
+
else:
|
|
123
|
+
model_kwargs[key] = value
|
|
124
|
+
|
|
125
|
+
for key, value in annotation_kwargs.items():
|
|
126
|
+
field_name, lookup = _split_having_key(key, self._annotations)
|
|
127
|
+
operator = _HAVING_LOOKUP_OPS.get(lookup)
|
|
128
|
+
if operator is None:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Unsupported lookup '{lookup}' for annotation "
|
|
131
|
+
f"'{field_name}' in having(). "
|
|
132
|
+
f"Supported: {', '.join(sorted(_HAVING_LOOKUP_OPS))}"
|
|
133
|
+
)
|
|
134
|
+
conditions_to_add.append(
|
|
135
|
+
ir.filter_condition(field_name, operator, value)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if model_kwargs:
|
|
139
|
+
node = Q(**model_kwargs).to_filter_node(self.model_class)
|
|
140
|
+
if node:
|
|
141
|
+
conditions_to_add.append(node)
|
|
142
|
+
|
|
143
|
+
if conditions_to_add:
|
|
144
|
+
if clone._having:
|
|
145
|
+
conditions_to_add.insert(0, clone._having)
|
|
146
|
+
|
|
147
|
+
if len(conditions_to_add) == 1:
|
|
148
|
+
clone._having = conditions_to_add[0]
|
|
149
|
+
else:
|
|
150
|
+
clone._having = ir.filter_and(*conditions_to_add)
|
|
151
|
+
|
|
152
|
+
return clone
|
|
153
|
+
|
|
154
|
+
async def _aggregate(
|
|
155
|
+
self,
|
|
156
|
+
agg_class: type,
|
|
157
|
+
field: str,
|
|
158
|
+
result_key: str,
|
|
159
|
+
*,
|
|
160
|
+
using: str | None = None,
|
|
161
|
+
client: SupportsExecute | None = None,
|
|
162
|
+
) -> Any:
|
|
163
|
+
"""Execute an aggregate query and return the result."""
|
|
164
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
165
|
+
|
|
166
|
+
# Build aggregate query (without limit/offset/order)
|
|
167
|
+
agg_query = self._clone()
|
|
168
|
+
agg_query._limit_value = None
|
|
169
|
+
agg_query._offset_value = None
|
|
170
|
+
agg_query._order_by_fields = []
|
|
171
|
+
agg_query = agg_query.annotate(**{result_key: agg_class(field)})
|
|
172
|
+
|
|
173
|
+
# Execute and extract value
|
|
174
|
+
query_ir = agg_query.to_ir()
|
|
175
|
+
result_bytes = await exec_client.execute(query_ir)
|
|
176
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
177
|
+
|
|
178
|
+
# Handle columnar format: (columns, rows)
|
|
179
|
+
if isinstance(result, (list, tuple)) and len(result) == 2:
|
|
180
|
+
first, second = result
|
|
181
|
+
if isinstance(first, list) and all(isinstance(c, str) for c in first):
|
|
182
|
+
# Columnar format
|
|
183
|
+
columns = first
|
|
184
|
+
rows = second
|
|
185
|
+
if rows:
|
|
186
|
+
row_dict = dict(zip(columns, rows[0]))
|
|
187
|
+
value = row_dict.get(result_key)
|
|
188
|
+
return self._coerce_aggregate(value, field, agg_class)
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
if isinstance(result, list) and len(result) > 0:
|
|
192
|
+
row = result[0]
|
|
193
|
+
if isinstance(row, dict):
|
|
194
|
+
return row.get(result_key)
|
|
195
|
+
else:
|
|
196
|
+
return getattr(row, result_key, None)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _coerce_aggregate(self, value: Any, field: str, agg_class: type) -> Any:
|
|
200
|
+
"""Coerce aggregate result to the field's Python type.
|
|
201
|
+
|
|
202
|
+
PG returns NUMERIC for SUM/AVG even on integer fields.
|
|
203
|
+
Rust encodes NUMERIC as string. Coerce back using field metadata.
|
|
204
|
+
"""
|
|
205
|
+
if value is None or not isinstance(value, str):
|
|
206
|
+
return value
|
|
207
|
+
|
|
208
|
+
meta = self.model_class._db_meta.field_metadata.get(field)
|
|
209
|
+
if meta is None:
|
|
210
|
+
return value
|
|
211
|
+
|
|
212
|
+
python_type = meta.python_type
|
|
213
|
+
|
|
214
|
+
# AVG always returns float/Decimal, even for int fields
|
|
215
|
+
if agg_class is Avg:
|
|
216
|
+
if python_type is Decimal:
|
|
217
|
+
return Decimal(value)
|
|
218
|
+
return float(value)
|
|
219
|
+
|
|
220
|
+
if python_type is int:
|
|
221
|
+
return int(value)
|
|
222
|
+
if python_type is float:
|
|
223
|
+
return float(value)
|
|
224
|
+
if python_type is Decimal:
|
|
225
|
+
return Decimal(value)
|
|
226
|
+
|
|
227
|
+
return value
|
|
228
|
+
|
|
229
|
+
async def count(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
using: str | None = None,
|
|
233
|
+
client: SupportsExecute | None = None,
|
|
234
|
+
) -> int:
|
|
235
|
+
"""
|
|
236
|
+
Count the number of records matching the query.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
int: Number of records
|
|
240
|
+
|
|
241
|
+
Examples:
|
|
242
|
+
count = await User.objects.filter(is_active=True).count()
|
|
243
|
+
"""
|
|
244
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
245
|
+
|
|
246
|
+
# Clone query with count flag — goes through to_ir() so col_types
|
|
247
|
+
# and all other IR fields are included automatically.
|
|
248
|
+
clone = self._clone()
|
|
249
|
+
clone._count = True
|
|
250
|
+
clone._limit_value = None
|
|
251
|
+
clone._offset_value = None
|
|
252
|
+
clone._order_by_fields = []
|
|
253
|
+
query_ir = clone.to_ir()
|
|
254
|
+
|
|
255
|
+
result_bytes = await exec_client.execute(query_ir)
|
|
256
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
257
|
+
|
|
258
|
+
# Handle columnar format: (columns, rows)
|
|
259
|
+
if isinstance(result, (list, tuple)) and len(result) == 2:
|
|
260
|
+
first, second = result
|
|
261
|
+
if isinstance(first, list) and all(isinstance(c, str) for c in first):
|
|
262
|
+
# Columnar format
|
|
263
|
+
columns = first
|
|
264
|
+
rows = second
|
|
265
|
+
if rows:
|
|
266
|
+
row_dict = dict(zip(columns, rows[0]))
|
|
267
|
+
return row_dict.get("_count", 0) or 0
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
# Result is [{"_count": N}]
|
|
271
|
+
if isinstance(result, list) and len(result) > 0:
|
|
272
|
+
row = result[0]
|
|
273
|
+
if isinstance(row, dict):
|
|
274
|
+
return row.get("_count", 0) or 0
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
async def sum(
|
|
278
|
+
self,
|
|
279
|
+
field: str,
|
|
280
|
+
*,
|
|
281
|
+
using: str | None = None,
|
|
282
|
+
client: SupportsExecute | None = None,
|
|
283
|
+
) -> Any:
|
|
284
|
+
"""Calculate sum of field values."""
|
|
285
|
+
return await self._aggregate(Sum, field, "_sum", using=using, client=client)
|
|
286
|
+
|
|
287
|
+
async def avg(
|
|
288
|
+
self,
|
|
289
|
+
field: str,
|
|
290
|
+
*,
|
|
291
|
+
using: str | None = None,
|
|
292
|
+
client: SupportsExecute | None = None,
|
|
293
|
+
) -> Any:
|
|
294
|
+
"""Calculate average of field values."""
|
|
295
|
+
return await self._aggregate(Avg, field, "_avg", using=using, client=client)
|
|
296
|
+
|
|
297
|
+
async def max(
|
|
298
|
+
self,
|
|
299
|
+
field: str,
|
|
300
|
+
*,
|
|
301
|
+
using: str | None = None,
|
|
302
|
+
client: SupportsExecute | None = None,
|
|
303
|
+
) -> Any:
|
|
304
|
+
"""Get maximum field value."""
|
|
305
|
+
return await self._aggregate(Max, field, "_max", using=using, client=client)
|
|
306
|
+
|
|
307
|
+
async def min(
|
|
308
|
+
self,
|
|
309
|
+
field: str,
|
|
310
|
+
*,
|
|
311
|
+
using: str | None = None,
|
|
312
|
+
client: SupportsExecute | None = None,
|
|
313
|
+
) -> Any:
|
|
314
|
+
"""Get minimum field value."""
|
|
315
|
+
return await self._aggregate(Min, field, "_min", using=using, client=client)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _split_having_key(key: str, annotations: Container[str] = ()) -> tuple[str, str]:
|
|
319
|
+
"""Split a having kwarg key into (field_name, lookup).
|
|
320
|
+
|
|
321
|
+
Keys are matched against annotation aliases, not lookup names: an alias
|
|
322
|
+
is that alias with "exact", even when it ends in a lookup name
|
|
323
|
+
("tags__contains"), and an alias followed by one more part is that alias
|
|
324
|
+
with that lookup. Any other key is returned whole for the model path.
|
|
325
|
+
|
|
326
|
+
Examples:
|
|
327
|
+
"total__gt" -> ("total", "gt")
|
|
328
|
+
"total" -> ("total", "exact")
|
|
329
|
+
"""
|
|
330
|
+
if key in annotations:
|
|
331
|
+
return key, "exact"
|
|
332
|
+
head, sep, tail = key.rpartition("__")
|
|
333
|
+
if sep and head in annotations:
|
|
334
|
+
return head, tail
|
|
335
|
+
return key, "exact"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Debug mixin for query building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from oxyde._msgpack import msgpack
|
|
11
|
+
from oxyde.core.wrapper import explain_query, render_sql_debug
|
|
12
|
+
from oxyde.db.pool import _msgpack_encoder
|
|
13
|
+
from oxyde.queries.base import SupportsExecute, _resolve_pool_name
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from oxyde.models.base import Model
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DebugMixin(Generic[TModel]):
|
|
23
|
+
"""Mixin providing debugging and introspection capabilities."""
|
|
24
|
+
|
|
25
|
+
# These attributes are defined in the base Query class
|
|
26
|
+
model_class: type[TModel]
|
|
27
|
+
_union_query: DebugMixin | None
|
|
28
|
+
_union_all: bool
|
|
29
|
+
|
|
30
|
+
def _clone(self) -> Self:
|
|
31
|
+
"""Must be implemented by the main Query class."""
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
def to_ir(self) -> dict[str, Any]:
|
|
35
|
+
"""Must be implemented by the main Query class."""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def sql(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
dialect: str = "postgres",
|
|
42
|
+
with_types: bool = False,
|
|
43
|
+
) -> tuple[str, list[Any]]:
|
|
44
|
+
"""
|
|
45
|
+
Return SQL representation and parameters for debugging.
|
|
46
|
+
|
|
47
|
+
This calls Rust's render_sql_debug() to generate real SQL using the same
|
|
48
|
+
logic that executes queries, ensuring consistency. Works without database
|
|
49
|
+
connection.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
dialect: SQL dialect - "postgres"
|
|
53
|
+
with_types: If True, params are returned as (type_tag, value) tuples
|
|
54
|
+
showing the exact sea_query Value variant used for binding.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
tuple: (sql_string, parameters)
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
sql, params = User.objects.filter(age__gte=18).sql()
|
|
61
|
+
sql, params = User.objects.filter(age__gte=18).sql(with_types=True)
|
|
62
|
+
# params = [("BigInt", 18)]
|
|
63
|
+
"""
|
|
64
|
+
query_ir = self.to_ir()
|
|
65
|
+
ir_bytes = msgpack.packb(query_ir, default=_msgpack_encoder)
|
|
66
|
+
result: tuple[str, list[Any]] = render_sql_debug(ir_bytes, dialect, with_types)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def query(self) -> dict[str, Any]:
|
|
70
|
+
"""
|
|
71
|
+
Return query IR (Intermediate Representation) for inspection.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
dict: Query IR structure that would be sent to Rust
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
query_ir = User.objects.filter(age__gte=18).query()
|
|
78
|
+
print(query_ir)
|
|
79
|
+
"""
|
|
80
|
+
return self.to_ir()
|
|
81
|
+
|
|
82
|
+
def explain(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
using: str | None = None,
|
|
86
|
+
client: SupportsExecute | None = None,
|
|
87
|
+
analyze: bool = False,
|
|
88
|
+
format: str = "text",
|
|
89
|
+
) -> Coroutine[Any, Any, str]:
|
|
90
|
+
"""
|
|
91
|
+
Get query execution plan from the database.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
using: Database alias to use
|
|
95
|
+
client: Optional database client
|
|
96
|
+
analyze: Whether to execute the query and show actual times
|
|
97
|
+
format: Output format - "text" or "json"
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Query plan from database
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
plan = await User.objects.filter(age__gte=18).explain()
|
|
104
|
+
plan = await User.objects.filter(age__gte=18).explain(analyze=True)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
async def runner() -> str:
|
|
108
|
+
# Resolve pool name from using/client
|
|
109
|
+
pool_name = _resolve_pool_name(using, client)
|
|
110
|
+
|
|
111
|
+
query_ir = self.to_ir()
|
|
112
|
+
ir_bytes = msgpack.packb(query_ir, default=_msgpack_encoder)
|
|
113
|
+
|
|
114
|
+
# Call Rust explain function
|
|
115
|
+
plan: str = await explain_query(
|
|
116
|
+
pool_name, ir_bytes, analyze=analyze, format=format
|
|
117
|
+
)
|
|
118
|
+
return plan
|
|
119
|
+
|
|
120
|
+
return runner()
|
|
121
|
+
|
|
122
|
+
def union(self, other_query: DebugMixin) -> Self:
|
|
123
|
+
"""
|
|
124
|
+
Combine with another query using UNION (removes duplicates).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
other_query: Query to union with
|
|
128
|
+
|
|
129
|
+
Examples:
|
|
130
|
+
active = User.objects.filter(status="active")
|
|
131
|
+
premium = User.objects.filter(status="premium")
|
|
132
|
+
combined = active.union(premium)
|
|
133
|
+
"""
|
|
134
|
+
clone = self._clone()
|
|
135
|
+
clone._union_query = other_query
|
|
136
|
+
clone._union_all = False
|
|
137
|
+
return clone
|
|
138
|
+
|
|
139
|
+
def union_all(self, other_query: DebugMixin) -> Self:
|
|
140
|
+
"""
|
|
141
|
+
Combine with another query using UNION ALL (keeps duplicates).
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
other_query: Query to union with
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
q1 = User.objects.filter(age__gte=18)
|
|
148
|
+
q2 = User.objects.filter(status="premium")
|
|
149
|
+
combined = q1.union_all(q2)
|
|
150
|
+
"""
|
|
151
|
+
clone = self._clone()
|
|
152
|
+
clone._union_query = other_query
|
|
153
|
+
clone._union_all = True
|
|
154
|
+
return clone
|