django-nested-values 0.6.14__tar.gz → 0.7.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-nested-values
3
- Version: 0.6.14
3
+ Version: 0.7.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/
@@ -4,8 +4,7 @@ from django_nested_values.queryset import (
4
4
  AttrDict,
5
5
  NestedValuesQuerySet,
6
6
  NestedValuesQuerySetMixin,
7
- RelatedList,
8
7
  )
9
8
 
10
- __all__ = ["AttrDict", "NestedValuesQuerySet", "NestedValuesQuerySetMixin", "RelatedList"]
11
- __version__ = "0.5.2"
9
+ __all__ = ["AttrDict", "NestedValuesQuerySet", "NestedValuesQuerySetMixin"]
10
+ __version__ = "0.7.0"
@@ -15,6 +15,40 @@ from django.db.models.query import BaseIterable
15
15
  # TypeVar for the model type, used for generic typing with django-stubs
16
16
  _ModelT_co = TypeVar("_ModelT_co", bound=Model, covariant=True)
17
17
 
18
+ # Type alias for dict-like container classes
19
+ _ContainerType = type[dict[str, Any]]
20
+
21
+
22
+ class AttrDict(dict[str, Any]):
23
+ """Dict subclass with attribute access - minimal overhead.
24
+
25
+ Inherits from dict so isinstance(x, dict) is True and all dict
26
+ operations work unchanged. Just adds __getattr__ for dot access.
27
+ """
28
+
29
+ __slots__ = ()
30
+
31
+ def __getattr__(self, name: str) -> Any:
32
+ """Get item as attribute."""
33
+ try:
34
+ return self[name]
35
+ except KeyError:
36
+ msg = f"'{type(self).__name__}' object has no attribute '{name}'"
37
+ raise AttributeError(msg) from None
38
+
39
+ def __setattr__(self, name: str, value: Any) -> None:
40
+ """Set item as attribute."""
41
+ self[name] = value
42
+
43
+ def __delattr__(self, name: str) -> None:
44
+ """Delete item as attribute."""
45
+ try:
46
+ del self[name]
47
+ except KeyError:
48
+ msg = f"'{type(self).__name__}' object has no attribute '{name}'"
49
+ raise AttributeError(msg) from None
50
+
51
+
18
52
  if TYPE_CHECKING:
19
53
  from collections.abc import Iterator
20
54
 
@@ -27,71 +61,57 @@ else:
27
61
  _MixinBase = Generic
28
62
 
29
63
 
