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.
- {django_nested_values-0.7.0 → django_nested_values-1.1.0}/PKG-INFO +13 -11
- {django_nested_values-0.7.0 → django_nested_values-1.1.0}/README.md +9 -7
- django_nested_values-1.1.0/django_nested_values/__init__.py +6 -0
- django_nested_values-1.1.0/django_nested_values/prefetch.py +425 -0
- django_nested_values-1.1.0/django_nested_values/queryset.py +101 -0
- django_nested_values-1.1.0/django_nested_values/rows.py +79 -0
- {django_nested_values-0.7.0 → django_nested_values-1.1.0}/pyproject.toml +25 -23
- django_nested_values-0.7.0/django_nested_values/__init__.py +0 -10
- django_nested_values-0.7.0/django_nested_values/queryset.py +0 -1003
- {django_nested_values-0.7.0 → django_nested_values-1.1.0}/.gitignore +0 -0
- {django_nested_values-0.7.0 → django_nested_values-1.1.0}/LICENSE +0 -0
- {django_nested_values-0.7.0 → django_nested_values-1.1.0}/django_nested_values/py.typed +0 -0
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: django-nested-values
|
|
3
|
-
Version:
|
|
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
|
[](https://pypi.org/project/django-nested-values/)
|
|
32
32
|
[](https://github.com/oliverhaas/django-nested-values/actions/workflows/ci.yml)
|
|
33
33
|
|
|
34
|
-
|
|
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
|
-
|
|
|
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
|
[](https://pypi.org/project/django-nested-values/)
|
|
4
4
|
[](https://github.com/oliverhaas/django-nested-values/actions/workflows/ci.yml)
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
|
|
|
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,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()."""
|