Skip to content

fix(explain): share one explainable-statement check across all six strategies - #274

Merged
cevheri merged 2 commits into
mainfrom
feat/explain-select-prefix
Aug 4, 2026
Merged

fix(explain): share one explainable-statement check across all six strategies#274
cevheri merged 2 commits into
mainfrom
feat/explain-select-prefix

Conversation

@cevheri

@cevheri cevheri commented Aug 4, 2026

Copy link
Copy Markdown
Member

Follow-up to #271, which fixed this for Druid only.

All six explain strategies carried their own copy of /^\s*SELECT\b/i, and every one of them refused
two statements its engine explains perfectly well — a CTE, and a SELECT behind a comment. The
Explain button was simply absent for both. The check now lives in one place,
src/lib/explain/select-prefix.ts.

The CTE case was also an internal disagreement: the shared analyzeQuery
(db/utils/query-limiter.ts) already classifies
WITH … SELECT as a SELECT and injects a LIMIT into one, so six strategies declining it
contradicted the rest of the pipeline.

Verified per dialect, not assumed

Each statement below was wrapped by the real strategy, executed against the real engine, and
rendered through extractPlantoRenderModel:

engine forms checked result
PostgreSQL 18 CTE, line comment, block comment all explain
MySQL 9 CTE, line comment, block comment all explain
SQLite CTE, line comment, block comment all explain
ClickHouse 26.7 CTE, line comment, block comment all explain
Couchbase 8.0.2 WITH binding, line comment, block comment all explain
Apache Druid 37 CTE, line comment, block comment all explain

Couchbase needed its own spelling — SQL++ WITH alias AS (<expression>) binds a value, not a
subquery. That is the kind of thing a shared regex would have papered over.

PostgreSQL could not take the shared answer, and that is why this was verified per engine

Its strategy emits EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON), which executes the statement — and a
data-modifying CTE is a write wearing a WITH:

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
  WITH t AS (INSERT INTO probe(id) VALUES (42) RETURNING id) SELECT * FROM t

rows before: 0
rows after:  1   (id = 42)

Explaining performed the insert. So accepting WITH there without a screen would have turned the
Explain button into a write — a regression introduced by a change whose whole purpose was consistency.

postgres-json.ts pairs the shared classification with hasDataModifyingStatement() and applies that
screen only to the "with" case. Two reasons it is scoped that way, both verified:

  • PostgreSQL refuses a data-modifying CTE anywhere but the top level — "WITH clause containing a
    data-modifying statement must be at the top level"
    — so one cannot hide behind a leading SELECT.
  • Screening SELECT too would strip the button off anything that merely mentions a keyword.
    SELECT 'insert' AS word and SELECT updated_at FROM t explain fine today and still do.

The screen errs toward refusing: a read-only CTE that merely mentions insert loses its Explain
button rather than risking a write. That is the only direction this can safely err in, and it is
documented as such.

classifySelectPrefix() returns "select" | "with" | null rather than a boolean precisely so that
asymmetry is expressible by a caller.

The regex shape is carried over deliberately

#271 arrived at this pattern the hard way — all three alternatives sit inside a * quantifier, so each
had to be made unambiguous independently:

ambiguity cost input needed
leading \s* beside a \s alternative 958ms 20 KB
lazy [\s\S]*?\*\/ spanning two comments 852ms 4 KB
--[^\n]* with no (?:\n|$) tail exponential, 634ms 49 chars

The third was found by CodeQL after the first two were fixed, and it is the one worth remembering: it
needs three orders of magnitude less input, and it slipped past a guard that exercised -- a\n
repetitions, because the newline is exactly what makes that branch unambiguous.

The bounded-time guard moves into select-prefix.test.ts with the regex — the property belongs to the
pattern, not to Druid — and now covers bare-dash runs explicitly. Druid keeps a lighter test proving it
still routes through the shared check.

Verification

format · lint (0 errors) · typecheck · knip · test (0 failures) · build
check-coverage: OK — 28534/28534 lines (100.00%)

All 8 files under src/lib/explain/ that carry executable lines are at 100% individually —
clickhouse-json, couchbase-json, druid-native, index, mysql-json, postgres-json,
select-prefix, sqlite-queryplan. The ninth, types.ts, is types only and so never appears in the
lcov at all.

(An earlier revision of this description said "nine files under src/lib/explain/". That was wrong:
the grep behind it matched /explain/ and so also caught src/app/api/ai/explain/route.ts, which is
an API route rather than a strategy. Corrected here — thanks to the reviewer who asked for the
coverage report to be confirmed rather than taken on trust.)

docs/ADDING_A_PROVIDER.md gains both rules: use classifySelectPrefix() rather than a fresh regex,
and ask whether your engine's EXPLAIN executes before widening what it accepts.