30
- def _build_dict_from_klass_info(
64
+ def _build_from_klass_info(
31
65
  row: tuple[Any, ...],
32
66
  klass_info: dict[str, Any],
33
67
  select: list[tuple[Any, ...]],
68
+ container: _ContainerType = dict,
34
69
  ) -> dict[str, Any]:
35
- """Build a dict for a model from a row using klass_info metadata.
70
+ """Build a dict (or dict subclass like AttrDict) directly from a row.
36
71
 
37
72
  This uses Django's internal compiler metadata to know exactly which
38
73
  columns belong to which model, avoiding manual field path parsing.
39
74
 
40
75
  Args:
41
76
  row: A tuple of values from the database row
42
- klass_info: The klass_info dict from compiler, containing:
43
- - 'model': the model class
44
- - 'select_fields': list of column indices for this model
45
- - 'related_klass_infos': list of klass_info for select_related models
46
- select: The compiler.select list, mapping indices to column expressions
77
+ klass_info: The klass_info dict from compiler
78
+ select: The compiler.select list
79
+ container: The dict-like class to use (dict or AttrDict)
47
80
 
48
81
  Returns:
49
- A dict with field names as keys and values from the row
82
+ A container instance with field names as keys
50
83
 
51
84
  """
52
- result: dict[str, Any] = {}
85
+ # Build directly into the container - no intermediate dict
86
+ result = container()
53
87
 
54
88
  for idx in klass_info["select_fields"]:
55
89
  col_expr = select[idx][0]
56
- field_name = col_expr.target.attname # attname gives 'publisher_id' for FKs
57
- result[field_name] = row[idx]
90
+ result[col_expr.target.attname] = row[idx]
58
91
 
59
92
  for related_ki in klass_info.get("related_klass_infos", []):
60
- relation_name = related_ki["field"].name
61
-
62
- # Check if related object is NULL (first select_field is typically the PK)
63
93
  pk_idx = related_ki["select_fields"][0]
64
94
  if row[pk_idx] is None:
65
95
  continue
66
-
67
- nested_dict = _build_dict_from_klass_info(row, related_ki, select)
68
- result[relation_name] = nested_dict
96
+ result[related_ki["field"].name] = _build_from_klass_info(row, related_ki, select, container)
69
97
 
70
98
  return result
71
99
 
72
100
 
73
- def _execute_queryset_as_dicts(
101
+ def _execute_queryset(
74
102
  queryset: QuerySet[Any, Any],
75
103
  db: str,
104
+ container: _ContainerType = dict,
76
105
  ) -> list[dict[str, Any]]:
77
- """Execute a queryset using the compiler and return results as nested dicts.
78
-
79
- This uses Django's internal compiler to execute the query and builds
80
- nested dictionaries directly from the raw row tuples - NO model instantiation.
81
-
82
- The pipeline is: raw SQL rows → dicts (via klass_info metadata)
83
-
84
- This handles:
85
- - select_related: Automatically builds nested dicts for related objects
86
- - only()/defer(): Compiler respects deferred field loading
87
- - Custom querysets: Works with any queryset (base, filtered, etc.)
106
+ """Execute a queryset and return results as nested dicts (or AttrDict).
88
107
 
89
108
  Args:
90
- queryset: The queryset to execute (may have select_related, only, etc.)
109
+ queryset: The queryset to execute
91
110
  db: Database alias to use
111
+ container: The dict-like class to use (dict or AttrDict)
92
112
 
93
113
  Returns:
94
- List of nested dictionaries, one per row
114
+ List of nested containers, one per row
95
115
 
96
116
  """
97
117
  compiler = queryset.query.get_compiler(using=db)
@@ -106,20 +126,25 @@ def _execute_queryset_as_dicts(
106
126
  if klass_info is None:
107
127
  return []
108
128
 
109
- return [_build_dict_from_klass_info(row, klass_info, select) for row in compiler.results_iter(results)]
129
+ return [_build_from_klass_info(row, klass_info, select, container) for row in compiler.results_iter(results)]
110
130
 
111
131
 
112
- def _execute_prefetch_as_dicts(
132
+ def _execute_prefetch(
113
133
  queryset: QuerySet[Any, Any],
114
134
  db: str,
135
+ container: _ContainerType = dict,
115
136
  ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
116
- """Execute a prefetch queryset and return (dicts, extra_values).
137
+ """Execute a prefetch queryset and return (containers, extra_values).
138
+
139
+ This extracts the extra columns Django adds for prefetch grouping.
117
140
 
118
- This is similar to _execute_queryset_as_dicts but also extracts the extra
119
- columns that Django adds for prefetch grouping (like _prefetch_related_val_*).
141
+ Args:
142
+ queryset: The queryset to execute
143
+ db: Database alias to use
144
+ container: The dict-like class to use (dict or AttrDict)
120
145
 
121
146
  Returns:
122
- Tuple of (list of model dicts, list of extra column dicts for grouping)
147
+ Tuple of (list of model containers, list of extra column dicts for grouping)
123
148
 
124
149
  """
125
150
  compiler = queryset.query.get_compiler(using=db)
@@ -135,152 +160,22 @@ def _execute_prefetch_as_dicts(
135
160
  return [], []
136
161
 
137
162
  # Find extra column indices (columns with _prefetch_related_val_* alias)
138
- # Extra columns from .extra() have format: (RawSQL, (sql, params), alias)
139
163
  extra_indices = [
140
164
  (i, s[2])
141
165
  for i, s in enumerate(select)
142
166
  if len(s) >= 3 and s[2] and s[2].startswith("_prefetch_related_val_") # noqa: PLR2004
143
167
  ]
144
168
 
145
- dicts = []
169
+ containers = []
146
170
  extra_values = []
147
171
 
148
172
  for row in compiler.results_iter(results):
149
- row_dict = _build_dict_from_klass_info(row, klass_info, select)
173
+ row_container = _build_from_klass_info(row, klass_info, select, container)
150
174
  extra_vals = {alias: row[idx] for idx, alias in extra_indices}
151
-
152
- dicts.append(row_dict)
175
+ containers.append(row_container)
153
176
  extra_values.append(extra_vals)
154
177
 
155
- return dicts, extra_values
156
-
157
-
158
- class RelatedList(list[Any]):
159
- """List wrapper that provides .all() method to mimic Django's related manager.
160
-
161
- This allows code like `for book in author.books.all()` to work with nested values,
162
- making the results more drop-in compatible with Django ORM patterns.
163
- """
164
-
165
- __slots__ = ()
166
-
167
- def all(self) -> Self:
168
- """Return self to mimic Django's related manager .all() method."""
169
- return self
170
-
171
-
172
- class AttrDict:
173
- """Dict wrapper that provides attribute access to mimic Django model instances.
174
-
175
- Supports both attribute access (book.title) and dict access (book["title"]).
176
- Nested dicts are wrapped as AttrDict, lists are wrapped as RelatedList.
177
-
178
- Example:
179
- >>> obj = AttrDict({"title": "Django", "publisher": {"name": "Pub"}})
180
- >>> obj.title
181
- 'Django'
182
- >>> obj["title"]
183
- 'Django'
184
- >>> obj.publisher.name
185
- 'Pub'
186
-
187
- """
188
-
189
- __slots__ = ("_data",)
190
-
191
- def __init__(self, data: dict[str, Any]) -> None:
192
- """Initialize with a dict. Nested values are wrapped eagerly."""
193
- # Wrap nested structures eagerly
194
- wrapped: dict[str, Any] = {}
195
- for key, value in data.items():
196
- wrapped[key] = self._wrap_value(value)
197
- object.__setattr__(self, "_data", wrapped)
198
-
199
- @staticmethod
200
- def _wrap_value(value: Any) -> Any:
201
- """Wrap a value: dicts become AttrDict, lists become RelatedList."""
202
- if isinstance(value, dict):
203
- return AttrDict(value)
204
- if isinstance(value, list):
205
- return RelatedList(AttrDict._wrap_value(item) for item in value)
206
- return value
207
-
208
- def __getattr__(self, name: str) -> Any:
209
- """Get attribute from underlying dict."""
210
- try:
211
- return self._data[name]
212
- except KeyError:
213
- msg = f"'{type(self).__name__}' object has no attribute '{name}'"
214
- raise AttributeError(msg) from None
215
-
216
- def __setattr__(self, name: str, value: Any) -> None:
217
- """Set attribute in underlying dict."""
218
- if name == "_data":
219
- object.__setattr__(self, name, value)
220
- else:
221
- self._data[name] = self._wrap_value(value)
222
-
223
- def __getitem__(self, key: str) -> Any:
224
- """Get item from underlying dict."""
225
- return self._data[key]
226
-
227
- def __setitem__(self, key: str, value: Any) -> None:
228
- """Set item in underlying dict."""
229
- self._data[key] = self._wrap_value(value)
230
-
231
- def __contains__(self, key: object) -> bool:
232
- """Check if key exists in underlying dict."""
233
- return key in self._data
234
-
235
- def __iter__(self) -> Iterator[str]:
236
- """Iterate over keys in underlying dict."""
237
- return iter(self._data)
238
-
239
- def __len__(self) -> int:
240
- """Return number of keys in underlying dict."""
241
- return len(self._data)
242
-
243
- def __repr__(self) -> str:
244
- """Return repr of underlying dict."""
245
- return f"AttrDict({self._data!r})"
246
-
247
- def __eq__(self, other: object) -> bool:
248
- """Compare with another AttrDict or dict."""
249
- if isinstance(other, AttrDict):
250
- return bool(self._data == other._data)
251
- if isinstance(other, dict):
252
- return bool(self._data == other)
253
- return NotImplemented
254
-
255
- __hash__: None = None # type: ignore[assignment] # Mutable container, so explicitly unhashable
256
-
257
- def keys(self) -> Any:
258
- """Return keys of underlying dict."""
259
- return self._data.keys()
260
-
261
- def values(self) -> Any:
262
- """Return values of underlying dict."""
263
- return self._data.values()
264
-
265
- def items(self) -> Any:
266
- """Return items of underlying dict."""
267
- return self._data.items()
268
-
269
- def get(self, key: str, default: Any = None) -> Any:
270
- """Get item with default from underlying dict."""
271
- return self._data.get(key, default)
272
-
273
- def to_dict(self) -> dict[str, Any]:
274
- """Convert back to a plain dict (recursively unwraps nested objects)."""
275
- result: dict[str, Any] = {}
276
- for key, value in self._data.items():
277
- if isinstance(value, AttrDict):
278
- result[key] = value.to_dict()
279
- elif isinstance(value, RelatedList):
280
- result[key] = [item.to_dict() if isinstance(item, AttrDict) else item for item in value]
281
- else:
282
- result[key] = value
283
- return result
178
+ return containers, extra_values
284
179
 
285
180
 
286
181
  class NestedValuesIterable(BaseIterable): # type: ignore[type-arg]
@@ -294,12 +189,10 @@ class NestedValuesIterable(BaseIterable): # type: ignore[type-arg]
294
189
  # The queryset is expected to be a NestedValuesQuerySetMixin
295
190
  queryset: NestedValuesQuerySetMixin[Any]
296
191
 
297
- def __iter__(self) -> Iterator[dict[str, Any] | AttrDict]:
298
- """Iterate over the queryset, yielding nested dictionaries or AttrDicts."""
192
+ def __iter__(self) -> Iterator[dict[str, Any]]:
193
+ """Iterate over the queryset, yielding nested dictionaries (or AttrDict)."""
299
194
  queryset = self.queryset
300
195
  db = queryset.db
301
- # Use _nested_prefetch_lookups which is set by values_nested() - we clear
302
- # _prefetch_related_lookups to prevent Django from double-prefetching
303
196
  prefetch_lookups = getattr(queryset, "_nested_prefetch_lookups", ())
304
197
 
305
198
  main_qs = queryset._build_main_queryset()
@@ -318,34 +211,40 @@ class NestedValuesIterable(BaseIterable): # type: ignore[type-arg]
318
211
  if klass_info is None:
319
212
  return
320
213
 
321
- main_results = [_build_dict_from_klass_info(row, klass_info, select) for row in compiler.results_iter(results)]
214
+ # Choose container based on as_attr_dicts flag
215
+ as_attr_dicts = getattr(queryset, "_as_attr_dicts", False)
216
+ container: _ContainerType = AttrDict if as_attr_dicts else dict # type: ignore[assignment]
217
+ pk_name = queryset.model._meta.pk.name
218
+
219
+ # Build main results - unified path for both dict and AttrDict
220
+ main_results = [
221
+ _build_from_klass_info(row, klass_info, select, container) for row in compiler.results_iter(results)
222
+ ]
322
223
  if not main_results:
323
224
  return
324
225
 
325
- # Check if we should wrap results as AttrDict
326
- as_attr_dicts = getattr(queryset, "_as_attr_dicts", False)
327
-
328
226
  if not prefetch_lookups:
329
- for row in main_results:
330
- yield AttrDict(row) if as_attr_dicts else row
227
+ yield from main_results
331
228
  return
332
229
 
333
- pk_name = queryset.model._meta.pk.name
230
+ # Fetch prefetched data and attach to main results
334
231
  pk_values = [r[pk_name] for r in main_results]
335
- prefetched_data = queryset._fetch_all_prefetched(prefetch_lookups, pk_values, main_results)
232
+ prefetched_data = queryset._fetch_all_prefetched(
233
+ prefetch_lookups,
234
+ pk_values,
235
+ main_results,
236
+ container=container,
237
+ )
336
238
 
337
239
  for row in main_results:
338
240
  pk = row[pk_name]
339
241
  for attr_name, data_by_pk in prefetched_data.items():
340
242
  prefetch_value = data_by_pk.get(pk, [])
341
243
  self._set_nested_value(row, attr_name, prefetch_value)
342
- yield AttrDict(row) if as_attr_dicts else row
244
+ yield row
343
245
 
344
246
  def _set_nested_value(self, row: dict[str, Any], attr_path: str, value: Any) -> None:
345
- """Set a value in a nested dict using a path like 'publisher__books'.
346
-
347
- Navigates to the correct nested location and sets or merges the value.
348
- """
247
+ """Set a value in a nested dict using a path like 'publisher__books'."""
349
248
  parts = attr_path.split("__")
350
249
  target = row
351
250
 
@@ -361,17 +260,11 @@ class NestedValuesIterable(BaseIterable): # type: ignore[type-arg]
361
260
  existing = target[final_key]
362
261
  if isinstance(existing, dict) and isinstance(value, dict):
363
262
  self._merge_dicts(existing, value)
364
- # Otherwise keep existing (select_related takes precedence)
365
263
  else:
366
264
  target[final_key] = value
367
265
 
368
266
  def _merge_dicts(self, target: dict[str, Any], source: dict[str, Any]) -> None:
369
- """Recursively merge source dict into target dict.
370
-
371
- New keys from source are added. For existing keys where both values
372
- are dicts, recursively merge. Otherwise, target value takes precedence
373
- (select_related data wins over prefetch data for same field).
374
- """
267
+ """Recursively merge source dict into target dict."""
375
268
  for key, value in source.items():
376
269
  if key not in target:
377
270
  target[key] = value
@@ -379,12 +272,7 @@ class NestedValuesIterable(BaseIterable): # type: ignore[type-arg]
379
272
  self._merge_dicts(target[key], value)
380
273
 
381
274
  def _ensure_fk_fields_not_deferred(self, qs: QuerySet[Any, Any]) -> None:
382
- """Ensure FK fields for select_related are not deferred.
383
-
384
- When using .only() without the FK field and then select_related(),
385
- Django would raise an error. This method clears deferral for FK fields
386
- that are traversed via select_related.
387
- """
275
+ """Ensure FK fields for select_related are not deferred."""
388
276
  select_related = qs.query.select_related
389
277
  if not select_related:
390
278
  return
@@ -393,13 +281,9 @@ class NestedValuesIterable(BaseIterable): # type: ignore[type-arg]
393
281
  if not deferred_fields:
394
282
  return
395
283
 
396
- # Get FK field names for select_related relations
397
284
  if select_related is True:
398
- # select_related() with no args - need all FK fields
399
285
  fk_fields = {f.attname for f in qs.model._meta.concrete_fields if isinstance(f, ForeignKey)}
400
286
  else:
401
- # select_related is a dict like {'publisher': {}, 'author': {'publisher': {}}}
402
- # We need the FK attnames for top-level relations
403
287
  fk_fields = set()
404
288
  for relation_name in select_related:
405
289
  try:
@@ -433,19 +317,14 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
433
317
  objects = MyQuerySet.as_manager()
434
318
 
435
319
  Or use the pre-built NestedValuesQuerySet if you don't need a custom QuerySet.
436
-
437
- Type hints: After calling values_nested(), the queryset yields dict[str, Any]
438
- when iterated, similar to Django's values() method.
439
320
  """
440
321
 
441
- # Custom attributes that should be preserved when cloning
442
322
  _nested_prefetch_lookups: tuple[Any, ...] = ()
443
323
  _as_attr_dicts: bool = False
444
324
 
445
325
  def _clone(self) -> Self:
446
326
  """Clone the queryset, preserving our custom attributes."""
447
327
  clone: Self = super()._clone() # type: ignore[misc]
448
- # Copy our custom attributes to the clone
449
328
  clone._nested_prefetch_lookups = self._nested_prefetch_lookups
450
329
  clone._as_attr_dicts = self._as_attr_dicts
451
330
  return clone
@@ -474,24 +353,15 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
474
353
  Args:
475
354
  as_attr_dicts: If True, return AttrDict instances instead of plain dicts.
476
355
  AttrDict supports attribute access (book.title) in addition to
477
- dict access (book["title"]), and related lists support .all() to
478
- mimic Django's related manager interface.
479
-
480
- Use standard Django methods to control output:
481
- - .only() to select which fields to include
482
- - .select_related() for ForeignKey relations (single dict/object)
483
- - .prefetch_related() for ManyToMany/reverse FK relations (list of dicts/objects)
356
+ dict access (book["title"]).
484
357
 
485
358
  Returns:
486
- A QuerySet that yields dict[str, Any] or AttrDict when iterated,
487
- with nested dictionaries/objects for related objects.
359
+ A QuerySet that yields dict[str, Any] or AttrDict when iterated.
488
360
 
489
361
  """
490
362
  clone: Self = self._clone() # type: ignore[assignment]
491
363
  clone._iterable_class = NestedValuesIterable
492
364
  clone._as_attr_dicts = as_attr_dicts
493
- # Store prefetch lookups for our own use, then clear them so Django
494
- # doesn't try to prefetch on our dict/AttrDict results
495
365
  clone._nested_prefetch_lookups = clone._prefetch_related_lookups # type: ignore[attr-defined]
496
366
  clone._prefetch_related_lookups = () # type: ignore[attr-defined]
497
367
  return clone # type: ignore[return-value]
@@ -508,9 +378,11 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
508
378
  prefetch_lookups: tuple[Any, ...],
509
379
  parent_pks: list[Any],
510
380
  main_results: list[dict[str, Any]],
511
- ) -> dict[str, dict[Any, list[dict[str, Any]] | dict[str, Any] | None]]:
381
+ *,
382
+ container: _ContainerType = dict,
383
+ ) -> dict[str, dict[Any, Any]]:
512
384
  """Fetch all prefetched relations."""
513
- result: dict[str, dict[Any, list[dict[str, Any]] | dict[str, Any] | None]] = {}
385
+ result: dict[str, dict[Any, Any]] = {}
514
386
  lookup_map = self._group_prefetch_lookups(prefetch_lookups)
515
387
 
516
388
  for attr_name, lookup_info in lookup_map.items():
@@ -518,10 +390,11 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
518
390
  nested = lookup_info["nested"]
519
391
 
520
392
  if lookup_info.get("is_generic_fk"):
521
- result[attr_name] = self._fetch_generic_fk_values( # type: ignore[assignment]
393
+ result[attr_name] = self._fetch_generic_fk_values(
522
394
  lookup,
523
395
  parent_pks,
524
396
  main_results,
397
+ container=container,
525
398
  )
526
399
  else:
527
400
  result[attr_name] = self._fetch_relation_values(
@@ -529,6 +402,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
529
402
  nested,
530
403
  parent_pks,
531
404
  main_results,
405
+ container=container,
532
406
  )
533
407
 
534
408
  return result
@@ -551,7 +425,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
551
425
 
552
426
  if "__" in relation_path:
553
427
  parts = relation_path.split("__", 1)
554
- if parts[0] == attr_name or attr_name == parts[0]:
428
+ if parts[0] == attr_name:
555
429
  result[attr_name]["nested"].append(parts[1])
556
430
  else:
557
431
  attr_name = lookup.split("__")[0]
@@ -573,6 +447,8 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
573
447
  nested_relations: list[str],
574
448
  parent_pks: list[Any],
575
449
  main_results: list[dict[str, Any]],
450
+ *,
451
+ container: _ContainerType = dict,
576
452
  ) -> dict[Any, list[dict[str, Any]] | dict[str, Any] | None]:
577
453
  """Fetch a related model's data and group by parent PK."""
578
454
  relation_name = (
@@ -598,14 +474,11 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
598
474
  parent_path="",
599
475
  parent_data={r[pk_name]: r for r in main_results},
600
476
  custom_qs=custom_qs,
477
+ container=container,
601
478
  )
602
479
 
603
480
  def _get_select_related_from_queryset(self, qs: QuerySet[Any, Any] | None) -> dict[str, Any]:
604
- """Get select_related structure from a queryset.
605
-
606
- Returns a dict like {'publisher': {}, 'publisher__country': {}} for
607
- select_related('publisher', 'publisher__country').
608
- """
481
+ """Get select_related structure from a queryset."""
609
482
  if qs is None:
610
483
  return {}
611
484
 
@@ -614,15 +487,12 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
614
487
  return {}
615
488
 
616
489
  if select_related is True:
617
- # select_related() with no args - get all FK fields
618
490
  result: dict[str, Any] = {}
619
491
  for field in qs.model._meta.concrete_fields:
620
492
  if isinstance(field, ForeignKey):
621
493
  result[field.name] = {}
622
494
  return result
623
495
 
624
- # select_related is a nested dict like {'publisher': {'country': {}}}
625
- # Flatten it to paths
626
496
  result = {}
627
497
  self._flatten_select_related_to_paths(select_related, "", result)
628
498
  return result
@@ -651,12 +521,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
651
521
  main_results: list[dict[str, Any]] | None,
652
522
  parent_path: str,
653
523
  m2m_field: ManyToManyField[Any, Any] | ManyToManyRel,
524
+ *,
525
+ container: _ContainerType = dict,
654
526
  ) -> dict[Any, list[dict[str, Any]]]:
655
- """Fetch M2M data for forward/reverse M2M lookups, top-level and nested.
656
-
657
- Use Django's compiler with .extra() annotation to match Django's native
658
- prefetch_related behavior. Raw rows → dicts, no model instantiation.
659
- """
527
+ """Fetch M2M data for forward/reverse M2M lookups."""
660
528
  related_pk_name = related_model._meta.pk.name
661
529
 
662
530
  if custom_qs is not None:
@@ -666,7 +534,6 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
666
534
 
667
535
  actual_field = m2m_field.field if isinstance(m2m_field, ManyToManyRel) else m2m_field
668
536
 
669
- # Add extra select for parent FK (matching Django's prefetch approach)
670
537
  through_model = actual_field.remote_field.through
671
538
  assert through_model is not None # noqa: S101
672
539
  source_field_name = (
@@ -679,23 +546,24 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
679
546
  extra_select = {
680
547
  f"_prefetch_related_val_{f.attname}": f"{qn(join_table)}.{qn(f.column)}" for f in fk.local_related_fields
681
548
  }
682
- related_qs = related_qs.extra(select=extra_select) # noqa: S610 # safe: identifiers quoted via qn()
549
+ related_qs = related_qs.extra(select=extra_select) # noqa: S610
683
550
 
684
- dicts, extra_values = _execute_prefetch_as_dicts(related_qs, self.db)
685
- if not dicts:
551
+ # Build directly into container (dict or AttrDict) - no conversion needed
552
+ containers, extra_values = _execute_prefetch(related_qs, self.db, container)
553
+ if not containers:
686
554
  return {pk: [] for pk in parent_pks}
687
555
 
688
556
  result: dict[Any, list[dict[str, Any]]] = {pk: [] for pk in parent_pks}
689
557
  related_data: dict[Any, dict[str, Any]] = {}
690
558
 
691
- for row_dict, extra_vals in zip(dicts, extra_values, strict=True):
559
+ for row_container, extra_vals in zip(containers, extra_values, strict=True):
692
560
  source_pk = next(iter(extra_vals.values())) if extra_vals else None
693
561
  if source_pk in result:
694
- result[source_pk].append(row_dict)
562
+ result[source_pk].append(row_container)
695
563
 
696
- related_pk = row_dict.get(related_pk_name)
564
+ related_pk = row_container.get(related_pk_name)
697
565
  if nested_relations and related_pk not in related_data:
698
- related_data[related_pk] = row_dict
566
+ related_data[related_pk] = row_container
699
567
 
700
568
  if nested_relations and related_data:
701
569
  select_related_paths = self._get_select_related_from_queryset(custom_qs)
@@ -709,6 +577,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
709
577
  list(related_data.keys()),
710
578
  main_results,
711
579
  f"{full_path}__",
580
+ container=container,
712
581
  )
713
582
  for items in result.values():
714
583
  for item in items:
@@ -730,18 +599,21 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
730
599
  custom_qs: QuerySet[Any, Any] | None,
731
600
  main_results: list[dict[str, Any]] | None,
732
601
  parent_path: str,
602
+ *,
603
+ container: _ContainerType = dict,
733
604
  ) -> dict[Any, list[dict[str, Any]]]:
734
- """Fetch reverse FK data. Raw rows → dicts, no model instantiation."""
605
+ """Fetch reverse FK data."""
735
606
  related_pk_name = related_model._meta.pk.name
736
607
  fk_field = related_model._meta.get_field(fk_field_name)
737
- fk_attname = fk_field.attname # type: ignore[union-attr] # e.g., 'book_id'
608
+ fk_attname = fk_field.attname # type: ignore[union-attr]
738
609
 
739
610
  if custom_qs is not None:
740
611
  related_qs = custom_qs.filter(**{f"{fk_field_name}__in": parent_pks})
741
612
  else:
742
613
  related_qs = related_model._default_manager.filter(**{f"{fk_field_name}__in": parent_pks})
743
614
 
744
- related_data = _execute_queryset_as_dicts(related_qs, self.db)
615
+ # Build directly into container (dict or AttrDict)
616
+ related_data = _execute_queryset(related_qs, self.db, container)
745
617
  if not related_data:
746
618
  return {pk: [] for pk in parent_pks}
747
619
 
@@ -752,14 +624,14 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
752
624
  related_pks = [r[related_pk_name] for r in related_data]
753
625
  nested_dict = {r[related_pk_name]: r for r in related_data}
754
626
  full_path = f"{parent_path}{relation_name}" if parent_path else relation_name
