django-nplus1 0.1.0a1__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.0a1/.gitignore +33 -0
- django_nplus1-0.1.0a1/LICENSE +21 -0
- django_nplus1-0.1.0a1/PKG-INFO +82 -0
- django_nplus1-0.1.0a1/README.md +47 -0
- django_nplus1-0.1.0a1/django_nplus1/__init__.py +11 -0
- django_nplus1-0.1.0a1/django_nplus1/apps.py +10 -0
- django_nplus1-0.1.0a1/django_nplus1/detect.py +202 -0
- django_nplus1-0.1.0a1/django_nplus1/exceptions.py +2 -0
- django_nplus1-0.1.0a1/django_nplus1/middleware.py +60 -0
- django_nplus1-0.1.0a1/django_nplus1/notifiers.py +52 -0
- django_nplus1-0.1.0a1/django_nplus1/patch.py +386 -0
- django_nplus1-0.1.0a1/django_nplus1/profiler.py +34 -0
- django_nplus1-0.1.0a1/django_nplus1/py.typed +0 -0
- django_nplus1-0.1.0a1/django_nplus1/pytest_plugin.py +30 -0
- django_nplus1-0.1.0a1/django_nplus1/signals.py +88 -0
- django_nplus1-0.1.0a1/pyproject.toml +169 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
*.py[cod]
|
|
2
|
+
__pycache__/
|
|
3
|
+
.DS_Store
|
|
4
|
+
*.sql
|
|
5
|
+
*.bz2
|
|
6
|
+
*~
|
|
7
|
+
*.log
|
|
8
|
+
*.json
|
|
9
|
+
*.wsgi
|
|
10
|
+
local_settings.py
|
|
11
|
+
development_settings.py
|
|
12
|
+
*.egg-info
|
|
13
|
+
.project
|
|
14
|
+
.pydevproject
|
|
15
|
+
.settings
|
|
16
|
+
versiontools*
|
|
17
|
+
_build*
|
|
18
|
+
doc/index.html
|
|
19
|
+
/build/
|
|
20
|
+
/dist/
|
|
21
|
+
*.swp
|
|
22
|
+
\#*
|
|
23
|
+
.\#*
|
|
24
|
+
.tox
|
|
25
|
+
dump.rdb
|
|
26
|
+
.idea
|
|
27
|
+
.venv
|
|
28
|
+
.coverage
|
|
29
|
+
coverage.xml
|
|
30
|
+
cobertura.xml
|
|
31
|
+
CLAUDE.md
|
|
32
|
+
site/
|
|
33
|
+
*.sqlite3
|
|
@@ -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,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-nplus1
|
|
3
|
+
Version: 0.1.0a1
|
|
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
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: detection,django,n+1,orm,performance,query
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Framework :: Django :: 6.0
|
|
18
|
+
Classifier: Framework :: Pytest
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Programming Language :: Python
|
|
23
|
+
Classifier: Programming Language :: Python :: 3
|
|
24
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
28
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
29
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
30
|
+
Classifier: Topic :: Software Development :: Testing
|
|
31
|
+
Classifier: Typing :: Typed
|
|
32
|
+
Requires-Python: >=3.12
|
|
33
|
+
Requires-Dist: django<7,>=5.2
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# django-nplus1
|
|
37
|
+
|
|
38
|
+
N+1 query detection for Django.
|
|
39
|
+
|
|
40
|
+
Detects N+1 queries (lazy loading related objects in a loop) and unused eager loads (`select_related`/`prefetch_related` that are never accessed) in your Django application.
|
|
41
|
+
|
|
42
|
+
Modernized fork of [nplusone](https://github.com/jmcarp/nplusone), stripped down to Django-only support with Python 3.12+ / Django 5.2+.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **N+1 detection**: Warns when a related object is lazily loaded on an instance that was part of a bulk query
|
|
47
|
+
- **Unused eager load detection**: Warns when `select_related` or `prefetch_related` results are never accessed
|
|
48
|
+
- **Middleware**: Automatically monitors all requests
|
|
49
|
+
- **pytest plugin**: `nplus1` fixture and `@pytest.mark.nplus1` marker for test-time detection
|
|
50
|
+
- **Profiler**: Context manager for manual use in scripts or tests
|
|
51
|
+
- **Whitelisting**: Ignore specific model/field combinations with wildcard support
|
|
52
|
+
- **Zero dependencies**: Only requires Django (no blinker, no six)
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install django-nplus1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
# settings.py
|
|
62
|
+
INSTALLED_APPS = [
|
|
63
|
+
...,
|
|
64
|
+
"django_nplus1",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
MIDDLEWARE = [
|
|
68
|
+
...,
|
|
69
|
+
"django_nplus1.NPlusOneMiddleware",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Optional
|
|
73
|
+
NPLUS1_RAISE = True # Raise exceptions instead of logging (recommended for tests)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Documentation
|
|
77
|
+
|
|
78
|
+
Full documentation at [oliverhaas.github.io/django-nplus1](https://oliverhaas.github.io/django-nplus1/).
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# django-nplus1
|
|
2
|
+
|
|
3
|
+
N+1 query detection for Django.
|
|
4
|
+
|
|
5
|
+
Detects N+1 queries (lazy loading related objects in a loop) and unused eager loads (`select_related`/`prefetch_related` that are never accessed) in your Django application.
|
|
6
|
+
|
|
7
|
+
Modernized fork of [nplusone](https://github.com/jmcarp/nplusone), stripped down to Django-only support with Python 3.12+ / Django 5.2+.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **N+1 detection**: Warns when a related object is lazily loaded on an instance that was part of a bulk query
|
|
12
|
+
- **Unused eager load detection**: Warns when `select_related` or `prefetch_related` results are never accessed
|
|
13
|
+
- **Middleware**: Automatically monitors all requests
|
|
14
|
+
- **pytest plugin**: `nplus1` fixture and `@pytest.mark.nplus1` marker for test-time detection
|
|
15
|
+
- **Profiler**: Context manager for manual use in scripts or tests
|
|
16
|
+
- **Whitelisting**: Ignore specific model/field combinations with wildcard support
|
|
17
|
+
- **Zero dependencies**: Only requires Django (no blinker, no six)
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install django-nplus1
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# settings.py
|
|
27
|
+
INSTALLED_APPS = [
|
|
28
|
+
...,
|
|
29
|
+
"django_nplus1",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
MIDDLEWARE = [
|
|
33
|
+
...,
|
|
34
|
+
"django_nplus1.NPlusOneMiddleware",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Optional
|
|
38
|
+
NPLUS1_RAISE = True # Raise exceptions instead of logging (recommended for tests)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
Full documentation at [oliverhaas.github.io/django-nplus1](https://oliverhaas.github.io/django-nplus1/).
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from django_nplus1.middleware import NPlusOneMiddleware
|
|
4
|
+
from django_nplus1.profiler import Profiler
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = version("django-nplus1")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "0.0.0+unknown"
|
|
10
|
+
|
|
11
|
+
__all__ = ["NPlusOneMiddleware", "Profiler", "__version__"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoNPlus1Config(AppConfig):
|
|
5
|
+
name = "django_nplus1"
|
|
6
|
+
verbose_name = "Django N+1"
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
|
|
9
|
+
def ready(self) -> None:
|
|
10
|
+
from django_nplus1 import patch # noqa: F401
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Rule:
|
|
12
|
+
def __init__(self, label: str | None = None, model: str | type | None = None, field: str | None = None) -> None:
|
|
13
|
+
self.label = label
|
|
14
|
+
self.model = model
|
|
15
|
+
self.field = field
|
|
16
|
+
|
|
17
|
+
def compare(self, label: str, model: type, field: str) -> bool:
|
|
18
|
+
return bool(
|
|
19
|
+
(self.label or self.model or self.field)
|
|
20
|
+
and (self.label is None or self.label == label)
|
|
21
|
+
and (self.model is None or self.match_model(model))
|
|
22
|
+
and (self.field is None or self.field == field),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def match_model(self, model: type) -> bool:
|
|
26
|
+
if self.model is model:
|
|
27
|
+
return True
|
|
28
|
+
if isinstance(self.model, str):
|
|
29
|
+
return fnmatch.fnmatch(model.__name__, self.model)
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Message:
|
|
34
|
+
label: str = ""
|
|
35
|
+
formatter: str = ""
|
|
36
|
+
|
|
37
|
+
def __init__(self, model: type, field: str) -> None:
|
|
38
|
+
self.model = model
|
|
39
|
+
self.field = field
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def message(self) -> str:
|
|
43
|
+
return self.formatter.format(
|
|
44
|
+
label=self.label,
|
|
45
|
+
model=self.model.__name__,
|
|
46
|
+
field=self.field,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def match(self, rules: Sequence[Rule]) -> bool:
|
|
50
|
+
return any(rule.compare(self.label, self.model, self.field) for rule in rules)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LazyLoadMessage(Message):
|
|
54
|
+
label = "n_plus_one"
|
|
55
|
+
formatter = "Potential n+1 query detected on `{model}.{field}`"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EagerLoadMessage(Message):
|
|
59
|
+
label = "unused_eager_load"
|
|
60
|
+
formatter = "Potential unnecessary eager load detected on `{model}.{field}`"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Listener:
|
|
64
|
+
def __init__(self, parent: Any) -> None:
|
|
65
|
+
self.parent = parent
|
|
66
|
+
|
|
67
|
+
def setup(self) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def teardown(self) -> None:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LazyListener(Listener):
|
|
75
|
+
loaded: set[str]
|
|
76
|
+
ignore: set[str]
|
|
77
|
+
|
|
78
|
+
def setup(self) -> None:
|
|
79
|
+
from django_nplus1 import signals
|
|
80
|
+
|
|
81
|
+
self.loaded = set()
|
|
82
|
+
self.ignore = set()
|
|
83
|
+
signals.connect(signals.LOAD, self.handle_load, sender=signals.get_worker())
|
|
84
|
+
signals.connect(signals.IGNORE_LOAD, self.handle_ignore, sender=signals.get_worker())
|
|
85
|
+
signals.connect(signals.LAZY_LOAD, self.handle_lazy, sender=signals.get_worker())
|
|
86
|
+
|
|
87
|
+
def teardown(self) -> None:
|
|
88
|
+
from django_nplus1 import signals
|
|
89
|
+
|
|
90
|
+
signals.disconnect(signals.LOAD, self.handle_load, sender=signals.get_worker())
|
|
91
|
+
signals.disconnect(signals.IGNORE_LOAD, self.handle_ignore, sender=signals.get_worker())
|
|
92
|
+
signals.disconnect(signals.LAZY_LOAD, self.handle_lazy, sender=signals.get_worker())
|
|
93
|
+
|
|
94
|
+
def handle_load(
|
|
95
|
+
self,
|
|
96
|
+
args: tuple[Any, ...] | None = None,
|
|
97
|
+
kwargs: dict[str, Any] | None = None,
|
|
98
|
+
context: dict[str, Any] | None = None,
|
|
99
|
+
ret: Any = None,
|
|
100
|
+
parser: Any = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
instances = parser(args, kwargs, context, ret)
|
|
103
|
+
self.loaded.update(instances)
|
|
104
|
+
|
|
105
|
+
def handle_ignore(
|
|
106
|
+
self,
|
|
107
|
+
args: tuple[Any, ...] | None = None,
|
|
108
|
+
kwargs: dict[str, Any] | None = None,
|
|
109
|
+
context: dict[str, Any] | None = None,
|
|
110
|
+
ret: Any = None,
|
|
111
|
+
parser: Any = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
instances = parser(args, kwargs, context, ret)
|
|
114
|
+
self.ignore.update(instances)
|
|
115
|
+
|
|
116
|
+
def handle_lazy(
|
|
117
|
+
self,
|
|
118
|
+
args: tuple[Any, ...] | None = None,
|
|
119
|
+
kwargs: dict[str, Any] | None = None,
|
|
120
|
+
context: dict[str, Any] | None = None,
|
|
121
|
+
ret: Any = None,
|
|
122
|
+
parser: Any = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
model, instance, field = parser(args, kwargs, context)
|
|
125
|
+
if instance in self.loaded and instance not in self.ignore:
|
|
126
|
+
message = LazyLoadMessage(model, field)
|
|
127
|
+
self.parent.notify(message)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class EagerListener(Listener):
|
|
131
|
+
tracker: EagerTracker
|
|
132
|
+
touched: list[tuple[type, str, list[str]] | None]
|
|
133
|
+
|
|
134
|
+
def setup(self) -> None:
|
|
135
|
+
from django_nplus1 import signals
|
|
136
|
+
|
|
137
|
+
self.tracker = EagerTracker()
|
|
138
|
+
self.touched = []
|
|
139
|
+
signals.connect(signals.EAGER_LOAD, self.handle_eager, sender=signals.get_worker())
|
|
140
|
+
|
|
141
|
+
def teardown(self) -> None:
|
|
142
|
+
from django_nplus1 import signals
|
|
143
|
+
|
|
144
|
+
self.log_eager()
|
|
145
|
+
signals.disconnect(signals.EAGER_LOAD, self.handle_eager, sender=signals.get_worker())
|
|
146
|
+
signals.disconnect(signals.TOUCH, self.handle_touch, sender=signals.get_worker())
|
|
147
|
+
|
|
148
|
+
def handle_eager(
|
|
149
|
+
self,
|
|
150
|
+
args: tuple[Any, ...] | None = None,
|
|
151
|
+
kwargs: dict[str, Any] | None = None,
|
|
152
|
+
context: dict[str, Any] | None = None,
|
|
153
|
+
ret: Any = None,
|
|
154
|
+
parser: Any = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
from django_nplus1 import signals
|
|
157
|
+
|
|
158
|
+
self.tracker.track(*parser(args, kwargs, context))
|
|
159
|
+
signals.connect(signals.TOUCH, self.handle_touch, sender=signals.get_worker())
|
|
160
|
+
|
|
161
|
+
def handle_touch(
|
|
162
|
+
self,
|
|
163
|
+
args: tuple[Any, ...] | None = None,
|
|
164
|
+
kwargs: dict[str, Any] | None = None,
|
|
165
|
+
context: dict[str, Any] | None = None,
|
|
166
|
+
ret: Any = None,
|
|
167
|
+
parser: Any = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self.touched.append(parser(args, kwargs, context))
|
|
170
|
+
|
|
171
|
+
def log_eager(self) -> None:
|
|
172
|
+
self.tracker.prune([each for each in self.touched if each])
|
|
173
|
+
for model, field in self.tracker.unused:
|
|
174
|
+
message = EagerLoadMessage(model, field)
|
|
175
|
+
self.parent.notify(message)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class EagerTracker:
|
|
179
|
+
def __init__(self) -> None:
|
|
180
|
+
self.data: defaultdict[tuple[type, str], defaultdict[int, set[str]]] = defaultdict(
|
|
181
|
+
lambda: defaultdict(set),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def track(self, model: type, field: str, instances: list[str], key: int) -> None:
|
|
185
|
+
self.data[(model, field)][key].update(instances)
|
|
186
|
+
|
|
187
|
+
def prune(self, touched: list[tuple[type, str, list[str]]]) -> None:
|
|
188
|
+
for model, field, touch_instances in touched:
|
|
189
|
+
group = self.data[(model, field)]
|
|
190
|
+
for key, fetch_instances in list(group.items()):
|
|
191
|
+
if touch_instances and fetch_instances.intersection(touch_instances):
|
|
192
|
+
group.pop(key, None)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def unused(self) -> list[tuple[type, str]]:
|
|
196
|
+
return [(model, field) for (model, field), group in self.data.items() if group]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
LISTENERS: dict[str, type[Listener]] = {
|
|
200
|
+
"lazy_load": LazyListener,
|
|
201
|
+
"eager_load": EagerListener,
|
|
202
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import weakref
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
from django_nplus1 import notifiers
|
|
10
|
+
from django_nplus1.detect import LISTENERS, Message, Rule
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from django.http import HttpRequest, HttpResponse
|
|
14
|
+
|
|
15
|
+
from django_nplus1.detect import Listener
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DjangoRule(Rule):
|
|
19
|
+
def match_model(self, model: type) -> bool:
|
|
20
|
+
if self.model is model:
|
|
21
|
+
return True
|
|
22
|
+
if isinstance(self.model, str):
|
|
23
|
+
return fnmatch.fnmatch(
|
|
24
|
+
f"{model._meta.app_label}.{model.__name__}", # type: ignore[attr-defined]
|
|
25
|
+
self.model,
|
|
26
|
+
)
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NPlusOneMiddleware:
|
|
31
|
+
def __init__(self, get_response: Any) -> None:
|
|
32
|
+
self.get_response = get_response
|
|
33
|
+
self._listeners: weakref.WeakKeyDictionary[HttpRequest, dict[str, Listener]] = weakref.WeakKeyDictionary()
|
|
34
|
+
self._notifiers: list[notifiers.Notifier] = []
|
|
35
|
+
self._whitelist: list[DjangoRule] = []
|
|
36
|
+
|
|
37
|
+
def _load_config(self) -> None:
|
|
38
|
+
config = dict(vars(settings._wrapped)) # type: ignore[misc]
|
|
39
|
+
self._notifiers = notifiers.init(config)
|
|
40
|
+
self._whitelist = [DjangoRule(**item) for item in getattr(settings, "NPLUS1_WHITELIST", [])]
|
|
41
|
+
|
|
42
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
43
|
+
self._load_config()
|
|
44
|
+
self._listeners[request] = {}
|
|
45
|
+
for name, listener_type in LISTENERS.items():
|
|
46
|
+
self._listeners[request][name] = listener_type(self)
|
|
47
|
+
self._listeners[request][name].setup()
|
|
48
|
+
try:
|
|
49
|
+
response = self.get_response(request)
|
|
50
|
+
finally:
|
|
51
|
+
for name in list(LISTENERS.keys()):
|
|
52
|
+
listener = self._listeners.get(request, {}).pop(name, None)
|
|
53
|
+
if listener:
|
|
54
|
+
listener.teardown()
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
def notify(self, message: Message) -> None:
|
|
58
|
+
if not message.match(self._whitelist):
|
|
59
|
+
for notifier in self._notifiers:
|
|
60
|
+
notifier.notify(message)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from django_nplus1.exceptions import NPlusOneError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from django_nplus1.detect import Message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Notifier:
|
|
13
|
+
CONFIG_KEY: str | None = None
|
|
14
|
+
ENABLED_DEFAULT: bool = False
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def is_enabled(cls, config: dict[str, Any]) -> bool:
|
|
18
|
+
if cls.CONFIG_KEY is None:
|
|
19
|
+
return cls.ENABLED_DEFAULT
|
|
20
|
+
if cls.CONFIG_KEY in config:
|
|
21
|
+
return bool(config[cls.CONFIG_KEY])
|
|
22
|
+
return cls.ENABLED_DEFAULT
|
|
23
|
+
|
|
24
|
+
def notify(self, message: Message) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LogNotifier(Notifier):
|
|
29
|
+
CONFIG_KEY = "NPLUS1_LOG"
|
|
30
|
+
ENABLED_DEFAULT = True
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
33
|
+
self.logger: logging.Logger = config.get("NPLUS1_LOGGER", logging.getLogger("django_nplus1"))
|
|
34
|
+
self.level: int = config.get("NPLUS1_LOG_LEVEL", logging.WARNING)
|
|
35
|
+
|
|
36
|
+
def notify(self, message: Message) -> None:
|
|
37
|
+
self.logger.log(self.level, message.message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ErrorNotifier(Notifier):
|
|
41
|
+
CONFIG_KEY = "NPLUS1_RAISE"
|
|
42
|
+
ENABLED_DEFAULT = False
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
45
|
+
self.error: type[Exception] = config.get("NPLUS1_ERROR", NPlusOneError)
|
|
46
|
+
|
|
47
|
+
def notify(self, message: Message) -> None:
|
|
48
|
+
raise self.error(message.message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def init(config: dict[str, Any]) -> list[Notifier]:
|
|
52
|
+
return [notifier_cls(config) for notifier_cls in (LogNotifier, ErrorNotifier) if notifier_cls.is_enabled(config)]
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import functools
|
|
5
|
+
import importlib
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django.db.models import Model, query
|
|
10
|
+
from django.db.models.fields.related_descriptors import (
|
|
11
|
+
ForwardManyToOneDescriptor,
|
|
12
|
+
ReverseOneToOneDescriptor,
|
|
13
|
+
create_forward_many_to_many_manager,
|
|
14
|
+
create_reverse_many_to_one_manager,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from django_nplus1 import signals
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_worker() -> str:
|
|
21
|
+
return str(threading.current_thread().ident)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def to_key(instance: Model) -> str:
|
|
25
|
+
return f"{type(instance).__name__}:{instance.pk}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _patch(original: Any, patched: Any) -> None:
|
|
29
|
+
module = importlib.import_module(original.__module__)
|
|
30
|
+
setattr(module, original.__name__, patched)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def signalify_queryset(
|
|
34
|
+
func: Any,
|
|
35
|
+
parser: Any = None,
|
|
36
|
+
**context: Any,
|
|
37
|
+
) -> Any:
|
|
38
|
+
@functools.wraps(func)
|
|
39
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
queryset = func(*args, **kwargs)
|
|
41
|
+
ctx = copy.copy(context)
|
|
42
|
+
ctx["args"] = context.get("args", args)
|
|
43
|
+
ctx["kwargs"] = context.get("kwargs", kwargs)
|
|
44
|
+
queryset._clone = signalify_queryset(queryset._clone, parser=parser, **ctx)
|
|
45
|
+
queryset._fetch_all = signalify_fetch_all(queryset, parser=parser, **ctx)
|
|
46
|
+
queryset._context = ctx
|
|
47
|
+
return queryset
|
|
48
|
+
|
|
49
|
+
return wrapped
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def signalify_fetch_all(queryset: Any, parser: Any = None, **context: Any) -> Any:
|
|
53
|
+
func = queryset._fetch_all
|
|
54
|
+
|
|
55
|
+
@functools.wraps(func)
|
|
56
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
57
|
+
if queryset._result_cache is None:
|
|
58
|
+
signals.send(
|
|
59
|
+
signals.LAZY_LOAD,
|
|
60
|
+
sender=get_worker(),
|
|
61
|
+
args=args,
|
|
62
|
+
kwargs=kwargs,
|
|
63
|
+
ret=None,
|
|
64
|
+
context=context,
|
|
65
|
+
parser=parser,
|
|
66
|
+
)
|
|
67
|
+
return func(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
return wrapped
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_related_name(model: type[Model]) -> str:
|
|
73
|
+
return f"{model._meta.model_name}_set"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_field(field: Any) -> tuple[type[Model], str]:
|
|
77
|
+
related_model = field.related_model
|
|
78
|
+
name = field.remote_field.name or get_related_name(field.related_model)
|
|
79
|
+
return related_model, name
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_reverse_field(field: Any) -> tuple[type[Model], str]:
|
|
83
|
+
return field.model, field.name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_related(context: dict[str, Any]) -> tuple[type[Model], str]:
|
|
87
|
+
field = context["rel_field"]
|
|
88
|
+
model = field.related_model
|
|
89
|
+
related_name = field.remote_field.related_name
|
|
90
|
+
related_model = context["rel_model"]
|
|
91
|
+
return parse_related_parts(model, related_name, related_model)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_related_parts(
|
|
95
|
+
model: type[Model], related_name: str | None, related_model: type[Model]
|
|
96
|
+
) -> tuple[type[Model], str]:
|
|
97
|
+
return (
|
|
98
|
+
model,
|
|
99
|
+
related_name or get_related_name(related_model),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_reverse_one_to_one_queryset(
|
|
104
|
+
args: Any,
|
|
105
|
+
kwargs: Any,
|
|
106
|
+
context: dict[str, Any],
|
|
107
|
+
) -> tuple[type[Model], str, str]:
|
|
108
|
+
descriptor = context["args"][0]
|
|
109
|
+
field = descriptor.related.field
|
|
110
|
+
model, name = parse_field(field)
|
|
111
|
+
instance = context["kwargs"]["instance"]
|
|
112
|
+
return model, to_key(instance), name
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_forward_many_to_one_queryset(
|
|
116
|
+
args: Any,
|
|
117
|
+
kwargs: Any,
|
|
118
|
+
context: dict[str, Any],
|
|
119
|
+
) -> tuple[type[Model], str, str]:
|
|
120
|
+
descriptor = context["args"][0]
|
|
121
|
+
instance = context["kwargs"]["instance"]
|
|
122
|
+
return descriptor.field.model, to_key(instance), descriptor.field.name
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_many_related_queryset(
|
|
126
|
+
args: Any,
|
|
127
|
+
kwargs: Any,
|
|
128
|
+
context: dict[str, Any],
|
|
129
|
+
) -> tuple[type[Model], str, str]:
|
|
130
|
+
rel = context["rel"]
|
|
131
|
+
manager = context["args"][0]
|
|
132
|
+
model = manager.instance.__class__
|
|
133
|
+
related_model = manager.target_field.related_model
|
|
134
|
+
field = manager.prefetch_cache_name if rel.related_name else None
|
|
135
|
+
return (
|
|
136
|
+
model,
|
|
137
|
+
to_key(manager.instance),
|
|
138
|
+
field or get_related_name(related_model),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_foreign_related_queryset(
|
|
143
|
+
args: Any,
|
|
144
|
+
kwargs: Any,
|
|
145
|
+
context: dict[str, Any],
|
|
146
|
+
) -> tuple[type[Model], str, str]:
|
|
147
|
+
model, name = parse_related(context)
|
|
148
|
+
descriptor = context["args"][0]
|
|
149
|
+
return model, to_key(descriptor.instance), name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Suppress lazy_load signals during prefetch_one_level
|
|
153
|
+
query.prefetch_one_level = signals.designalify( # type: ignore[attr-defined]
|
|
154
|
+
signals.LAZY_LOAD,
|
|
155
|
+
query.prefetch_one_level, # type: ignore[attr-defined]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_get(
|
|
160
|
+
args: Any,
|
|
161
|
+
kwargs: Any,
|
|
162
|
+
context: dict[str, Any],
|
|
163
|
+
ret: Any,
|
|
164
|
+
) -> list[str]:
|
|
165
|
+
return [to_key(ret)] if isinstance(ret, Model) else []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Ignore records loaded during `get`
|
|
169
|
+
query.QuerySet.get = signals.signalify( # type: ignore[method-assign]
|
|
170
|
+
signals.IGNORE_LOAD,
|
|
171
|
+
query.QuerySet.get,
|
|
172
|
+
parser=parse_get,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Patch descriptor get_queryset methods
|
|
176
|
+
ReverseOneToOneDescriptor.get_queryset = signalify_queryset( # type: ignore[method-assign]
|
|
177
|
+
ReverseOneToOneDescriptor.get_queryset,
|
|
178
|
+
parser=parse_reverse_one_to_one_queryset,
|
|
179
|
+
)
|
|
180
|
+
ForwardManyToOneDescriptor.get_queryset = signalify_queryset( # type: ignore[method-assign]
|
|
181
|
+
ForwardManyToOneDescriptor.get_queryset,
|
|
182
|
+
parser=parse_forward_many_to_one_queryset,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _create_forward_many_to_many_manager(superclass: Any, rel: Any, **kwargs: Any) -> Any:
|
|
187
|
+
manager = create_forward_many_to_many_manager(superclass, rel, **kwargs)
|
|
188
|
+
manager.get_queryset = signalify_queryset( # type: ignore[method-assign]
|
|
189
|
+
manager.get_queryset,
|
|
190
|
+
parser=parse_many_related_queryset,
|
|
191
|
+
rel=rel,
|
|
192
|
+
rel_field=rel.field,
|
|
193
|
+
rel_model=rel.related_model,
|
|
194
|
+
)
|
|
195
|
+
return manager
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_patch(create_forward_many_to_many_manager, _create_forward_many_to_many_manager)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _create_reverse_many_to_one_manager(superclass: Any, rel: Any) -> Any:
|
|
202
|
+
manager = create_reverse_many_to_one_manager(superclass, rel)
|
|
203
|
+
manager.get_queryset = signalify_queryset( # type: ignore[method-assign]
|
|
204
|
+
manager.get_queryset,
|
|
205
|
+
parser=parse_foreign_related_queryset,
|
|
206
|
+
rel_field=rel.field,
|
|
207
|
+
rel_model=rel.related_model,
|
|
208
|
+
)
|
|
209
|
+
return manager
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_patch(create_reverse_many_to_one_manager, _create_reverse_many_to_one_manager)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_forward_many_to_one_get(
|
|
216
|
+
args: Any,
|
|
217
|
+
kwargs: Any,
|
|
218
|
+
context: dict[str, Any],
|
|
219
|
+
) -> tuple[type[Model], str, list[str]] | None:
|
|
220
|
+
descriptor, instance, *_ = args
|
|
221
|
+
if instance is None:
|
|
222
|
+
return None
|
|
223
|
+
field, model = parse_reverse_field(descriptor.field)
|
|
224
|
+
return field, model, [to_key(instance)]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
ForwardManyToOneDescriptor.__get__ = signals.signalify( # type: ignore[method-assign]
|
|
228
|
+
signals.TOUCH,
|
|
229
|
+
ForwardManyToOneDescriptor.__get__,
|
|
230
|
+
parser=parse_forward_many_to_one_get,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def parse_reverse_one_to_one_get(
|
|
235
|
+
args: Any,
|
|
236
|
+
kwargs: Any,
|
|
237
|
+
context: dict[str, Any],
|
|
238
|
+
) -> tuple[type[Model], str, list[str]] | None:
|
|
239
|
+
descriptor, instance = args[:2]
|
|
240
|
+
if instance is None:
|
|
241
|
+
return None
|
|
242
|
+
model, field = parse_field(descriptor.related.field)
|
|
243
|
+
return model, field, [to_key(instance)]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
ReverseOneToOneDescriptor.__get__ = signals.signalify( # type: ignore[method-assign]
|
|
247
|
+
signals.TOUCH,
|
|
248
|
+
ReverseOneToOneDescriptor.__get__,
|
|
249
|
+
parser=parse_reverse_one_to_one_get,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_fetch_all(
|
|
254
|
+
args: Any,
|
|
255
|
+
kwargs: Any,
|
|
256
|
+
context: dict[str, Any],
|
|
257
|
+
) -> tuple[type[Model], str, list[str]] | None:
|
|
258
|
+
self = args[0]
|
|
259
|
+
if hasattr(self, "_context"):
|
|
260
|
+
manager = self._context["args"][0]
|
|
261
|
+
instance = manager.instance
|
|
262
|
+
if manager.__class__.__name__ == "ManyRelatedManager":
|
|
263
|
+
return (
|
|
264
|
+
instance.__class__,
|
|
265
|
+
parse_manager_field(manager, self._context["rel"]),
|
|
266
|
+
[to_key(instance)],
|
|
267
|
+
)
|
|
268
|
+
model, field = parse_related(self._context)
|
|
269
|
+
return model, field, [to_key(instance)]
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_manager_field(manager: Any, rel: Any) -> str:
|
|
274
|
+
if manager.reverse:
|
|
275
|
+
return rel.related_name or get_related_name(rel.related_model)
|
|
276
|
+
return rel.field.name or get_related_name(rel.model)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_load(
|
|
280
|
+
args: Any,
|
|
281
|
+
kwargs: Any,
|
|
282
|
+
context: dict[str, Any],
|
|
283
|
+
ret: Any,
|
|
284
|
+
) -> list[str]:
|
|
285
|
+
return [to_key(row) for row in ret if isinstance(row, Model)]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def is_single(low: int, high: int | None) -> bool:
|
|
289
|
+
return high is not None and high - low == 1
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# Patch _fetch_all to emit load/ignore_load and touch signals
|
|
293
|
+
_original_fetch_all = query.QuerySet._fetch_all
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _fetch_all(self: Any) -> None:
|
|
297
|
+
if self._prefetch_done:
|
|
298
|
+
signals.send(
|
|
299
|
+
signals.TOUCH,
|
|
300
|
+
sender=get_worker(),
|
|
301
|
+
args=(self,),
|
|
302
|
+
parser=parse_fetch_all,
|
|
303
|
+
)
|
|
304
|
+
_original_fetch_all(self)
|
|
305
|
+
signal = signals.IGNORE_LOAD if is_single(self.query.low_mark, self.query.high_mark) else signals.LOAD
|
|
306
|
+
signals.send(
|
|
307
|
+
signal,
|
|
308
|
+
sender=get_worker(),
|
|
309
|
+
args=(self,),
|
|
310
|
+
ret=self._result_cache,
|
|
311
|
+
parser=parse_load,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
query.QuerySet._fetch_all = _fetch_all # type: ignore[method-assign]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Patch RelatedPopulator.__init__ to capture args for eager load parsing
|
|
319
|
+
_original_related_populator_init = query.RelatedPopulator.__init__ # type: ignore[attr-defined]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _related_populator_init(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
323
|
+
_original_related_populator_init(self, *args, **kwargs)
|
|
324
|
+
self.__nplus1__ = {"args": args, "kwargs": kwargs}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
query.RelatedPopulator.__init__ = _related_populator_init # type: ignore[attr-defined]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_eager_select(
|
|
331
|
+
args: Any,
|
|
332
|
+
kwargs: Any,
|
|
333
|
+
context: dict[str, Any],
|
|
334
|
+
) -> tuple[type[Model], str, list[str], int]:
|
|
335
|
+
populator = args[0]
|
|
336
|
+
instance = args[2]
|
|
337
|
+
meta = populator.__nplus1__
|
|
338
|
+
klass_info, select, *_ = meta["args"]
|
|
339
|
+
field = klass_info["field"]
|
|
340
|
+
model, name = parse_field(field) if instance._meta.model != field.model else parse_reverse_field(field)
|
|
341
|
+
return model, name, [to_key(instance)], id(select)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# Emit eager_load on populating from select_related
|
|
345
|
+
query.RelatedPopulator.populate = signals.signalify( # type: ignore[attr-defined]
|
|
346
|
+
signals.EAGER_LOAD,
|
|
347
|
+
query.RelatedPopulator.populate, # type: ignore[attr-defined]
|
|
348
|
+
parser=parse_eager_select,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def parse_eager_join(
|
|
353
|
+
args: Any,
|
|
354
|
+
kwargs: Any,
|
|
355
|
+
context: dict[str, Any],
|
|
356
|
+
) -> tuple[type[Model], str, list[str], int]:
|
|
357
|
+
instances, _descriptor, fetcher, level = args
|
|
358
|
+
model = instances[0].__class__
|
|
359
|
+
field, _ = fetcher.get_current_to_attr(level)
|
|
360
|
+
keys = [to_key(instance) for instance in instances]
|
|
361
|
+
return model, field, keys, id(instances)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Emit eager_load on populating from prefetch_related
|
|
365
|
+
query.prefetch_one_level = signals.signalify( # type: ignore[attr-defined]
|
|
366
|
+
signals.EAGER_LOAD,
|
|
367
|
+
query.prefetch_one_level, # type: ignore[attr-defined]
|
|
368
|
+
parser=parse_eager_join,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Emit touch on indexing into prefetched QuerySet instances
|
|
372
|
+
_original_getitem = query.QuerySet.__getitem__
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _getitem_queryset(self: Any, index: Any) -> Any:
|
|
376
|
+
if self._prefetch_done:
|
|
377
|
+
signals.send(
|
|
378
|
+
signals.TOUCH,
|
|
379
|
+
sender=get_worker(),
|
|
380
|
+
args=(self,),
|
|
381
|
+
parser=parse_fetch_all,
|
|
382
|
+
)
|
|
383
|
+
return _original_getitem(self, index)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
query.QuerySet.__getitem__ = _getitem_queryset # type: ignore[method-assign]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from django_nplus1.detect import LISTENERS, Listener, Message, Rule
|
|
6
|
+
from django_nplus1.exceptions import NPlusOneError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Profiler:
|
|
13
|
+
def __init__(self, whitelist: list[dict[str, Any]] | None = None) -> None:
|
|
14
|
+
self.whitelist = [Rule(**item) for item in (whitelist or [])]
|
|
15
|
+
self._listeners: dict[str, Listener] = {}
|
|
16
|
+
|
|
17
|
+
def __enter__(self) -> Profiler:
|
|
18
|
+
for name, listener_type in LISTENERS.items():
|
|
19
|
+
self._listeners[name] = listener_type(self)
|
|
20
|
+
self._listeners[name].setup()
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def __exit__(
|
|
24
|
+
self,
|
|
25
|
+
exc_type: type[BaseException] | None,
|
|
26
|
+
exc_val: BaseException | None,
|
|
27
|
+
exc_tb: TracebackType | None,
|
|
28
|
+
) -> None:
|
|
29
|
+
for name in list(LISTENERS.keys()):
|
|
30
|
+
self._listeners.pop(name).teardown()
|
|
31
|
+
|
|
32
|
+
def notify(self, message: Message) -> None:
|
|
33
|
+
if not message.match(self.whitelist):
|
|
34
|
+
raise NPlusOneError(message.message)
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from django_nplus1.profiler import Profiler
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pytest_configure(config: Any) -> None:
|
|
14
|
+
config.addinivalue_line("markers", "nplus1: mark test to detect N+1 queries")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def nplus1() -> Generator[Profiler]:
|
|
19
|
+
with Profiler() as p:
|
|
20
|
+
yield p
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture(autouse=True)
|
|
24
|
+
def auto_nplus1(request: pytest.FixtureRequest) -> Generator[None]:
|
|
25
|
+
marker = request.node.get_closest_marker("nplus1")
|
|
26
|
+
if marker:
|
|
27
|
+
with Profiler(whitelist=marker.kwargs.get("whitelist")):
|
|
28
|
+
yield
|
|
29
|
+
else:
|
|
30
|
+
yield
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import functools
|
|
5
|
+
import threading
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable, Generator
|
|
11
|
+
|
|
12
|
+
_listeners: defaultdict[str, list[Callable[..., Any]]] = defaultdict(list)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_worker() -> str:
|
|
16
|
+
return str(threading.current_thread().ident)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def connect(signal_name: str, callback: Callable[..., Any], *, sender: str | None = None) -> None:
|
|
20
|
+
_listeners[_key(signal_name, sender)].append(callback)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def disconnect(signal_name: str, callback: Callable[..., Any], *, sender: str | None = None) -> None:
|
|
24
|
+
key = _key(signal_name, sender)
|
|
25
|
+
try:
|
|
26
|
+
_listeners[key].remove(callback)
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def send(signal_name: str, *, sender: str | None = None, **kwargs: Any) -> None:
|
|
32
|
+
for callback in _listeners[_key(signal_name, sender)]:
|
|
33
|
+
callback(**kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _key(signal_name: str, sender: str | None) -> str:
|
|
37
|
+
if sender is None:
|
|
38
|
+
return signal_name
|
|
39
|
+
return f"{signal_name}:{sender}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def signalify(
|
|
43
|
+
signal_name: str, func: Callable[..., Any], *, parser: Callable[..., Any] | None = None
|
|
44
|
+
) -> Callable[..., Any]:
|
|
45
|
+
@functools.wraps(func)
|
|
46
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
47
|
+
ret = func(*args, **kwargs)
|
|
48
|
+
send(
|
|
49
|
+
signal_name,
|
|
50
|
+
sender=get_worker(),
|
|
51
|
+
args=args,
|
|
52
|
+
kwargs=kwargs,
|
|
53
|
+
ret=ret,
|
|
54
|
+
context={},
|
|
55
|
+
parser=parser,
|
|
56
|
+
)
|
|
57
|
+
return ret
|
|
58
|
+
|
|
59
|
+
return wrapped
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def designalify(signal_name: str, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
63
|
+
@functools.wraps(func)
|
|
64
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
with suppress(signal_name):
|
|
66
|
+
return func(*args, **kwargs)
|
|
67
|
+
|
|
68
|
+
return wrapped
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextlib.contextmanager
|
|
72
|
+
def suppress(signal_name: str, sender: str | None = None) -> Generator[None]:
|
|
73
|
+
sender = sender or get_worker()
|
|
74
|
+
key = _key(signal_name, sender)
|
|
75
|
+
saved = _listeners[key][:]
|
|
76
|
+
_listeners[key].clear()
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
_listeners[key] = saved
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Signal names as constants
|
|
84
|
+
LOAD = "load"
|
|
85
|
+
IGNORE_LOAD = "ignore_load"
|
|
86
|
+
LAZY_LOAD = "lazy_load"
|
|
87
|
+
EAGER_LOAD = "eager_load"
|
|
88
|
+
TOUCH = "touch"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-nplus1"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "N+1 query detection for Django"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
keywords = ["django", "n+1", "query", "detection", "performance", "orm"]
|
|
9
|
+
authors = [{ name = "Oliver Haas", email = "ohaas@e1plus.de" }]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Web Environment",
|
|
13
|
+
"Framework :: Django",
|
|
14
|
+
"Framework :: Django :: 5.2",
|
|
15
|
+
"Framework :: Django :: 6.0",
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
"Topic :: Software Development :: Libraries",
|
|
28
|
+
"Topic :: Software Development :: Testing",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
dependencies = ["Django>=5.2,<7"]
|
|
32
|
+
|
|
33
|
+
[project.entry-points.pytest11]
|
|
34
|
+
nplus1 = "django_nplus1.pytest_plugin"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/oliverhaas/django-nplus1"
|
|
38
|
+
Documentation = "https://oliverhaas.github.io/django-nplus1/"
|
|
39
|
+
Repository = "https://github.com/oliverhaas/django-nplus1.git"
|
|
40
|
+
Changelog = "https://oliverhaas.github.io/django-nplus1/reference/changelog/"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"django-stubs==5.2.9",
|
|
45
|
+
"mypy==1.19.1",
|
|
46
|
+
"pre-commit==4.5.1",
|
|
47
|
+
"pytest-cov==7.0.0",
|
|
48
|
+
"pytest-django==4.12.0",
|
|
49
|
+
"pytest-mock==3.15.1",
|
|
50
|
+
"pytest==9.0.2",
|
|
51
|
+
"ruff==0.15.5",
|
|
52
|
+
"ty==0.0.21",
|
|
53
|
+
]
|
|
54
|
+
docs = ["mkdocs==1.6.1", "mkdocs-material==9.7.3", "mike==2.1.3"]
|
|
55
|
+
|
|
56
|
+
[build-system]
|
|
57
|
+
requires = ["hatchling"]
|
|
58
|
+
build-backend = "hatchling.build"
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.wheel]
|
|
61
|
+
packages = ["django_nplus1"]
|
|
62
|
+
|
|
63
|
+
[tool.hatch.build.targets.sdist]
|
|
64
|
+
include = ["django_nplus1", "LICENSE", "README.md"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
target-version = "py313"
|
|
68
|
+
line-length = 120
|
|
69
|
+
fix = true
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = ["ALL"]
|
|
73
|
+
ignore = [
|
|
74
|
+
"ANN401", # Any type is acceptable for generic ORM internals
|
|
75
|
+
"ARG001", # Unused function args required by parser callback interface (args, kwargs, context, ret)
|
|
76
|
+
"ARG002", # Unused method args required by signal callback interface
|
|
77
|
+
"COM812", # Conflicts with ruff-format
|
|
78
|
+
"D", # Docstrings - will add incrementally
|
|
79
|
+
"E501", # Line length handled by config
|
|
80
|
+
"EM", # Exception strings inline are fine
|
|
81
|
+
"FBT", # Boolean params acceptable for config flags
|
|
82
|
+
"PERF203", # try/except in loop is fine for signal dispatch
|
|
83
|
+
"PLC0415", # Dynamic imports needed to avoid circular imports (detect.py -> signals.py)
|
|
84
|
+
"PLR0913", # Many args needed for signal parsers
|
|
85
|
+
"PLR2004", # Magic values like slice indices are acceptable
|
|
86
|
+
"PYI034", # __enter__ returning Self vs concrete type is preference
|
|
87
|
+
"SIM105", # try/except/pass is clearer than contextlib.suppress for single-line
|
|
88
|
+
"SLF001", # Private member access needed for ORM monkeypatching
|
|
89
|
+
"TRY003", # Long exception messages are helpful for debugging
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint.flake8-annotations]
|
|
93
|
+
allow-star-arg-any = true
|
|
94
|
+
suppress-none-returning = true
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint.per-file-ignores]
|
|
97
|
+
"tests/**" = [
|
|
98
|
+
"ANN", # Type annotations not required in tests
|
|
99
|
+
"ARG", # Unused arguments common in fixtures
|
|
100
|
+
"B018", # Useless expressions are how we trigger lazy loads in tests
|
|
101
|
+
"DJ008", # Test models don't need __str__
|
|
102
|
+
"E402", # Import order in settings files
|
|
103
|
+
"F841", # Unused variables okay in tests
|
|
104
|
+
"PT006", # Parametrize tuple style is preference
|
|
105
|
+
"PT011", # Broad pytest.raises okay in some tests
|
|
106
|
+
"PT012", # Multi-statement pytest.raises needed for N+1 trigger patterns
|
|
107
|
+
"PT013", # pytest import style is preference
|
|
108
|
+
"PT018", # Compound assertions okay in tests
|
|
109
|
+
"RUF043", # Regex metacharacters in match= are intentional (dot in model.field)
|
|
110
|
+
"S101", # assert in tests
|
|
111
|
+
"S105", # Hardcoded passwords in test settings
|
|
112
|
+
"T201", # print in test views is intentional
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
[tool.mypy]
|
|
116
|
+
python_version = "3.12"
|
|
117
|
+
plugins = ["mypy_django_plugin.main"]
|
|
118
|
+
pretty = true
|
|
119
|
+
show_error_codes = true
|
|
120
|
+
show_error_context = true
|
|
121
|
+
warn_redundant_casts = true
|
|
122
|
+
warn_unused_ignores = true
|
|
123
|
+
warn_unreachable = false
|
|
124
|
+
|
|
125
|
+
[tool.django-stubs]
|
|
126
|
+
django_settings_module = "tests.settings.base"
|
|
127
|
+
|
|
128
|
+
[[tool.mypy.overrides]]
|
|
129
|
+
module = "testapp.*"
|
|
130
|
+
ignore_missing_imports = true
|
|
131
|
+
|
|
132
|
+
[tool.ty.rules]
|
|
133
|
+
invalid-method-override = "ignore"
|
|
134
|
+
|
|
135
|
+
[tool.ty.environment]
|
|
136
|
+
python-version = "3.13"
|
|
137
|
+
|
|
138
|
+
[tool.ty.analysis]
|
|
139
|
+
respect-type-ignore-comments = false
|
|
140
|
+
|
|
141
|
+
[[tool.ty.overrides]]
|
|
142
|
+
include = ["django_nplus1/patch.py", "django_nplus1/middleware.py"]
|
|
143
|
+
|
|
144
|
+
[tool.ty.overrides.rules]
|
|
145
|
+
# patch.py monkeypatches Django internals; middleware.py accesses _meta on type
|
|
146
|
+
unresolved-attribute = "ignore"
|
|
147
|
+
invalid-assignment = "ignore"
|
|
148
|
+
|
|
149
|
+
[[tool.ty.overrides]]
|
|
150
|
+
include = ["tests/**"]
|
|
151
|
+
|
|
152
|
+
[tool.ty.overrides.rules]
|
|
153
|
+
unresolved-attribute = "ignore"
|
|
154
|
+
invalid-argument-type = "ignore"
|
|
155
|
+
invalid-assignment = "ignore"
|
|
156
|
+
|
|
157
|
+
[tool.pytest.ini_options]
|
|
158
|
+
DJANGO_SETTINGS_MODULE = "settings.base"
|
|
159
|
+
addopts = "--cov=django_nplus1 --cov-report=term-missing --cov-report=xml --no-cov-on-fail"
|
|
160
|
+
pythonpath = ["tests"]
|
|
161
|
+
testpaths = ["tests"]
|
|
162
|
+
xfail_strict = true
|
|
163
|
+
|
|
164
|
+
[tool.coverage.run]
|
|
165
|
+
omit = ["tests/*"]
|
|
166
|
+
|
|
167
|
+
[tool.coverage.report]
|
|
168
|
+
precision = 2
|
|
169
|
+
skip_covered = true
|