django-nested-values 0.7.0__tar.gz → 1.1.0__tar.gz

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.
@@ -1,20 +1,20 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: django-nested-values
3
- Version: 0.7.0
3
+ Version: 1.1.0
4
4
  Summary: Add .values_nested() to Django querysets for nested prefetch dictionaries
5
5
  Project-URL: Homepage, https://github.com/oliverhaas/django-nested-values
6
6
  Project-URL: Documentation, https://oliverhaas.github.io/django-nested-values/
7
7
  Project-URL: Repository, https://github.com/oliverhaas/django-nested-values.git
8
8
  Author-email: Oliver Haas <ohaas@e1plus.de>
9
- License: MIT
9
+ License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: django,nested,orm,prefetch_related,queryset,values
12
12
  Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Framework :: Django
14
14
  Classifier: Framework :: Django :: 5.2
15
15
  Classifier: Framework :: Django :: 6.0
16
+ Classifier: Framework :: Django :: 6.1
16
17
  Classifier: Intended Audience :: Developers
17
- Classifier: License :: OSI Approved :: MIT License
18
18
  Classifier: Operating System :: OS Independent
19
19
  Classifier: Programming Language :: Python :: 3
20
20
  Classifier: Programming Language :: Python :: 3.13
@@ -31,14 +31,13 @@ Description-Content-Type: text/markdown
31
31
  [![PyPI version](https://img.shields.io/pypi/v/django-nested-values.svg)](https://pypi.org/project/django-nested-values/)
32
32
  [![CI](https://github.com/oliverhaas/django-nested-values/actions/workflows/ci.yml/badge.svg)](https://github.com/oliverhaas/django-nested-values/actions/workflows/ci.yml)
33
33
 
34
- An experimental package that adds `.values_nested()` to Django querysets, returning nested dictionaries with related objects included.
34
+ Adds `.values_nested()` to Django querysets. Rows come back as dictionaries with the `select_related()` and `prefetch_related()` data nested inside, built straight from the database rows without model instances.
35
35
 
36
36
  ## Quick Example
37
37
 
38
38
  ```python
39
- # Uses familiar Django patterns
40
39
  Book.objects.only("title").select_related("publisher").prefetch_related("authors").values_nested()
41
- # [{"id": 1, "title": "...", "publisher": {...}, "authors": [...]}]
40
+ # [{"id": 1, "title": "...", "publisher_id": 1, "publisher": {...}, "authors": [...]}, ...]
42
41
  ```
43
42
 
44
43
  ## Documentation
@@ -47,10 +46,13 @@ See the [full documentation](https://oliverhaas.github.io/django-nested-values/)
47
46
 
48
47
  ## Supported Versions
49
48
 
50
- | | Python 3.13 | Python 3.14 |
51
- |---------|:-----------:|:-----------:|
52
- | Django 5.2 | ✓ | ✓ |
53
- | Django 6.0 | ✓ | ✓ |
49
+ | | Python 3.13 | Python 3.14 |
50
+ |------------|:-----------:|:-----------:|
51
+ | Django 5.2 | ✓ | ✓ |
52
+ | Django 6.0 | ✓ | ✓ |
53
+ | Django 6.1 | ✓ | ✓ |
54
+
55
+ CI also runs the test suite on the free-threaded Python 3.14 build.
54
56
 
55
57
  ## License
56
58
 
@@ -3,14 +3,13 @@
3
3
  [![PyPI version](https://img.shields.io/pypi/v/django-nested-values.svg)](https://pypi.org/project/django-nested-values/)
4
4
  [![CI](https://github.com/oliverhaas/django-nested-values/actions/workflows/ci.yml/badge.svg)](https://github.com/oliverhaas/django-nested-values/actions/workflows/ci.yml)
5
5
 
6
- An experimental package that adds `.values_nested()` to Django querysets, returning nested dictionaries with related objects included.
6
+ Adds `.values_nested()` to Django querysets. Rows come back as dictionaries with the `select_related()` and `prefetch_related()` data nested inside, built straight from the database rows without model instances.
7
7
 
8
8
  ## Quick Example
9
9
 
10
10
  ```python
11
- # Uses familiar Django patterns
12
11
  Book.objects.only("title").select_related("publisher").prefetch_related("authors").values_nested()
13
- # [{"id": 1, "title": "...", "publisher": {...}, "authors": [...]}]
12
+ # [{"id": 1, "title": "...", "publisher_id": 1, "publisher": {...}, "authors": [...]}, ...]
14
13
  ```
15
14
 
16
15
  ## Documentation
@@ -19,10 +18,13 @@ See the [full documentation](https://oliverhaas.github.io/django-nested-values/)
19
18
 
20
19
  ## Supported Versions
21
20
 
22
- | | Python 3.13 | Python 3.14 |
23
- |---------|:-----------:|:-----------:|
24
- | Django 5.2 | ✓ | ✓ |
25
- | Django 6.0 | ✓ | ✓ |
21
+ | | Python 3.13 | Python 3.14 |
22
+ |------------|:-----------:|:-----------:|
23
+ | Django 5.2 | ✓ | ✓ |
24
+ | Django 6.0 | ✓ | ✓ |
25
+ | Django 6.1 | ✓ | ✓ |
26
+
27
+ CI also runs the test suite on the free-threaded Python 3.14 build.
26
28
 
27
29
  ## License
28
30
 
@@ -0,0 +1,6 @@
1
+ """Nested dictionaries from Django querysets through values_nested()."""
2
+
3
+ from django_nested_values.queryset import NestedValuesQuerySet, NestedValuesQuerySetMixin
4
+
5
+ __all__ = ["NestedValuesQuerySet", "NestedValuesQuerySetMixin"]
6
+ __version__ = "1.1.0"
@@ -0,0 +1,425 @@
1
+ """Prefetch related rows onto the dictionaries values_nested() produces, mirroring prefetch_related_objects()."""
2
+
3
+ import copy
4
+ from collections.abc import Hashable, Iterable, Sequence
5
+ from dataclasses import dataclass
6
+ from typing import Any, cast
7
+
8
+ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
9
+ from django.contrib.contenttypes.models import ContentType
10
+ from django.db import connections
11
+ from django.db.models import (
12
+ Field,
13
+ ForeignKey,
14
+ ForeignObject,
15
+ ForeignObjectRel,
16
+ ManyToManyField,
17
+ ManyToManyRel,
18
+ ManyToOneRel,
19
+ Model,
20
+ OneToOneRel,
21
+ Prefetch,
22
+ QuerySet,
23
+ )
24
+ from django.db.models.constants import LOOKUP_SEP
25
+ from django.db.models.fields.related_descriptors import _filter_prefetch_queryset # type: ignore[attr-defined] # ty: ignore[unresolved-import]
26
+ from django.db.models.query import normalize_prefetch_lookups
27
+
28
+ from django_nested_values.rows import NestedValuesIterable, Row, load_columns
29
+
30
+ type Lookup = str | Prefetch[Any]
31
+ type _Prefetch = Prefetch[Any]
32
+ type _Querysets = Sequence[QuerySet[Any, Any]] | None
33
+ type _Relation = ForeignObject[Any, Any] | ManyToManyField[Any, Any] | ManyToOneRel | ManyToManyRel | GenericForeignKey
34
+ type _ForeignObject = ForeignObject[Any, Any]
35
+ type _ManyToManyField = ManyToManyField[Any, Any]
36
+ type _Level = tuple[list[Row], type[Model] | None]
37
+
38
+ _RELATION_TYPES = (ForeignObject, ManyToManyField, ManyToOneRel, ManyToManyRel, GenericForeignKey)
39
+ _NOT_PREFETCHABLE = (
40
+ "'{}' does not resolve to an item that supports prefetching - this is an invalid parameter to prefetch_related()."
41
+ )
42
+
43
+
44
+ @dataclass(kw_only=True, slots=True, frozen=True)
45
+ class _Parents:
46
+ rows: list[Row]
47
+ model: type[Model]
48
+ querysets: _Querysets
49
+ db: str
50
+
51
+
52
+ @dataclass(kw_only=True, slots=True, frozen=True)
53
+ class _Fetched:
54
+ rows: list[Row]
55
+ row_keys: Sequence[Hashable]
56
+ parent_keys: Sequence[Hashable]
57
+ single: bool
58
+ model: type[Model] | None
59
+ additional_lookups: list[Any]
60
+ strip: tuple[str, ...] = ()
61
+
62
+
63
+ @dataclass(kw_only=True, slots=True)
64
+ class _Walker:
65
+ db: str
66
+ pending: list[Any]
67
+ done_queries: dict[str, _Level]
68
+ auto_lookups: set[Any]
69
+ followed: set[Any]
70
+ stripping: list[tuple[list[Row], tuple[str, ...]]]
71
+
72
+ def walk(self, *, lookup: _Prefetch, rows: list[Row], model: type[Model]) -> None:
73
+ obj_list = rows
74
+ obj_model: type[Model] | None = model
75
+ for level, through_attr in enumerate(lookup.prefetch_through.split(LOOKUP_SEP)):
76
+ if not obj_list:
77
+ return
78
+ prefetch_to = lookup.get_current_prefetch_to(level)
79
+ if prefetch_to in self.done_queries:
80
+ obj_list, obj_model = self.done_queries[prefetch_to]
81
+ continue
82
+ if obj_model is None:
83
+ msg = _NOT_PREFETCHABLE.format(lookup.prefetch_through)
84
+ raise ValueError(msg)
85
+ relation = _resolve(model=obj_model, name=through_attr, lookup=lookup)
86
+ to_attr, as_attr = lookup.get_current_to_attr(level)
87
+ if as_attr and hasattr(obj_model, to_attr):
88
+ msg = f"to_attr={to_attr} conflicts with a field on the {obj_model.__name__} model."
89
+ raise ValueError(msg)
90
+ rows_to_fetch = [row for row in obj_list if to_attr not in row]
91
+ if not rows_to_fetch:
92
+ values = [row[to_attr] for row in obj_list]
93
+ obj_list = [
94
+ nested
95
+ for value in values
96
+ for nested in (value if isinstance(value, list) else [value])
97
+ if nested is not None
98
+ ]
99
+ obj_model = relation.related_model
100
+ continue
101
+ parents = _Parents(
102
+ rows=rows_to_fetch,
103
+ model=obj_model,
104
+ querysets=lookup.get_current_querysets(level),
105
+ db=self.db,
106
+ )
107
+ fetched = _fetch(relation=relation, parents=parents)
108
+ cache: dict[Hashable, list[Row]] = {}
109
+ for related_row, key in zip(fetched.rows, fetched.row_keys, strict=True):
110
+ cache.setdefault(key, []).append(related_row)
111
+ for row, key in zip(rows_to_fetch, fetched.parent_keys, strict=True):
112
+ vals = cache.get(key, [])
113
+ row[to_attr] = (vals[0] if vals else None) if fetched.single else vals
114
+ self.record(prefetch_to=prefetch_to, lookup=lookup, relation=relation, fetched=fetched)
115
+ obj_list, obj_model = fetched.rows, fetched.model
116
+
117
+ def record(self, *, prefetch_to: str, lookup: _Prefetch, relation: _Relation, fetched: _Fetched) -> None:
118
+ if not (prefetch_to in self.done_queries and lookup in self.auto_lookups and relation in self.followed):
119
+ self.done_queries[prefetch_to] = (fetched.rows, fetched.model)
120
+ new_lookups = normalize_prefetch_lookups(list(reversed(fetched.additional_lookups)), prefetch_to)
121
+ self.auto_lookups.update(new_lookups)
122
+ self.pending.extend(new_lookups)
123
+ self.followed.add(relation)
124
+ if fetched.strip:
125
+ self.stripping.append((fetched.rows, fetched.strip))
126
+
127
+
128
+ def prefetch_related_dicts(*, rows: list[Row], model: type[Model], lookups: Sequence[Lookup], db: str) -> None:
129
+ """Attach the relations named in ``lookups`` to ``rows``, the way prefetch_related_objects() does for instances."""
130
+ if not rows:
131
+ return
132
+ walker = _Walker(
133
+ db=db,
134
+ pending=normalize_prefetch_lookups(list(reversed(lookups))),
135
+ done_queries={},
136
+ auto_lookups=set(),
137
+ followed=set(),
138
+ stripping=[],
139
+ )
140
+ while walker.pending:
141
+ lookup = walker.pending.pop()
142
+ if lookup.prefetch_to in walker.done_queries:
143
+ if lookup.queryset is not None:
144
+ msg = (
145
+ f"'{lookup.prefetch_to}' lookup was already seen with a different queryset. "
146
+ "You may need to adjust the ordering of your lookups."
147
+ )
148
+ raise ValueError(msg)
149
+ continue
150
+ walker.walk(lookup=lookup, rows=rows, model=model)
151
+ for fetched_rows, attnames in walker.stripping:
152
+ for row in fetched_rows:
153
+ for attname in attnames:
154
+ row.pop(attname, None)
155
+
156
+
157
+ def _resolve(*, model: type[Model], name: str, lookup: _Prefetch) -> _Relation:
158
+ for candidate in model._meta.get_fields():
159
+ accessor = candidate.get_accessor_name() if isinstance(candidate, ForeignObjectRel) else candidate.name
160
+ if accessor == name and isinstance(candidate, _RELATION_TYPES):
161
+ return candidate
162
+ if not hasattr(model, name):
163
+ msg = (
164
+ f"Cannot find '{name}' on {model.__name__} object, "
165
+ f"'{lookup.prefetch_through}' is an invalid parameter to prefetch_related()"
166
+ )
167
+ raise AttributeError(msg)
168
+ msg = _NOT_PREFETCHABLE.format(lookup.prefetch_through)
169
+ raise ValueError(msg)
170
+
171
+
172
+ def _fetch(*, relation: _Relation, parents: _Parents) -> _Fetched:
173
+ if isinstance(relation, GenericForeignKey):
174
+ return _fetch_generic_foreign_key(field=relation, parents=parents)
175
+ if isinstance(relation, GenericRelation):
176
+ return _fetch_generic_relation(field=relation, parents=parents)
177
+ if isinstance(relation, ManyToManyField):
178
+ return _fetch_many_to_many(field=relation, reverse=False, parents=parents)
179
+ if isinstance(relation, ManyToManyRel):
180
+ return _fetch_many_to_many(field=relation.field, reverse=True, parents=parents)
181
+ if isinstance(relation, ManyToOneRel):
182
+ return _fetch_reverse(rel=relation, parents=parents)
183
+ return _fetch_forward(field=relation, parents=parents)
184
+
185
+
186
+ def _prepare(*, querysets: _Querysets, default: QuerySet[Any, Any], db: str) -> tuple[QuerySet[Any, Any], list[Any]]:
187
+ if querysets and len(querysets) != 1:
188
+ msg = "querysets argument of get_prefetch_querysets() should have a length of 1."
189
+ raise ValueError(msg)
190
+ queryset = querysets[0] if querysets else default
191
+ lookups = [copy.copy(lookup) for lookup in queryset._prefetch_related_lookups] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
192
+ queryset = queryset.using(queryset._db or db) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
193
+ queryset._prefetch_related_lookups = () # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
194
+ return queryset, lookups
195
+
196
+
197
+ def _execute(*, queryset: QuerySet[Any, Any], load: Iterable[str] = ()) -> list[Row]:
198
+ return list(NestedValuesIterable(load_columns(queryset=queryset, names=load)))
199
+
200
+
201
+ def _column_values(*, parents: _Parents, attnames: Sequence[str]) -> list[tuple[Any, ...]]:
202
+ """Read ``attnames`` from every parent row, querying the columns that only() or defer() left out."""
203
+ missing = [attname for attname in attnames if attname not in parents.rows[0]]
204
+ if not missing:
205
+ return [tuple(row[attname] for attname in attnames) for row in parents.rows]
206
+ pk_attnames = [pk_field.attname for pk_field in parents.model._meta.pk_fields]
207
+ pks = {tuple(row[attname] for attname in pk_attnames) for row in parents.rows}
208
+ queryset = parents.model._base_manager.using(parents.db).filter(
209
+ pk__in=[pk[0] if len(pk) == 1 else pk for pk in pks],
210
+ )
211
+ loaded: dict[tuple[Any, ...], dict[str, Any]] = {
212
+ tuple(values[: len(pk_attnames)]): dict(zip(missing, values[len(pk_attnames) :], strict=True))
213
+ for values in queryset.values_list(*pk_attnames, *missing)
214
+ }
215
+ result = []
216
+ for row in parents.rows:
217
+ extra = loaded[tuple(row[attname] for attname in pk_attnames)]
218
+ result.append(tuple(row[attname] if attname in row else extra[attname] for attname in attnames))
219
+ return result
220
+
221
+
222
+ def _fetch_forward(*, field: _ForeignObject, parents: _Parents) -> _Fetched:
223
+ related_model = field.remote_field.model
224
+ queryset, lookups = _prepare(
225
+ querysets=parents.querysets,
226
+ default=related_model._base_manager.get_queryset(),
227
+ db=parents.db,
228
+ )
229
+ parent_keys = _column_values(parents=parents, attnames=[f.attname for f in field.local_related_fields])
230
+ wanted = {key for key in parent_keys if None not in key}
231
+ related_rows: list[Row] = []
232
+ if wanted:
233
+ for index, target in enumerate(field.foreign_related_fields):
234
+ queryset = queryset.filter(**{f"{target.name}__in": {key[index] for key in wanted}})
235
+ queryset.query.clear_ordering()
236
+ related_rows = _execute(
237
+ queryset=queryset,
238
+ load={name for f in field.foreign_related_fields for name in (f.name, f.attname)},
239
+ )
240
+ row_keys = [tuple(row[f.attname] for f in field.foreign_related_fields) for row in related_rows]
241
+ return _Fetched(
242
+ rows=related_rows,
243
+ row_keys=row_keys,
244
+ parent_keys=parent_keys,
245
+ single=True,
246
+ model=related_model,
247
+ additional_lookups=lookups,
248
+ )
249
+
250
+
251
+ def _fetch_reverse(*, rel: ManyToOneRel, parents: _Parents) -> _Fetched:
252
+ field = rel.field
253
+ related_model = rel.related_model
254
+ single = isinstance(rel, OneToOneRel)
255
+ manager = related_model._base_manager if single else related_model._default_manager
256
+ queryset, lookups = _prepare(querysets=parents.querysets, default=manager.get_queryset(), db=parents.db)
257
+ parent_keys = _column_values(parents=parents, attnames=[f.attname for f in field.foreign_related_fields])
258
+ wanted = [key[0] for key in set(parent_keys) if None not in key]
259
+ related_rows: list[Row] = []
260
+ if wanted:
261
+ if single:
262
+ queryset = queryset.filter(**{f"{field.name}__in": wanted})
263
+ else:
264
+ queryset = _filter_prefetch_queryset(queryset, field.name, wanted)
265
+ related_rows = _execute(queryset=queryset, load=(field.name, field.attname))
266
+ row_keys = [(row[field.attname],) for row in related_rows]
267
+ return _Fetched(
268
+ rows=related_rows,
269
+ row_keys=row_keys,
270
+ parent_keys=parent_keys,
271
+ single=single,
272
+ model=related_model,
273
+ additional_lookups=lookups,
274
+ strip=() if field in related_model._meta.pk_fields else (field.attname,),
275
+ )
276
+
277
+
278
+ def _fetch_many_to_many(*, field: _ManyToManyField, reverse: bool, parents: _Parents) -> _Fetched:
279
+ through = cast("type[Model]", field.remote_field.through)
280
+ if reverse:
281
+ related_model = field.model
282
+ query_field_name = field.name
283
+ source_field_name = field.m2m_reverse_field_name()
284
+ else:
285
+ related_model = field.remote_field.model
286
+ query_field_name = field.related_query_name()
287
+ source_field_name = field.m2m_field_name()
288
+ queryset, lookups = _prepare(
289
+ querysets=parents.querysets,
290
+ default=related_model._default_manager.get_queryset(),
291
+ db=parents.db,
292
+ )
293
+ fk = cast("ForeignKey[Any, Any]", through._meta.get_field(source_field_name))
294
+ connection = connections[queryset.db]
295
+ parent_values = _column_values(parents=parents, attnames=[f.attname for f in fk.foreign_related_fields])
296
+ parent_keys = [
297
+ tuple(
298
+ f.get_db_prep_value(value, connection) for f, value in zip(fk.foreign_related_fields, values, strict=True)
299
+ )
300
+ for values in parent_values
301
+ ]
302
+ wanted = [values[0] for values in set(parent_values) if None not in values]
303
+ related_rows: list[Row] = []
304
+ row_keys: list[tuple[Any, ...]] = []
305
+ if wanted:
306
+ queryset = _filter_prefetch_queryset(queryset._next_is_sticky(), query_field_name, wanted) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
307
+ qn = connection.ops.quote_name
308
+ join_table = fk.model._meta.db_table
309
+ queryset = queryset.extra( # noqa: S610 # table and column names come from quote_name(), not from input
310
+ select={
311
+ f"_prefetch_related_val_{f.attname}": f"{qn(join_table)}.{qn(cast('str', f.column))}"
312
+ for f in fk.local_related_fields
313
+ },
314
+ )
315
+ related_rows = _execute(queryset=queryset)
316
+ row_keys = [
317
+ tuple(
318
+ f.get_db_prep_value(row.pop(f"_prefetch_related_val_{f.attname}"), connection)
319
+ for f in fk.local_related_fields
320
+ )
321
+ for row in related_rows
322
+ ]
323
+ return _Fetched(
324
+ rows=related_rows,
325
+ row_keys=row_keys,
326
+ parent_keys=parent_keys,
327
+ single=False,
328
+ model=related_model,
329
+ additional_lookups=lookups,
330
+ )
331
+
332
+
333
+ def _fetch_generic_relation(*, field: GenericRelation, parents: _Parents) -> _Fetched:
334
+ related_model = field.remote_field.model
335
+ queryset, lookups = _prepare(
336
+ querysets=parents.querysets,
337
+ default=related_model._default_manager.get_queryset(),
338
+ db=parents.db,
339
+ )
340
+ content_type = ContentType.objects.db_manager(queryset.db).get_for_model(
341
+ parents.model,
342
+ for_concrete_model=field.for_concrete_model,
343
+ )
344
+ pk_field = parents.model._meta.pk
345
+ ct_attname = f"{field.content_type_field_name}_id"
346
+ parent_keys = [
347
+ (values[0], content_type.pk) for values in _column_values(parents=parents, attnames=[pk_field.attname])
348
+ ]
349
+ queryset = queryset.filter(
350
+ **{
351
+ f"{field.content_type_field_name}__pk": content_type.pk,
352
+ f"{field.object_id_field_name}__in": {key[0] for key in parent_keys},
353
+ },
354
+ )
355
+ related_rows = _execute(
356
+ queryset=queryset,
357
+ load=(field.object_id_field_name, field.content_type_field_name, ct_attname),
358
+ )
359
+ row_keys = [(pk_field.to_python(row[field.object_id_field_name]), row[ct_attname]) for row in related_rows]
360
+ return _Fetched(
361
+ rows=related_rows,
362
+ row_keys=row_keys,
363
+ parent_keys=parent_keys,
364
+ single=False,
365
+ model=related_model,
366
+ additional_lookups=lookups,
367
+ strip=(field.object_id_field_name, ct_attname),
368
+ )
369
+
370
+
371
+ def _fetch_generic_foreign_key(*, field: GenericForeignKey, parents: _Parents) -> _Fetched:
372
+ ct_attname = f"{field.ct_field}_id"
373
+ custom: dict[Any, QuerySet[Any, Any]] = {}
374
+ for queryset in parents.querysets or ():
375
+ content_type = ContentType.objects.db_manager(queryset.db).get_for_model(
376
+ queryset.model,
377
+ for_concrete_model=field.for_concrete_model,
378
+ )
379
+ if content_type.pk in custom:
380
+ msg = "Only one queryset is allowed for each content type."
381
+ raise ValueError(msg)
382
+ custom[content_type.pk] = queryset
383
+ parent_values = _column_values(parents=parents, attnames=[ct_attname, field.fk_field])
384
+ fk_values: dict[Any, set[Any]] = {}
385
+ for ct_id, fk_value in parent_values:
386
+ if ct_id is not None and fk_value is not None:
387
+ fk_values.setdefault(ct_id, set()).add(fk_value)
388
+ related_rows: list[Row] = []
389
+ row_keys: list[tuple[Any, Any]] = []
390
+ pk_fields: dict[Any, Field[Any, Any]] = {}
391
+ for ct_id, values in fk_values.items():
392
+ related_model: type[Model]
393
+ if ct_id in custom:
394
+ queryset = custom[ct_id].filter(pk__in=values)
395
+ related_model = queryset.model
396
+ rows = _execute(queryset=queryset)
397
+ prefetch_related_dicts(
398
+ rows=rows,
399
+ model=related_model,
400
+ lookups=queryset._prefetch_related_lookups, # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
401
+ db=queryset.db,
402
+ )
403
+ else:
404
+ model_class = ContentType.objects.db_manager(parents.db).get_for_id(ct_id).model_class()
405
+ if model_class is None:
406
+ continue
407
+ related_model = model_class
408
+ rows = _execute(queryset=related_model._base_manager.using(parents.db).filter(pk__in=values))
409
+ pk_field = related_model._meta.pk
410
+ pk_fields[ct_id] = pk_field
411
+ related_rows.extend(rows)
412
+ row_keys.extend((row[pk_field.attname], ct_id) for row in rows)
413
+ parent_keys = [
414
+ (pk_fields[ct_id].get_prep_value(fk_value), ct_id) if ct_id in pk_fields and fk_value is not None else None
415
+ for ct_id, fk_value in parent_values
416
+ ]
417
+ models = {field.model for field in pk_fields.values()}
418
+ return _Fetched(
419
+ rows=related_rows,
420
+ row_keys=row_keys,
421
+ parent_keys=parent_keys,
422
+ single=True,
423
+ model=next(iter(models)) if len(models) == 1 else None,
424
+ additional_lookups=[],
425
+ )
@@ -0,0 +1,101 @@
1
+ """QuerySet classes that add values_nested()."""
2
+
3
+ from collections.abc import AsyncIterator, Iterator
4
+ from itertools import islice
5
+ from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, cast, override
6
+
7
+ from asgiref.sync import sync_to_async
8
+ from django.db import connections
9
+ from django.db.models import Model, QuerySet
10
+
11
+ from django_nested_values.prefetch import prefetch_related_dicts
12
+ from django_nested_values.rows import NestedValuesIterable, Row
13
+
14
+ _ModelT_co = TypeVar("_ModelT_co", bound=Model, covariant=True)
15
+
16
+ if TYPE_CHECKING:
17
+ from django.db.models import Prefetch
18
+
19
+ class _MixinBase(QuerySet[_ModelT_co, _ModelT_co]):
20
+ """Private QuerySet members the mixin relies on but django-stubs leaves out."""
21
+
22
+ _prefetch_related_lookups: tuple[str | Prefetch[Any], ...]
23
+ _prefetch_done: bool
24
+ _fields: tuple[str, ...] | None
25
+
26
+ def _chain(self) -> Self:
27
+ raise NotImplementedError
28
+
29
+ def _iterator(self, use_chunked_fetch: bool, chunk_size: int | None) -> Iterator[_ModelT_co]: # noqa: FBT001
30
+ raise NotImplementedError
31
+
32
+ def _prefetch_related_objects(self) -> None:
33
+ raise NotImplementedError
34
+
35
+ else:
36
+ _MixinBase = Generic
37
+
38
+
39
+ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
40
+ """Add values_nested() to a QuerySet subclass; list the mixin before QuerySet in the bases."""
41
+
42
+ def values_nested(self) -> QuerySet[_ModelT_co, Row]:
43
+ """Return a queryset whose rows are dicts with select_related() and prefetch_related() data nested inside."""
44
+ if self._fields is not None:
45
+ msg = "Cannot call values_nested() after .values() or .values_list()"
46
+ raise TypeError(msg)
47
+ clone = self._chain()
48
+ clone._iterable_class = NestedValuesIterable
49
+ return cast("QuerySet[_ModelT_co, Row]", clone)
50
+
51
+ @override
52
+ def _prefetch_related_objects(self) -> None:
53
+ if self._iterable_class is not NestedValuesIterable:
54
+ super()._prefetch_related_objects()
55
+ return
56
+ prefetch_related_dicts(
57
+ rows=cast("list[Row]", self._result_cache),
58
+ model=self.model,
59
+ lookups=self._prefetch_related_lookups,
60
+ db=self.db,
61
+ )
62
+ self._prefetch_done = True
63
+
64
+ @override
65
+ def _iterator(self, use_chunked_fetch: bool, chunk_size: int | None) -> Iterator[Any]:
66
+ if self._iterable_class is not NestedValuesIterable or not self._prefetch_related_lookups or chunk_size is None:
67
+ yield from super()._iterator(use_chunked_fetch, chunk_size)
68
+ return
69
+ rows = iter(NestedValuesIterable(self, chunked_fetch=use_chunked_fetch, chunk_size=chunk_size))
70
+ while chunk := list(islice(rows, chunk_size)):
71
+ prefetch_related_dicts(rows=chunk, model=self.model, lookups=self._prefetch_related_lookups, db=self.db)
72
+ yield from chunk
73
+
74
+ @override
75
+ async def aiterator(self, chunk_size: int = 2000) -> AsyncIterator[Any]:
76
+ """Yield rows asynchronously, prefetching each chunk of dicts before handing it out."""
77
+ if self._iterable_class is not NestedValuesIterable or not self._prefetch_related_lookups:
78
+ async for row in super().aiterator(chunk_size=chunk_size):
79
+ yield row
80
+ return
81
+ if chunk_size <= 0:
82
+ msg = "Chunk size must be strictly positive."
83
+ raise ValueError(msg)
84
+ use_chunked_fetch = not connections[self.db].settings_dict.get("DISABLE_SERVER_SIDE_CURSORS")
85
+ prefetch = sync_to_async(prefetch_related_dicts)
86
+ chunk: list[Row] = []
87
+ async for row in NestedValuesIterable(self, chunked_fetch=use_chunked_fetch, chunk_size=chunk_size):
88
+ chunk.append(row)
89
+ if len(chunk) >= chunk_size:
90
+ await prefetch(rows=chunk, model=self.model, lookups=self._prefetch_related_lookups, db=self.db)
91
+ for item in chunk:
92
+ yield item
93
+ chunk.clear()
94
+ if chunk:
95
+ await prefetch(rows=chunk, model=self.model, lookups=self._prefetch_related_lookups, db=self.db)
96
+ for item in chunk:
97
+ yield item
98
+
99
+
100
+ class NestedValuesQuerySet(NestedValuesQuerySetMixin[_ModelT_co], QuerySet[_ModelT_co, _ModelT_co]):
101
+ """QuerySet with values_nested()."""