Metadata-Version: 2.5
Name: drf-prefetch-hint
Version: 0.1.0
Summary: Tells you exactly which select_related/prefetch_related to add to your DRF viewset.
Project-URL: Homepage, https://github.com/papansarkar101/drf-prefetch-hint
Project-URL: Issues, https://github.com/papansarkar101/drf-prefetch-hint/issues
Project-URL: Changelog, https://github.com/papansarkar101/drf-prefetch-hint/blob/main/CHANGELOG.md
Author-email: Papan Sarkar <papansarkar101@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: django,djangorestframework,n+1,orm,performance,prefetch_related
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Debuggers
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Requires-Dist: djangorestframework>=3.14
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-django; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: tox; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Description-Content-Type: text/markdown

# drf-prefetch-hint

[![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)
[![pypi](https://img.shields.io/pypi/v/drf-prefetch-hint.svg)](https://pypi.org/project/drf-prefetch-hint/)

**Tells you exactly which `select_related` / `prefetch_related` to add to your DRF viewset.**

Other tools tell you that you have an N+1. This one writes the fix.

```
$ python manage.py prefetch_hint shop.views.AuthorViewSet --count 10

AuthorViewSet — 51 queries on 10 objects

  books.reviews         reverse FK   → prefetch_related
                          20 queries   shop/views.py:7
  books                 reverse FK   → prefetch_related
                          10 queries   shop/views.py:11
  publisher.name        FK           → select_related
                          10 queries   shop/views.py:10
  summary               method       → 10 queries, manual fix needed
                          10 queries   shop/views.py:12

  Add to get_queryset():

    .select_related("publisher")
    .prefetch_related(Prefetch("books", queryset=Book.objects.prefetch_related("reviews")))

  Projected: 51 → ~13 queries (estimate)

  Not auto-fixable:
    summary — SerializerMethodField runs arbitrary code
```

Pasting that suggestion verbatim takes this endpoint from **51 queries to 3**.

Note what it worked out on its own: `reviews` hangs off `books`, which is itself a
reverse FK, so it belongs in a `prefetch_related` on the **inner** `Prefetch`
queryset — not flattened onto the outer one. That is the part that costs you an hour.

## Requirements

| | Supported |
|---|---|
| Python | 3.10 – 3.13 |
| Django | 4.2, 5.0, 5.1 |
| Django REST Framework | 3.14+ |

Tested in CI against the oldest and newest supported combinations.

## Install

```bash
pip install drf-prefetch-hint
```

```python
INSTALLED_APPS = [
    ...
    "prefetch_hint",
]
```

It is a development tool. There is no middleware and no runtime hook — nothing
runs unless you type the command. You can leave it out of production requirements
entirely.

## When to use it

You have one list endpoint that is slow. You already know it is an N+1. You do not
want to spend an hour working out the exact nested `Prefetch` incantation.

The loop is:

1. **Run it** against the viewset, from your project root:

   ```bash
   python manage.py prefetch_hint shop.views.AuthorViewSet --count 25
   ```

2. **Paste** the expression into that viewset's `get_queryset()`.

3. **Run it again.** The query count should drop and the fields should disappear
   from the report. Anything still listed is either a `SerializerMethodField` or
   something worth a closer look.

It is not a monitor and not a linter. Point it at one endpoint when that endpoint
is the problem.

## Applying the fix

Before:

```python
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = AuthorSerializer

    def get_queryset(self):
        return Author.objects.all()
```

After — the generated expression pasted onto the end of the chain:

```python
from django.db.models import Prefetch   # only needed when the output uses Prefetch(...)

from shop.models import Author, Book


class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = AuthorSerializer

    def get_queryset(self):
        return (
            Author.objects.all()
            .select_related("publisher")
            .prefetch_related(
                Prefetch("books", queryset=Book.objects.prefetch_related("reviews"))
            )
        )
```

You supply two imports the tool cannot add for you: `Prefetch`, and whichever
models appear inside a `Prefetch(queryset=...)` (here, `Book`). If the output is
only strings, you need neither.

## Usage

```bash
python manage.py prefetch_hint <dotted.path.to.ViewSet> [options]
```

| Flag | Default | Purpose |
|---|---|---|
| `--count N` | 25 | Objects to serialize. Must be > 1 or N+1 is invisible. |
| `--user <pk\|username>` | `AnonymousUser` | For permission-gated `get_queryset()` |
| `--action <name>` | `list` | ViewSet action — affects serializer selection |
| `--raw` | off | Print raw SQL per field group |
| `--no-color` | off | Plain output for piping |
| `--force` | off | Run even when `DEBUG=False` |

Everything runs inside a transaction that is always rolled back. The command never
writes, and DRF is left unpatched on every exit path including exceptions.

## Troubleshooting

**`get_queryset() raised AttributeError`** — your `get_queryset()` depends on the
request user. Pass one:

```bash
python manage.py prefetch_hint shop.views.AuthorViewSet --user 1
python manage.py prefetch_hint shop.views.AuthorViewSet --user alice
```

**It analysed the wrong serializer.** Most real viewsets return a different
serializer per action. Pass the one you care about:

```bash
python manage.py prefetch_hint shop.views.AuthorViewSet --action retrieve
```

**`DEBUG=False. prefetch_hint is a development tool`** — intentional. This
serializes real rows; it is not meant for production. `--force` overrides it if
you know what you are doing.

**It reported nothing.** Either the viewset is already optimized (good — it is
built to stay silent in that case) or `--count` is too low for the pattern to show.
Try `--count 50`.

**A field is listed but no fix was generated.** It is a `SerializerMethodField`,
or a path that could not be resolved through `_meta`. See Limitations.

## What it does not do

Deliberately. These are other packages' jobs and several already do them well:

- **Watching queries as you browse** — use [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar)
- **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)
- **Detecting N+1 at runtime across your whole app** — use [zealot](https://github.com/jmcarp/zealot)
- **Production monitoring** — use Sentry or Scout

No middleware, no pytest plugin, no CI mode, no config file, no web UI, no
auto-patching of your source. One command, one output.

It also only supports DRF serializers — not plain Django views, generic CBVs,
templates, the admin, GraphQL, or Django Ninja.

## Limitations

Read these before trusting the output.

- **`SerializerMethodField` cannot be resolved.** It runs arbitrary code, so
  nothing in the field declaration reveals which relations it touches. These are
  reported with their query count and marked *manual fix needed*. The tool will
  not guess — a wrong guess is worse than silence.
- **The projection is an estimate, not a promise.** It assumes every method-field
  query survives the fix. In practice a prefetch often satisfies them for free, so
  the real result is frequently better than projected. It also does not model
  queries fired outside serialization, such as a paginator's `COUNT`.
- **Suggestions are a starting point, not gospel.** They reflect the one code path
  that ran, with the user and action you passed.
- **Development only.** It refuses to run under `DEBUG=False` without `--force`.
- **`--count` matters.** Too small and a relation may not look like an N+1 yet.

## How it works

DRF resolves each serializer field through `get_attribute` and `to_representation`;
the package wraps both and keeps a `ContextVar` stack of whichever field is
currently being resolved. Django's `connection.execute_wrapper` sees every query as
it fires and tags it with whatever is on top of that stack — so each query is
attributed to the exact serializer field that caused it. Those field paths are then
walked through `model._meta` to decide whether each one needs a JOIN or a second
query, and the result is assembled into an ORM expression that is
`ast.parse`-validated before it is ever printed.

More detail, including the two traps that make this harder than it looks, in
[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md).

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports with a minimal serializer that
reproduces the problem are the most useful thing you can send.

## License

MIT
