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.
@@ -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,9 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoNPlus1Config(AppConfig):
5
+ name = "django_nplus1"
6
+ verbose_name = "Django N+1"
7
+
8
+ def ready(self) -> None:
9
+ from django_nplus1 import patch # noqa: F401
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ class NPlus1Error(Exception):
2
+ pass