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.
Files changed (28) hide show
  1. drf_prefetch_hint-0.1.0/.gitignore +13 -0
  2. drf_prefetch_hint-0.1.0/CHANGELOG.md +35 -0
  3. drf_prefetch_hint-0.1.0/LICENSE +21 -0
  4. drf_prefetch_hint-0.1.0/PKG-INFO +263 -0
  5. drf_prefetch_hint-0.1.0/README.md +226 -0
  6. drf_prefetch_hint-0.1.0/pyproject.toml +72 -0
  7. drf_prefetch_hint-0.1.0/src/prefetch_hint/__init__.py +8 -0
  8. drf_prefetch_hint-0.1.0/src/prefetch_hint/apps.py +11 -0
  9. drf_prefetch_hint-0.1.0/src/prefetch_hint/build.py +248 -0
  10. drf_prefetch_hint-0.1.0/src/prefetch_hint/capture.py +43 -0
  11. drf_prefetch_hint-0.1.0/src/prefetch_hint/management/__init__.py +0 -0
  12. drf_prefetch_hint-0.1.0/src/prefetch_hint/management/commands/__init__.py +0 -0
  13. drf_prefetch_hint-0.1.0/src/prefetch_hint/management/commands/prefetch_hint.py +269 -0
  14. drf_prefetch_hint-0.1.0/src/prefetch_hint/probe.py +296 -0
  15. drf_prefetch_hint-0.1.0/src/prefetch_hint/render.py +125 -0
  16. drf_prefetch_hint-0.1.0/src/prefetch_hint/resolve.py +162 -0
  17. drf_prefetch_hint-0.1.0/src/prefetch_hint/types.py +96 -0
  18. drf_prefetch_hint-0.1.0/tests/__init__.py +0 -0
  19. drf_prefetch_hint-0.1.0/tests/conftest.py +98 -0
  20. drf_prefetch_hint-0.1.0/tests/settings.py +19 -0
  21. drf_prefetch_hint-0.1.0/tests/test_build.py +135 -0
  22. drf_prefetch_hint-0.1.0/tests/test_command.py +105 -0
  23. drf_prefetch_hint-0.1.0/tests/test_probe.py +129 -0
  24. drf_prefetch_hint-0.1.0/tests/test_resolve.py +88 -0
  25. drf_prefetch_hint-0.1.0/tests/testapp/__init__.py +0 -0
  26. drf_prefetch_hint-0.1.0/tests/testapp/models.py +60 -0
  27. drf_prefetch_hint-0.1.0/tests/testapp/serializers.py +79 -0
  28. drf_prefetch_hint-0.1.0/tests/testapp/views.py +29 -0
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .tox/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ *.sqlite3
12
+ .coverage
13
+ htmlcov/
@@ -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
+ [![ci](https://github.com/papansarkar101/drf-prefetch-hint/actions/workflows/ci.yml/badge.svg)](https://github.com/papansarkar101/drf-prefetch-hint/actions/workflows/ci.yml)
41
+ [![pypi](https://img.shields.io/pypi/v/drf-prefetch-hint.svg)](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
+ [![ci](https://github.com/papansarkar101/drf-prefetch-hint/actions/workflows/ci.yml/badge.svg)](https://github.com/papansarkar101/drf-prefetch-hint/actions/workflows/ci.yml)
4
+ [![pypi](https://img.shields.io/pypi/v/drf-prefetch-hint.svg)](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.