pytest-queryguard 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.
- pytest_queryguard-0.1.0/.gitignore +12 -0
- pytest_queryguard-0.1.0/3rdparty_licenses.md +16 -0
- pytest_queryguard-0.1.0/CHANGELOG.md +42 -0
- pytest_queryguard-0.1.0/LICENSE +21 -0
- pytest_queryguard-0.1.0/PKG-INFO +451 -0
- pytest_queryguard-0.1.0/README.md +421 -0
- pytest_queryguard-0.1.0/docs/design.md +243 -0
- pytest_queryguard-0.1.0/pyproject.toml +94 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/__init__.py +14 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/api.py +34 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/callsite.py +96 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/collector.py +46 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/detectors.py +104 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/events.py +24 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/listeners.py +118 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/messages.py +81 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/plugin.py +290 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/py.typed +0 -0
- pytest_queryguard-0.1.0/src/pytest_queryguard/warnings.py +9 -0
- pytest_queryguard-0.1.0/tests/__init__.py +0 -0
- pytest_queryguard-0.1.0/tests/conftest.py +3 -0
- pytest_queryguard-0.1.0/tests/models.py +50 -0
- pytest_queryguard-0.1.0/tests/test_async_and_fastapi.py +317 -0
- pytest_queryguard-0.1.0/tests/test_budgets.py +325 -0
- pytest_queryguard-0.1.0/tests/test_callsite.py +73 -0
- pytest_queryguard-0.1.0/tests/test_detectors.py +165 -0
- pytest_queryguard-0.1.0/tests/test_listeners.py +161 -0
- pytest_queryguard-0.1.0/tests/test_messages.py +87 -0
- pytest_queryguard-0.1.0/tests/test_plugin.py +331 -0
- pytest_queryguard-0.1.0/tests/test_plugin_loads.py +8 -0
- pytest_queryguard-0.1.0/tests/test_report.py +183 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Third-party licenses
|
|
2
|
+
|
|
3
|
+
Direct dependencies of this repository.
|
|
4
|
+
|
|
5
|
+
| source | name | license type | license link |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| https://github.com/pytest-dev/pytest | pytest | MIT | https://github.com/pytest-dev/pytest/blob/main/LICENSE |
|
|
8
|
+
| https://github.com/sqlalchemy/sqlalchemy | SQLAlchemy | MIT | https://github.com/sqlalchemy/sqlalchemy/blob/main/LICENSE |
|
|
9
|
+
| https://github.com/python-greenlet/greenlet | greenlet | MIT AND PSF-2.0 | https://github.com/python-greenlet/greenlet/blob/master/LICENSE |
|
|
10
|
+
| https://github.com/omnilib/aiosqlite | aiosqlite | MIT | https://github.com/omnilib/aiosqlite/blob/main/LICENSE |
|
|
11
|
+
| https://github.com/fastapi/fastapi | FastAPI | MIT | https://github.com/fastapi/fastapi/blob/master/LICENSE |
|
|
12
|
+
| https://github.com/encode/httpx | httpx | BSD-3-Clause | https://github.com/encode/httpx/blob/master/LICENSE.md |
|
|
13
|
+
| https://github.com/coveragepy/coveragepy | coverage | Apache-2.0 | https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt |
|
|
14
|
+
| https://github.com/astral-sh/ruff | ruff | MIT | https://github.com/astral-sh/ruff/blob/main/LICENSE |
|
|
15
|
+
| https://github.com/python/mypy | mypy | MIT | https://github.com/python/mypy/blob/master/LICENSE |
|
|
16
|
+
| https://github.com/pypa/hatch | hatchling | MIT | https://github.com/pypa/hatch/blob/master/LICENSE.txt |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
First release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Automatic N+1 detection for SQLAlchemy 2 in every test, with no
|
|
17
|
+
configuration. Two rules: one relationship lazy-loaded for several parent
|
|
18
|
+
objects, and one normalized `SELECT` repeated several times.
|
|
19
|
+
- Failure messages that point to the line of application code that caused
|
|
20
|
+
the N+1, show the SQL involved and, for relationship loads, suggest a
|
|
21
|
+
`selectinload` fix.
|
|
22
|
+
- `@pytest.mark.max_queries(n)` marker (also `max_queries(n=...)`) that fails
|
|
23
|
+
a test running more than `n` SQL statements and lists every statement with
|
|
24
|
+
its call site.
|
|
25
|
+
- `pytest_queryguard.max_queries()` context manager, which raises
|
|
26
|
+
`QueryBudgetExceeded` and also works outside pytest.
|
|
27
|
+
- `@pytest.mark.allow_nplusone` marker to skip N+1 detection for one test.
|
|
28
|
+
- Ini options `queryguard_mode` (`fail`, `warn`, `off`),
|
|
29
|
+
`queryguard_relationship_threshold` and `queryguard_repeat_threshold`,
|
|
30
|
+
readable from `[tool.pytest.ini_options]`, `pytest.ini`, `setup.cfg` and
|
|
31
|
+
pytest 9's native `[tool.pytest]` table.
|
|
32
|
+
- `--queryguard-mode` command-line option that overrides the ini value.
|
|
33
|
+
- `--queryguard-report` command-line option that prints the ten tests with
|
|
34
|
+
the most queries at the end of the session.
|
|
35
|
+
- Support for `AsyncSession`, FastAPI's `TestClient` and code that queries
|
|
36
|
+
from other threads.
|
|
37
|
+
- Only the test function body is measured; fixture setup and teardown are not.
|
|
38
|
+
- An internal error disables the plugin with a single warning instead of
|
|
39
|
+
failing the test suite.
|
|
40
|
+
|
|
41
|
+
[Unreleased]: https://github.com/MaciejZiel/pytest-queryguard/compare/v0.1.0...HEAD
|
|
42
|
+
[0.1.0]: https://github.com/MaciejZiel/pytest-queryguard/tree/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maciej Zieliński
|
|
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,451 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-queryguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect N+1 queries and enforce query budgets for SQLAlchemy 2 in pytest.
|
|
5
|
+
Project-URL: Homepage, https://github.com/MaciejZiel/pytest-queryguard
|
|
6
|
+
Project-URL: Repository, https://github.com/MaciejZiel/pytest-queryguard
|
|
7
|
+
Project-URL: Changelog, https://github.com/MaciejZiel/pytest-queryguard/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/MaciejZiel/pytest-queryguard/issues
|
|
9
|
+
Author: Maciej Zieliński
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: fastapi,n+1,pytest,sqlalchemy,testing
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: pytest>=8
|
|
28
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# pytest-queryguard
|
|
32
|
+
|
|
33
|
+
[](https://github.com/MaciejZiel/pytest-queryguard/actions/workflows/ci.yml)
|
|
34
|
+
|
|
35
|
+
A pytest plugin that fails tests with N+1 queries and enforces query budgets
|
|
36
|
+
for SQLAlchemy 2, in sync, async and FastAPI code.
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
ORM code that looks correct can send one query per row instead of one query
|
|
41
|
+
per page:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
orders = session.scalars(select(Order)).all() # 1 query
|
|
45
|
+
for order in orders:
|
|
46
|
+
print(order.customer.name) # +1 query per order
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The tests pass. A test database holds a handful of rows, so 5 extra queries
|
|
50
|
+
cost nothing. In production the same endpoint loads 500 orders, sends 501
|
|
51
|
+
queries and gets slow. Nothing in the test run warned you.
|
|
52
|
+
|
|
53
|
+
pytest-queryguard watches the SQL each test sends. When it sees the N+1
|
|
54
|
+
pattern, it fails the test and points to the line that caused it, even if
|
|
55
|
+
the test database has only a few rows.
|
|
56
|
+
|
|
57
|
+
## Before and after
|
|
58
|
+
|
|
59
|
+
The code below is a small example project. All output in this section is
|
|
60
|
+
copied from a real run.
|
|
61
|
+
|
|
62
|
+
`app/orders.py`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from sqlalchemy import select
|
|
66
|
+
from sqlalchemy.orm import Session
|
|
67
|
+
|
|
68
|
+
from app.models import Order
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def list_orders(session: Session) -> list[dict[str, object]]:
|
|
72
|
+
orders = session.scalars(select(Order)).all()
|
|
73
|
+
return [{"id": order.id, "customer": order.customer.name} for order in orders]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`app/customers.py`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from sqlalchemy.orm import Session
|
|
80
|
+
|
|
81
|
+
from app.models import Customer
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fetch_customers(session: Session, ids: list[int]) -> list[Customer]:
|
|
85
|
+
customers = []
|
|
86
|
+
for customer_id in ids:
|
|
87
|
+
customers.append(session.get(Customer, customer_id))
|
|
88
|
+
return customers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def rename_customers(session: Session, names: dict[int, str]) -> None:
|
|
92
|
+
for customer_id, name in names.items():
|
|
93
|
+
session.get(Customer, customer_id).name = name
|
|
94
|
+
session.flush()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`tests/test_orders.py` (the `session` fixture in `tests/conftest.py`
|
|
98
|
+
creates 50 customers with one order each; `tests/__init__.py` exists, so
|
|
99
|
+
pytest puts the project root on `sys.path` and `app` can be imported):
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import pytest
|
|
103
|
+
|
|
104
|
+
from app.customers import fetch_customers, rename_customers
|
|
105
|
+
from app.orders import list_orders
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_list_orders(session):
|
|
109
|
+
assert len(list_orders(session)) == 50
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_fetch_customers(session):
|
|
113
|
+
assert len(fetch_customers(session, list(range(1, 51)))) == 50
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.max_queries(2)
|
|
117
|
+
def test_rename_customers(session):
|
|
118
|
+
rename_customers(session, {1: "Ada", 2: "Grace"})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
All three tests pass without the plugin. With it installed,
|
|
122
|
+
`pytest --queryguard-report` gives the output below. It comes from Python
|
|
123
|
+
3.13 with SQLAlchemy 2.1; Python 3.12 prints the same. Older versions print
|
|
124
|
+
slightly different SQL or function names, for example `in <listcomp>` on
|
|
125
|
+
Python 3.10 and 3.11, or column aliases in the SQL on SQLAlchemy 2.0.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
tests/test_orders.py FFF [100%]
|
|
129
|
+
|
|
130
|
+
=================================== FAILURES ===================================
|
|
131
|
+
_______________________________ test_list_orders _______________________________
|
|
132
|
+
queryguard: N+1 detected
|
|
133
|
+
|
|
134
|
+
Order.customer was lazy-loaded 50 times
|
|
135
|
+
first at app/orders.py:9 in list_orders
|
|
136
|
+
sql: SELECT customer.id, customer.name FROM customer WHERE customer.id = ?
|
|
137
|
+
fix: load it with the parent query, e.g.
|
|
138
|
+
select(Order).options(selectinload(Order.customer))
|
|
139
|
+
_____________________________ test_fetch_customers _____________________________
|
|
140
|
+
queryguard: N+1 detected
|
|
141
|
+
|
|
142
|
+
the same statement ran 50 times
|
|
143
|
+
first at app/customers.py:9 in fetch_customers
|
|
144
|
+
SELECT customer.id, customer.name FROM customer WHERE customer.id = ?
|
|
145
|
+
fix: fetch all rows in one query (WHERE id IN (...)) instead of in a loop
|
|
146
|
+
____________________________ test_rename_customers _____________________________
|
|
147
|
+
queryguard: expected at most 2 queries, got 4
|
|
148
|
+
|
|
149
|
+
1. SELECT customer.id, customer.name FROM customer WHERE customer.id = ? (app/customers.py:15 in rename_customers)
|
|
150
|
+
2. UPDATE customer SET name=? WHERE customer.id = ? (app/customers.py:16 in rename_customers)
|
|
151
|
+
3. SELECT customer.id, customer.name FROM customer WHERE customer.id = ? (app/customers.py:15 in rename_customers)
|
|
152
|
+
4. UPDATE customer SET name=? WHERE customer.id = ? (app/customers.py:16 in rename_customers)
|
|
153
|
+
============================== queryguard report ===============================
|
|
154
|
+
51 tests/test_orders.py::test_list_orders
|
|
155
|
+
50 tests/test_orders.py::test_fetch_customers
|
|
156
|
+
4 tests/test_orders.py::test_rename_customers
|
|
157
|
+
=========================== short test summary info ============================
|
|
158
|
+
FAILED tests/test_orders.py::test_list_orders - Failed: queryguard: N+1 detected
|
|
159
|
+
FAILED tests/test_orders.py::test_fetch_customers - Failed: queryguard: N+1 d...
|
|
160
|
+
FAILED tests/test_orders.py::test_rename_customers - Failed: queryguard: expe...
|
|
161
|
+
============================== 3 failed in 0.07s ===============================
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The fixed code loads related rows with the parent query and fetches many
|
|
165
|
+
rows with one `IN` query.
|
|
166
|
+
|
|
167
|
+
`app/orders.py`, fixed:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from sqlalchemy import select
|
|
171
|
+
from sqlalchemy.orm import Session, selectinload
|
|
172
|
+
|
|
173
|
+
from app.models import Order
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def list_orders(session: Session) -> list[dict[str, object]]:
|
|
177
|
+
orders = session.scalars(select(Order).options(selectinload(Order.customer))).all()
|
|
178
|
+
return [{"id": order.id, "customer": order.customer.name} for order in orders]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`app/customers.py`, fixed:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from collections.abc import Sequence
|
|
185
|
+
|
|
186
|
+
from sqlalchemy import select
|
|
187
|
+
from sqlalchemy.orm import Session
|
|
188
|
+
|
|
189
|
+
from app.models import Customer
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fetch_customers(session: Session, ids: list[int]) -> Sequence[Customer]:
|
|
193
|
+
return session.scalars(select(Customer).where(Customer.id.in_(ids))).all()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def rename_customers(session: Session, names: dict[int, str]) -> None:
|
|
197
|
+
customers = session.scalars(select(Customer).where(Customer.id.in_(names)))
|
|
198
|
+
for customer in customers:
|
|
199
|
+
customer.name = names[customer.id]
|
|
200
|
+
session.flush()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The same tests, unchanged:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
tests/test_orders.py ... [100%]
|
|
207
|
+
|
|
208
|
+
============================== queryguard report ===============================
|
|
209
|
+
2 tests/test_orders.py::test_list_orders
|
|
210
|
+
2 tests/test_orders.py::test_rename_customers
|
|
211
|
+
1 tests/test_orders.py::test_fetch_customers
|
|
212
|
+
============================== 3 passed in 0.04s ===============================
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Install
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pip install pytest-queryguard
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
It needs Python 3.10 or newer, pytest 8 or newer and SQLAlchemy 2.0 or
|
|
222
|
+
newer. The plugin registers itself with pytest. There is nothing to import
|
|
223
|
+
or configure.
|
|
224
|
+
|
|
225
|
+
## Usage
|
|
226
|
+
|
|
227
|
+
### Automatic N+1 detection
|
|
228
|
+
|
|
229
|
+
Every test is checked. A test fails when, inside the test function:
|
|
230
|
+
|
|
231
|
+
- one relationship (for example `Order.customer`) is lazy-loaded for 2 or
|
|
232
|
+
more different parent objects, or
|
|
233
|
+
- one `SELECT`, with its parameters and literals removed, runs 5 or more
|
|
234
|
+
times.
|
|
235
|
+
|
|
236
|
+
Both thresholds can be changed (see [Configuration](#configuration)).
|
|
237
|
+
|
|
238
|
+
### Query budgets with the `max_queries` marker
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
import pytest
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.max_queries(3)
|
|
245
|
+
def test_order_page(client):
|
|
246
|
+
client.get("/orders/1")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@pytest.mark.max_queries(n=3) # the same, with a keyword
|
|
250
|
+
def test_order_page_again(client):
|
|
251
|
+
client.get("/orders/1")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The test fails when its body runs more SQL statements than the limit. The
|
|
255
|
+
failure lists every statement with the line that issued it. Budgets count
|
|
256
|
+
all statements, writes included, and they always fail, whatever
|
|
257
|
+
`queryguard_mode` is set to. If the test already fails on its own, an
|
|
258
|
+
exceeded budget is not reported, because the original failure wins.
|
|
259
|
+
|
|
260
|
+
### Query budgets with the `max_queries()` context manager
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from pytest_queryguard import max_queries
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_checkout(service, cart):
|
|
267
|
+
with max_queries(2):
|
|
268
|
+
service.checkout(cart)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Only the statements inside the `with` block count. Above the limit it raises
|
|
272
|
+
`QueryBudgetExceeded`, a subclass of `AssertionError`. The context manager
|
|
273
|
+
does not depend on pytest, so it also works in a script or a benchmark. It
|
|
274
|
+
checks the budget only; N+1 detection is done by the plugin around the
|
|
275
|
+
whole test.
|
|
276
|
+
|
|
277
|
+
### Opting out
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
@pytest.mark.allow_nplusone
|
|
281
|
+
def test_legacy_report(session): ...
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The test is not checked for N+1. A `max_queries` marker on the same test is
|
|
285
|
+
still enforced.
|
|
286
|
+
|
|
287
|
+
### Async and FastAPI
|
|
288
|
+
|
|
289
|
+
Nothing changes for async code. `AsyncSession` and `create_async_engine` run
|
|
290
|
+
on a sync engine underneath, which the plugin already watches. On SQLAlchemy
|
|
291
|
+
2.1 and newer, async support needs `pip install "sqlalchemy[asyncio]"`,
|
|
292
|
+
which provides greenlet. The failure
|
|
293
|
+
points to the `await` in your code, not to SQLAlchemy internals:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# app/orders.py; the models use SQLAlchemy's AsyncAttrs mixin.
|
|
297
|
+
async def customer_names(session: AsyncSession) -> list[str]:
|
|
298
|
+
orders = (await session.scalars(select(Order))).all()
|
|
299
|
+
return [(await order.awaitable_attrs.customer).name for order in orders]
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
# tests/test_async.py; main() creates the engine and data, then calls
|
|
304
|
+
# customer_names(session).
|
|
305
|
+
def test_customer_names():
|
|
306
|
+
asyncio.run(main())
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
This fails with `Order.customer was lazy-loaded 3 times`, first at the line
|
|
310
|
+
with the `await`. pytest cannot run `async def` tests by itself. With
|
|
311
|
+
pytest-asyncio or anyio you can write the test as `async def` instead of
|
|
312
|
+
calling `asyncio.run`.
|
|
313
|
+
|
|
314
|
+
FastAPI's `TestClient` runs the application in another thread. Queries from
|
|
315
|
+
any thread are counted, so an N+1 inside an endpoint fails the test that
|
|
316
|
+
called it:
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
def test_orders_endpoint(client):
|
|
320
|
+
assert client.get("/orders").status_code == 200
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Session report
|
|
324
|
+
|
|
325
|
+
`pytest --queryguard-report` prints, at the end of the run, the 10 tests
|
|
326
|
+
that ran the most queries, failing tests included. See the output in
|
|
327
|
+
[Before and after](#before-and-after).
|
|
328
|
+
|
|
329
|
+
## Configuration
|
|
330
|
+
|
|
331
|
+
| Option | Values | Default | Meaning |
|
|
332
|
+
|---|---|---|---|
|
|
333
|
+
| `queryguard_mode` | `fail`, `warn`, `off` | `fail` | `fail` fails a test with N+1. `warn` reports it as a `QueryGuardWarning` and lets the test pass. `off` turns detection off; tests without a budget are not measured. |
|
|
334
|
+
| `queryguard_relationship_threshold` | integer ≥ 2 | `2` | Number of different parent objects for which one relationship must be lazy-loaded to count as N+1. |
|
|
335
|
+
| `queryguard_repeat_threshold` | integer ≥ 2 | `5` | Number of times one normalized `SELECT` must run to count as N+1. |
|
|
336
|
+
|
|
337
|
+
Any invalid value stops pytest with a usage error.
|
|
338
|
+
|
|
339
|
+
In `pyproject.toml`:
|
|
340
|
+
|
|
341
|
+
```toml
|
|
342
|
+
[tool.pytest.ini_options]
|
|
343
|
+
queryguard_mode = "warn"
|
|
344
|
+
queryguard_relationship_threshold = 3
|
|
345
|
+
queryguard_repeat_threshold = 10
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
With pytest 9 you can use the native TOML table instead:
|
|
349
|
+
|
|
350
|
+
```toml
|
|
351
|
+
[tool.pytest]
|
|
352
|
+
queryguard_mode = "warn"
|
|
353
|
+
queryguard_relationship_threshold = 3
|
|
354
|
+
queryguard_repeat_threshold = 10
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`pytest.ini` and `setup.cfg` work as usual.
|
|
358
|
+
|
|
359
|
+
The warning classes, `pytest_queryguard.QueryGuardWarning` for N+1 in
|
|
360
|
+
`warn` mode and `pytest_queryguard.QueryGuardInternalWarning` for an
|
|
361
|
+
internal error, work with pytest's usual warning filters. For example, to
|
|
362
|
+
turn N+1 warnings back into failures for one job:
|
|
363
|
+
|
|
364
|
+
```toml
|
|
365
|
+
[tool.pytest.ini_options]
|
|
366
|
+
queryguard_mode = "warn"
|
|
367
|
+
filterwarnings = ["error::pytest_queryguard.QueryGuardWarning"]
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Command-line options:
|
|
371
|
+
|
|
372
|
+
| Option | Meaning |
|
|
373
|
+
|---|---|
|
|
374
|
+
| `--queryguard-mode=fail\|warn\|off` | Overrides `queryguard_mode` for this run. |
|
|
375
|
+
| `--queryguard-report` | Prints the 10 tests with the most queries at the end of the session. |
|
|
376
|
+
|
|
377
|
+
## How it works
|
|
378
|
+
|
|
379
|
+
1. When pytest starts (unless `queryguard_mode = off`; budgets still install
|
|
380
|
+
the listeners when a test needs them), the plugin adds two class-level
|
|
381
|
+
SQLAlchemy listeners:
|
|
382
|
+
`do_orm_execute` on `Session` sees relationship lazy loads, and
|
|
383
|
+
`before_cursor_execute` on `Engine` sees every SQL statement.
|
|
384
|
+
2. Measurement covers only the test function body, so queries in fixture
|
|
385
|
+
setup and teardown are not counted.
|
|
386
|
+
3. Each statement is stored with the first stack frame outside SQLAlchemy,
|
|
387
|
+
pytest, the standard library, `site-packages` and the plugin itself. For
|
|
388
|
+
async sessions the search continues in the parent greenlets, where the
|
|
389
|
+
awaiting code runs.
|
|
390
|
+
4. After the test, the recorded events are checked against the budget and
|
|
391
|
+
the two N+1 rules. A bug inside the plugin disables it with one warning;
|
|
392
|
+
it never fails your tests.
|
|
393
|
+
|
|
394
|
+
## Limitations
|
|
395
|
+
|
|
396
|
+
- SQLAlchemy only. Raw DB-API connections and other ORMs are not seen.
|
|
397
|
+
- Everything in `site-packages` counts as library code. If your application
|
|
398
|
+
is installed non-editable (for example `pip install .` in a Docker CI
|
|
399
|
+
image), its frames are skipped too, and call sites point to the test file
|
|
400
|
+
instead of the application line.
|
|
401
|
+
- One measurement is active per process at a time, so tests in one process
|
|
402
|
+
must run sequentially. Under pytest-xdist each worker measures its own
|
|
403
|
+
tests, so detection and budgets work. The report needs a single process,
|
|
404
|
+
so under pytest-xdist it prints
|
|
405
|
+
`queryguard report: not available with pytest-xdist`.
|
|
406
|
+
- Tests that use `pytester` to run pytest in-process are also measured by the
|
|
407
|
+
outer test, which sees the inner run's queries. Mark such tests with
|
|
408
|
+
`allow_nplusone` or use `pytester.runpytest_subprocess()`.
|
|
409
|
+
- unittest `setUp` and `tearDown` run inside the test call, so their queries
|
|
410
|
+
are counted, unlike pytest fixtures.
|
|
411
|
+
- A non-strict `xfail` test that has an N+1 is reported as xfailed rather
|
|
412
|
+
than XPASS, because the N+1 failure looks like the expected failure.
|
|
413
|
+
- `INSERT`, `UPDATE` and `DELETE` are never reported as N+1. At cursor level
|
|
414
|
+
a unit-of-work flush cannot be told apart from an application write, so
|
|
415
|
+
all writes are left out. Use a budget to catch write loops.
|
|
416
|
+
|
|
417
|
+
## Comparison with nplusone
|
|
418
|
+
|
|
419
|
+
[nplusone](https://github.com/jmcarp/nplusone) solved the same problem
|
|
420
|
+
earlier and also covers Django and Peewee. Its last release, 1.0.0, came
|
|
421
|
+
out in 2018, and it does not support SQLAlchemy 2. pytest-queryguard targets
|
|
422
|
+
SQLAlchemy 2 only, including async sessions, and adds query budgets and a
|
|
423
|
+
fix hint in the failure message. It does not detect unused eager loads,
|
|
424
|
+
which nplusone does.
|
|
425
|
+
|
|
426
|
+
## Roadmap
|
|
427
|
+
|
|
428
|
+
- 0.2: missing index detection on PostgreSQL 16 and newer, by running each
|
|
429
|
+
statement through `EXPLAIN (GENERIC_PLAN)` with `enable_seqscan=off`.
|
|
430
|
+
- Later: Django ORM support.
|
|
431
|
+
|
|
432
|
+
## Development
|
|
433
|
+
|
|
434
|
+
The project uses [uv](https://docs.astral.sh/uv/).
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
uv sync
|
|
438
|
+
uv run coverage run -m pytest
|
|
439
|
+
uv run coverage report
|
|
440
|
+
uv run ruff check .
|
|
441
|
+
uv run ruff format --check .
|
|
442
|
+
uv run mypy src
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Coverage is measured with `coverage run` rather than `pytest --cov`, because
|
|
446
|
+
`--cov` starts after pytest has imported the plugin and misses its
|
|
447
|
+
import-time lines.
|
|
448
|
+
|
|
449
|
+
## License
|
|
450
|
+
|
|
451
|
+
MIT, see the LICENSE file.
|