755
- new_parent_path = f"{full_path}__"
756
627
  self._add_nested_relations(
757
628
  related_model,
758
629
  nested_dict,
759
630
  remaining_nested,
760
631
  related_pks,
761
632
  main_results,
762
- new_parent_path,
633
+ f"{full_path}__",
634
+ container=container,
763
635
  )
764
636
  related_data = list(nested_dict.values())
765
637
 
@@ -767,9 +639,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
767
639
  for row in related_data:
768
640
  parent_pk = row.get(fk_attname)
769
641
  if parent_pk in result:
770
- row_dict = dict(row)
771
- row_dict.pop(fk_attname, None) # Remove FK attname from output
772
- result[parent_pk].append(row_dict)
642
+ # Remove FK attname from output
643
+ if fk_attname in row:
644
+ del row[fk_attname]
645
+ result[parent_pk].append(row)
773
646
 
774
647
  return result
775
648
 
@@ -783,8 +656,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
783
656
  main_results: list[dict[str, Any]] | None,
784
657
  parent_path: str,
785
658
  parent_model: type[Model] | None = None,
659
+ *,
660
+ container: _ContainerType = dict,
786
661
  ) -> dict[Any, dict[str, Any] | None]:
787
- """Fetch FK data. Raw rows → dicts, no model instantiation."""
662
+ """Fetch FK data."""
788
663
  related_model = cast("type[Model]", field.related_model)
