django-nplus1-hunter 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,29 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ - name: Install hatch
23
+ run: pip install hatch
24
+
25
+ - name: Build package
26
+ run: hatch build
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,32 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12"]
15
+ django-version: ["5.2"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ cache: "pip"
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install hatch tox tox-gh-actions
30
+
31
+ - name: Test with tox
32
+ run: tox
@@ -0,0 +1,78 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Environments
65
+ .env
66
+ .venv
67
+ env/
68
+ venv/
69
+ ENV/
70
+ env.bak/
71
+ venv.bak/
72
+
73
+ # Editors
74
+ .idea/
75
+ .vscode/
76
+ *.swp
77
+
78
+ guide.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mohammadjalal pouromid
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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-nplus1-hunter
3
+ Version: 0.1.0
4
+ Summary: A development tool to detect N+1 queries in Django.
5
+ Project-URL: Documentation, https://github.com/iamjalipo/django-nplus1-hunter#readme
6
+ Project-URL: Issues, https://github.com/iamjalipo/django-nplus1-hunter/issues
7
+ Project-URL: Source, https://github.com/iamjalipo/django-nplus1-hunter
8
+ Author-email: Developer <developer@example.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 5.2
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: django>=5.2
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Django N+1 Hunter
22
+
23
+ A powerful, zero-configuration middleware for detecting N+1 queries in your Django applications during development.
24
+
25
+ N+1 queries are the silent performance killers of Django apps. This tool automatically monitors your SQL executions and points you exactly to the line of code in your view or model that caused the problem.
26
+
27
+ **Supports:** Django 5.2+ and Python 3.10+
28
+ **Databases:** Works with PostgreSQL, MySQL, SQLite, and any other Django-supported backend.
29
+
30
+ ---
31
+
32
+ ## Features
33
+
34
+ - **Automatic Interception:** Hooks into Django's query execution to monitor SQL statements seamlessly across all configured databases.
35
+ - **Traceback Filtering:** Analyzes the call stack to point you exactly to the line of user code that triggered the loop.
36
+ - **Production Safe:** Automatically disables itself if `DEBUG = False`. It also raises a startup warning if accidentally deployed to production.
37
+ - **Zero Dependencies:** Relies entirely on built-in Python and Django features.
38
+
39
+ ---
40
+
41
+ ## Installation & Setup
42
+
43
+ 1. Install the package via pip:
44
+ ```bash
45
+ pip install django-nplus1-hunter
46
+ ```
47
+
48
+ 2. Add it to your `INSTALLED_APPS`. For safety, it is highly recommended to only add it in your local/development settings:
49
+ ```python
50
+ # settings.py
51
+ if DEBUG:
52
+ INSTALLED_APPS += [
53
+ 'django_nplus1_hunter',
54
+ ]
55
+ ```
56
+
57
+ 3. Add the middleware to `MIDDLEWARE`. Place it near the top of the list so it can track queries generated by other middlewares (like SessionMiddleware or AuthenticationMiddleware):
58
+ ```python
59
+ # settings.py
60
+ if DEBUG:
61
+ MIDDLEWARE.insert(0, 'django_nplus1_hunter.middleware.NPlus1HunterMiddleware')
62
+ ```
63
+
64
+ That's it! Just browse your app locally. If a view triggers an N+1 query, your runserver console will light up with a warning showing the exact file and line number.
65
+
66
+ ---
67
+
68
+ ## Configuration Options
69
+
70
+ You can customize the detection sensitivity by adding these variables to your `settings.py`:
71
+
72
+ ```python
73
+ # Number of queries generated from the exact same line of code before it is flagged as an N+1.
74
+ # Default: 3
75
+ NPLUS1_HUNTER_THRESHOLD = 3
76
+
77
+ # The maximum number of total queries allowed in a single request before a warning is thrown.
78
+ # Default: 50
79
+ NPLUS1_HUNTER_TOTAL_THRESHOLD = 50
80
+
81
+ # A list of URL path prefixes to completely ignore. Useful for admin panels or debug toolbars.
82
+ # Default: []
83
+ NPLUS1_HUNTER_IGNORE_PATHS = [
84
+ '/admin/',
85
+ '/__debug__/',
86
+ ]
87
+ ```
88
+
89
+ ## How it works under the hood
90
+
91
+ The middleware utilizes `contextlib.ExitStack` and Django's built-in `connections.all()[...].execute_wrapper` to wrap every database connection during the request/response lifecycle. When a query executes, the wrapper captures the raw SQL, execution time, and a full Python stack trace.
92
+
93
+ It filters out internal Django frames (`django/db/models/*`) to find the first frame belonging to your codebase. If that specific line of code executes more times than `NPLUS1_HUNTER_THRESHOLD`, a warning is logged.
@@ -0,0 +1,73 @@
1
+ # Django N+1 Hunter
2
+
3
+ A powerful, zero-configuration middleware for detecting N+1 queries in your Django applications during development.
4
+
5
+ N+1 queries are the silent performance killers of Django apps. This tool automatically monitors your SQL executions and points you exactly to the line of code in your view or model that caused the problem.
6
+
7
+ **Supports:** Django 5.2+ and Python 3.10+
8
+ **Databases:** Works with PostgreSQL, MySQL, SQLite, and any other Django-supported backend.
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ - **Automatic Interception:** Hooks into Django's query execution to monitor SQL statements seamlessly across all configured databases.
15
+ - **Traceback Filtering:** Analyzes the call stack to point you exactly to the line of user code that triggered the loop.
16
+ - **Production Safe:** Automatically disables itself if `DEBUG = False`. It also raises a startup warning if accidentally deployed to production.
17
+ - **Zero Dependencies:** Relies entirely on built-in Python and Django features.
18
+
19
+ ---
20
+
21
+ ## Installation & Setup
22
+
23
+ 1. Install the package via pip:
24
+ ```bash
25
+ pip install django-nplus1-hunter
26
+ ```
27
+
28
+ 2. Add it to your `INSTALLED_APPS`. For safety, it is highly recommended to only add it in your local/development settings:
29
+ ```python
30
+ # settings.py
31
+ if DEBUG:
32
+ INSTALLED_APPS += [
33
+ 'django_nplus1_hunter',
34
+ ]
35
+ ```
36
+
37
+ 3. Add the middleware to `MIDDLEWARE`. Place it near the top of the list so it can track queries generated by other middlewares (like SessionMiddleware or AuthenticationMiddleware):
38
+ ```python
39
+ # settings.py
40
+ if DEBUG:
41
+ MIDDLEWARE.insert(0, 'django_nplus1_hunter.middleware.NPlus1HunterMiddleware')
42
+ ```
43
+
44
+ That's it! Just browse your app locally. If a view triggers an N+1 query, your runserver console will light up with a warning showing the exact file and line number.
45
+
46
+ ---
47
+
48
+ ## Configuration Options
49
+
50
+ You can customize the detection sensitivity by adding these variables to your `settings.py`:
51
+
52
+ ```python
53
+ # Number of queries generated from the exact same line of code before it is flagged as an N+1.
54
+ # Default: 3
55
+ NPLUS1_HUNTER_THRESHOLD = 3
56
+
57
+ # The maximum number of total queries allowed in a single request before a warning is thrown.
58
+ # Default: 50
59
+ NPLUS1_HUNTER_TOTAL_THRESHOLD = 50
60
+
61
+ # A list of URL path prefixes to completely ignore. Useful for admin panels or debug toolbars.
62
+ # Default: []
63
+ NPLUS1_HUNTER_IGNORE_PATHS = [
64
+ '/admin/',
65
+ '/__debug__/',
66
+ ]
67
+ ```
68
+
69
+ ## How it works under the hood
70
+
71
+ The middleware utilizes `contextlib.ExitStack` and Django's built-in `connections.all()[...].execute_wrapper` to wrap every database connection during the request/response lifecycle. When a query executes, the wrapper captures the raw SQL, execution time, and a full Python stack trace.
72
+
73
+ It filters out internal Django frames (`django/db/models/*`) to find the first frame belonging to your codebase. If that specific line of code executes more times than `NPLUS1_HUNTER_THRESHOLD`, a warning is logged.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "django-nplus1-hunter"
7
+ dynamic = ["version"]
8
+ description = "A development tool to detect N+1 queries in Django."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Developer", email = "developer@example.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 5.2",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+ dependencies = [
24
+ "Django>=5.2",
25
+ ]
26
+
27
+ [project.urls]
28
+ Documentation = "https://github.com/iamjalipo/django-nplus1-hunter#readme"
29
+ Issues = "https://github.com/iamjalipo/django-nplus1-hunter/issues"
30
+ Source = "https://github.com/iamjalipo/django-nplus1-hunter"
31
+
32
+ [tool.hatch.version]
33
+ path = "src/django_nplus1_hunter/__init__.py"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/django_nplus1_hunter"]
37
+
38
+ [tool.ruff]
39
+ line-length = 88
40
+ target-version = "py310"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "W", "I", "UP"]
44
+ ignore = []
45
+
46
+ [tool.pytest.ini_options]
47
+ minversion = "7.0"
48
+ addopts = "-ra -q --ds=tests.test_project.settings"
49
+ testpaths = ["tests"]
50
+ python_files = "test_*.py"
51
+
52
+ [tool.tox]
53
+ legacy_tox_ini = """
54
+ [tox]
55
+ envlist = py310-django52, py311-django52, py312-django52
56
+ isolated_build = True
57
+
58
+ [testenv]
59
+ deps =
60
+ django52: Django>=5.2,<6.0
61
+ pytest
62
+ pytest-django
63
+ commands =
64
+ pytest {posargs}
65
+ """
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ import sys
2
+ import warnings
3
+ from django.apps import AppConfig
4
+ from django.conf import settings
5
+
6
+ class NPlus1HunterConfig(AppConfig):
7
+ name = "django_nplus1_hunter"
8
+ verbose_name = "N+1 Hunter"
9
+
10
+ def ready(self):
11
+ """
12
+ Safety check: Ensure the app is not accidentally deployed to production.
13
+ """
14
+ if not getattr(settings, "DEBUG", False):
15
+ # Try to determine if we are running in a test suite.
16
+ # If so, we might not want to scream, but in real production we do.
17
+ is_testing = "test" in sys.argv or "pytest" in sys.modules
18
+
19
+ if not is_testing:
20
+ warnings.warn(
21
+ "\n\n"
22
+ "========================================================================\n"
23
+ "WARNING: django-nplus1-hunter is installed but settings.DEBUG is False.\n"
24
+ "This tool incurs significant performance overhead by capturing stack\n"
25
+ "traces for every database query. It MUST NOT be used in production.\n"
26
+ "Please remove 'django_nplus1_hunter' from INSTALLED_APPS.\n"
27
+ "========================================================================\n",
28
+ RuntimeWarning,
29
+ )
@@ -0,0 +1,74 @@
1
+ import logging
2
+ from collections import defaultdict
3
+ from django.conf import settings
4
+ from contextlib import ExitStack
5
+ from django.db import connections
6
+ from .trackers import NPlus1QueryWrapper, get_query_data, clear_query_data
7
+
8
+ logger = logging.getLogger("django_nplus1_hunter")
9
+
10
+ class NPlus1HunterMiddleware:
11
+ def __init__(self, get_response):
12
+ self.get_response = get_response
13
+
14
+ # Configuration defaults
15
+ # We only enable it if DEBUG is True to prevent production catastrophes.
16
+ self.enabled = getattr(settings, "DEBUG", False)
17
+ # How many times the same line of code must generate a query to be considered N+1
18
+ self.nplus1_threshold = getattr(settings, "NPLUS1_HUNTER_THRESHOLD", 3)
19
+ # Warn if the total query count per request exceeds this
20
+ self.total_query_threshold = getattr(settings, "NPLUS1_HUNTER_TOTAL_THRESHOLD", 50)
21
+ # Paths to completely ignore
22
+ self.ignore_paths = getattr(settings, "NPLUS1_HUNTER_IGNORE_PATHS", [])
23
+
24
+ def __call__(self, request):
25
+ if not self.enabled or any(request.path.startswith(p) for p in self.ignore_paths):
26
+ return self.get_response(request)
27
+
28
+ # Clear any stale data from previous requests on this thread
29
+ clear_query_data()
30
+
31
+ # Wrap all database executions for the duration of the request
32
+ with ExitStack() as stack:
33
+ for conn in connections.all():
34
+ stack.enter_context(conn.execute_wrapper(NPlus1QueryWrapper()))
35
+ response = self.get_response(request)
36
+
37
+ # Analysis Engine
38
+ self.analyze_queries(request)
39
+
40
+ # Cleanup memory
41
+ clear_query_data()
42
+
43
+ return response
44
+
45
+ def analyze_queries(self, request):
46
+ queries = get_query_data()
47
+ total_queries = len(queries)
48
+
49
+ if total_queries >= self.total_query_threshold:
50
+ logger.warning(
51
+ f"\n[N+1 Hunter] HIGH QUERY COUNT DETECTED: {total_queries} queries "
52
+ f"executed on {request.path}"
53
+ )
54
+
55
+ # Detect N+1 patterns by grouping queries by the exact line of user code
56
+ # that generated them. If a loop is executing queries, the same line will
57
+ # trigger multiple queries.
58
+ frame_counts = defaultdict(list)
59
+ for q in queries:
60
+ if q["frame"]:
61
+ key = f"{q['frame'].filename}:{q['frame'].lineno}"
62
+ frame_counts[key].append(q)
63
+
64
+ for frame_key, q_list in frame_counts.items():
65
+ if len(q_list) >= self.nplus1_threshold:
66
+ sample_sql = q_list[0]["sql"]
67
+ if len(sample_sql) > 100:
68
+ sample_sql = sample_sql[:100] + "..."
69
+
70
+ logger.warning(
71
+ f"\n[N+1 Hunter] N+1 QUERY DETECTED: {len(q_list)} queries originated from "
72
+ f"{frame_key}.\n"
73
+ f"Sample SQL: {sample_sql}\n"
74
+ )
@@ -0,0 +1,67 @@
1
+ import time
2
+ import traceback
3
+ from threading import local
4
+
5
+ # Thread-local storage for queries during a request
6
+ _thread_locals = local()
7
+
8
+ def get_query_data():
9
+ """Retrieve the query data for the current thread."""
10
+ if not hasattr(_thread_locals, 'query_data'):
11
+ _thread_locals.query_data = []
12
+ return _thread_locals.query_data
13
+
14
+ def clear_query_data():
15
+ """Clear the query data for the current thread."""
16
+ if hasattr(_thread_locals, 'query_data'):
17
+ del _thread_locals.query_data
18
+
19
+ def filter_traceback(tb_list):
20
+ """
21
+ Filter the traceback to find the first frame that is NOT from django internals.
22
+ This helps pinpoint the exact line of user code that triggered the query.
23
+ """
24
+ # Start from the bottom of the stack (most recent call) and work backwards
25
+ for tb in reversed(tb_list):
26
+ filename = tb.filename
27
+
28
+ # Ignore Django internal database frames
29
+ if 'django/db/' in filename.replace('\\', '/'):
30
+ continue
31
+
32
+ # Ignore Python standard library or test runner internals if needed here
33
+
34
+ return tb
35
+
36
+ return tb_list[-1] if tb_list else None
37
+
38
+ class NPlus1QueryWrapper:
39
+ """
40
+ A callable that wraps database executions to capture query data and tracebacks.
41
+ Designed to be used with Django's connection.execute_wrapper().
42
+ """
43
+ def __call__(self, execute, sql, params, many, context):
44
+ start_time = time.time()
45
+ try:
46
+ return execute(sql, params, many, context)
47
+ finally:
48
+ duration = time.time() - start_time
49
+
50
+ # Capture the current call stack
51
+ # extract_stack() returns a list of FrameSummary objects
52
+ tb = traceback.extract_stack()
53
+
54
+ # Remove the last few frames which belong to this wrapper and traceback module itself
55
+ # Typically: traceback.extract_stack(), __call__(), etc.
56
+ tb = tb[:-2]
57
+
58
+ user_frame = filter_traceback(tb)
59
+
60
+ query_info = {
61
+ 'sql': sql,
62
+ 'params': params,
63
+ 'duration': duration,
64
+ 'frame': user_frame,
65
+ }
66
+
67
+ get_query_data().append(query_info)
@@ -0,0 +1,45 @@
1
+ Task Tracker: Django N+1 Hunter
2
+ Phase 1: Setup & Foundation
3
+ [x] 1.1 Project Initialization
4
+ [x] Initialize project with hatch (or manually create pyproject.toml and src/ layout).
5
+ [x] Set up .gitignore.
6
+ [x] 1.2 Tooling Configuration
7
+ [x] Configure Ruff in pyproject.toml.
8
+ [x] Configure pytest and pytest-django.
9
+ [x] Set up tox for multi-environment testing.
10
+ [x] 1.3 CI/CD Pipeline
11
+ [x] Create GitHub Actions workflow (.github/workflows/test.yml).
12
+ [x] 1.4 Test Project Setup
13
+ [x] Create tests/test_project/.
14
+ [x] Configure minimal manage.py, settings.py, and urls.py.
15
+ [x] Create dummy models/views for testing.
16
+ Phase 2: Core Logic & Tracking
17
+ [x] 2.1 Query Tracker
18
+ [x] Implement ExecuteWrapper to capture SQL, time, and raw traceback.
19
+ [x] Write tests to confirm interception.
20
+ [x] 2.2 Traceback Filtering
21
+ [x] Implement logic to filter Django/Python internal frames.
22
+ [x] Isolate user code lines originating the query.
23
+ [x] 2.3 Thread-Local Storage
24
+ [x] Implement threading.local() or contextvars to store per-request queries.
25
+ Phase 3: Middleware & Developer Experience
26
+ [x] 3.1 Django Middleware
27
+ [x] Create middleware class.
28
+ [x] Enable tracking on process_request.
29
+ [x] Process data on process_response.
30
+ [x] 3.2 Analysis Engine
31
+ [x] Detect N+1 patterns (consecutive similar queries).
32
+ [x] Detect total query threshold violations.
33
+ [x] 3.3 Reporting Interface
34
+ [x] Output structured warnings showing the offending file and line.
35
+ [x] 3.4 AppConfig Safety
36
+ [x] Create apps.py with checks for settings.DEBUG to prevent production execution.
37
+ Phase 4: Polish & Documentation
38
+ [x] 4.1 Configuration Options
39
+ [x] Define customizable thresholds and ignored URLs in settings.py.
40
+ [x] 4.2 README & Docs
41
+ [x] Write comprehensive documentation.
42
+ [x] 4.3 Test Coverage
43
+ [x] Ensure high test coverage for all modules.
44
+ [x] 4.4 PyPI Publish Setup
45
+ [x] Configure GitHub Actions for PyPI deployment (publish.yml).
@@ -0,0 +1 @@
1
+ # Empty init
@@ -0,0 +1,35 @@
1
+ import pytest
2
+ from django.test import Client
3
+ from tests.test_project.models import Author, Book
4
+
5
+ @pytest.fixture
6
+ def client():
7
+ return Client()
8
+
9
+ @pytest.fixture
10
+ def setup_data():
11
+ author1 = Author.objects.create(name="Author 1")
12
+ author2 = Author.objects.create(name="Author 2")
13
+ Book.objects.create(title="Book 1", author=author1)
14
+ Book.objects.create(title="Book 2", author=author2)
15
+ Book.objects.create(title="Book 3", author=author1)
16
+ Book.objects.create(title="Book 4", author=author2)
17
+
18
+ @pytest.mark.django_db
19
+ def test_n_plus_one_view(client, setup_data, caplog):
20
+ # Call the view that triggers N+1
21
+ response = client.get('/test-n-plus-one/')
22
+ assert response.status_code == 200
23
+
24
+ # Check that our middleware logged an N+1 warning
25
+ assert "N+1 QUERY DETECTED" in caplog.text
26
+ assert "queries originated from" in caplog.text
27
+
28
+ @pytest.mark.django_db
29
+ def test_ok_view(client, setup_data, caplog):
30
+ # Call the view that does NOT trigger N+1
31
+ response = client.get('/test-ok/')
32
+ assert response.status_code == 200
33
+
34
+ # Check that our middleware did NOT log an N+1 warning
35
+ assert "N+1 QUERY DETECTED" not in caplog.text
@@ -0,0 +1 @@
1
+ # Empty init
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+ class TestProjectConfig(AppConfig):
4
+ default_auto_field = 'django.db.models.BigAutoField'
5
+ name = 'tests.test_project'
@@ -0,0 +1,8 @@
1
+ from django.db import models
2
+
3
+ class Author(models.Model):
4
+ name = models.CharField(max_length=100)
5
+
6
+ class Book(models.Model):
7
+ title = models.CharField(max_length=100)
8
+ author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
@@ -0,0 +1,42 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ BASE_DIR = Path(__file__).resolve().parent
5
+
6
+ SECRET_KEY = "dummy-secret-key-for-testing"
7
+ DEBUG = True
8
+ ALLOWED_HOSTS = []
9
+
10
+ INSTALLED_APPS = [
11
+ "django.contrib.admin",
12
+ "django.contrib.auth",
13
+ "django.contrib.contenttypes",
14
+ "django.contrib.sessions",
15
+ "django.contrib.messages",
16
+ "django.contrib.staticfiles",
17
+ "django_nplus1_hunter",
18
+ "tests.test_project",
19
+ ]
20
+
21
+ MIDDLEWARE = [
22
+ "django.middleware.security.SecurityMiddleware",
23
+ "django.contrib.sessions.middleware.SessionMiddleware",
24
+ "django.middleware.common.CommonMiddleware",
25
+ "django.middleware.csrf.CsrfViewMiddleware",
26
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
27
+ "django.contrib.messages.middleware.MessageMiddleware",
28
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
29
+ "django_nplus1_hunter.middleware.NPlus1HunterMiddleware",
30
+ ]
31
+
32
+ ROOT_URLCONF = "tests.test_project.urls"
33
+
34
+ DATABASES = {
35
+ "default": {
36
+ "ENGINE": "django.db.backends.sqlite3",
37
+ "NAME": BASE_DIR / "db.sqlite3",
38
+ }
39
+ }
40
+
41
+ USE_TZ = True
42
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+ from . import views
3
+
4
+ urlpatterns = [
5
+ path('test-n-plus-one/', views.test_n_plus_one_view, name='test_n_plus_one'),
6
+ path('test-ok/', views.test_ok_view, name='test_ok'),
7
+ ]
@@ -0,0 +1,14 @@
1
+ from django.http import HttpResponse
2
+ from .models import Book
3
+
4
+ def test_n_plus_one_view(request):
5
+ books = Book.objects.all()
6
+ # Trigger N+1
7
+ titles_with_authors = [f"{book.title} by {book.author.name}" for book in books]
8
+ return HttpResponse("\n".join(titles_with_authors))
9
+
10
+ def test_ok_view(request):
11
+ books = Book.objects.select_related('author').all()
12
+ # No N+1
13
+ titles_with_authors = [f"{book.title} by {book.author.name}" for book in books]
14
+ return HttpResponse("\n".join(titles_with_authors))
@@ -0,0 +1,28 @@
1
+ import pytest
2
+ from django.db import connection
3
+ from django_nplus1_hunter.trackers import (
4
+ NPlus1QueryWrapper,
5
+ get_query_data,
6
+ clear_query_data,
7
+ )
8
+ from tests.test_project.models import Author
9
+
10
+ @pytest.mark.django_db
11
+ def test_query_interception():
12
+ clear_query_data()
13
+
14
+ with connection.execute_wrapper(NPlus1QueryWrapper()):
15
+ Author.objects.create(name="Test Author")
16
+ list(Author.objects.all())
17
+
18
+ data = get_query_data()
19
+ assert len(data) >= 2
20
+
21
+ # Check that SQL was captured
22
+ assert any("INSERT INTO" in item['sql'] for item in data)
23
+ assert any("SELECT" in item['sql'] for item in data)
24
+
25
+ # Check that frame was captured
26
+ assert all(item['frame'] is not None for item in data)
27
+ # The frame filename should ideally be this test file
28
+ assert any('test_trackers.py' in item['frame'].filename for item in data)