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.
@@ -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)