789
664
  fk_attname = field.attname
790
665
  relation_name = field.name
@@ -819,14 +694,14 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
819
694
  for row in parent_data.values(): # type: ignore[union-attr]
820
695
  nested = row.get(relation_name)
821
696
  if isinstance(nested, dict) and nested.get(related_pk_name) is not None:
822
- related_data[nested[related_pk_name]] = dict(nested)
697
+ related_data[nested[related_pk_name]] = container(nested)
823
698
  else:
824
699
  if custom_qs is not None:
825
700
  related_qs = custom_qs.filter(pk__in=fk_values)
826
701
  else:
827
702
  related_qs = related_model._default_manager.filter(pk__in=fk_values)
828
703
 
829
- results = _execute_queryset_as_dicts(related_qs, self.db)
704
+ results = _execute_queryset(related_qs, self.db, container)
830
705
  related_data = {r[related_pk_name]: r for r in results}
831
706
 
832
707
  if not related_data:
@@ -841,12 +716,13 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
841
716
  fk_values,
842
717
  main_results,
843
718
  full_path,
719
+ container=container,
844
720
  )
845
721
 
846
722
  result: dict[Any, dict[str, Any] | None] = {}
847
723
  for parent_pk in parent_pks:
848
724
  fk_value = fk_data.get(parent_pk)
