pytest-querycount 0.3.0__py3-none-any.whl
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_querycount/__init__.py +31 -0
- pytest_querycount/backends/__init__.py +59 -0
- pytest_querycount/backends/sqlalchemy.py +215 -0
- pytest_querycount/checks.py +166 -0
- pytest_querycount/errors.py +39 -0
- pytest_querycount/explain.py +175 -0
- pytest_querycount/normalize.py +97 -0
- pytest_querycount/plugin.py +284 -0
- pytest_querycount/py.typed +0 -0
- pytest_querycount/recorder.py +214 -0
- pytest_querycount/records.py +66 -0
- pytest_querycount/report.py +64 -0
- pytest_querycount-0.3.0.dist-info/METADATA +336 -0
- pytest_querycount-0.3.0.dist-info/RECORD +17 -0
- pytest_querycount-0.3.0.dist-info/WHEEL +4 -0
- pytest_querycount-0.3.0.dist-info/entry_points.txt +2 -0
- pytest_querycount-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Fail your tests when they run too many SQL queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.3.0"
|
|
6
|
+
|
|
7
|
+
from pytest_querycount.errors import (
|
|
8
|
+
DuplicateQueryError,
|
|
9
|
+
ExplainUnavailableError,
|
|
10
|
+
NoBackendError,
|
|
11
|
+
SeqScanError,
|
|
12
|
+
TooManyQueriesError,
|
|
13
|
+
)
|
|
14
|
+
from pytest_querycount.explain import SeqScan
|
|
15
|
+
from pytest_querycount.normalize import fingerprint
|
|
16
|
+
from pytest_querycount.recorder import Recorder
|
|
17
|
+
from pytest_querycount.records import Duplicate, QueryRecord
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Duplicate",
|
|
21
|
+
"DuplicateQueryError",
|
|
22
|
+
"ExplainUnavailableError",
|
|
23
|
+
"NoBackendError",
|
|
24
|
+
"QueryRecord",
|
|
25
|
+
"Recorder",
|
|
26
|
+
"SeqScan",
|
|
27
|
+
"SeqScanError",
|
|
28
|
+
"TooManyQueriesError",
|
|
29
|
+
"__version__",
|
|
30
|
+
"fingerprint",
|
|
31
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Instrumentation: turning driver activity into :class:`QueryRecord` objects.
|
|
2
|
+
|
|
3
|
+
A backend is responsible for one database library. It reports queries to every
|
|
4
|
+
recorder currently on the active stack, which is what makes nesting work: the
|
|
5
|
+
per-test recorder and a ``querycount(...)`` block inside that test both see the
|
|
6
|
+
same queries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from pytest_querycount.recorder import Recorder
|
|
15
|
+
from pytest_querycount.records import QueryRecord
|
|
16
|
+
|
|
17
|
+
_active: list[Recorder] = []
|
|
18
|
+
_installed: list[str] = []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def push(recorder: Recorder) -> None:
|
|
22
|
+
"""Start sending queries to ``recorder``."""
|
|
23
|
+
_active.append(recorder)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pop(recorder: Recorder) -> None:
|
|
27
|
+
"""Stop sending queries to ``recorder``."""
|
|
28
|
+
with contextlib.suppress(ValueError): # a pop without a push is not fatal
|
|
29
|
+
_active.remove(recorder)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def active() -> Sequence[Recorder]:
|
|
33
|
+
return _active
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit(record: QueryRecord) -> None:
|
|
37
|
+
"""Hand ``record`` to every listening recorder."""
|
|
38
|
+
for recorder in _active:
|
|
39
|
+
recorder.add(record)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def installed() -> Sequence[str]:
|
|
43
|
+
"""Names of the backends that were successfully instrumented."""
|
|
44
|
+
return _installed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_all() -> Sequence[str]:
|
|
48
|
+
"""Instrument every library we support and that is importable.
|
|
49
|
+
|
|
50
|
+
Missing libraries are not an error -- most projects use one ORM, not all of
|
|
51
|
+
them. But *no* backend at all is an error the moment a check runs, since a
|
|
52
|
+
query budget that cannot see queries would pass silently forever.
|
|
53
|
+
"""
|
|
54
|
+
from pytest_querycount.backends import sqlalchemy as sqlalchemy_backend
|
|
55
|
+
|
|
56
|
+
for backend in (sqlalchemy_backend,):
|
|
57
|
+
if backend.install() and backend.NAME not in _installed:
|
|
58
|
+
_installed.append(backend.NAME)
|
|
59
|
+
return _installed
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""SQLAlchemy 2.x instrumentation.
|
|
2
|
+
|
|
3
|
+
We listen on the ``Engine`` *class* rather than on individual engines, so every
|
|
4
|
+
engine the suite builds is covered without the project having to register
|
|
5
|
+
anything. Async engines are covered too: ``AsyncEngine`` wraps a sync ``Engine``
|
|
6
|
+
and the cursor events fire on that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pytest_querycount import backends, explain
|
|
16
|
+
from pytest_querycount.normalize import fingerprint, statement_kind
|
|
17
|
+
from pytest_querycount.recorder import caller_location
|
|
18
|
+
from pytest_querycount.records import QueryRecord
|
|
19
|
+
|
|
20
|
+
NAME = "sqlalchemy"
|
|
21
|
+
|
|
22
|
+
# Only PostgreSQL exposes a machine-readable plan we can reason about this way.
|
|
23
|
+
_EXPLAINABLE_DIALECTS = {"postgresql"}
|
|
24
|
+
_SAVEPOINT = "pytest_querycount_explain"
|
|
25
|
+
|
|
26
|
+
_installed = False
|
|
27
|
+
|
|
28
|
+
# Fallback timing stack, used when SQLAlchemy hands us no execution context
|
|
29
|
+
# (raw ``exec_driver_sql`` calls, mainly). Durations are informational, so a
|
|
30
|
+
# plain stack is enough and we never let it grow without bound.
|
|
31
|
+
_fallback_starts: list[float] = []
|
|
32
|
+
_CONTEXT_ATTR = "_querycount_start"
|
|
33
|
+
_SCANS_ATTR = "_querycount_scans"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def install() -> bool:
|
|
37
|
+
"""Attach the listeners. Returns False when SQLAlchemy is not available."""
|
|
38
|
+
global _installed
|
|
39
|
+
if _installed:
|
|
40
|
+
return True
|
|
41
|
+
try:
|
|
42
|
+
from sqlalchemy import event
|
|
43
|
+
from sqlalchemy.engine import Engine
|
|
44
|
+
except ImportError:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
event.listen(Engine, "before_cursor_execute", _before_cursor_execute)
|
|
48
|
+
event.listen(Engine, "after_cursor_execute", _after_cursor_execute)
|
|
49
|
+
_installed = True
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def instrument(engine: Any) -> None:
|
|
54
|
+
"""Attach the listeners to one engine explicitly.
|
|
55
|
+
|
|
56
|
+
Only needed for an engine built before the plugin loaded, which in a pytest
|
|
57
|
+
run should not happen. Kept because "should not happen" is not "cannot".
|
|
58
|
+
"""
|
|
59
|
+
from sqlalchemy import event
|
|
60
|
+
|
|
61
|
+
target = getattr(engine, "sync_engine", engine)
|
|
62
|
+
event.listen(target, "before_cursor_execute", _before_cursor_execute)
|
|
63
|
+
event.listen(target, "after_cursor_execute", _after_cursor_execute)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _before_cursor_execute(
|
|
67
|
+
conn: Any,
|
|
68
|
+
cursor: Any,
|
|
69
|
+
statement: str,
|
|
70
|
+
parameters: Any,
|
|
71
|
+
context: Any,
|
|
72
|
+
executemany: bool,
|
|
73
|
+
) -> None:
|
|
74
|
+
recorders = backends.active()
|
|
75
|
+
if not recorders:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
scans: tuple[explain.SeqScan, ...] | None = None
|
|
79
|
+
if any(recorder.explain for recorder in recorders) and not executemany:
|
|
80
|
+
scans = _plan_seq_scans(conn, statement, parameters)
|
|
81
|
+
|
|
82
|
+
start = time.perf_counter()
|
|
83
|
+
if context is not None:
|
|
84
|
+
if scans is not None:
|
|
85
|
+
# Only reached when EXPLAIN is on, so the context manager's cost here
|
|
86
|
+
# does not matter the way it does on the per-query timing path.
|
|
87
|
+
with contextlib.suppress(AttributeError, TypeError):
|
|
88
|
+
setattr(context, _SCANS_ATTR, scans)
|
|
89
|
+
try:
|
|
90
|
+
setattr(context, _CONTEXT_ATTR, start)
|
|
91
|
+
return
|
|
92
|
+
except (AttributeError, TypeError): # pragma: no cover - slotted context
|
|
93
|
+
pass
|
|
94
|
+
_fallback_starts.append(start)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _after_cursor_execute(
|
|
98
|
+
conn: Any,
|
|
99
|
+
cursor: Any,
|
|
100
|
+
statement: str,
|
|
101
|
+
parameters: Any,
|
|
102
|
+
context: Any,
|
|
103
|
+
executemany: bool,
|
|
104
|
+
) -> None:
|
|
105
|
+
if not backends.active():
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
start = _take_start(context)
|
|
109
|
+
duration = time.perf_counter() - start if start is not None else 0.0
|
|
110
|
+
scans = _take_scans(context)
|
|
111
|
+
|
|
112
|
+
backends.emit(
|
|
113
|
+
QueryRecord(
|
|
114
|
+
sql=statement,
|
|
115
|
+
fingerprint=fingerprint(statement),
|
|
116
|
+
kind=statement_kind(statement),
|
|
117
|
+
duration=duration,
|
|
118
|
+
executemany=bool(executemany),
|
|
119
|
+
rowcount=_rowcount(cursor),
|
|
120
|
+
seq_scans=scans or (),
|
|
121
|
+
explained=scans is not None,
|
|
122
|
+
location=caller_location(),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _take_scans(context: Any) -> tuple[explain.SeqScan, ...] | None:
|
|
128
|
+
if context is None:
|
|
129
|
+
return None
|
|
130
|
+
scans = getattr(context, _SCANS_ATTR, None)
|
|
131
|
+
if scans is None:
|
|
132
|
+
return None
|
|
133
|
+
try: # noqa: SIM105
|
|
134
|
+
delattr(context, _SCANS_ATTR)
|
|
135
|
+
except (AttributeError, TypeError): # pragma: no cover
|
|
136
|
+
pass
|
|
137
|
+
return scans # type: ignore[no-any-return]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _plan_seq_scans(
|
|
141
|
+
conn: Any,
|
|
142
|
+
statement: str,
|
|
143
|
+
parameters: Any,
|
|
144
|
+
) -> tuple[explain.SeqScan, ...] | None:
|
|
145
|
+
"""Ask PostgreSQL for a plan with sequential scans penalised.
|
|
146
|
+
|
|
147
|
+
Returns the filtered sequential scans the planner kept anyway, or None when
|
|
148
|
+
no plan could be obtained -- a different answer from "found nothing", and
|
|
149
|
+
the caller must not conflate them.
|
|
150
|
+
|
|
151
|
+
Runs on a raw DBAPI cursor rather than through the engine, which keeps the
|
|
152
|
+
EXPLAIN out of SQLAlchemy's event stream: it is neither counted as one of
|
|
153
|
+
the test's queries nor able to recurse into this function.
|
|
154
|
+
"""
|
|
155
|
+
if statement_kind(statement) != "select":
|
|
156
|
+
# Explaining a write is possible but buys little and risks more.
|
|
157
|
+
return None
|
|
158
|
+
if getattr(conn.dialect, "name", None) not in _EXPLAINABLE_DIALECTS:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
cursor = conn.connection.cursor()
|
|
163
|
+
except Exception: # pragma: no cover - pool handed us nothing usable
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# The savepoint does double duty: it scopes the enable_seqscan change so we
|
|
167
|
+
# cannot leak it into the test, and it absorbs a failed EXPLAIN, which would
|
|
168
|
+
# otherwise abort the transaction the test is running in.
|
|
169
|
+
guarded = bool(conn.in_transaction())
|
|
170
|
+
try:
|
|
171
|
+
if guarded:
|
|
172
|
+
cursor.execute(f"SAVEPOINT {_SAVEPOINT}")
|
|
173
|
+
cursor.execute("SET enable_seqscan = off")
|
|
174
|
+
cursor.execute("EXPLAIN (FORMAT JSON) " + statement, parameters or None)
|
|
175
|
+
row = cursor.fetchone()
|
|
176
|
+
return explain.parse(row[0]) if row else ()
|
|
177
|
+
except Exception:
|
|
178
|
+
return None
|
|
179
|
+
finally:
|
|
180
|
+
try:
|
|
181
|
+
if guarded:
|
|
182
|
+
cursor.execute(f"ROLLBACK TO SAVEPOINT {_SAVEPOINT}")
|
|
183
|
+
else:
|
|
184
|
+
cursor.execute("RESET enable_seqscan")
|
|
185
|
+
except Exception: # pragma: no cover - connection already unusable
|
|
186
|
+
pass
|
|
187
|
+
try: # noqa: SIM105
|
|
188
|
+
cursor.close()
|
|
189
|
+
except Exception: # pragma: no cover
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _take_start(context: Any) -> float | None:
|
|
194
|
+
if context is not None:
|
|
195
|
+
start = getattr(context, _CONTEXT_ATTR, None)
|
|
196
|
+
if start is not None:
|
|
197
|
+
# try/except rather than contextlib.suppress: this runs once per
|
|
198
|
+
# query, and a context manager here is measurable overhead in a
|
|
199
|
+
# library whose whole job is to sit in that path.
|
|
200
|
+
try: # noqa: SIM105
|
|
201
|
+
delattr(context, _CONTEXT_ATTR)
|
|
202
|
+
except (AttributeError, TypeError): # pragma: no cover
|
|
203
|
+
pass
|
|
204
|
+
return float(start)
|
|
205
|
+
if _fallback_starts:
|
|
206
|
+
return _fallback_starts.pop()
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _rowcount(cursor: Any) -> int | None:
|
|
211
|
+
try:
|
|
212
|
+
count = cursor.rowcount
|
|
213
|
+
except Exception: # pragma: no cover - drivers may refuse after DDL
|
|
214
|
+
return None
|
|
215
|
+
return count if isinstance(count, int) and count >= 0 else None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Turning a recorder plus a budget into a pass or a failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from pytest_querycount import backends
|
|
9
|
+
from pytest_querycount.errors import (
|
|
10
|
+
DuplicateQueryError,
|
|
11
|
+
ExplainUnavailableError,
|
|
12
|
+
NoBackendError,
|
|
13
|
+
SeqScanError,
|
|
14
|
+
TooManyQueriesError,
|
|
15
|
+
)
|
|
16
|
+
from pytest_querycount.recorder import Recorder
|
|
17
|
+
from pytest_querycount.records import Duplicate, QueryRecord
|
|
18
|
+
|
|
19
|
+
DEFAULT_DUPLICATE_THRESHOLD = 2
|
|
20
|
+
DEFAULT_KINDS = ("select",)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Budget:
|
|
25
|
+
"""What a test is allowed to do to the database."""
|
|
26
|
+
|
|
27
|
+
max_queries: int | None = None
|
|
28
|
+
no_n_plus_one: bool = False
|
|
29
|
+
duplicate_threshold: int = DEFAULT_DUPLICATE_THRESHOLD
|
|
30
|
+
kinds: Sequence[str] | None = DEFAULT_KINDS
|
|
31
|
+
no_seq_scan: bool = False
|
|
32
|
+
ignore_tables: Sequence[str] = ()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_empty(self) -> bool:
|
|
36
|
+
return self.max_queries is None and not self.no_n_plus_one and not self.no_seq_scan
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def needs_explain(self) -> bool:
|
|
40
|
+
"""Whether the backend must pay for a query plan per SELECT."""
|
|
41
|
+
return self.no_seq_scan
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def enforce(budget: Budget, recorder: Recorder, label: str = "This test") -> None:
|
|
45
|
+
"""Raise if ``recorder`` violated ``budget``. Cheapest checks first."""
|
|
46
|
+
# Keep our own frames out of the traceback. The failure message is the
|
|
47
|
+
# product here; a wall of plugin internals above it is noise.
|
|
48
|
+
__tracebackhide__ = True
|
|
49
|
+
|
|
50
|
+
if budget.is_empty:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if not backends.installed():
|
|
54
|
+
raise NoBackendError(
|
|
55
|
+
"pytest-querycount has nothing to watch, so this query budget would "
|
|
56
|
+
"always pass.\n"
|
|
57
|
+
"Install a supported backend: pip install 'pytest-querycount[sqlalchemy]'\n"
|
|
58
|
+
"If your project uses a library we do not support yet, please open an issue."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if budget.max_queries is not None and recorder.count > budget.max_queries:
|
|
62
|
+
raise TooManyQueriesError(_too_many_message(budget, recorder, label))
|
|
63
|
+
|
|
64
|
+
if budget.no_n_plus_one:
|
|
65
|
+
duplicates = recorder.duplicates(
|
|
66
|
+
threshold=budget.duplicate_threshold,
|
|
67
|
+
kinds=budget.kinds,
|
|
68
|
+
)
|
|
69
|
+
if duplicates:
|
|
70
|
+
raise DuplicateQueryError(_duplicate_message(duplicates, recorder, label))
|
|
71
|
+
|
|
72
|
+
if budget.no_seq_scan:
|
|
73
|
+
_enforce_no_seq_scan(budget, recorder, label)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _enforce_no_seq_scan(budget: Budget, recorder: Recorder, label: str) -> None:
|
|
77
|
+
__tracebackhide__ = True
|
|
78
|
+
|
|
79
|
+
selects = recorder.of_kind("select")
|
|
80
|
+
if selects and not recorder.explained_count:
|
|
81
|
+
# Nothing was explained, so the check saw nothing and would pass no
|
|
82
|
+
# matter what the queries did. Say so instead.
|
|
83
|
+
raise ExplainUnavailableError(
|
|
84
|
+
"no_seq_scan could not obtain a query plan, so it would pass "
|
|
85
|
+
"regardless of what the queries do.\n"
|
|
86
|
+
"This check needs PostgreSQL: it reads EXPLAIN (FORMAT JSON), which "
|
|
87
|
+
"SQLite and MySQL do not provide in a comparable form.\n"
|
|
88
|
+
"Run these tests against PostgreSQL, or drop the marker."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
offenders = recorder.seq_scans(ignore=budget.ignore_tables)
|
|
92
|
+
if offenders:
|
|
93
|
+
raise SeqScanError(_seq_scan_message(offenders, budget, label))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _seq_scan_message(
|
|
97
|
+
offenders: Sequence[QueryRecord],
|
|
98
|
+
budget: Budget,
|
|
99
|
+
label: str,
|
|
100
|
+
) -> str:
|
|
101
|
+
skipped = {name.lower() for name in budget.ignore_tables}
|
|
102
|
+
lines = [
|
|
103
|
+
f"{label} ran {len(offenders)} quer"
|
|
104
|
+
f"{'y' if len(offenders) == 1 else 'ies'} that no index could serve.",
|
|
105
|
+
"",
|
|
106
|
+
"PostgreSQL still chose a sequential scan with enable_seqscan disabled, "
|
|
107
|
+
"which means no index covers the filtered columns.",
|
|
108
|
+
"",
|
|
109
|
+
]
|
|
110
|
+
for record in offenders[:5]:
|
|
111
|
+
for scan in record.seq_scans:
|
|
112
|
+
if scan.relation.lower() in skipped:
|
|
113
|
+
continue
|
|
114
|
+
lines.append(f" {scan.describe()}")
|
|
115
|
+
if record.location:
|
|
116
|
+
lines.append(f" from {record.location}")
|
|
117
|
+
lines.append(f" {record.short_sql(110)}")
|
|
118
|
+
lines.append("")
|
|
119
|
+
if len(offenders) > 5:
|
|
120
|
+
lines.append(f" ... and {len(offenders) - 5} more")
|
|
121
|
+
lines.append(
|
|
122
|
+
"If a scan is intentional -- a small lookup table read whole -- exclude it "
|
|
123
|
+
'with @pytest.mark.no_seq_scan(ignore=("table_name",)).'
|
|
124
|
+
)
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _too_many_message(budget: Budget, recorder: Recorder, label: str) -> str:
|
|
129
|
+
assert budget.max_queries is not None
|
|
130
|
+
over = recorder.count - budget.max_queries
|
|
131
|
+
lines = [
|
|
132
|
+
f"{label} ran {recorder.count} queries, budget is {budget.max_queries} ({over} over).",
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# Lead with the diagnosis. Someone reading a CI log wants the culprit in the
|
|
136
|
+
# first few lines, not after a list of thirteen SELECTs.
|
|
137
|
+
duplicates = recorder.duplicates(threshold=2, kinds=budget.kinds)
|
|
138
|
+
if duplicates:
|
|
139
|
+
lines += [
|
|
140
|
+
"",
|
|
141
|
+
"Repeated query shapes -- most likely where the extra queries come from:",
|
|
142
|
+
*(f" {duplicate.describe()}" for duplicate in duplicates[:3]),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
lines += ["", recorder.report(limit=12)]
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _duplicate_message(
|
|
150
|
+
duplicates: Sequence[Duplicate],
|
|
151
|
+
recorder: Recorder,
|
|
152
|
+
label: str,
|
|
153
|
+
) -> str:
|
|
154
|
+
worst = duplicates[0]
|
|
155
|
+
lines = [
|
|
156
|
+
f"{label} repeated the same query {worst.count} times ({recorder.count} queries in total).",
|
|
157
|
+
"",
|
|
158
|
+
"This is the N+1 pattern. Load the related rows in one go instead -- with "
|
|
159
|
+
"selectinload() or joinedload() for a relationship, or a single IN query.",
|
|
160
|
+
"",
|
|
161
|
+
]
|
|
162
|
+
for duplicate in duplicates[:5]:
|
|
163
|
+
lines.append(f" {duplicate.describe()}")
|
|
164
|
+
if len(duplicates) > 5:
|
|
165
|
+
lines.append(f" ... and {len(duplicates) - 5} more repeated shapes")
|
|
166
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Failures raised by the plugin.
|
|
2
|
+
|
|
3
|
+
All of them subclass ``AssertionError`` so pytest renders them as ordinary test
|
|
4
|
+
failures rather than errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QueryCountError(AssertionError):
|
|
11
|
+
"""Base class for every check failure."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TooManyQueriesError(QueryCountError):
|
|
15
|
+
"""A test ran more queries than its budget allowed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DuplicateQueryError(QueryCountError):
|
|
19
|
+
"""The same query shape was executed repeatedly -- the N+1 signature."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoBackendError(QueryCountError):
|
|
23
|
+
"""A check was requested but nothing is instrumented, so it would be a no-op.
|
|
24
|
+
|
|
25
|
+
This is deliberately loud: a query budget that silently always passes is
|
|
26
|
+
worse than no budget at all.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SeqScanError(QueryCountError):
|
|
31
|
+
"""A query scanned a table sequentially because no index could serve it."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExplainUnavailableError(QueryCountError):
|
|
35
|
+
"""A plan check ran where no plan could be obtained.
|
|
36
|
+
|
|
37
|
+
Same reasoning as :class:`NoBackendError`: a check that cannot fail is not a
|
|
38
|
+
check, so we say so rather than pass.
|
|
39
|
+
"""
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Reading PostgreSQL query plans to find scans no index could serve.
|
|
2
|
+
|
|
3
|
+
The naive version of this check -- "fail if the plan contains a Seq Scan" --
|
|
4
|
+
does not work, and it is worth saying why, because it shapes everything here.
|
|
5
|
+
|
|
6
|
+
On a test database of twenty rows PostgreSQL chooses a sequential scan even when
|
|
7
|
+
a perfect index exists, because reading twenty rows is cheaper than descending a
|
|
8
|
+
B-tree. So a Seq Scan tells you nothing about whether an index is missing, and
|
|
9
|
+
thresholding on table size means the check never fires on test data at all.
|
|
10
|
+
|
|
11
|
+
What we ask instead is whether an index *could* have been used. Run the EXPLAIN
|
|
12
|
+
with ``enable_seqscan`` off, which makes the planner treat sequential scans as
|
|
13
|
+
enormously expensive. If it still picks one, no index can serve that filter --
|
|
14
|
+
and that answer does not depend on how much data the table holds.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
_SEQ_SCAN_NODES = {"Seq Scan", "Parallel Seq Scan"}
|
|
26
|
+
|
|
27
|
+
# Tokens that look like columns in a filter expression but are not.
|
|
28
|
+
_NOT_A_COLUMN = {
|
|
29
|
+
"and",
|
|
30
|
+
"or",
|
|
31
|
+
"not",
|
|
32
|
+
"any",
|
|
33
|
+
"all",
|
|
34
|
+
"true",
|
|
35
|
+
"false",
|
|
36
|
+
"null",
|
|
37
|
+
"is",
|
|
38
|
+
"like",
|
|
39
|
+
"ilike",
|
|
40
|
+
"similar",
|
|
41
|
+
"between",
|
|
42
|
+
"in",
|
|
43
|
+
"case",
|
|
44
|
+
"when",
|
|
45
|
+
"then",
|
|
46
|
+
"else",
|
|
47
|
+
"end",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_LITERAL = re.compile(r"'(?:[^']|'')*'")
|
|
51
|
+
_CAST = re.compile(r"::\s*[a-z_][a-z0-9_]*(?:\s*\[\s*\])*", re.IGNORECASE)
|
|
52
|
+
_PLACEHOLDER = re.compile(r"\$\d+")
|
|
53
|
+
# PostgreSQL parenthesises a column before casting it, so a varchar filter
|
|
54
|
+
# reads "((city)::text = 'x'::text)". Once the cast is gone the wrapper must go
|
|
55
|
+
# too, or the commonest case of all -- a text column -- yields no column name.
|
|
56
|
+
# The lookbehind is what keeps "lower(email)" from being unwrapped into
|
|
57
|
+
# "lower email", which would suggest an index that does not apply.
|
|
58
|
+
_REDUNDANT_PARENS = re.compile(r"(?<![A-Za-z0-9_\"])\(\s*([a-z_][a-z0-9_]*)\s*\)", re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
_COLUMN_BEFORE_OPERATOR = re.compile(
|
|
61
|
+
r"\b([a-z_][a-z0-9_]*)\s*(?:<=|>=|<>|!=|=|<|>|~~\*?|!~~\*?|@@|\bIS\b|\bLIKE\b)",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class SeqScan:
|
|
68
|
+
"""A sequential scan the planner kept even with seq scans penalised."""
|
|
69
|
+
|
|
70
|
+
relation: str
|
|
71
|
+
filter_expression: str
|
|
72
|
+
columns: tuple[str, ...]
|
|
73
|
+
"""Best-effort column names pulled out of the filter, for the index hint."""
|
|
74
|
+
|
|
75
|
+
def suggested_index(self) -> str | None:
|
|
76
|
+
"""A ``CREATE INDEX`` to try, or None when the filter is too complex.
|
|
77
|
+
|
|
78
|
+
Deliberately a suggestion and not a promise: which columns to index, in
|
|
79
|
+
what order, and whether the index earns its write cost are judgement
|
|
80
|
+
calls that need the whole query pattern, not one plan node.
|
|
81
|
+
"""
|
|
82
|
+
if not self.columns:
|
|
83
|
+
return None
|
|
84
|
+
return f"CREATE INDEX ON {self.relation} ({', '.join(self.columns)});"
|
|
85
|
+
|
|
86
|
+
def describe(self) -> str:
|
|
87
|
+
lines = [f'Seq Scan on "{self.relation}" Filter: {self.filter_expression}']
|
|
88
|
+
hint = self.suggested_index()
|
|
89
|
+
if hint:
|
|
90
|
+
lines.append(f" try: {hint}")
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse(payload: Any) -> tuple[SeqScan, ...]:
|
|
95
|
+
"""Pull every filtered sequential scan out of an ``EXPLAIN (FORMAT JSON)`` result.
|
|
96
|
+
|
|
97
|
+
Accepts the payload however the driver hands it over: already-decoded JSON,
|
|
98
|
+
or a string still to decode.
|
|
99
|
+
"""
|
|
100
|
+
document = _decode(payload)
|
|
101
|
+
if document is None:
|
|
102
|
+
return ()
|
|
103
|
+
|
|
104
|
+
found: list[SeqScan] = []
|
|
105
|
+
for node in _walk(document):
|
|
106
|
+
if node.get("Node Type") not in _SEQ_SCAN_NODES:
|
|
107
|
+
continue
|
|
108
|
+
expression = node.get("Filter")
|
|
109
|
+
if not expression:
|
|
110
|
+
# An unfiltered sequential scan is a deliberate full read of the
|
|
111
|
+
# table. No index would help, and none is missing.
|
|
112
|
+
continue
|
|
113
|
+
relation = node.get("Relation Name") or node.get("Alias") or "?"
|
|
114
|
+
found.append(
|
|
115
|
+
SeqScan(
|
|
116
|
+
relation=str(relation),
|
|
117
|
+
filter_expression=str(expression),
|
|
118
|
+
columns=filter_columns(str(expression)),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return tuple(found)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def filter_columns(expression: str) -> tuple[str, ...]:
|
|
125
|
+
"""Column names appearing on the left of a comparison, in order, deduplicated.
|
|
126
|
+
|
|
127
|
+
A heuristic on a string PostgreSQL meant for humans, so it gives up rather
|
|
128
|
+
than guesses: a filter over a function call such as ``lower(email) = $1``
|
|
129
|
+
yields nothing, and the caller shows the raw expression instead.
|
|
130
|
+
"""
|
|
131
|
+
cleaned = _LITERAL.sub("''", expression)
|
|
132
|
+
cleaned = _CAST.sub("", cleaned)
|
|
133
|
+
cleaned = _PLACEHOLDER.sub("$", cleaned)
|
|
134
|
+
cleaned = _REDUNDANT_PARENS.sub(r"\1", cleaned)
|
|
135
|
+
|
|
136
|
+
seen: dict[str, None] = {}
|
|
137
|
+
for match in _COLUMN_BEFORE_OPERATOR.finditer(cleaned):
|
|
138
|
+
name = match.group(1)
|
|
139
|
+
if name.lower() in _NOT_A_COLUMN:
|
|
140
|
+
continue
|
|
141
|
+
# A name immediately followed by "(" is a function, not a column.
|
|
142
|
+
if cleaned[match.end(1) : match.end(1) + 1] == "(":
|
|
143
|
+
continue
|
|
144
|
+
seen.setdefault(name, None)
|
|
145
|
+
return tuple(seen)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _decode(payload: Any) -> Any:
|
|
149
|
+
if isinstance(payload, (str, bytes, bytearray)):
|
|
150
|
+
try:
|
|
151
|
+
return json.loads(payload)
|
|
152
|
+
except (ValueError, TypeError):
|
|
153
|
+
return None
|
|
154
|
+
return payload
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _walk(document: Any) -> Iterator[dict[str, Any]]:
|
|
158
|
+
"""Every plan node in the document, depth first.
|
|
159
|
+
|
|
160
|
+
``EXPLAIN (FORMAT JSON)`` returns a list of statements, each with a "Plan";
|
|
161
|
+
nodes nest under "Plans". Subplans and CTEs land there too, so one recursive
|
|
162
|
+
walk reaches all of them.
|
|
163
|
+
"""
|
|
164
|
+
if isinstance(document, list):
|
|
165
|
+
for entry in document:
|
|
166
|
+
yield from _walk(entry)
|
|
167
|
+
return
|
|
168
|
+
if not isinstance(document, dict):
|
|
169
|
+
return
|
|
170
|
+
if "Plan" in document:
|
|
171
|
+
yield from _walk(document["Plan"])
|
|
172
|
+
return
|
|
173
|
+
yield document
|
|
174
|
+
for child in document.get("Plans") or ():
|
|
175
|
+
yield from _walk(child)
|