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.
Files changed (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. 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)