Review follow-ups (a8a224e)

  • PostgreSQL over-reach pinned at the strategy boundary. select-prefix.test.ts covered
    hasDataModifyingStatement on a read-only CTE mentioning a keyword, but nothing pinned what
    buildSql does with it. It now does — together with the limit of that over-reach, which was the
    untested half: the word boundary is what stops the screen swallowing every CTE that touches an
    updated_at column, so that case still explains. Writing the pair caught a wrong assertion in my
    first draft, where I had expected updated_at to be refused.
  • DATA_MODIFYING membership is now recorded rather than assumed. MERGE is a real carrier —
    live, EXPLAIN (ANALYZE) WITH t AS (MERGE INTO probe … RETURNING id) SELECT * FROM t inserted the
    row. TRUNCATE is deliberately absent: it cannot ride inside a WITH at all
    (WITH t AS (TRUNCATE probe) SELECT 1 is a syntax error), and a statement leading with it is
    already refused by the prefix classification. Tested for TRUNCATE, DROP and CREATE.
  • The analyzeQuery note turned out to hide a live defect, not just drift risk. A leading comment
    makes it classify a SELECT as OTHER, so prepareQuery injects no LIMIT and the query runs
    unbounded — the same root cause as this PR, with a worse outcome. Filed as A leading SQL comment defeats the query limiter, so a commented SELECT runs unbounded #275 with the
    reproduction. Deliberately not fixed here: analyzeQuery feeds the query path of all ten providers
    plus pagination, so it needs its own change and its own tests.

…rategies

Every strategy carried its own `/^\s*SELECT\b/i`, and every one of them refused two
statements its engine explains perfectly well: a CTE, and a SELECT behind a comment.
The Explain button was simply absent for both. #271 fixed it for Druid only; this
extends it to the other five and moves the check into one place,
`explain/select-prefix.ts`.

The CTE case was also an internal disagreement: the shared `analyzeQuery` already
classifies `WITH ... SELECT` as a SELECT and injects a LIMIT into one, so six
strategies declining it contradicted the rest of the pipeline.

Verified per dialect rather than assumed - each statement below was wrapped by the
REAL strategy, executed against the REAL engine, and rendered:

    postgres 18     CTE, line comment, block comment      all explain
    mysql 9         CTE, line comment, block comment      all explain
    sqlite          CTE, line comment, block comment      all explain
    clickhouse 26.7 CTE, line comment, block comment      all explain
    couchbase 8.0.2 WITH binding, line, block comment     all explain
    druid 37        CTE, line comment, block comment      all explain

Couchbase needed its own spelling: SQL++ `WITH alias AS (<expression>)` binds a
value rather than a subquery.

PostgreSQL is the one dialect that could not simply take the shared answer, and
finding out why is the reason this was verified per engine. Its strategy emits
`EXPLAIN (ANALYZE, ...)`, which EXECUTES the statement, and a data-modifying CTE is
a write wearing a WITH:

    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
      WITH t AS (INSERT INTO probe(id) VALUES (42) RETURNING id) SELECT * FROM t

    -> 0 rows in the table before, 1 row (42) after. Explaining performed the insert.

So accepting WITH there without a screen would have turned the Explain button into a
write. `postgres-json.ts` pairs the classification with `hasDataModifyingStatement()`
and applies it ONLY to the `with` case: PostgreSQL refuses a data-modifying CTE
anywhere but the top level ("WITH clause containing a data-modifying statement must be
at the top level", verified), so one cannot hide behind a leading SELECT, and
screening SELECTs too would strip the button off `SELECT 'insert'`, which explains
fine today. The screen errs toward refusing - a read-only CTE that merely mentions a
keyword loses its button rather than risking a write.

`classifySelectPrefix` returns `"select" | "with" | null` rather than a boolean
precisely so that asymmetry is expressible.

The regex keeps the shape arrived at in #271, where all three of its alternatives had
to be made unambiguous independently - no leading `\s*`, a line comment anchored to
newline-or-end, and a tempered block-comment body. Its bounded-time guard moves here
with it, since the property belongs to the regex rather than to Druid; Druid keeps a
lighter test proving it still routes through.

Six gates green plus coverage at 100% (28534 lines), and all nine explain files at
100% individually.
Review follow-ups on #274, all verified before being accepted.

`select-prefix.test.ts` already covered `hasDataModifyingStatement` on a read-only CTE
that merely mentions a keyword, but nothing pinned what `postgresJsonStrategy.buildSql`
does with it. That is where the documented "errs toward refusing" behaviour actually
matters, so it now has a test:

    WITH t AS (SELECT 'insert' AS x) SELECT * FROM t   -> null

Paired with the LIMIT of that over-reach, which is the more interesting half and was
untested: the word boundary is what keeps the screen from swallowing every CTE that
touches an `updated_at` column, so

    WITH t AS (SELECT updated_at FROM u) SELECT * FROM t

still explains. Writing that pair is what caught a wrong assertion in the first draft -
I had expected `updated_at` to be refused, and it is not, correctly.

Also records two facts about DATA_MODIFYING's membership that were previously assumed:

- MERGE is a REAL carrier, not a defensive guess. Live on PostgreSQL 18,
  `EXPLAIN (ANALYZE, FORMAT JSON) WITH t AS (MERGE INTO probe ... RETURNING id)
  SELECT * FROM t` really inserted the row.
- TRUNCATE is deliberately absent. It cannot ride inside a WITH at all -
  `WITH t AS (TRUNCATE probe) SELECT 1` is a SYNTAX error - and a statement leading with
  it never reaches the screen because the prefix classification already refuses anything
  but SELECT or WITH. Tested for TRUNCATE, DROP and CREATE together.

The third review point - that `analyzeQuery` still carries its own regexes - turned out
to hide a live defect rather than only a drift risk: a leading comment makes it classify
a SELECT as OTHER, so `prepareQuery` injects no LIMIT and the query runs unbounded.
Filed as #275 with the reproduction; it feeds the query path of all ten providers and
belongs in its own change rather than in an explain PR.
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@cevheri
cevheri merged commit 3747313 into main Aug 4, 2026
18 checks passed
@cevheri
cevheri deleted the feat/explain-select-prefix branch August 4, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant