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,701 @@
|
|
|
1
|
+
"""Execution mixin for query building.
|
|
2
|
+
|
|
3
|
+
Provides fetch_models(), fetch_all(), fetch_one() and other execution methods.
|
|
4
|
+
JOIN fields (author__id, author__name) are handled via extra="ignore" in
|
|
5
|
+
Model.model_config - no sanitization needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Coroutine, Sequence
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
from pydantic import TypeAdapter
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
from oxyde._msgpack import msgpack
|
|
17
|
+
from oxyde.core import ir
|
|
18
|
+
from oxyde.exceptions import FieldLookupError, MultipleObjectsReturned, NotFoundError
|
|
19
|
+
from oxyde.models.registry import registered_tables
|
|
20
|
+
from oxyde.queries.base import (
|
|
21
|
+
_TYPE_ADAPTER_CACHE,
|
|
22
|
+
_TYPE_ADAPTER_LOCK,
|
|
23
|
+
SupportsExecute,
|
|
24
|
+
_primary_key_meta,
|
|
25
|
+
_resolve_execution_client,
|
|
26
|
+
_resolve_registered_model,
|
|
27
|
+
)
|
|
28
|
+
from oxyde.queries.joins import _JoinDescriptor
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from oxyde.models.base import Model
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _remap_columns(columns: list[str], model_class: type[Model]) -> list[str]:
|
|
38
|
+
"""Remap db_column names to field names using cached reverse_column_map."""
|
|
39
|
+
rmap = model_class._db_meta.reverse_column_map
|
|
40
|
+
if not rmap:
|
|
41
|
+
return columns
|
|
42
|
+
return [rmap.get(c, c) for c in columns]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExecutionMixin(Generic[TModel]):
|
|
46
|
+
"""Mixin providing query execution capabilities."""
|
|
47
|
+
|
|
48
|
+
# These attributes are defined in the base Query class
|
|
49
|
+
model_class: type[TModel]
|
|
50
|
+
_result_mode: str | None
|
|
51
|
+
_values_flat: bool
|
|
52
|
+
_selected_fields: list[str] | None
|
|
53
|
+
_join_specs: list[_JoinDescriptor]
|
|
54
|
+
_prefetch_paths: list[str]
|
|
55
|
+
_limit_value: int | None
|
|
56
|
+
_offset_value: int | None
|
|
57
|
+
_order_by_fields: list[tuple[str, str]]
|
|
58
|
+
_group_by_fields: list[str]
|
|
59
|
+
_exists: bool
|
|
60
|
+
|
|
61
|
+
def _clone(self) -> Self:
|
|
62
|
+
"""Must be implemented by the main Query class."""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def to_ir(self) -> dict[str, Any]:
|
|
66
|
+
"""Must be implemented by the main Query class."""
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
def limit(self, value: int) -> Self:
|
|
70
|
+
"""Must be implemented by PaginationMixin."""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def _build_filter_tree(self) -> ir.FilterNode | None:
|
|
74
|
+
"""Must be implemented by FilteringMixin."""
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def _join_specs_to_ir(self) -> list[dict[str, Any]]:
|
|
78
|
+
"""Must be implemented by JoiningMixin."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
async def fetch_all(self, client: SupportsExecute) -> list[Any]:
|
|
82
|
+
"""Execute query and return all results as dicts."""
|
|
83
|
+
result_bytes = await self.fetch_msgpack(client)
|
|
84
|
+
data = msgpack.unpackb(result_bytes, raw=False)
|
|
85
|
+
|
|
86
|
+
# Columnar format from Rust: [columns, rows]
|
|
87
|
+
columns = _remap_columns(data[0], self.model_class)
|
|
88
|
+
rows = [dict(zip(columns, row)) for row in data[1]]
|
|
89
|
+
|
|
90
|
+
if self._result_mode == "dict":
|
|
91
|
+
return rows
|
|
92
|
+
if self._result_mode == "list":
|
|
93
|
+
if not rows:
|
|
94
|
+
return []
|
|
95
|
+
fields = self._selected_fields or list(rows[0].keys())
|
|
96
|
+
if self._values_flat:
|
|
97
|
+
if len(fields) != 1:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"values_list(flat=True) requires exactly one field"
|
|
100
|
+
)
|
|
101
|
+
column = fields[0]
|
|
102
|
+
return [row.get(column) for row in rows]
|
|
103
|
+
return [tuple(row.get(field) for field in fields) for row in rows]
|
|
104
|
+
return rows
|
|
105
|
+
|
|
106
|
+
async def _fetch_by_mode(self, client: SupportsExecute) -> list[Any]:
|
|
107
|
+
"""Fetch results honoring the values()/values_list() result mode.
|
|
108
|
+
|
|
109
|
+
Single-row terminals (first/last/get) share this so a query in dict or
|
|
110
|
+
list mode yields the same row shape as ``all()`` would.
|
|
111
|
+
"""
|
|
112
|
+
if self._result_mode in {"dict", "list"}:
|
|
113
|
+
return await self.fetch_all(client)
|
|
114
|
+
return await self.fetch_models(client)
|
|
115
|
+
|
|
116
|
+
async def fetch_one(self, client: SupportsExecute) -> dict[str, Any] | None:
|
|
117
|
+
"""Execute query and return first result as dict."""
|
|
118
|
+
query = self.limit(1)
|
|
119
|
+
results = await query.fetch_all(client)
|
|
120
|
+
return results[0] if results else None
|
|
121
|
+
|
|
122
|
+
async def fetch_msgpack(self, client: SupportsExecute) -> bytes:
|
|
123
|
+
"""Execute query and return raw MessagePack bytes."""
|
|
124
|
+
query_ir = self.to_ir()
|
|
125
|
+
return await client.execute(query_ir)
|
|
126
|
+
|
|
127
|
+
async def fetch_rows(self, client: SupportsExecute) -> list[dict[str, Any]]:
|
|
128
|
+
"""Execute query and return rows as list[dict].
|
|
129
|
+
|
|
130
|
+
Uses direct msgpack path (no batching) for maximum speed on simple queries.
|
|
131
|
+
"""
|
|
132
|
+
result_bytes = await self.fetch_msgpack(client)
|
|
133
|
+
data = msgpack.unpackb(result_bytes, raw=False)
|
|
134
|
+
del result_bytes
|
|
135
|
+
if isinstance(data, (list, tuple)) and len(data) == 2:
|
|
136
|
+
first, second = data
|
|
137
|
+
if isinstance(first, list) and all(isinstance(c, str) for c in first):
|
|
138
|
+
columns = _remap_columns(first, self.model_class)
|
|
139
|
+
return [dict(zip(columns, row)) for row in second]
|
|
140
|
+
return data if isinstance(data, list) else []
|
|
141
|
+
|
|
142
|
+
async def fetch_models(self, client: SupportsExecute) -> list[TModel]:
|
|
143
|
+
"""Execute query and return results as model instances.
|
|
144
|
+
|
|
145
|
+
JOIN relations use Rust-side dedup encoding (3-element msgpack array).
|
|
146
|
+
"""
|
|
147
|
+
if self._group_by_fields:
|
|
148
|
+
raise TypeError(
|
|
149
|
+
"group_by() returns partial rows that cannot be hydrated "
|
|
150
|
+
"into model instances. Use .values() or .fetch_all() instead. "
|
|
151
|
+
"Example: .group_by('field').values().all()"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Get or create cached TypeAdapter (thread-safe)
|
|
155
|
+
model_class = self.model_class
|
|
156
|
+
if model_class not in _TYPE_ADAPTER_CACHE:
|
|
157
|
+
with _TYPE_ADAPTER_LOCK:
|
|
158
|
+
if model_class not in _TYPE_ADAPTER_CACHE:
|
|
159
|
+
_TYPE_ADAPTER_CACHE[model_class] = TypeAdapter(list[model_class]) # type: ignore[valid-type]
|
|
160
|
+
|
|
161
|
+
adapter = _TYPE_ADAPTER_CACHE[model_class]
|
|
162
|
+
|
|
163
|
+
result_bytes = await self.fetch_msgpack(client)
|
|
164
|
+
data = msgpack.unpackb(result_bytes, raw=False, strict_map_key=False)
|
|
165
|
+
del result_bytes
|
|
166
|
+
|
|
167
|
+
if (
|
|
168
|
+
isinstance(data, (list, tuple))
|
|
169
|
+
and len(data) == 3
|
|
170
|
+
and self._join_specs
|
|
171
|
+
and isinstance(data[2], dict)
|
|
172
|
+
):
|
|
173
|
+
# Dedup format: [main_columns, main_rows, relations_map]
|
|
174
|
+
main_columns, main_rows, relations_map = data
|
|
175
|
+
columns = _remap_columns(main_columns, model_class)
|
|
176
|
+
rows = [dict(zip(columns, row)) for row in main_rows]
|
|
177
|
+
del main_rows, data
|
|
178
|
+
|
|
179
|
+
models: list[TModel] = adapter.validate_python(rows)
|
|
180
|
+
del rows
|
|
181
|
+
self._hydrate_from_dedup(models, relations_map)
|
|
182
|
+
elif (
|
|
183
|
+
isinstance(data, (list, tuple))
|
|
184
|
+
and len(data) == 2
|
|
185
|
+
and isinstance(data[0], list)
|
|
186
|
+
and (not data[0] or isinstance(data[0][0], str))
|
|
187
|
+
):
|
|
188
|
+
# Columnar format: [columns, rows] (columns may be empty for 0 rows)
|
|
189
|
+
raw_columns, row_values = data
|
|
190
|
+
columns = _remap_columns(raw_columns, model_class)
|
|
191
|
+
rows = [dict(zip(columns, row)) for row in row_values]
|
|
192
|
+
del row_values, data
|
|
193
|
+
|
|
194
|
+
models = adapter.validate_python(rows)
|
|
195
|
+
del rows
|
|
196
|
+
else:
|
|
197
|
+
rows = data if isinstance(data, list) else []
|
|
198
|
+
|
|
199
|
+
models = adapter.validate_python(rows)
|
|
200
|
+
del rows
|
|
201
|
+
|
|
202
|
+
if self._prefetch_paths:
|
|
203
|
+
await self._run_prefetch(models, client)
|
|
204
|
+
return models
|
|
205
|
+
|
|
206
|
+
def all(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
using: str | None = None,
|
|
210
|
+
client: SupportsExecute | None = None,
|
|
211
|
+
) -> Coroutine[Any, Any, list[TModel]]:
|
|
212
|
+
"""Execute query and return results as Pydantic models.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
using: Name of connection from registry
|
|
216
|
+
client: Explicit client for execution
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
list[Model] - Pydantic model instances
|
|
220
|
+
"""
|
|
221
|
+
return self._execute(using=using, client=client)
|
|
222
|
+
|
|
223
|
+
async def first(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
using: str | None = None,
|
|
227
|
+
client: SupportsExecute | None = None,
|
|
228
|
+
) -> TModel | None:
|
|
229
|
+
"""
|
|
230
|
+
Return the first result or None.
|
|
231
|
+
|
|
232
|
+
Applies LIMIT 1 and returns a single row in the query's result mode:
|
|
233
|
+
a model instance by default, a dict after values(), a tuple (or bare
|
|
234
|
+
value with flat=True) after values_list().
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Single result or None if no results
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
user = await User.objects.filter(is_active=True).first()
|
|
241
|
+
"""
|
|
242
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
243
|
+
query = self.limit(1)
|
|
244
|
+
results = await query._fetch_by_mode(exec_client)
|
|
245
|
+
return results[0] if results else None
|
|
246
|
+
|
|
247
|
+
async def last(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
using: str | None = None,
|
|
251
|
+
client: SupportsExecute | None = None,
|
|
252
|
+
) -> TModel | None:
|
|
253
|
+
"""
|
|
254
|
+
Return the last result or None.
|
|
255
|
+
|
|
256
|
+
Reverses the current ordering (or orders by PK desc) and returns one
|
|
257
|
+
result in the query's result mode (model / dict / tuple — see first()).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Single result or None if no results
|
|
261
|
+
|
|
262
|
+
Examples:
|
|
263
|
+
user = await User.objects.order_by("created_at").last()
|
|
264
|
+
"""
|
|
265
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
266
|
+
query = self._clone()
|
|
267
|
+
|
|
268
|
+
# Reverse existing order or default to -pk
|
|
269
|
+
if query._order_by_fields:
|
|
270
|
+
reversed_order = []
|
|
271
|
+
for field, direction in query._order_by_fields:
|
|
272
|
+
new_dir = "DESC" if direction == "ASC" else "ASC"
|
|
273
|
+
reversed_order.append((field, new_dir))
|
|
274
|
+
query._order_by_fields = reversed_order
|
|
275
|
+
else:
|
|
276
|
+
# No ordering specified - use primary key descending
|
|
277
|
+
pk_meta = _primary_key_meta(self.model_class)
|
|
278
|
+
if pk_meta:
|
|
279
|
+
query._order_by_fields = [(pk_meta.name, "DESC")]
|
|
280
|
+
|
|
281
|
+
query = query.limit(1)
|
|
282
|
+
results = await query._fetch_by_mode(exec_client)
|
|
283
|
+
return results[0] if results else None
|
|
284
|
+
|
|
285
|
+
async def exists(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
using: str | None = None,
|
|
289
|
+
client: SupportsExecute | None = None,
|
|
290
|
+
) -> bool:
|
|
291
|
+
"""
|
|
292
|
+
Check if any records match the query.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
bool: True if at least one record exists
|
|
296
|
+
|
|
297
|
+
Examples:
|
|
298
|
+
await User.objects.filter(age__gte=18).exists()
|
|
299
|
+
"""
|
|
300
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
301
|
+
|
|
302
|
+
# Clone query with exists flag goes through to_ir() so col_types
|
|
303
|
+
# and all other IR fields are included automatically.
|
|
304
|
+
clone = self._clone()
|
|
305
|
+
clone._exists = True
|
|
306
|
+
clone._limit_value = 1
|
|
307
|
+
query_ir = clone.to_ir()
|
|
308
|
+
|
|
309
|
+
result_bytes = await exec_client.execute(query_ir)
|
|
310
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
311
|
+
|
|
312
|
+
# Handle columnar format: (columns, rows)
|
|
313
|
+
if isinstance(result, (list, tuple)) and len(result) == 2:
|
|
314
|
+
first, second = result
|
|
315
|
+
if isinstance(first, list) and all(isinstance(c, str) for c in first):
|
|
316
|
+
# Columnar format
|
|
317
|
+
rows = second
|
|
318
|
+
if rows:
|
|
319
|
+
return bool(rows[0][0]) if rows[0] else False
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
# Result is [{exists: true/false}] or [[true/false]]
|
|
323
|
+
if isinstance(result, list) and len(result) > 0:
|
|
324
|
+
row = result[0]
|
|
325
|
+
if isinstance(row, dict):
|
|
326
|
+
# Get first value from dict
|
|
327
|
+
return bool(next(iter(row.values()), False))
|
|
328
|
+
if isinstance(row, (list, tuple)):
|
|
329
|
+
return bool(row[0]) if row else False
|
|
330
|
+
return bool(row)
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
async def get(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
using: str | None = None,
|
|
337
|
+
client: SupportsExecute | None = None,
|
|
338
|
+
) -> TModel:
|
|
339
|
+
"""
|
|
340
|
+
Return exactly one result matching the query.
|
|
341
|
+
|
|
342
|
+
Raises NotFoundError if no match, MultipleObjectsReturned if more than
|
|
343
|
+
one. The result comes back in the query's result mode (model / dict /
|
|
344
|
+
tuple — see first()).
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Single result
|
|
348
|
+
|
|
349
|
+
Examples:
|
|
350
|
+
user = await User.objects.filter(id=1).get()
|
|
351
|
+
"""
|
|
352
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
353
|
+
query = self.limit(2)
|
|
354
|
+
results = await query._fetch_by_mode(exec_client)
|
|
355
|
+
if not results:
|
|
356
|
+
raise NotFoundError(f"{self.model_class.__name__} matching query not found")
|
|
357
|
+
if len(results) > 1:
|
|
358
|
+
raise MultipleObjectsReturned(
|
|
359
|
+
f"Query for {self.model_class.__name__} returned multiple objects"
|
|
360
|
+
)
|
|
361
|
+
result: TModel = results[0]
|
|
362
|
+
return result
|
|
363
|
+
|
|
364
|
+
async def get_or_none(
|
|
365
|
+
self,
|
|
366
|
+
*,
|
|
367
|
+
using: str | None = None,
|
|
368
|
+
client: SupportsExecute | None = None,
|
|
369
|
+
) -> TModel | None:
|
|
370
|
+
"""
|
|
371
|
+
Return one result or None if not found.
|
|
372
|
+
|
|
373
|
+
Raises MultipleObjectsReturned if more than one match. The result
|
|
374
|
+
comes back in the query's result mode (model / dict / tuple — see
|
|
375
|
+
first()).
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Single result or None
|
|
379
|
+
|
|
380
|
+
Examples:
|
|
381
|
+
user = await User.objects.filter(email="test@example.com").get_or_none()
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
return await self.get(using=using, client=client)
|
|
385
|
+
except NotFoundError:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
def _execute(
|
|
389
|
+
self,
|
|
390
|
+
*,
|
|
391
|
+
using: str | None,
|
|
392
|
+
client: SupportsExecute | None,
|
|
393
|
+
) -> Coroutine[Any, Any, Any]:
|
|
394
|
+
"""Internal execution dispatcher.
|
|
395
|
+
|
|
396
|
+
The payload type depends on the result mode (models / dicts / tuples /
|
|
397
|
+
raw msgpack); public terminals narrow it in their own signatures.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
async def runner() -> bytes | list[Any]:
|
|
401
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
402
|
+
if self._result_mode == "msgpack":
|
|
403
|
+
return await self.fetch_msgpack(exec_client)
|
|
404
|
+
if self._result_mode in {"dict", "list"}:
|
|
405
|
+
return await self.fetch_all(exec_client)
|
|
406
|
+
return await self.fetch_models(exec_client)
|
|
407
|
+
|
|
408
|
+
return runner()
|
|
409
|
+
|
|
410
|
+
# --- Join hydration methods ---
|
|
411
|
+
|
|
412
|
+
def _hydrate_from_dedup(
|
|
413
|
+
self,
|
|
414
|
+
models: Sequence[Model],
|
|
415
|
+
relations_map: dict[str, Any],
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Hydrate joined relations from Rust dedup format.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
models: Main model instances (already validated)
|
|
421
|
+
relations_map: {prefix: {columns, data, refs}} from Rust encoder
|
|
422
|
+
"""
|
|
423
|
+
if not models or not self._join_specs:
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Sort specs by depth (shallow first) for nested joins
|
|
427
|
+
ordered_specs = sorted(
|
|
428
|
+
self._join_specs,
|
|
429
|
+
key=lambda spec: spec.path.count("__"),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Build lookup: result_prefix → spec
|
|
433
|
+
spec_by_prefix: dict[str, _JoinDescriptor] = {
|
|
434
|
+
spec.result_prefix: spec for spec in ordered_specs
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for prefix, rel_data in relations_map.items():
|
|
438
|
+
spec = spec_by_prefix.get(prefix)
|
|
439
|
+
if spec is None:
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
columns = rel_data["columns"]
|
|
443
|
+
data = rel_data["data"]
|
|
444
|
+
refs = rel_data["refs"]
|
|
445
|
+
|
|
446
|
+
# Build pk → related instance cache
|
|
447
|
+
instance_cache: dict[Any, Model] = {}
|
|
448
|
+
for pk_val, row_values in data.items():
|
|
449
|
+
related_dict = dict(zip(columns, row_values))
|
|
450
|
+
instance_cache[pk_val] = spec.target_model(**related_dict)
|
|
451
|
+
|
|
452
|
+
# Assign to each model via refs
|
|
453
|
+
for model, ref_pk in zip(models, refs):
|
|
454
|
+
parent = self._resolve_join_parent(model, spec.parent_path)
|
|
455
|
+
if parent is None:
|
|
456
|
+
continue
|
|
457
|
+
if ref_pk is None:
|
|
458
|
+
setattr(parent, spec.attr_name, None)
|
|
459
|
+
else:
|
|
460
|
+
instance = instance_cache.get(ref_pk)
|
|
461
|
+
if instance is not None:
|
|
462
|
+
setattr(parent, spec.attr_name, instance)
|
|
463
|
+
else:
|
|
464
|
+
setattr(parent, spec.attr_name, None)
|
|
465
|
+
|
|
466
|
+
def _resolve_join_parent(
|
|
467
|
+
self,
|
|
468
|
+
model: Model,
|
|
469
|
+
parent_path: str | None,
|
|
470
|
+
) -> Model | None:
|
|
471
|
+
"""Resolve parent model for nested join."""
|
|
472
|
+
if not parent_path:
|
|
473
|
+
return model
|
|
474
|
+
current: Any = model
|
|
475
|
+
for segment in parent_path.split("__"):
|
|
476
|
+
current = getattr(current, segment, None)
|
|
477
|
+
if current is None:
|
|
478
|
+
return None
|
|
479
|
+
parent: Model = current
|
|
480
|
+
return parent
|
|
481
|
+
|
|
482
|
+
# --- Prefetch methods ---
|
|
483
|
+
|
|
484
|
+
async def _run_prefetch(
|
|
485
|
+
self,
|
|
486
|
+
parents: Sequence[Model],
|
|
487
|
+
client: SupportsExecute,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Run prefetch for all specified paths."""
|
|
490
|
+
for path in self._prefetch_paths:
|
|
491
|
+
segments = path.split("__")
|
|
492
|
+
await self._prefetch_path(parents, client, segments, self.model_class)
|
|
493
|
+
|
|
494
|
+
async def _prefetch_path(
|
|
495
|
+
self,
|
|
496
|
+
parents: Sequence[Model],
|
|
497
|
+
client: SupportsExecute,
|
|
498
|
+
segments: list[str],
|
|
499
|
+
current_model: type[Model],
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Prefetch a single relation path."""
|
|
502
|
+
if not parents:
|
|
503
|
+
return
|
|
504
|
+
relation_name = segments[0]
|
|
505
|
+
relation = current_model._db_meta.relations.get(relation_name)
|
|
506
|
+
if relation is None:
|
|
507
|
+
raise FieldLookupError(
|
|
508
|
+
f"{current_model.__name__} has no relation '{relation_name}'"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Handle many_to_many separately
|
|
512
|
+
if relation.kind == "many_to_many":
|
|
513
|
+
await self._prefetch_m2m(
|
|
514
|
+
parents, client, relation, current_model, relation_name
|
|
515
|
+
)
|
|
516
|
+
# Handle nested prefetch for M2M
|
|
517
|
+
if len(segments) > 1:
|
|
518
|
+
target_model = _resolve_registered_model(relation.target)
|
|
519
|
+
all_targets: list[Model] = []
|
|
520
|
+
for parent in parents:
|
|
521
|
+
targets = getattr(parent, relation_name, [])
|
|
522
|
+
all_targets.extend(targets)
|
|
523
|
+
if all_targets:
|
|
524
|
+
await self._prefetch_path(
|
|
525
|
+
all_targets, client, segments[1:], target_model
|
|
526
|
+
)
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
# one_to_many handling
|
|
530
|
+
if relation.kind != "one_to_many":
|
|
531
|
+
raise FieldLookupError(
|
|
532
|
+
f"prefetch('{relation_name}') supports one-to-many and many-to-many only"
|
|
533
|
+
)
|
|
534
|
+
if relation.remote_field is None:
|
|
535
|
+
raise FieldLookupError(
|
|
536
|
+
f"Relation '{relation_name}' is missing a remote_field definition"
|
|
537
|
+
)
|
|
538
|
+
target_model = _resolve_registered_model(relation.target)
|
|
539
|
+
parent_pk = _primary_key_meta(current_model)
|
|
540
|
+
parent_ids = [
|
|
541
|
+
getattr(parent, parent_pk.name)
|
|
542
|
+
for parent in parents
|
|
543
|
+
if getattr(parent, parent_pk.name, None) is not None
|
|
544
|
+
]
|
|
545
|
+
unique_ids: list[Any] = []
|
|
546
|
+
seen: set[Any] = set()
|
|
547
|
+
for value in parent_ids:
|
|
548
|
+
if value not in seen:
|
|
549
|
+
seen.add(value)
|
|
550
|
+
unique_ids.append(value)
|
|
551
|
+
|
|
552
|
+
grouped: dict[Any, list[Model]] = {}
|
|
553
|
+
if unique_ids:
|
|
554
|
+
# Determine FK column: if remote_field is a virtual FK, use its db_column
|
|
555
|
+
fk_meta = target_model._db_meta.field_metadata.get(relation.remote_field)
|
|
556
|
+
if fk_meta and fk_meta.foreign_key:
|
|
557
|
+
fk_column = fk_meta.db_column # e.g., "post_id"
|
|
558
|
+
else:
|
|
559
|
+
fk_column = relation.remote_field # fallback for explicit columns
|
|
560
|
+
|
|
561
|
+
# Use Manager.filter() with __in lookup
|
|
562
|
+
filter_kwargs = {f"{fk_column}__in": unique_ids}
|
|
563
|
+
children: list[Model] = await target_model.objects.filter(
|
|
564
|
+
**filter_kwargs
|
|
565
|
+
).all(client=client)
|
|
566
|
+
for child in children:
|
|
567
|
+
key = getattr(child, fk_column, None)
|
|
568
|
+
if key is None:
|
|
569
|
+
continue
|
|
570
|
+
grouped.setdefault(key, []).append(child)
|
|
571
|
+
|
|
572
|
+
descriptor = getattr(current_model, relation_name, None)
|
|
573
|
+
|
|
574
|
+
for parent in parents:
|
|
575
|
+
parent_id = getattr(parent, parent_pk.name, None)
|
|
576
|
+
values = grouped.get(parent_id, [])
|
|
577
|
+
if descriptor is not None and hasattr(descriptor, "__set__"):
|
|
578
|
+
descriptor.__set__(parent, list(values))
|
|
579
|
+
else:
|
|
580
|
+
parent.__dict__[relation_name] = list(values)
|
|
581
|
+
|
|
582
|
+
if len(segments) > 1:
|
|
583
|
+
nested_children: list[Model] = [
|
|
584
|
+
child for collection in grouped.values() for child in collection
|
|
585
|
+
]
|
|
586
|
+
if nested_children:
|
|
587
|
+
await self._prefetch_path(
|
|
588
|
+
nested_children,
|
|
589
|
+
client,
|
|
590
|
+
segments[1:],
|
|
591
|
+
target_model,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
async def _prefetch_m2m(
|
|
595
|
+
self,
|
|
596
|
+
parents: Sequence[Model],
|
|
597
|
+
client: SupportsExecute,
|
|
598
|
+
relation: Any, # RelationInfo
|
|
599
|
+
source_model: type[Model],
|
|
600
|
+
relation_name: str,
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Prefetch many-to-many relation."""
|
|
603
|
+
if not parents or not relation.through:
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
# Find through model in registry
|
|
607
|
+
through_model: type[Model] | None = None
|
|
608
|
+
tables = registered_tables()
|
|
609
|
+
for key, model in tables.items():
|
|
610
|
+
if (
|
|
611
|
+
key.endswith(f".{relation.through}")
|
|
612
|
+
or model.__name__ == relation.through
|
|
613
|
+
):
|
|
614
|
+
through_model = model
|
|
615
|
+
break
|
|
616
|
+
|
|
617
|
+
if through_model is None:
|
|
618
|
+
raise FieldLookupError(
|
|
619
|
+
f"Through model '{relation.through}' not found in registry"
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
target_model = _resolve_registered_model(relation.target)
|
|
623
|
+
|
|
624
|
+
# Find FK fields in through model
|
|
625
|
+
source_fk_column: str | None = None
|
|
626
|
+
target_fk_column: str | None = None
|
|
627
|
+
source_key = f".{source_model.__name__}"
|
|
628
|
+
target_key = f".{target_model.__name__}"
|
|
629
|
+
|
|
630
|
+
for meta in through_model._db_meta.field_metadata.values():
|
|
631
|
+
if meta.foreign_key:
|
|
632
|
+
if meta.foreign_key.target.endswith(source_key):
|
|
633
|
+
source_fk_column = meta.foreign_key.column_name
|
|
634
|
+
elif meta.foreign_key.target.endswith(target_key):
|
|
635
|
+
target_fk_column = meta.foreign_key.column_name
|
|
636
|
+
|
|
637
|
+
if source_fk_column is None or target_fk_column is None:
|
|
638
|
+
raise FieldLookupError(
|
|
639
|
+
f"Through model '{relation.through}' must have FK fields "
|
|
640
|
+
f"to both '{source_model.__name__}' and '{target_model.__name__}'"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Collect parent IDs
|
|
644
|
+
parent_pk = _primary_key_meta(source_model)
|
|
645
|
+
parent_ids = [
|
|
646
|
+
getattr(parent, parent_pk.name)
|
|
647
|
+
for parent in parents
|
|
648
|
+
if getattr(parent, parent_pk.name, None) is not None
|
|
649
|
+
]
|
|
650
|
+
if not parent_ids:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Deduplicate
|
|
654
|
+
unique_ids: list[Any] = []
|
|
655
|
+
seen: set[Any] = set()
|
|
656
|
+
for value in parent_ids:
|
|
657
|
+
if value not in seen:
|
|
658
|
+
seen.add(value)
|
|
659
|
+
unique_ids.append(value)
|
|
660
|
+
|
|
661
|
+
# Query through table for links
|
|
662
|
+
# Use synthetic FK column names (e.g., "post_id", "tag_id") not relation names
|
|
663
|
+
filter_kwargs = {f"{source_fk_column}__in": unique_ids}
|
|
664
|
+
links = await through_model.objects.filter(**filter_kwargs).all(client=client)
|
|
665
|
+
|
|
666
|
+
# Collect target IDs from links using FK column names
|
|
667
|
+
target_ids: list[Any] = []
|
|
668
|
+
for link in links:
|
|
669
|
+
target_id = getattr(link, target_fk_column, None)
|
|
670
|
+
if target_id is not None and target_id not in target_ids:
|
|
671
|
+
target_ids.append(target_id)
|
|
672
|
+
|
|
673
|
+
# Query target model
|
|
674
|
+
target_pk = _primary_key_meta(target_model)
|
|
675
|
+
targets_by_pk: dict[Any, Model] = {}
|
|
676
|
+
if target_ids:
|
|
677
|
+
filter_kwargs = {f"{target_pk.name}__in": target_ids}
|
|
678
|
+
targets: list[Model] = await target_model.objects.filter(
|
|
679
|
+
**filter_kwargs
|
|
680
|
+
).all(client=client)
|
|
681
|
+
for target in targets:
|
|
682
|
+
pk_val = getattr(target, target_pk.name)
|
|
683
|
+
targets_by_pk[pk_val] = target
|
|
684
|
+
|
|
685
|
+
# Group targets by source ID using FK column names
|
|
686
|
+
grouped: dict[Any, list[Model]] = {}
|
|
687
|
+
for link in links:
|
|
688
|
+
source_id = getattr(link, source_fk_column, None)
|
|
689
|
+
target_id = getattr(link, target_fk_column, None)
|
|
690
|
+
if source_id is not None and target_id in targets_by_pk:
|
|
691
|
+
grouped.setdefault(source_id, []).append(targets_by_pk[target_id])
|
|
692
|
+
|
|
693
|
+
# Assign to parents
|
|
694
|
+
descriptor = getattr(source_model, relation_name, None)
|
|
695
|
+
for parent in parents:
|
|
696
|
+
parent_id = getattr(parent, parent_pk.name, None)
|
|
697
|
+
values = grouped.get(parent_id, [])
|
|
698
|
+
if descriptor is not None and hasattr(descriptor, "__set__"):
|
|
699
|
+
descriptor.__set__(parent, list(values))
|
|
700
|
+
else:
|
|
701
|
+
parent.__dict__[relation_name] = list(values)
|