drf-prefetch-hint 0.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.
- drf_prefetch_hint-0.1.0/.gitignore +13 -0
- drf_prefetch_hint-0.1.0/CHANGELOG.md +35 -0
- drf_prefetch_hint-0.1.0/LICENSE +21 -0
- drf_prefetch_hint-0.1.0/PKG-INFO +263 -0
- drf_prefetch_hint-0.1.0/README.md +226 -0
- drf_prefetch_hint-0.1.0/pyproject.toml +72 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/__init__.py +8 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/apps.py +11 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/build.py +248 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/capture.py +43 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/management/__init__.py +0 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/management/commands/__init__.py +0 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/management/commands/prefetch_hint.py +269 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/probe.py +296 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/render.py +125 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/resolve.py +162 -0
- drf_prefetch_hint-0.1.0/src/prefetch_hint/types.py +96 -0
- drf_prefetch_hint-0.1.0/tests/__init__.py +0 -0
- drf_prefetch_hint-0.1.0/tests/conftest.py +98 -0
- drf_prefetch_hint-0.1.0/tests/settings.py +19 -0
- drf_prefetch_hint-0.1.0/tests/test_build.py +135 -0
- drf_prefetch_hint-0.1.0/tests/test_command.py +105 -0
- drf_prefetch_hint-0.1.0/tests/test_probe.py +129 -0
- drf_prefetch_hint-0.1.0/tests/test_resolve.py +88 -0
- drf_prefetch_hint-0.1.0/tests/testapp/__init__.py +0 -0
- drf_prefetch_hint-0.1.0/tests/testapp/models.py +60 -0
- drf_prefetch_hint-0.1.0/tests/testapp/serializers.py +79 -0
- drf_prefetch_hint-0.1.0/tests/testapp/views.py +29 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-08-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `prefetch_hint` management command: point it at a DRF ViewSet and it prints the
|
|
15
|
+
`select_related` / `prefetch_related` expression that viewset needs.
|
|
16
|
+
- Field-level query attribution via a `ContextVar` stack around DRF's
|
|
17
|
+
`get_attribute` and `to_representation`, paired with Django's
|
|
18
|
+
`connection.execute_wrapper`.
|
|
19
|
+
- Relation classification through `model._meta`, covering forward FK, reverse FK,
|
|
20
|
+
M2M, and reverse OneToOne (which Django can `select_related` across).
|
|
21
|
+
- Nested `Prefetch(queryset=...)` generation, including `select_related` on the
|
|
22
|
+
inner queryset for a forward FK hanging off a reverse relation.
|
|
23
|
+
- `ast.parse` validation of every generated expression before it is printed.
|
|
24
|
+
- Flags: `--count`, `--user`, `--action`, `--raw`, `--force`.
|
|
25
|
+
- Graceful degradation for `SerializerMethodField`: reported with its query count
|
|
26
|
+
and marked as needing a manual fix, never guessed.
|
|
27
|
+
|
|
28
|
+
### Safety
|
|
29
|
+
|
|
30
|
+
- All analysis runs inside a transaction that is always rolled back.
|
|
31
|
+
- DRF is restored to its unpatched state on every exit path, including exceptions.
|
|
32
|
+
- Refuses to run under `DEBUG=False` without `--force`.
|
|
33
|
+
|
|
34
|
+
[Unreleased]: https://github.com/papansarkar101/drf-prefetch-hint/compare/v0.1.0...HEAD
|
|
35
|
+
[0.1.0]: https://github.com/papansarkar101/drf-prefetch-hint/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Papan Sarkar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: drf-prefetch-hint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tells you exactly which select_related/prefetch_related to add to your DRF viewset.
|
|
5
|
+
Project-URL: Homepage, https://github.com/papansarkar101/drf-prefetch-hint
|
|
6
|
+
Project-URL: Issues, https://github.com/papansarkar101/drf-prefetch-hint/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/papansarkar101/drf-prefetch-hint/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Papan Sarkar <papansarkar101@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: django,djangorestframework,n+1,orm,performance,prefetch_related
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: django>=4.2
|
|
26
|
+
Requires-Dist: djangorestframework>=3.14
|
|
27
|
+
Requires-Dist: rich>=13.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-django; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
34
|
+
Requires-Dist: tox; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# drf-prefetch-hint
|
|
39
|
+
|
|
40
|
+
[](https://github.com/papansarkar101/drf-prefetch-hint/actions/workflows/ci.yml)
|
|
41
|
+
[](https://pypi.org/project/drf-prefetch-hint/)
|
|
42
|
+
|
|
43
|
+
**Tells you exactly which `select_related` / `prefetch_related` to add to your DRF viewset.**
|
|
44
|
+
|
|
45
|
+
Other tools tell you that you have an N+1. This one writes the fix.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ python manage.py prefetch_hint shop.views.AuthorViewSet --count 10
|
|
49
|
+
|
|
50
|
+
AuthorViewSet — 51 queries on 10 objects
|
|
51
|
+
|
|
52
|
+
books.reviews reverse FK → prefetch_related
|
|
53
|
+
20 queries shop/views.py:7
|
|
54
|
+
books reverse FK → prefetch_related
|
|
55
|
+
10 queries shop/views.py:11
|
|
56
|
+
publisher.name FK → select_related
|
|
57
|
+
10 queries shop/views.py:10
|
|
58
|
+
summary method → 10 queries, manual fix needed
|
|
59
|
+
10 queries shop/views.py:12
|
|
60
|
+
|
|
61
|
+
Add to get_queryset():
|
|
62
|
+
|
|
63
|
+
.select_related("publisher")
|
|
64
|
+
.prefetch_related(Prefetch("books", queryset=Book.objects.prefetch_related("reviews")))
|
|
65
|
+
|
|
66
|
+
Projected: 51 → ~13 queries (estimate)
|
|
67
|
+
|
|
68
|
+
Not auto-fixable:
|
|
69
|
+
summary — SerializerMethodField runs arbitrary code
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pasting that suggestion verbatim takes this endpoint from **51 queries to 3**.
|
|
73
|
+
|
|
74
|
+
Note what it worked out on its own: `reviews` hangs off `books`, which is itself a
|
|
75
|
+
reverse FK, so it belongs in a `prefetch_related` on the **inner** `Prefetch`
|
|
76
|
+
queryset — not flattened onto the outer one. That is the part that costs you an hour.
|
|
77
|
+
|
|
78
|
+
## Requirements
|
|
79
|
+
|
|
80
|
+
| | Supported |
|
|
81
|
+
|---|---|
|
|
82
|
+
| Python | 3.10 – 3.13 |
|
|
83
|
+
| Django | 4.2, 5.0, 5.1 |
|
|
84
|
+
| Django REST Framework | 3.14+ |
|
|
85
|
+
|
|
86
|
+
Tested in CI against the oldest and newest supported combinations.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install drf-prefetch-hint
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
INSTALLED_APPS = [
|
|
96
|
+
...
|
|
97
|
+
"prefetch_hint",
|
|
98
|
+
]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It is a development tool. There is no middleware and no runtime hook — nothing
|
|
102
|
+
runs unless you type the command. You can leave it out of production requirements
|
|
103
|
+
entirely.
|
|
104
|
+
|
|
105
|
+
## When to use it
|
|
106
|
+
|
|
107
|
+
You have one list endpoint that is slow. You already know it is an N+1. You do not
|
|
108
|
+
want to spend an hour working out the exact nested `Prefetch` incantation.
|
|
109
|
+
|
|
110
|
+
The loop is:
|
|
111
|
+
|
|
112
|
+
1. **Run it** against the viewset, from your project root:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --count 25
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
2. **Paste** the expression into that viewset's `get_queryset()`.
|
|
119
|
+
|
|
120
|
+
3. **Run it again.** The query count should drop and the fields should disappear
|
|
121
|
+
from the report. Anything still listed is either a `SerializerMethodField` or
|
|
122
|
+
something worth a closer look.
|
|
123
|
+
|
|
124
|
+
It is not a monitor and not a linter. Point it at one endpoint when that endpoint
|
|
125
|
+
is the problem.
|
|
126
|
+
|
|
127
|
+
## Applying the fix
|
|
128
|
+
|
|
129
|
+
Before:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
|
|
133
|
+
serializer_class = AuthorSerializer
|
|
134
|
+
|
|
135
|
+
def get_queryset(self):
|
|
136
|
+
return Author.objects.all()
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
After — the generated expression pasted onto the end of the chain:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from django.db.models import Prefetch # only needed when the output uses Prefetch(...)
|
|
143
|
+
|
|
144
|
+
from shop.models import Author, Book
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
|
|
148
|
+
serializer_class = AuthorSerializer
|
|
149
|
+
|
|
150
|
+
def get_queryset(self):
|
|
151
|
+
return (
|
|
152
|
+
Author.objects.all()
|
|
153
|
+
.select_related("publisher")
|
|
154
|
+
.prefetch_related(
|
|
155
|
+
Prefetch("books", queryset=Book.objects.prefetch_related("reviews"))
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
You supply two imports the tool cannot add for you: `Prefetch`, and whichever
|
|
161
|
+
models appear inside a `Prefetch(queryset=...)` (here, `Book`). If the output is
|
|
162
|
+
only strings, you need neither.
|
|
163
|
+
|
|
164
|
+
## Usage
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python manage.py prefetch_hint <dotted.path.to.ViewSet> [options]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
| Flag | Default | Purpose |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `--count N` | 25 | Objects to serialize. Must be > 1 or N+1 is invisible. |
|
|
173
|
+
| `--user <pk\|username>` | `AnonymousUser` | For permission-gated `get_queryset()` |
|
|
174
|
+
| `--action <name>` | `list` | ViewSet action — affects serializer selection |
|
|
175
|
+
| `--raw` | off | Print raw SQL per field group |
|
|
176
|
+
| `--no-color` | off | Plain output for piping |
|
|
177
|
+
| `--force` | off | Run even when `DEBUG=False` |
|
|
178
|
+
|
|
179
|
+
Everything runs inside a transaction that is always rolled back. The command never
|
|
180
|
+
writes, and DRF is left unpatched on every exit path including exceptions.
|
|
181
|
+
|
|
182
|
+
## Troubleshooting
|
|
183
|
+
|
|
184
|
+
**`get_queryset() raised AttributeError`** — your `get_queryset()` depends on the
|
|
185
|
+
request user. Pass one:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --user 1
|
|
189
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --user alice
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**It analysed the wrong serializer.** Most real viewsets return a different
|
|
193
|
+
serializer per action. Pass the one you care about:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --action retrieve
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**`DEBUG=False. prefetch_hint is a development tool`** — intentional. This
|
|
200
|
+
serializes real rows; it is not meant for production. `--force` overrides it if
|
|
201
|
+
you know what you are doing.
|
|
202
|
+
|
|
203
|
+
**It reported nothing.** Either the viewset is already optimized (good — it is
|
|
204
|
+
built to stay silent in that case) or `--count` is too low for the pattern to show.
|
|
205
|
+
Try `--count 50`.
|
|
206
|
+
|
|
207
|
+
**A field is listed but no fix was generated.** It is a `SerializerMethodField`,
|
|
208
|
+
or a path that could not be resolved through `_meta`. See Limitations.
|
|
209
|
+
|
|
210
|
+
## What it does not do
|
|
211
|
+
|
|
212
|
+
Deliberately. These are other packages' jobs and several already do them well:
|
|
213
|
+
|
|
214
|
+
- **Watching queries as you browse** — use [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar)
|
|
215
|
+
- **Failing CI when a view gets slower** — use [django-query-guard](https://pypi.org/project/django-query-guard/) or [django-perf-rec](https://github.com/adamchainz/django-perf-rec)
|
|
216
|
+
- **Detecting N+1 at runtime across your whole app** — use [zealot](https://github.com/jmcarp/zealot)
|
|
217
|
+
- **Production monitoring** — use Sentry or Scout
|
|
218
|
+
|
|
219
|
+
No middleware, no pytest plugin, no CI mode, no config file, no web UI, no
|
|
220
|
+
auto-patching of your source. One command, one output.
|
|
221
|
+
|
|
222
|
+
It also only supports DRF serializers — not plain Django views, generic CBVs,
|
|
223
|
+
templates, the admin, GraphQL, or Django Ninja.
|
|
224
|
+
|
|
225
|
+
## Limitations
|
|
226
|
+
|
|
227
|
+
Read these before trusting the output.
|
|
228
|
+
|
|
229
|
+
- **`SerializerMethodField` cannot be resolved.** It runs arbitrary code, so
|
|
230
|
+
nothing in the field declaration reveals which relations it touches. These are
|
|
231
|
+
reported with their query count and marked *manual fix needed*. The tool will
|
|
232
|
+
not guess — a wrong guess is worse than silence.
|
|
233
|
+
- **The projection is an estimate, not a promise.** It assumes every method-field
|
|
234
|
+
query survives the fix. In practice a prefetch often satisfies them for free, so
|
|
235
|
+
the real result is frequently better than projected. It also does not model
|
|
236
|
+
queries fired outside serialization, such as a paginator's `COUNT`.
|
|
237
|
+
- **Suggestions are a starting point, not gospel.** They reflect the one code path
|
|
238
|
+
that ran, with the user and action you passed.
|
|
239
|
+
- **Development only.** It refuses to run under `DEBUG=False` without `--force`.
|
|
240
|
+
- **`--count` matters.** Too small and a relation may not look like an N+1 yet.
|
|
241
|
+
|
|
242
|
+
## How it works
|
|
243
|
+
|
|
244
|
+
DRF resolves each serializer field through `get_attribute` and `to_representation`;
|
|
245
|
+
the package wraps both and keeps a `ContextVar` stack of whichever field is
|
|
246
|
+
currently being resolved. Django's `connection.execute_wrapper` sees every query as
|
|
247
|
+
it fires and tags it with whatever is on top of that stack — so each query is
|
|
248
|
+
attributed to the exact serializer field that caused it. Those field paths are then
|
|
249
|
+
walked through `model._meta` to decide whether each one needs a JOIN or a second
|
|
250
|
+
query, and the result is assembled into an ORM expression that is
|
|
251
|
+
`ast.parse`-validated before it is ever printed.
|
|
252
|
+
|
|
253
|
+
More detail, including the two traps that make this harder than it looks, in
|
|
254
|
+
[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md).
|
|
255
|
+
|
|
256
|
+
## Contributing
|
|
257
|
+
|
|
258
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports with a minimal serializer that
|
|
259
|
+
reproduces the problem are the most useful thing you can send.
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# drf-prefetch-hint
|
|
2
|
+
|
|
3
|
+
[](https://github.com/papansarkar101/drf-prefetch-hint/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/drf-prefetch-hint/)
|
|
5
|
+
|
|
6
|
+
**Tells you exactly which `select_related` / `prefetch_related` to add to your DRF viewset.**
|
|
7
|
+
|
|
8
|
+
Other tools tell you that you have an N+1. This one writes the fix.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
$ python manage.py prefetch_hint shop.views.AuthorViewSet --count 10
|
|
12
|
+
|
|
13
|
+
AuthorViewSet — 51 queries on 10 objects
|
|
14
|
+
|
|
15
|
+
books.reviews reverse FK → prefetch_related
|
|
16
|
+
20 queries shop/views.py:7
|
|
17
|
+
books reverse FK → prefetch_related
|
|
18
|
+
10 queries shop/views.py:11
|
|
19
|
+
publisher.name FK → select_related
|
|
20
|
+
10 queries shop/views.py:10
|
|
21
|
+
summary method → 10 queries, manual fix needed
|
|
22
|
+
10 queries shop/views.py:12
|
|
23
|
+
|
|
24
|
+
Add to get_queryset():
|
|
25
|
+
|
|
26
|
+
.select_related("publisher")
|
|
27
|
+
.prefetch_related(Prefetch("books", queryset=Book.objects.prefetch_related("reviews")))
|
|
28
|
+
|
|
29
|
+
Projected: 51 → ~13 queries (estimate)
|
|
30
|
+
|
|
31
|
+
Not auto-fixable:
|
|
32
|
+
summary — SerializerMethodField runs arbitrary code
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Pasting that suggestion verbatim takes this endpoint from **51 queries to 3**.
|
|
36
|
+
|
|
37
|
+
Note what it worked out on its own: `reviews` hangs off `books`, which is itself a
|
|
38
|
+
reverse FK, so it belongs in a `prefetch_related` on the **inner** `Prefetch`
|
|
39
|
+
queryset — not flattened onto the outer one. That is the part that costs you an hour.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
| | Supported |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Python | 3.10 – 3.13 |
|
|
46
|
+
| Django | 4.2, 5.0, 5.1 |
|
|
47
|
+
| Django REST Framework | 3.14+ |
|
|
48
|
+
|
|
49
|
+
Tested in CI against the oldest and newest supported combinations.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install drf-prefetch-hint
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
INSTALLED_APPS = [
|
|
59
|
+
...
|
|
60
|
+
"prefetch_hint",
|
|
61
|
+
]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It is a development tool. There is no middleware and no runtime hook — nothing
|
|
65
|
+
runs unless you type the command. You can leave it out of production requirements
|
|
66
|
+
entirely.
|
|
67
|
+
|
|
68
|
+
## When to use it
|
|
69
|
+
|
|
70
|
+
You have one list endpoint that is slow. You already know it is an N+1. You do not
|
|
71
|
+
want to spend an hour working out the exact nested `Prefetch` incantation.
|
|
72
|
+
|
|
73
|
+
The loop is:
|
|
74
|
+
|
|
75
|
+
1. **Run it** against the viewset, from your project root:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --count 25
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
2. **Paste** the expression into that viewset's `get_queryset()`.
|
|
82
|
+
|
|
83
|
+
3. **Run it again.** The query count should drop and the fields should disappear
|
|
84
|
+
from the report. Anything still listed is either a `SerializerMethodField` or
|
|
85
|
+
something worth a closer look.
|
|
86
|
+
|
|
87
|
+
It is not a monitor and not a linter. Point it at one endpoint when that endpoint
|
|
88
|
+
is the problem.
|
|
89
|
+
|
|
90
|
+
## Applying the fix
|
|
91
|
+
|
|
92
|
+
Before:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
|
|
96
|
+
serializer_class = AuthorSerializer
|
|
97
|
+
|
|
98
|
+
def get_queryset(self):
|
|
99
|
+
return Author.objects.all()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
After — the generated expression pasted onto the end of the chain:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from django.db.models import Prefetch # only needed when the output uses Prefetch(...)
|
|
106
|
+
|
|
107
|
+
from shop.models import Author, Book
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
|
|
111
|
+
serializer_class = AuthorSerializer
|
|
112
|
+
|
|
113
|
+
def get_queryset(self):
|
|
114
|
+
return (
|
|
115
|
+
Author.objects.all()
|
|
116
|
+
.select_related("publisher")
|
|
117
|
+
.prefetch_related(
|
|
118
|
+
Prefetch("books", queryset=Book.objects.prefetch_related("reviews"))
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
You supply two imports the tool cannot add for you: `Prefetch`, and whichever
|
|
124
|
+
models appear inside a `Prefetch(queryset=...)` (here, `Book`). If the output is
|
|
125
|
+
only strings, you need neither.
|
|
126
|
+
|
|
127
|
+
## Usage
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python manage.py prefetch_hint <dotted.path.to.ViewSet> [options]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Flag | Default | Purpose |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `--count N` | 25 | Objects to serialize. Must be > 1 or N+1 is invisible. |
|
|
136
|
+
| `--user <pk\|username>` | `AnonymousUser` | For permission-gated `get_queryset()` |
|
|
137
|
+
| `--action <name>` | `list` | ViewSet action — affects serializer selection |
|
|
138
|
+
| `--raw` | off | Print raw SQL per field group |
|
|
139
|
+
| `--no-color` | off | Plain output for piping |
|
|
140
|
+
| `--force` | off | Run even when `DEBUG=False` |
|
|
141
|
+
|
|
142
|
+
Everything runs inside a transaction that is always rolled back. The command never
|
|
143
|
+
writes, and DRF is left unpatched on every exit path including exceptions.
|
|
144
|
+
|
|
145
|
+
## Troubleshooting
|
|
146
|
+
|
|
147
|
+
**`get_queryset() raised AttributeError`** — your `get_queryset()` depends on the
|
|
148
|
+
request user. Pass one:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --user 1
|
|
152
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --user alice
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**It analysed the wrong serializer.** Most real viewsets return a different
|
|
156
|
+
serializer per action. Pass the one you care about:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python manage.py prefetch_hint shop.views.AuthorViewSet --action retrieve
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**`DEBUG=False. prefetch_hint is a development tool`** — intentional. This
|
|
163
|
+
serializes real rows; it is not meant for production. `--force` overrides it if
|
|
164
|
+
you know what you are doing.
|
|
165
|
+
|
|
166
|
+
**It reported nothing.** Either the viewset is already optimized (good — it is
|
|
167
|
+
built to stay silent in that case) or `--count` is too low for the pattern to show.
|
|
168
|
+
Try `--count 50`.
|
|
169
|
+
|
|
170
|
+
**A field is listed but no fix was generated.** It is a `SerializerMethodField`,
|
|
171
|
+
or a path that could not be resolved through `_meta`. See Limitations.
|
|
172
|
+
|
|
173
|
+
## What it does not do
|
|
174
|
+
|
|
175
|
+
Deliberately. These are other packages' jobs and several already do them well:
|
|
176
|
+
|
|
177
|
+
- **Watching queries as you browse** — use [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar)
|
|
178
|
+
- **Failing CI when a view gets slower** — use [django-query-guard](https://pypi.org/project/django-query-guard/) or [django-perf-rec](https://github.com/adamchainz/django-perf-rec)
|
|
179
|
+
- **Detecting N+1 at runtime across your whole app** — use [zealot](https://github.com/jmcarp/zealot)
|
|
180
|
+
- **Production monitoring** — use Sentry or Scout
|
|
181
|
+
|
|
182
|
+
No middleware, no pytest plugin, no CI mode, no config file, no web UI, no
|
|
183
|
+
auto-patching of your source. One command, one output.
|
|
184
|
+
|
|
185
|
+
It also only supports DRF serializers — not plain Django views, generic CBVs,
|
|
186
|
+
templates, the admin, GraphQL, or Django Ninja.
|
|
187
|
+
|
|
188
|
+
## Limitations
|
|
189
|
+
|
|
190
|
+
Read these before trusting the output.
|
|
191
|
+
|
|
192
|
+
- **`SerializerMethodField` cannot be resolved.** It runs arbitrary code, so
|
|
193
|
+
nothing in the field declaration reveals which relations it touches. These are
|
|
194
|
+
reported with their query count and marked *manual fix needed*. The tool will
|
|
195
|
+
not guess — a wrong guess is worse than silence.
|
|
196
|
+
- **The projection is an estimate, not a promise.** It assumes every method-field
|
|
197
|
+
query survives the fix. In practice a prefetch often satisfies them for free, so
|
|
198
|
+
the real result is frequently better than projected. It also does not model
|
|
199
|
+
queries fired outside serialization, such as a paginator's `COUNT`.
|
|
200
|
+
- **Suggestions are a starting point, not gospel.** They reflect the one code path
|
|
201
|
+
that ran, with the user and action you passed.
|
|
202
|
+
- **Development only.** It refuses to run under `DEBUG=False` without `--force`.
|
|
203
|
+
- **`--count` matters.** Too small and a relation may not look like an N+1 yet.
|
|
204
|
+
|
|
205
|
+
## How it works
|
|
206
|
+
|
|
207
|
+
DRF resolves each serializer field through `get_attribute` and `to_representation`;
|
|
208
|
+
the package wraps both and keeps a `ContextVar` stack of whichever field is
|
|
209
|
+
currently being resolved. Django's `connection.execute_wrapper` sees every query as
|
|
210
|
+
it fires and tags it with whatever is on top of that stack — so each query is
|
|
211
|
+
attributed to the exact serializer field that caused it. Those field paths are then
|
|
212
|
+
walked through `model._meta` to decide whether each one needs a JOIN or a second
|
|
213
|
+
query, and the result is assembled into an ORM expression that is
|
|
214
|
+
`ast.parse`-validated before it is ever printed.
|
|
215
|
+
|
|
216
|
+
More detail, including the two traps that make this harder than it looks, in
|
|
217
|
+
[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md).
|
|
218
|
+
|
|
219
|
+
## Contributing
|
|
220
|
+
|
|
221
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports with a minimal serializer that
|
|
222
|
+
reproduces the problem are the most useful thing you can send.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "drf-prefetch-hint"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Tells you exactly which select_related/prefetch_related to add to your DRF viewset."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Papan Sarkar", email = "papansarkar101@gmail.com" }]
|
|
14
|
+
keywords = ["django", "djangorestframework", "n+1", "orm", "performance", "prefetch_related"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Framework :: Django :: 4.2",
|
|
20
|
+
"Framework :: Django :: 5.0",
|
|
21
|
+
"Framework :: Django :: 5.1",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development :: Debuggers",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"Django>=4.2",
|
|
31
|
+
"djangorestframework>=3.14",
|
|
32
|
+
"rich>=13.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/papansarkar101/drf-prefetch-hint"
|
|
37
|
+
Issues = "https://github.com/papansarkar101/drf-prefetch-hint/issues"
|
|
38
|
+
Changelog = "https://github.com/papansarkar101/drf-prefetch-hint/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["pytest", "pytest-django", "ruff", "mypy", "tox", "build", "twine"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "src/prefetch_hint/__init__.py"
|
|
45
|
+
|
|
46
|
+
# why: without this hatchling guesses the package location. Being explicit means
|
|
47
|
+
# the sdist and the wheel cannot disagree about what ships — the single most
|
|
48
|
+
# common cause of "works locally, ImportError after pip install".
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/prefetch_hint"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["src/prefetch_hint", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
target-version = "py310"
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
strict = true
|
|
61
|
+
# why: DRF ships no type stubs. Without this, strict mode fails on every DRF
|
|
62
|
+
# import before it can check any of our own code.
|
|
63
|
+
[[tool.mypy.overrides]]
|
|
64
|
+
module = ["rest_framework.*"]
|
|
65
|
+
ignore_missing_imports = true
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
69
|
+
python_files = ["test_*.py"]
|
|
70
|
+
# why: the fixture app lives under tests/, so tests/ must be importable as a
|
|
71
|
+
# package root. Without this the testapp models cannot be found by Django.
|
|
72
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Tells you exactly which select_related/prefetch_related to add to your DRF viewset."""
|
|
2
|
+
|
|
3
|
+
# why: single source of truth for the version. hatch reads this file (see
|
|
4
|
+
# [tool.hatch.version] in pyproject.toml), so there is no second place to bump
|
|
5
|
+
# and no chance of the package metadata disagreeing with the runtime value.
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PrefetchHintConfig(AppConfig):
|
|
5
|
+
name = "prefetch_hint"
|
|
6
|
+
verbose_name = "DRF prefetch hint"
|
|
7
|
+
|
|
8
|
+
# why: no ready() hook and no signal registration on purpose. Adding this app
|
|
9
|
+
# to INSTALLED_APPS must do nothing at all except make the management command
|
|
10
|
+
# discoverable — §2 rules out middleware and runtime magic, and an app that
|
|
11
|
+
# patches anything at import time would be exactly that.
|