849
- result[parent_pk] = dict(related_data[fk_value]) if fk_value in related_data else None
725
+ result[parent_pk] = container(related_data[fk_value]) if fk_value in related_data else None
850
726
 
851
727
  return result
852
728
 
@@ -859,8 +735,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
859
735
  custom_qs: QuerySet[Any, Any] | None,
860
736
  main_results: list[dict[str, Any]] | None,
861
737
  parent_path: str,
738
+ *,
739
+ container: _ContainerType = dict,
862
740
  ) -> dict[Any, list[dict[str, Any]]]:
863
- """Fetch GenericRelation data for top-level and nested lookups."""
741
+ """Fetch GenericRelation data."""
864
742
  related_model = cast("type[Model]", field.related_model)
865
743
  ct_field_name = field.content_type_field_name
866
744
  obj_id_field_name = field.object_id_field_name
@@ -874,7 +752,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
874
752
  else:
875
753
  related_qs = related_model._default_manager.filter(**filter_kwargs)
876
754
 
877
- related_data = _execute_queryset_as_dicts(related_qs, self.db)
755
+ related_data = _execute_queryset(related_qs, self.db, container)
878
756
 
879
757
  if nested_relations and related_data:
880
758
  related_pks = [r[related_pk_name] for r in related_data]
@@ -887,16 +765,19 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
887
765
  related_pks,
