pytest-querycount 0.3.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 (30) hide show
  1. pytest_querycount-0.3.0/.github/dependabot.yml +11 -0
  2. pytest_querycount-0.3.0/.github/workflows/ci.yml +73 -0
  3. pytest_querycount-0.3.0/.github/workflows/publish.yml +23 -0
  4. pytest_querycount-0.3.0/.gitignore +13 -0
  5. pytest_querycount-0.3.0/CHANGELOG.md +75 -0
  6. pytest_querycount-0.3.0/LICENSE +21 -0
  7. pytest_querycount-0.3.0/PKG-INFO +336 -0
  8. pytest_querycount-0.3.0/README.md +277 -0
  9. pytest_querycount-0.3.0/RELEASING.md +81 -0
  10. pytest_querycount-0.3.0/pyproject.toml +72 -0
  11. pytest_querycount-0.3.0/src/pytest_querycount/__init__.py +31 -0
  12. pytest_querycount-0.3.0/src/pytest_querycount/backends/__init__.py +59 -0
  13. pytest_querycount-0.3.0/src/pytest_querycount/backends/sqlalchemy.py +215 -0
  14. pytest_querycount-0.3.0/src/pytest_querycount/checks.py +166 -0
  15. pytest_querycount-0.3.0/src/pytest_querycount/errors.py +39 -0
  16. pytest_querycount-0.3.0/src/pytest_querycount/explain.py +175 -0
  17. pytest_querycount-0.3.0/src/pytest_querycount/normalize.py +97 -0
  18. pytest_querycount-0.3.0/src/pytest_querycount/plugin.py +284 -0
  19. pytest_querycount-0.3.0/src/pytest_querycount/py.typed +0 -0
  20. pytest_querycount-0.3.0/src/pytest_querycount/recorder.py +214 -0
  21. pytest_querycount-0.3.0/src/pytest_querycount/records.py +66 -0
  22. pytest_querycount-0.3.0/src/pytest_querycount/report.py +64 -0
  23. pytest_querycount-0.3.0/tests/conftest.py +184 -0
  24. pytest_querycount-0.3.0/tests/test_fixture.py +153 -0
  25. pytest_querycount-0.3.0/tests/test_max_queries.py +166 -0
  26. pytest_querycount-0.3.0/tests/test_n_plus_one.py +128 -0
  27. pytest_querycount-0.3.0/tests/test_no_backend.py +42 -0
  28. pytest_querycount-0.3.0/tests/test_normalize.py +83 -0
  29. pytest_querycount-0.3.0/tests/test_report.py +95 -0
  30. pytest_querycount-0.3.0/tests/test_seq_scan.py +243 -0
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ # Actions go stale quietly: a pinned action keeps working while its runtime is
4
+ # deprecated underneath it, and the only signal is a warning in a log nobody
5
+ # reads. Weekly bumps turn that into a pull request.
6
+ - package-ecosystem: github-actions
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ commit-message:
11
+ prefix: "ci"
@@ -0,0 +1,73 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ sqlalchemy: ["2.0.*", "2.1.*"]
16
+ name: py${{ matrix.python-version }} / sqlalchemy ${{ matrix.sqlalchemy }}
17
+ services:
18
+ postgres:
19
+ image: postgres:17-alpine
20
+ env:
21
+ POSTGRES_PASSWORD: querycount
22
+ POSTGRES_DB: querycount
23
+ ports:
24
+ - 55432:5432
25
+ options: >-
26
+ --health-cmd pg_isready
27
+ --health-interval 5s
28
+ --health-timeout 5s
29
+ --health-retries 10
30
+ steps:
31
+ - uses: actions/checkout@v5
32
+ - uses: actions/setup-python@v6
33
+ with:
34
+ python-version: ${{ matrix.python-version }}
35
+ - name: Install
36
+ run: |
37
+ python -m pip install --upgrade pip
38
+ python -m pip install -e ".[dev]"
39
+ python -m pip install "sqlalchemy==${{ matrix.sqlalchemy }}"
40
+ - name: Test
41
+ # Without REQUIRE_PG the plan tests would skip on a broken service and
42
+ # the build would still be green, which is the worst outcome.
43
+ env:
44
+ QUERYCOUNT_REQUIRE_PG: "1"
45
+ QUERYCOUNT_TEST_PG_URL: postgresql+psycopg://postgres:querycount@localhost:55432/querycount
46
+ run: python -m pytest tests/ -v
47
+
48
+ quality:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v5
52
+ - uses: actions/setup-python@v6
53
+ with:
54
+ python-version: "3.13"
55
+ - run: python -m pip install -e ".[dev]"
56
+ - name: Lint
57
+ run: ruff check src tests
58
+ - name: Format
59
+ run: ruff format --check src tests
60
+ - name: Types
61
+ run: mypy
62
+
63
+ package:
64
+ runs-on: ubuntu-latest
65
+ steps:
66
+ - uses: actions/checkout@v5
67
+ - uses: actions/setup-python@v6
68
+ with:
69
+ python-version: "3.13"
70
+ - run: python -m pip install build twine
71
+ - run: python -m build
72
+ - name: Check metadata
73
+ run: python -m twine check --strict dist/*
@@ -0,0 +1,23 @@
1
+ name: Publish
2
+
3
+ # Trusted Publishing: PyPI verifies this workflow's identity, so there is no
4
+ # API token to store or leak. Configure it once at
5
+ # https://pypi.org/manage/project/pytest-querycount/settings/publishing/
6
+ on:
7
+ release:
8
+ types: [published]
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v5
18
+ - uses: actions/setup-python@v6
19
+ with:
20
+ python-version: "3.13"
21
+ - run: python -m pip install build
22
+ - run: python -m build
23
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
@@ -0,0 +1,75 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [semantic versioning](https://semver.org/).
6
+
7
+ ## Unreleased
8
+
9
+ ### Planned
10
+
11
+ - Django and raw psycopg backends.
12
+ - `pytest-xdist` aware reporting.
13
+ - A `--querycount-write-budgets` mode that inserts the markers for you.
14
+
15
+ ## [0.3.0] - 2026-09-26
16
+
17
+ ### Added
18
+
19
+ - `@pytest.mark.no_seq_scan(ignore=())` -- fail a test whose query scans a table
20
+ sequentially because no index can serve its filter. PostgreSQL only.
21
+ - `--querycount-no-seq-scan`, the same check across a whole suite.
22
+ - `no_seq_scan=` and `ignore=` on the `querycount` fixture.
23
+ - Failure messages carry a suggested `CREATE INDEX`, derived from the columns in
24
+ the plan's filter expression.
25
+
26
+ ### How it works, and why not the obvious way
27
+
28
+ Failing on the presence of a `Seq Scan` does not work. On a test database of
29
+ twenty rows PostgreSQL picks a sequential scan *even when a perfect index
30
+ exists*, because reading twenty rows is cheaper than descending a B-tree. So the
31
+ plan alone cannot tell a missing index from a small table, and thresholding on
32
+ table size means the check never fires on test data at all.
33
+
34
+ Instead the plan is taken with `enable_seqscan` disabled. If PostgreSQL still
35
+ chooses a sequential scan, no index can serve that filter -- an answer that does
36
+ not depend on how much data the table holds. The tests for this feature run
37
+ against a table of eight rows to hold that claim honest.
38
+
39
+ ### Notes
40
+
41
+ - The `EXPLAIN` runs on a raw DBAPI cursor, so it is invisible to SQLAlchemy's
42
+ events: it is not counted among the test's queries and cannot recurse.
43
+ - It is wrapped in a savepoint, which both scopes the `enable_seqscan` change and
44
+ absorbs a failed `EXPLAIN` that would otherwise abort the test's transaction.
45
+ - An unfiltered `Seq Scan` is never reported: reading a whole table is sometimes
46
+ the point, and no index would improve it.
47
+ - On SQLite or MySQL the check raises rather than passing, for the same reason a
48
+ missing backend does.
49
+
50
+ ## [0.2.0] - 2026-09-26
51
+
52
+ First public release. Requires Python 3.10+, pytest 8+, and SQLAlchemy 2.x.
53
+
54
+ ### Added
55
+
56
+ - `@pytest.mark.max_queries(n)` -- fail a test that runs more than `n` queries.
57
+ - `@pytest.mark.no_n_plus_one` -- fail a test that executes one query shape
58
+ repeatedly, with `threshold` and `kinds` to tune it.
59
+ - The `querycount` fixture, for budgeting a block rather than a whole test.
60
+ - SQL fingerprinting, so `WHERE id = 42` and `WHERE id = 43` are recognised as
61
+ the same query. Handles every bind-parameter style, comments, dollar quoting,
62
+ and collapses `IN (...)` and multi-row `VALUES` lists.
63
+ - Caller attribution: failures name the line of your code that emitted the
64
+ query, not a line inside SQLAlchemy.
65
+ - `--querycount-report`, a table of the tests that run the most queries.
66
+ - `--querycount-max=N` and the `querycount_max` ini option, for applying a
67
+ budget across a whole suite.
68
+ - SQLAlchemy 2.x instrumentation, sync and async, with no configuration.
69
+
70
+ ### Notes
71
+
72
+ - A missing backend raises rather than passing silently, on the grounds that a
73
+ budget which cannot fail is worse than no budget.
74
+ - Only the call phase is measured, so fixture setup and teardown never consume a
75
+ budget.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis David Senra
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,336 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-querycount
3
+ Version: 0.3.0
4
+ Summary: Fail your tests when they run too many SQL queries. Catch N+1 problems in CI, not in production.
5
+ Project-URL: Homepage, https://github.com/ldavidsm/pytest-querycount
6
+ Project-URL: Issues, https://github.com/ldavidsm/pytest-querycount/issues
7
+ Project-URL: Changelog, https://github.com/ldavidsm/pytest-querycount/blob/main/CHANGELOG.md
8
+ Author: Luis David Senra
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Luis David Senra
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: database,n+1,orm,performance,pytest,query-count,sql,sqlalchemy,testing
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Framework :: Pytest
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Database
43
+ Classifier: Topic :: Software Development :: Testing
44
+ Classifier: Typing :: Typed
45
+ Requires-Python: >=3.10
46
+ Requires-Dist: pytest>=8.0
47
+ Provides-Extra: dev
48
+ Requires-Dist: mypy>=1.8; extra == 'dev'
49
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
50
+ Requires-Dist: pytest>=8.0; extra == 'dev'
51
+ Requires-Dist: ruff>=0.4; extra == 'dev'
52
+ Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
53
+ Provides-Extra: postgresql
54
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'postgresql'
55
+ Requires-Dist: sqlalchemy>=2.0; extra == 'postgresql'
56
+ Provides-Extra: sqlalchemy
57
+ Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
58
+ Description-Content-Type: text/markdown
59
+
60
+ # pytest-querycount
61
+
62
+ [![CI](https://github.com/ldavidsm/pytest-querycount/actions/workflows/ci.yml/badge.svg)](https://github.com/ldavidsm/pytest-querycount/actions/workflows/ci.yml)
63
+ [![PyPI](https://img.shields.io/pypi/v/pytest-querycount.svg)](https://pypi.org/project/pytest-querycount/)
64
+ [![Python versions](https://img.shields.io/pypi/pyversions/pytest-querycount.svg)](https://pypi.org/project/pytest-querycount/)
65
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
66
+
67
+ Fail your tests when they run too many SQL queries.
68
+
69
+ An endpoint that quietly grows from 2 queries to 200 does not break any test. It
70
+ just gets slower, until one day it is slow enough to notice. This plugin turns
71
+ that drift into a red test.
72
+
73
+ ```python
74
+ @pytest.mark.max_queries(2)
75
+ def test_billing_summary(db):
76
+ assert len(serialise(db)) == 12
77
+ ```
78
+
79
+ Add a lazy-loaded relationship to that serialiser and the test fails on the next
80
+ CI run, naming the repeated query and the line of your code that caused it.
81
+
82
+ ## Install
83
+
84
+ ```bash
85
+ pip install "pytest-querycount[sqlalchemy]"
86
+ ```
87
+
88
+ Requires Python 3.10+, pytest 8+, and SQLAlchemy 2.x. There is nothing to
89
+ configure: the plugin instruments every engine your suite creates, sync or async.
90
+
91
+ ## What it gives you
92
+
93
+ ### A query budget
94
+
95
+ ```python
96
+ @pytest.mark.max_queries(2)
97
+ def test_billing_summary(db):
98
+ assert len(serialise(db)) == 12
99
+ ```
100
+
101
+ When the serialiser loops, you get this -- real output, not an illustration:
102
+
103
+ ```
104
+ _____________________________ test_billing_summary _____________________________
105
+
106
+ E TooManyQueriesError: test_billing_summary ran 13 queries, budget is 2 (11 over).
107
+
108
+ Repeated query shapes -- most likely where the extra queries come from:
109
+ 12x SELECT invoices.id, invoices.customer_id, invoices.cents FROM invoices WHERE ? = invoices.custome…
110
+ from test_billing.py:10 (12x)
111
+
112
+ 13 queries in 0.1ms
113
+ 1. [ 0.02ms] SELECT customers.id, customers.email FROM customers
114
+ from test_billing.py:11
115
+ 2-13. [ 0.06ms] 12x SELECT invoices.id, invoices.customer_id, invoices.cents FROM invoices WHERE …
116
+ from test_billing.py:10
117
+ ```
118
+
119
+ Three things to notice, because they are the reason this is a package and not a
120
+ snippet:
121
+
122
+ - **The diagnosis comes first.** `12x` on one query shape, before any listing.
123
+ In a CI log you get the culprit in the first three lines.
124
+ - **`test_billing.py:10` is your code**, not a line inside SQLAlchemy. The
125
+ plugin walks up the stack past the ORM to find the line that actually caused
126
+ the query.
127
+ - **The twelve identical queries print as one line.** Twelve copies of the same
128
+ SELECT tell you nothing that `12x` does not, and they would push the useful
129
+ lines off the screen.
130
+
131
+ ### N+1 detection without picking a number
132
+
133
+ Sometimes you do not want to choose a budget, you just want to assert that
134
+ nothing is looping.
135
+
136
+ ```python
137
+ @pytest.mark.no_n_plus_one
138
+ def test_billing_summary(db):
139
+ assert len(serialise(db)) == 12
140
+ ```
141
+
142
+ This groups queries by *shape* -- `WHERE id = 42` and `WHERE id = 43` are the
143
+ same shape -- and fails when one shape repeats. By default it looks only at
144
+ SELECTs, since repeated `SAVEPOINT`s are just how transactions work.
145
+
146
+ ```python
147
+ @pytest.mark.no_n_plus_one(threshold=5) # tolerate up to 4 repeats
148
+ @pytest.mark.no_n_plus_one(kinds=("select", "insert"))
149
+ @pytest.mark.no_n_plus_one(kinds=None) # consider every statement
150
+ ```
151
+
152
+ ### Missing indexes (PostgreSQL)
153
+
154
+ ```python
155
+ @pytest.mark.no_seq_scan
156
+ def test_customer_search(db):
157
+ assert len(find_by_city(db, "city3")) == 1
158
+ ```
159
+
160
+ ```
161
+ _____________________________ test_customer_search _____________________________
162
+
163
+ E SeqScanError: test_customer_search ran 1 query that no index could serve.
164
+
165
+ PostgreSQL still chose a sequential scan with enable_seqscan disabled, which
166
+ means no index covers the filtered columns.
167
+
168
+ Seq Scan on "demo_customers" Filter: ((city)::text = 'city3'::text)
169
+ try: CREATE INDEX ON demo_customers (city);
170
+ from test_search.py:7
171
+ SELECT demo_customers.id, ... FROM demo_customers WHERE demo_customers.…
172
+ ```
173
+
174
+ That table holds **eight rows**, and that matters more than it looks.
175
+
176
+ The obvious way to write this check does not work. Failing on the presence of a
177
+ `Seq Scan` is useless, because on a test database of twenty rows PostgreSQL picks
178
+ a sequential scan *even when a perfect index exists* -- reading twenty rows is
179
+ cheaper than descending a B-tree. So the plan alone cannot distinguish a missing
180
+ index from a small table. And thresholding on table size, the usual next idea,
181
+ means the check never fires on test data at all. Either way you get a check that
182
+ lies to you.
183
+
184
+ So this asks a different question: not "did it scan?" but "**could** it have used
185
+ an index?". The plan is taken with `enable_seqscan` disabled, which makes the
186
+ planner treat sequential scans as enormously expensive. If it still picks one, no
187
+ index can serve that filter -- and that answer does not depend on data volume.
188
+ The test suite for this feature runs against eight rows on purpose, to keep that
189
+ claim honest.
190
+
191
+ Excluding a table you read whole deliberately:
192
+
193
+ ```python
194
+ @pytest.mark.no_seq_scan(ignore=("countries", "settings"))
195
+ ```
196
+
197
+ Three things this is careful about:
198
+
199
+ - The `EXPLAIN` runs on a raw DBAPI cursor, invisible to SQLAlchemy's events, so
200
+ it is never counted among your test's queries and cannot recurse.
201
+ - It is wrapped in a savepoint, which both scopes the `enable_seqscan` change and
202
+ absorbs a failed `EXPLAIN` that would otherwise abort your test's transaction.
203
+ - An unfiltered `Seq Scan` is never reported. Reading a whole table is sometimes
204
+ exactly what you meant.
205
+
206
+ Needs `pip install "pytest-querycount[postgresql]"`. On SQLite or MySQL the check
207
+ raises rather than passing, because a check that cannot fail is not a check.
208
+
209
+ ### A fixture, for when a marker is too coarse
210
+
211
+ A marker covers the whole test. When you only care about one block:
212
+
213
+ ```python
214
+ def test_detail(db, querycount):
215
+ with querycount(max_queries=2, no_seq_scan=True) as queries:
216
+ serialise(db)
217
+
218
+ assert queries.count == 2
219
+ assert queries.duplicates() == []
220
+ print(queries.report())
221
+ ```
222
+
223
+ Call it with no arguments to observe without asserting -- that is how you find
224
+ out what the budget should be before committing to one. The object from the
225
+ `with` is the recorder, and it keeps its records after the block ends.
226
+
227
+ ### The report you leave switched on
228
+
229
+ ```bash
230
+ pytest --querycount-report
231
+ ```
232
+
233
+ ```
234
+ ============================== querycount summary ==============================
235
+ queries time dupes test
236
+ ------- --------- ----- -------------------------------------------
237
+ 13 0.1ms 12! test_billing.py::test_billing_summary
238
+ 2 0.0ms - test_billing.py::test_billing_summary_fixed
239
+
240
+ 15 queries in 0.1ms across 2 tests
241
+ ! marks a repeated query shape -- add @pytest.mark.no_n_plus_one to see the detail.
242
+ ```
243
+
244
+ A budget tells you when you crossed a line you drew. This tells you where the
245
+ lines should go, and it is the honest way to adopt the plugin on an existing
246
+ suite: run it once, look at the rows with a `!`, write budgets for those.
247
+
248
+ ## Options
249
+
250
+ | Flag | Effect |
251
+ |---|---|
252
+ | `--querycount-report` | Print the summary table |
253
+ | `--querycount-top=N` | How many tests the table lists (default 10) |
254
+ | `--querycount-max=N` | Apply a budget of N to every test without an explicit one |
255
+ | `--querycount-no-seq-scan` | Apply the missing-index check to every test |
256
+
257
+ `--querycount-max` is how you ratchet: set it just above your current worst
258
+ test, then lower it as you fix things.
259
+
260
+ In `pyproject.toml`:
261
+
262
+ ```toml
263
+ [tool.pytest.ini_options]
264
+ querycount_max = "20"
265
+ querycount_duplicate_threshold = "3"
266
+ ```
267
+
268
+ ## Design decisions worth knowing
269
+
270
+ - **Only the call phase is measured.** Fixture setup and teardown run outside it,
271
+ so creating a schema and seeding it never consume a budget.
272
+ - **A failing test is never second-guessed.** If your assertion fails, you see
273
+ that failure, not a complaint about query counts on top of it.
274
+ - **`executemany` counts as one query**, because it is one round trip. That is
275
+ the point of it, and the fix for a loop of inserts.
276
+ - **No backend is an error, not a pass.** If nothing is instrumented, every
277
+ count is zero and every budget is trivially satisfied. The plugin raises
278
+ instead, because a budget that cannot fail is worse than no budget.
279
+
280
+ ## Why not just count them yourself
281
+
282
+ You can, in about twenty lines of `event.listen`. What you will not have is the
283
+ fingerprinting that tells `WHERE id = 42` and `WHERE id = 43` apart from each
284
+ other but together against `WHERE email = ?`; the walk up the stack to find the
285
+ line in *your* code rather than in SQLAlchemy's; the collapsing of `IN (?,?,?)`
286
+ so that batch size does not change the shape; or the failure message that names
287
+ the culprit before it shows the evidence. That is what this package is.
288
+
289
+ ## Prior art
290
+
291
+ [`nplusone`](https://pypi.org/project/nplusone/) did the detection half of this
292
+ for Django and SQLAlchemy, but its last release was in **May 2018** and it does
293
+ not support SQLAlchemy 2.x. Django users have `assertNumQueries` built in, which
294
+ covers budgets but not shapes. This plugin is aimed at the SQLAlchemy side --
295
+ FastAPI, Flask, Litestar -- where nothing is currently maintained.
296
+
297
+ ## Limitations
298
+
299
+ - SQLAlchemy 2.x only for now. Django and raw psycopg are on the roadmap.
300
+ - Under `pytest-xdist` the summary table is per worker, so it will be partial.
301
+ Budgets and N+1 detection are unaffected.
302
+ - `no_seq_scan` is PostgreSQL only, and costs one `EXPLAIN` per SELECT while
303
+ enabled. Budgets and N+1 detection work on any SQLAlchemy backend.
304
+ - The suggested `CREATE INDEX` is a starting point, not advice. Which columns to
305
+ index, in what order, and whether the index earns its write cost need the whole
306
+ query pattern, not one plan node. A filter over a function call
307
+ (`lower(email) = ...`) yields no suggestion at all rather than a wrong one.
308
+
309
+ ## Contributing
310
+
311
+ ```bash
312
+ python -m venv .venv && . .venv/bin/activate
313
+ pip install -e ".[dev]"
314
+
315
+ # The plan tests need PostgreSQL; without one they skip.
316
+ docker run -d --name qc-pg -e POSTGRES_PASSWORD=querycount \
317
+ -e POSTGRES_DB=querycount -p 55432:5432 postgres:17-alpine
318
+
319
+ pytest && ruff check src tests && mypy
320
+ ```
321
+
322
+ Set `QUERYCOUNT_REQUIRE_PG=1` to turn those skips into failures, as CI does, and
323
+ `QUERYCOUNT_TEST_PG_URL` to point elsewhere.
324
+
325
+ The suite runs pytest inside pytest via `pytester`: it writes a throwaway test,
326
+ runs it in a subprocess, and asserts on the outcome. That is the only honest way
327
+ to test a plugin whose job is to make tests fail.
328
+
329
+ ## Releasing
330
+
331
+ See [RELEASING.md](RELEASING.md). Publication goes through PyPI Trusted
332
+ Publishing, so there is no API token in this repository or its secrets.
333
+
334
+ ## Licence
335
+
336
+ MIT