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.
Files changed (31) hide show
  1. pytest_queryguard-0.1.0/.gitignore +12 -0
  2. pytest_queryguard-0.1.0/3rdparty_licenses.md +16 -0
  3. pytest_queryguard-0.1.0/CHANGELOG.md +42 -0
  4. pytest_queryguard-0.1.0/LICENSE +21 -0
  5. pytest_queryguard-0.1.0/PKG-INFO +451 -0
  6. pytest_queryguard-0.1.0/README.md +421 -0
  7. pytest_queryguard-0.1.0/docs/design.md +243 -0
  8. pytest_queryguard-0.1.0/pyproject.toml +94 -0
  9. pytest_queryguard-0.1.0/src/pytest_queryguard/__init__.py +14 -0
  10. pytest_queryguard-0.1.0/src/pytest_queryguard/api.py +34 -0
  11. pytest_queryguard-0.1.0/src/pytest_queryguard/callsite.py +96 -0
  12. pytest_queryguard-0.1.0/src/pytest_queryguard/collector.py +46 -0
  13. pytest_queryguard-0.1.0/src/pytest_queryguard/detectors.py +104 -0
  14. pytest_queryguard-0.1.0/src/pytest_queryguard/events.py +24 -0
  15. pytest_queryguard-0.1.0/src/pytest_queryguard/listeners.py +118 -0
  16. pytest_queryguard-0.1.0/src/pytest_queryguard/messages.py +81 -0
  17. pytest_queryguard-0.1.0/src/pytest_queryguard/plugin.py +290 -0
  18. pytest_queryguard-0.1.0/src/pytest_queryguard/py.typed +0 -0
  19. pytest_queryguard-0.1.0/src/pytest_queryguard/warnings.py +9 -0
  20. pytest_queryguard-0.1.0/tests/__init__.py +0 -0
  21. pytest_queryguard-0.1.0/tests/conftest.py +3 -0
  22. pytest_queryguard-0.1.0/tests/models.py +50 -0
  23. pytest_queryguard-0.1.0/tests/test_async_and_fastapi.py +317 -0
  24. pytest_queryguard-0.1.0/tests/test_budgets.py +325 -0
  25. pytest_queryguard-0.1.0/tests/test_callsite.py +73 -0
  26. pytest_queryguard-0.1.0/tests/test_detectors.py +165 -0
  27. pytest_queryguard-0.1.0/tests/test_listeners.py +161 -0
  28. pytest_queryguard-0.1.0/tests/test_messages.py +87 -0
  29. pytest_queryguard-0.1.0/tests/test_plugin.py +331 -0
  30. pytest_queryguard-0.1.0/tests/test_plugin_loads.py +8 -0
  31. pytest_queryguard-0.1.0/tests/test_report.py +183 -0
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ coverage.xml
9
+ htmlcov/
10
+ dist/
11
+ build/
12
+ *.egg-info/
@@ -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
+ [![CI](https://github.com/MaciejZiel/pytest-queryguard/actions/workflows/ci.yml/badge.svg)](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.