888
766
  main_results,
889
767
  f"{full_path}__",
768
+ container=container,
890
769
  )
891
770
  related_data = list(nested_dict.values())
892
771
 
893
772
  result: dict[Any, list[dict[str, Any]]] = {pk: [] for pk in parent_pks}
894
773
  for row in related_data:
895
774
  parent_pk = row[obj_id_field_name]
896
- row_dict = dict(row)
897
- row_dict.pop(obj_id_field_name, None)
898
- row_dict.pop(f"{ct_field_name}_id", None)
899
- result[parent_pk].append(row_dict)
775
+ if obj_id_field_name in row:
776
+ del row[obj_id_field_name]
777
+ ct_key = f"{ct_field_name}_id"
778
+ if ct_key in row:
779
+ del row[ct_key]
780
+ result[parent_pk].append(row)
900
781
 
901
782
  return result
902
783
 
@@ -905,12 +786,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
905
786
  lookup: GenericPrefetch[Any],
906
787
  parent_pks: list[Any],
907
788
  main_results: list[dict[str, Any]],
789
+ *,
790
+ container: _ContainerType = dict,
908
791
  ) -> dict[Any, dict[str, Any] | None]:
909
- """Fetch GenericForeignKey data using GenericPrefetch.
910
-
911
- GenericForeignKey can point to different model types. GenericPrefetch
912
- provides a list of querysets, one for each possible content type.
913
- """
792
+ """Fetch GenericForeignKey data using GenericPrefetch."""
914
793
  gfk_attr = lookup.prefetch_to
915
794
  gfk_descriptor = getattr(self.model, gfk_attr, None)
916
795
  if not isinstance(gfk_descriptor, GenericForeignKey):
@@ -950,7 +829,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
950
829
  object_ids = [obj_id for _, obj_id in parent_obj_pairs]
951
830
 
952
831
  related_qs = qs.filter(pk__in=object_ids)
953
- results = _execute_queryset_as_dicts(related_qs, self.db)
832
+ results = _execute_queryset(related_qs, self.db, container)
954
833
  related_data = {r[related_pk_name]: r for r in results}
955
834
 
956
835
  if qs._prefetch_related_lookups: # type: ignore[attr-defined]
@@ -961,6 +840,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
961
840
  qs._prefetch_related_lookups, # type: ignore[attr-defined]
962
841
  nested_pks,
963
842
  list(related_data.values()),
843
+ container=container,
964
844
  )
965
845
  for pk_val, row_data in related_data.items():
966
846
  for attr, data_by_pk in nested_prefetched.items():
@@ -968,7 +848,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
968
848
 
969
849
  for parent_pk, obj_id in parent_obj_pairs:
970
850
  if obj_id in related_data:
971
- result[parent_pk] = dict(related_data[obj_id])
851
+ result[parent_pk] = container(related_data[obj_id])
972
852
 
973
853
  return result
974
854
 
@@ -978,14 +858,16 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
978
858
  prefetch_lookups: tuple[Any, ...],
979
859
  parent_pks: list[Any],
980
860
  main_results: list[dict[str, Any]],
861
+ *,
862
+ container: _ContainerType = dict,
981
863
  ) -> dict[str, dict[Any, list[dict[str, Any]] | dict[str, Any] | None]]:
982
- """Fetch prefetched relations for a related model (used by GenericForeignKey)."""
864
+ """Fetch prefetched relations for a related model."""
983
865
  temp_qs = NestedValuesQuerySetMixin.__new__(NestedValuesQuerySetMixin)
984
866
  temp_qs.model = model
985
867
  temp_qs.db = self.db # type: ignore[misc]
986
868
  temp_qs.query = model._default_manager.all().query
987
869
 
988
- return temp_qs._fetch_all_prefetched(prefetch_lookups, parent_pks, main_results)
870
+ return temp_qs._fetch_all_prefetched(prefetch_lookups, parent_pks, main_results, container=container)
989
871
 
990
872
  def _add_nested_relations( # noqa: PLR0913
991
873
  self,
@@ -995,6 +877,8 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
995
877
  parent_pks: list[Any],
996
878
  main_results: list[dict[str, Any]] | None = None,
997
879
  parent_path: str = "",
880
+ *,
881
+ container: _ContainerType = dict,
998
882
  ) -> None:
999
883
  """Fetch and add nested relation data to already-fetched parent data."""
1000
884
  for nested_rel in nested_relations:
@@ -1015,6 +899,7 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1015
899
  main_results=main_results,
1016
900
  parent_path=parent_path,
1017
901
  parent_data=data,
902
+ container=container,
1018
903
  )
1019
904
 
1020
905
  for pk, row in data.items():
@@ -1030,10 +915,12 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1030
915
  parent_path: str = "",
1031
916
  parent_data: dict[Any, dict[str, Any]] | None = None,
1032
917
  custom_qs: QuerySet[Any, Any] | None = None,
918
+ *,
919
+ container: _ContainerType = dict,
1033
920
  ) -> dict[Any, list[dict[str, Any]] | dict[str, Any] | None]:
1034
921
  """Dispatch to the appropriate fetch method based on field type."""
1035
922
  if isinstance(field, ManyToManyField):
1036
- return self._fetch_m2m_internal(
923
+ return self._fetch_m2m_internal( # type: ignore[return-value]
1037
924
  related_model=cast("type[Model]", field.related_model),
1038
925
  accessor=field.related_query_name(),
1039
926
  relation_name=field.name,
@@ -1043,9 +930,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1043
930
  main_results=main_results,
1044
931
  parent_path=parent_path,
1045
932
  m2m_field=field,
1046
- ) # type: ignore[return-value]
933
+ container=container,
934
+ )
1047
935
  if isinstance(field, ManyToOneRel):
1048
- return self._fetch_reverse_fk_internal(
936
+ return self._fetch_reverse_fk_internal( # type: ignore[return-value]
1049
937
  related_model=cast("type[Model]", field.related_model),
1050
938
  fk_field_name=field.field.name,
1051
939
  relation_name=field.get_accessor_name() or field.name,
@@ -1054,9 +942,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1054
942
  custom_qs=custom_qs,
1055
943
  main_results=main_results,
1056
944
  parent_path=parent_path,
1057
- ) # type: ignore[return-value]
945
+ container=container,
946
+ )
1058
947
  if isinstance(field, ForeignKey):
1059
- return self._fetch_fk_internal(
948
+ return self._fetch_fk_internal( # type: ignore[return-value]
1060
949
  field=field,
1061
950
  nested_relations=nested_relations,
1062
951
  parent_pks=parent_pks,
@@ -1065,9 +954,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1065
954
  main_results=main_results,
1066
955
  parent_path=parent_path,
1067
956
  parent_model=parent_model,
1068
- ) # type: ignore[return-value]
957
+ container=container,
958
+ )
1069
959
  if isinstance(field, ManyToManyRel):
1070
- return self._fetch_m2m_internal(
960
+ return self._fetch_m2m_internal( # type: ignore[return-value]
1071
961
  related_model=cast("type[Model]", field.related_model),
1072
962
  accessor=field.field.name,
1073
963
  relation_name=field.get_accessor_name() or field.name,
@@ -1077,9 +967,10 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1077
967
  main_results=main_results,
1078
968
  parent_path=parent_path,
1079
969
  m2m_field=field,
1080
- ) # type: ignore[return-value]
970
+ container=container,
971
+ )
1081
972
  if isinstance(field, GenericRelation):
1082
- return self._fetch_generic_relation_internal(
973
+ return self._fetch_generic_relation_internal( # type: ignore[return-value]
1083
974
  field,
1084
975
  nested_relations,
1085
976
  parent_pks,
@@ -1087,7 +978,8 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1087
978
  custom_qs,
1088
979
  main_results,
1089
980
  parent_path,
1090
- ) # type: ignore[return-value]
981
+ container=container,
982
+ )
1091
983
  return {}
1092
984
 
1093
985
  def _is_many_relation(self, field: Any) -> bool:
@@ -1098,22 +990,14 @@ class NestedValuesQuerySetMixin(_MixinBase[_ModelT_co]):
1098
990
  class NestedValuesQuerySet(NestedValuesQuerySetMixin[_ModelT_co], QuerySet[_ModelT_co, _ModelT_co]):
1099
991
  """QuerySet that adds .values_nested() for nested dictionaries.
1100
992
 
1101
- This is a ready-to-use QuerySet combining NestedValuesQuerySetMixin with Django's QuerySet.
1102
-
1103
993
  Usage:
1104
994
  class Book(models.Model):
1105
995
  objects = NestedValuesQuerySet.as_manager()
1106
996
 
1107
- # Use standard Django patterns:
1108
- Book.objects.only("title").select_related("publisher").prefetch_related("authors").values_nested()
997
+ Book.objects.select_related("publisher").prefetch_related("authors").values_nested()
1109
998
  # Returns: [{'id': 1, 'title': '...', 'publisher': {...}, 'authors': [...]}, ...]
1110
999
 
1111
- If you have a custom QuerySet, use NestedValuesQuerySetMixin instead:
1112
-
1113
- class MyQuerySet(NestedValuesQuerySetMixin, QuerySet):
1114
- def my_custom_method(self):
1115
- ...
1116
-
1117
- Type hints: The queryset is generic over the model type. After calling values_nested(),
1118
- it yields dict[str, Any] when iterated, similar to Django's values() method.
1000
+ With attribute access:
1001
+ Book.objects.values_nested(as_attr_dicts=True)
1002
+ # Returns AttrDict instances: book.title, book.publisher.name work
1119
1003
  """
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-nested-values"
3
- version = "0.6.14"
3
+ version = "0.7.0"
4
4
  description = "Add .values_nested() to Django querysets for nested prefetch dictionaries"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }