django-nplus1 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.
- django_nplus1-0.1.0/.gitignore +33 -0
- django_nplus1-0.1.0/LICENSE +21 -0
- django_nplus1-0.1.0/PKG-INFO +92 -0
- django_nplus1-0.1.0/README.md +37 -0
- django_nplus1-0.1.0/django_nplus1/__init__.py +14 -0
- django_nplus1-0.1.0/django_nplus1/apps.py +9 -0
- django_nplus1-0.1.0/django_nplus1/detect.py +438 -0
- django_nplus1-0.1.0/django_nplus1/exceptions.py +2 -0
- django_nplus1-0.1.0/django_nplus1/middleware.py +159 -0
- django_nplus1-0.1.0/django_nplus1/notifiers.py +88 -0
- django_nplus1-0.1.0/django_nplus1/patch.py +482 -0
- django_nplus1-0.1.0/django_nplus1/profiler.py +47 -0
- django_nplus1-0.1.0/django_nplus1/py.typed +0 -0
- django_nplus1-0.1.0/django_nplus1/pytest_plugin.py +30 -0
- django_nplus1-0.1.0/django_nplus1/signals.py +110 -0
- django_nplus1-0.1.0/django_nplus1/util.py +50 -0
- django_nplus1-0.1.0/pyproject.toml +170 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
*.py[cod]
|
|
2
|
+
__pycache__/
|
|
3
|
+
.DS_Store
|
|
4
|
+
*.sql
|
|
5
|
+
*.bz2
|
|
6
|
+
*~
|
|
7
|
+
*.log
|
|
8
|
+
*.wsgi
|
|
9
|
+
local_settings.py
|
|
10
|
+
development_settings.py
|
|
11
|
+
*.egg-info
|
|
12
|
+
.project
|
|
13
|
+
.pydevproject
|
|
14
|
+
.settings
|
|
15
|
+
versiontools*
|
|
16
|
+
_build*
|
|
17
|
+
doc/index.html
|
|
18
|
+
/build/
|
|
19
|
+
/dist/
|
|
20
|
+
*.swp
|
|
21
|
+
\#*
|
|
22
|
+
.\#*
|
|
23
|
+
.tox
|
|
24
|
+
dump.rdb
|
|
25
|
+
.idea
|
|
26
|
+
.venv
|
|
27
|
+
.coverage
|
|
28
|
+
coverage.xml
|
|
29
|
+
cobertura.xml
|
|
30
|
+
CLAUDE.md
|
|
31
|
+
site/
|
|
32
|
+
*.sqlite3
|
|
33
|
+
docs/superpowers/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oliver Haas
|
|
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,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-nplus1
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: N+1 query detection for Django
|
|
5
|
+
Project-URL: Homepage, https://github.com/oliverhaas/django-nplus1
|
|
6
|
+
Project-URL: Documentation, https://oliverhaas.github.io/django-nplus1/
|
|
7
|
+
Project-URL: Repository, https://github.com/oliverhaas/django-nplus1.git
|
|
8
|
+
Project-URL: Changelog, https://oliverhaas.github.io/django-nplus1/reference/changelog/
|
|
9
|
+
Author-email: Oliver Haas <ohaas@e1plus.de>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Oliver Haas
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: detection,django,n+1,orm,performance,query
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Environment :: Web Environment
|
|
35
|
+
Classifier: Framework :: Django
|
|
36
|
+
Classifier: Framework :: Django :: 5.2
|
|
37
|
+
Classifier: Framework :: Django :: 6.0
|
|
38
|
+
Classifier: Framework :: Pytest
|
|
39
|
+
Classifier: Intended Audience :: Developers
|
|
40
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
41
|
+
Classifier: Operating System :: OS Independent
|
|
42
|
+
Classifier: Programming Language :: Python
|
|
43
|
+
Classifier: Programming Language :: Python :: 3
|
|
44
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
48
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
49
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
50
|
+
Classifier: Topic :: Software Development :: Testing
|
|
51
|
+
Classifier: Typing :: Typed
|
|
52
|
+
Requires-Python: >=3.12
|
|
53
|
+
Requires-Dist: django<7,>=5.2
|
|
54
|
+
Description-Content-Type: text/markdown
|
|
55
|
+
|
|
56
|
+
# django-nplus1
|
|
57
|
+
|
|
58
|
+
N+1 query detection for Django. Beta - API may still change before 1.0.
|
|
59
|
+
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install django-nplus1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# settings.py
|
|
68
|
+
INSTALLED_APPS = [..., "django_nplus1"]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# tests.py
|
|
73
|
+
@pytest.mark.nplus1
|
|
74
|
+
class TestMyView:
|
|
75
|
+
def test_list_books(self, books):
|
|
76
|
+
response = client.get("/books/") # raises NPlus1Error if view has N+1
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Tests marked with `@pytest.mark.nplus1` will fail if the code under test triggers an N+1 query. Fix the N+1, or use `nplus1_allow()` in helper functions that intentionally defer prefetching to their callers.
|
|
80
|
+
|
|
81
|
+
See [examples/](examples/) for a working project and the [docs](https://oliverhaas.github.io/django-nplus1/) for full configuration.
|
|
82
|
+
|
|
83
|
+
## Credits
|
|
84
|
+
|
|
85
|
+
This project builds on the work of:
|
|
86
|
+
|
|
87
|
+
- [nplusone](https://github.com/jmcarp/nplusone) by Joshua Carp - the original automatic N+1 detection library for Python ORMs. django-nplus1 started as a Django-specific fork of nplusone's architecture.
|
|
88
|
+
- [django-zeal](https://github.com/taobojlen/django-zeal) by Tao Bojlen - inspired several features including deferred field detection, `.get()`-in-a-loop detection, `ContextVar`-based async safety, call-site tracking, and configurable thresholds.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# django-nplus1
|
|
2
|
+
|
|
3
|
+
N+1 query detection for Django. Beta - API may still change before 1.0.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install django-nplus1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
# settings.py
|
|
13
|
+
INSTALLED_APPS = [..., "django_nplus1"]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# tests.py
|
|
18
|
+
@pytest.mark.nplus1
|
|
19
|
+
class TestMyView:
|
|
20
|
+
def test_list_books(self, books):
|
|
21
|
+
response = client.get("/books/") # raises NPlus1Error if view has N+1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Tests marked with `@pytest.mark.nplus1` will fail if the code under test triggers an N+1 query. Fix the N+1, or use `nplus1_allow()` in helper functions that intentionally defer prefetching to their callers.
|
|
25
|
+
|
|
26
|
+
See [examples/](examples/) for a working project and the [docs](https://oliverhaas.github.io/django-nplus1/) for full configuration.
|
|
27
|
+
|
|
28
|
+
## Credits
|
|
29
|
+
|
|
30
|
+
This project builds on the work of:
|
|
31
|
+
|
|
32
|
+
- [nplusone](https://github.com/jmcarp/nplusone) by Joshua Carp - the original automatic N+1 detection library for Python ORMs. django-nplus1 started as a Django-specific fork of nplusone's architecture.
|
|
33
|
+
- [django-zeal](https://github.com/taobojlen/django-zeal) by Tao Bojlen - inspired several features including deferred field detection, `.get()`-in-a-loop detection, `ContextVar`-based async safety, call-site tracking, and configurable thresholds.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from django_nplus1.detect import nplus1_allow
|
|
4
|
+
from django_nplus1.exceptions import NPlus1Error
|
|
5
|
+
from django_nplus1.middleware import NPlus1Middleware
|
|
6
|
+
from django_nplus1.profiler import Profiler
|
|
7
|
+
from django_nplus1.signals import nplus1_detected
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("django-nplus1")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
__all__ = ["NPlus1Error", "NPlus1Middleware", "Profiler", "__version__", "nplus1_allow", "nplus1_detected"]
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import fnmatch
|
|
5
|
+
import re
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Generator, Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Rule:
|
|
15
|
+
def __init__(self, label: str | None = None, model: str | type | None = None, field: str | None = None) -> None:
|
|
16
|
+
self.label = label
|
|
17
|
+
self.model = model
|
|
18
|
+
self.field = field
|
|
19
|
+
|
|
20
|
+
def compare(self, label: str, model: type, field: str) -> bool:
|
|
21
|
+
return bool(
|
|
22
|
+
(self.label or self.model or self.field)
|
|
23
|
+
and (self.label is None or self.label == label)
|
|
24
|
+
and (self.model is None or self.match_model(model))
|
|
25
|
+
and (self.field is None or self.match_field(field)),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def match_field(self, field: str) -> bool:
|
|
29
|
+
if self.field is None:
|
|
30
|
+
return True
|
|
31
|
+
return fnmatch.fnmatch(field, self.field)
|
|
32
|
+
|
|
33
|
+
def match_model(self, model: type) -> bool:
|
|
34
|
+
if self.model is model:
|
|
35
|
+
return True
|
|
36
|
+
if isinstance(self.model, str):
|
|
37
|
+
return fnmatch.fnmatch(model.__name__, self.model)
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_allow_rules: ContextVar[list[Rule]] = ContextVar("nplus1_allow_rules")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_allowed(message: Message) -> bool:
|
|
45
|
+
"""Check if a message is suppressed by nplus1_allow rules."""
|
|
46
|
+
try:
|
|
47
|
+
rules = _allow_rules.get()
|
|
48
|
+
except LookupError:
|
|
49
|
+
return False
|
|
50
|
+
return message.match(rules) if rules else False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def nplus1_allow(whitelist: list[dict[str, Any]] | None = None) -> Generator[None]:
|
|
55
|
+
"""Context manager to suppress N+1 detection for specific model/field combinations.
|
|
56
|
+
|
|
57
|
+
With no arguments, suppresses all detections. With a whitelist, suppresses only
|
|
58
|
+
matching detections. Uses the same format as ``Profiler(whitelist=...)`` and
|
|
59
|
+
``@pytest.mark.nplus1(whitelist=...)``.
|
|
60
|
+
|
|
61
|
+
Usage::
|
|
62
|
+
|
|
63
|
+
# Suppress all detections
|
|
64
|
+
with nplus1_allow():
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
# Suppress specific model/field
|
|
68
|
+
with nplus1_allow([{"model": "User", "field": "hobbies"}]):
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
# Suppress all fields on a model (supports fnmatch wildcards)
|
|
72
|
+
with nplus1_allow([{"model": "User"}]):
|
|
73
|
+
...
|
|
74
|
+
"""
|
|
75
|
+
rules = [Rule(**item) for item in whitelist] if whitelist else [Rule(model="*", field="*")]
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
current = _allow_rules.get()
|
|
79
|
+
except LookupError:
|
|
80
|
+
current = []
|
|
81
|
+
token = _allow_rules.set([*current, *rules])
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
_allow_rules.reset(token)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Message:
|
|
89
|
+
label: str = ""
|
|
90
|
+
formatter: str = ""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
model: type,
|
|
95
|
+
field: str,
|
|
96
|
+
caller: tuple[str, int, str] | None = None,
|
|
97
|
+
callers: list[list[tuple[str, int, str]]] | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.model = model
|
|
100
|
+
self.field = field
|
|
101
|
+
self.caller = caller
|
|
102
|
+
self.callers = callers
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def message(self) -> str:
|
|
106
|
+
base = self.formatter.format(
|
|
107
|
+
label=self.label,
|
|
108
|
+
model=self.model.__name__,
|
|
109
|
+
field=self.field,
|
|
110
|
+
)
|
|
111
|
+
if self.callers:
|
|
112
|
+
parts = [base, " with calls:"]
|
|
113
|
+
for i, stack in enumerate(self.callers, 1):
|
|
114
|
+
parts.append(f"\nCALL {i}:")
|
|
115
|
+
for fn, lineno, funcname in stack:
|
|
116
|
+
parts.append(f"\n {fn}:{lineno} in {funcname}")
|
|
117
|
+
return "".join(parts)
|
|
118
|
+
if self.caller:
|
|
119
|
+
filename, lineno, funcname = self.caller
|
|
120
|
+
return f"{base} at {filename}:{lineno} in {funcname}"
|
|
121
|
+
return base
|
|
122
|
+
|
|
123
|
+
def match(self, rules: Sequence[Rule]) -> bool:
|
|
124
|
+
return any(rule.compare(self.label, self.model, self.field) for rule in rules)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LazyLoadMessage(Message):
|
|
128
|
+
label = "n_plus_one"
|
|
129
|
+
formatter = "Potential n+1 query detected on `{model}.{field}`"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class EagerLoadMessage(Message):
|
|
133
|
+
label = "unused_eager_load"
|
|
134
|
+
formatter = "Potential unnecessary eager load detected on `{model}.{field}`"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class GetLoopMessage(Message):
|
|
138
|
+
label = "get_in_loop"
|
|
139
|
+
formatter = "Potential n+1 query detected on `{model}.{field}`"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Listener:
|
|
143
|
+
def __init__(self, parent: Any) -> None:
|
|
144
|
+
self.parent = parent
|
|
145
|
+
|
|
146
|
+
def setup(self) -> None:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def teardown(self) -> None:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class LazyListener(Listener):
|
|
154
|
+
loaded: set[str]
|
|
155
|
+
ignore: set[str]
|
|
156
|
+
counts: defaultdict[tuple[type, str], int]
|
|
157
|
+
show_all_callers: bool
|
|
158
|
+
call_stacks: defaultdict[tuple[type, str], list[list[tuple[str, int, str]]]]
|
|
159
|
+
|
|
160
|
+
def setup(self) -> None:
|
|
161
|
+
from django.conf import settings
|
|
162
|
+
|
|
163
|
+
from django_nplus1 import signals
|
|
164
|
+
|
|
165
|
+
self.loaded = set()
|
|
166
|
+
self.ignore = set()
|
|
167
|
+
self.counts = defaultdict(int)
|
|
168
|
+
self.threshold = getattr(settings, "NPLUS1_THRESHOLD", 2)
|
|
169
|
+
self.show_all_callers = getattr(settings, "NPLUS1_SHOW_ALL_CALLERS", False)
|
|
170
|
+
self.call_stacks = defaultdict(list)
|
|
171
|
+
signals.connect(signals.LOAD, self.handle_load)
|
|
172
|
+
signals.connect(signals.IGNORE_LOAD, self.handle_ignore)
|
|
173
|
+
signals.connect(signals.LAZY_LOAD, self.handle_lazy)
|
|
174
|
+
signals.connect(signals.EAGER_LOAD, self.handle_eager)
|
|
175
|
+
|
|
176
|
+
def teardown(self) -> None:
|
|
177
|
+
from django_nplus1 import signals
|
|
178
|
+
|
|
179
|
+
signals.disconnect(signals.LOAD, self.handle_load)
|
|
180
|
+
signals.disconnect(signals.IGNORE_LOAD, self.handle_ignore)
|
|
181
|
+
signals.disconnect(signals.LAZY_LOAD, self.handle_lazy)
|
|
182
|
+
signals.disconnect(signals.EAGER_LOAD, self.handle_eager)
|
|
183
|
+
|
|
184
|
+
def handle_load(
|
|
185
|
+
self,
|
|
186
|
+
args: tuple[Any, ...] | None = None,
|
|
187
|
+
kwargs: dict[str, Any] | None = None,
|
|
188
|
+
context: dict[str, Any] | None = None,
|
|
189
|
+
ret: Any = None,
|
|
190
|
+
parser: Any = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
instances = parser(args, kwargs, context, ret)
|
|
193
|
+
self.loaded.update(instances)
|
|
194
|
+
|
|
195
|
+
def handle_ignore(
|
|
196
|
+
self,
|
|
197
|
+
args: tuple[Any, ...] | None = None,
|
|
198
|
+
kwargs: dict[str, Any] | None = None,
|
|
199
|
+
context: dict[str, Any] | None = None,
|
|
200
|
+
ret: Any = None,
|
|
201
|
+
parser: Any = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
instances = parser(args, kwargs, context, ret)
|
|
204
|
+
self.ignore.update(instances)
|
|
205
|
+
|
|
206
|
+
def handle_lazy(
|
|
207
|
+
self,
|
|
208
|
+
args: tuple[Any, ...] | None = None,
|
|
209
|
+
kwargs: dict[str, Any] | None = None,
|
|
210
|
+
context: dict[str, Any] | None = None,
|
|
211
|
+
ret: Any = None,
|
|
212
|
+
parser: Any = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
model, instance, field = parser(args, kwargs, context)
|
|
215
|
+
if instance in self.loaded and instance not in self.ignore:
|
|
216
|
+
key = (model, field)
|
|
217
|
+
self.counts[key] += 1
|
|
218
|
+
if self.show_all_callers:
|
|
219
|
+
from django_nplus1.util import get_stack
|
|
220
|
+
|
|
221
|
+
self.call_stacks[key].append(get_stack())
|
|
222
|
+
if self.counts[key] == self.threshold:
|
|
223
|
+
if self.show_all_callers:
|
|
224
|
+
message = LazyLoadMessage(model, field, callers=self.call_stacks[key])
|
|
225
|
+
else:
|
|
226
|
+
from django_nplus1.util import get_caller
|
|
227
|
+
|
|
228
|
+
caller = get_caller()
|
|
229
|
+
message = LazyLoadMessage(model, field, caller=caller)
|
|
230
|
+
self.parent.notify(message)
|
|
231
|
+
|
|
232
|
+
def handle_eager(
|
|
233
|
+
self,
|
|
234
|
+
args: tuple[Any, ...] | None = None,
|
|
235
|
+
kwargs: dict[str, Any] | None = None,
|
|
236
|
+
context: dict[str, Any] | None = None,
|
|
237
|
+
ret: Any = None,
|
|
238
|
+
parser: Any = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
# When a single bulk-loaded instance triggers an eager load (e.g.
|
|
241
|
+
# select_related on one item from a queryset), this is semantically
|
|
242
|
+
# an N+1 pattern, not an unused eager load. We use LazyLoadMessage
|
|
243
|
+
# with label "n_plus_one" intentionally.
|
|
244
|
+
model, field, keys, _key = parser(args, kwargs, context)
|
|
245
|
+
if len(keys) == 1 and keys[0] in self.loaded and keys[0] not in self.ignore:
|
|
246
|
+
key = (model, field)
|
|
247
|
+
self.counts[key] += 1
|
|
248
|
+
if self.show_all_callers:
|
|
249
|
+
from django_nplus1.util import get_stack
|
|
250
|
+
|
|
251
|
+
self.call_stacks[key].append(get_stack())
|
|
252
|
+
if self.counts[key] == self.threshold:
|
|
253
|
+
if self.show_all_callers:
|
|
254
|
+
message = LazyLoadMessage(model, field, callers=self.call_stacks[key])
|
|
255
|
+
else:
|
|
256
|
+
from django_nplus1.util import get_caller
|
|
257
|
+
|
|
258
|
+
caller = get_caller()
|
|
259
|
+
message = LazyLoadMessage(model, field, caller=caller)
|
|
260
|
+
self.parent.notify(message)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class EagerListener(Listener):
|
|
264
|
+
tracker: EagerTracker
|
|
265
|
+
touched: list[tuple[type, str, list[str]] | None]
|
|
266
|
+
|
|
267
|
+
def setup(self) -> None:
|
|
268
|
+
from django_nplus1 import signals
|
|
269
|
+
|
|
270
|
+
self.tracker = EagerTracker()
|
|
271
|
+
self.touched = []
|
|
272
|
+
signals.connect(signals.EAGER_LOAD, self.handle_eager)
|
|
273
|
+
signals.connect(signals.TOUCH, self.handle_touch)
|
|
274
|
+
|
|
275
|
+
def teardown(self) -> None:
|
|
276
|
+
from django_nplus1 import signals
|
|
277
|
+
|
|
278
|
+
signals.disconnect(signals.EAGER_LOAD, self.handle_eager)
|
|
279
|
+
signals.disconnect(signals.TOUCH, self.handle_touch)
|
|
280
|
+
self.log_eager()
|
|
281
|
+
|
|
282
|
+
def handle_eager(
|
|
283
|
+
self,
|
|
284
|
+
args: tuple[Any, ...] | None = None,
|
|
285
|
+
kwargs: dict[str, Any] | None = None,
|
|
286
|
+
context: dict[str, Any] | None = None,
|
|
287
|
+
ret: Any = None,
|
|
288
|
+
parser: Any = None,
|
|
289
|
+
) -> None:
|
|
290
|
+
self.tracker.track(*parser(args, kwargs, context))
|
|
291
|
+
|
|
292
|
+
def handle_touch(
|
|
293
|
+
self,
|
|
294
|
+
args: tuple[Any, ...] | None = None,
|
|
295
|
+
kwargs: dict[str, Any] | None = None,
|
|
296
|
+
context: dict[str, Any] | None = None,
|
|
297
|
+
ret: Any = None,
|
|
298
|
+
parser: Any = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
self.touched.append(parser(args, kwargs, context))
|
|
301
|
+
|
|
302
|
+
def log_eager(self) -> None:
|
|
303
|
+
self.tracker.prune([each for each in self.touched if each])
|
|
304
|
+
for model, field in self.tracker.unused:
|
|
305
|
+
message = EagerLoadMessage(model, field)
|
|
306
|
+
self.parent.notify(message)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class EagerTracker:
|
|
310
|
+
def __init__(self) -> None:
|
|
311
|
+
self.data: defaultdict[tuple[type, str], defaultdict[int, set[str]]] = defaultdict(
|
|
312
|
+
lambda: defaultdict(set),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def track(self, model: type, field: str, instances: list[str], key: int) -> None:
|
|
316
|
+
self.data[(model, field)][key].update(instances)
|
|
317
|
+
|
|
318
|
+
def prune(self, touched: list[tuple[type, str, list[str]]]) -> None:
|
|
319
|
+
for model, field, touch_instances in touched:
|
|
320
|
+
group = self.data[(model, field)]
|
|
321
|
+
for key, fetch_instances in list(group.items()):
|
|
322
|
+
if touch_instances and fetch_instances.intersection(touch_instances):
|
|
323
|
+
group.pop(key, None)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def unused(self) -> list[tuple[type, str]]:
|
|
327
|
+
return [(model, field) for (model, field), group in self.data.items() if group]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class GetLoopListener(Listener):
|
|
331
|
+
"""Detects Model.objects.get() called repeatedly from the same call-site."""
|
|
332
|
+
|
|
333
|
+
counts: defaultdict[tuple[Any, ...], int]
|
|
334
|
+
|
|
335
|
+
def setup(self) -> None:
|
|
336
|
+
from django.conf import settings
|
|
337
|
+
|
|
338
|
+
from django_nplus1 import signals
|
|
339
|
+
|
|
340
|
+
self.counts = defaultdict(int)
|
|
341
|
+
self.threshold = getattr(settings, "NPLUS1_GET_THRESHOLD", 2)
|
|
342
|
+
signals.connect(signals.GET_CALL, self.handle_get)
|
|
343
|
+
|
|
344
|
+
def teardown(self) -> None:
|
|
345
|
+
from django_nplus1 import signals
|
|
346
|
+
|
|
347
|
+
signals.disconnect(signals.GET_CALL, self.handle_get)
|
|
348
|
+
|
|
349
|
+
def handle_get(
|
|
350
|
+
self,
|
|
351
|
+
args: tuple[Any, ...] | None = None,
|
|
352
|
+
kwargs: dict[str, Any] | None = None,
|
|
353
|
+
context: dict[str, Any] | None = None,
|
|
354
|
+
ret: Any = None,
|
|
355
|
+
parser: Any = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
model, caller = parser(args, kwargs, context, ret)
|
|
358
|
+
key = (model, *caller)
|
|
359
|
+
self.counts[key] += 1
|
|
360
|
+
if self.counts[key] == self.threshold:
|
|
361
|
+
message = GetLoopMessage(model, "get()", caller=caller)
|
|
362
|
+
self.parent.notify(message)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class DuplicateQueryMessage(Message):
|
|
366
|
+
label = "duplicate_query"
|
|
367
|
+
formatter = "Potential n+1 query detected: duplicate query `{field}`"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _SQL:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_SQL_LITERAL_RE = re.compile(r"'(?:[^']|'')*'|\b\d+\b")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _fingerprint_sql(sql: str) -> str:
|
|
378
|
+
"""Normalize a SQL query by replacing literals with ?, collapsing whitespace."""
|
|
379
|
+
return " ".join(_SQL_LITERAL_RE.sub("?", sql).split())
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class DuplicateQueryListener(Listener):
|
|
383
|
+
"""Detects repeated identical SQL queries (N+1 fallback for raw SQL).
|
|
384
|
+
|
|
385
|
+
Uses Django's connection.execute_wrapper to intercept all queries,
|
|
386
|
+
fingerprints them by normalizing literals, and counts occurrences
|
|
387
|
+
from the same call-site. This catches N+1 patterns from raw SQL,
|
|
388
|
+
.raw(), and any ORM path not covered by the descriptor-level detection.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def __init__(self, parent: Any) -> None:
|
|
392
|
+
super().__init__(parent)
|
|
393
|
+
self.enabled = False
|
|
394
|
+
self.counts: defaultdict[tuple[str, str, int, str], int] = defaultdict(int)
|
|
395
|
+
self._connection: Any = None
|
|
396
|
+
|
|
397
|
+
def setup(self) -> None:
|
|
398
|
+
from django.conf import settings
|
|
399
|
+
from django.db import connection
|
|
400
|
+
|
|
401
|
+
self.enabled = getattr(settings, "NPLUS1_DETECT_DUPLICATE_QUERIES", False)
|
|
402
|
+
if not self.enabled:
|
|
403
|
+
return
|
|
404
|
+
self.counts = defaultdict(int)
|
|
405
|
+
self.threshold = getattr(settings, "NPLUS1_DUPLICATE_QUERY_THRESHOLD", 2)
|
|
406
|
+
self._connection = connection
|
|
407
|
+
self._connection.execute_wrappers.append(self._wrapper)
|
|
408
|
+
|
|
409
|
+
def teardown(self) -> None:
|
|
410
|
+
if not self.enabled:
|
|
411
|
+
return
|
|
412
|
+
try:
|
|
413
|
+
self._connection.execute_wrappers.remove(self._wrapper)
|
|
414
|
+
except ValueError:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
def _wrapper(self, execute: Any, sql: str, params: Any, many: bool, context: Any) -> Any:
|
|
418
|
+
result = execute(sql, params, many, context)
|
|
419
|
+
if not many:
|
|
420
|
+
from django_nplus1.util import get_caller
|
|
421
|
+
|
|
422
|
+
fingerprint = _fingerprint_sql(sql)
|
|
423
|
+
caller = get_caller()
|
|
424
|
+
key = (fingerprint, *caller)
|
|
425
|
+
self.counts[key] += 1
|
|
426
|
+
if self.counts[key] == self.threshold:
|
|
427
|
+
short_sql = fingerprint[:120] + ("..." if len(fingerprint) > 120 else "")
|
|
428
|
+
message = DuplicateQueryMessage(_SQL, short_sql, caller=caller)
|
|
429
|
+
self.parent.notify(message)
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
LISTENERS: dict[str, type[Listener]] = {
|
|
434
|
+
"lazy_load": LazyListener,
|
|
435
|
+
"eager_load": EagerListener,
|
|
436
|
+
"get_loop": GetLoopListener,
|
|
437
|
+
"duplicate_query": DuplicateQueryListener,
|
|
438
|
+
}
|