Skip to content

fix(#655): return query plan rows from EXPLAIN statements - #657

Merged
cofin merged 11 commits into
mainfrom
fix/explain-plan-rows
Jul 25, 2026
Merged

fix(#655): return query plan rows from EXPLAIN statements#657
cofin merged 11 commits into
mainfrom
fix/explain-plan-rows

Conversation

@cofin

@cofin cofin commented Jul 25, 2026

Copy link
Copy Markdown
Member

EXPLAIN statements were classified as non-row-returning, so drivers ran the plan and then threw it away — driver.select(query.explain()) came back empty. This classifies explained statements correctly, which fixes every affected adapter at once, and stops .explain() from discarding the statement's configuration along the way.

  • Query plans now come back. Every adapter chooses between fetching rows and just executing based on this classification, so this was not specific to one driver. DuckDB, ADBC, Oracle, and BigQuery already recovered the plan through a cursor-metadata fallback; the other fifteen adapters silently returned nothing.
  • SHOW and DESCRIBE are fixed too. They were misclassified the same way, and MySQL's EXPLAIN is literally parsed as DESCRIBE.
  • Oracle is deliberately unchanged. It spells the statement EXPLAIN PLAN FOR, which writes a plan table and returns no rows; the plan is read back separately through DBMS_XPLAN.
  • Explaining no longer resets the statement configuration. An explained PostgreSQL statement used to compile with ? placeholders instead of $n, and a named parameter used twice was sent twice instead of being deduplicated.
  • SQL.copy(statement_config=...) no longer raises TypeError. The configuration was being passed both explicitly and through keyword arguments.

The classification is a single keyword check that runs once per unique statement and is memoized with the parsed statement, so execution does no extra work. Generated SQL is untouched.

The set of operation types treated as row-returning also listed five entries the compiler cannot produce, so they never had any effect. They are gone, and a test now requires every entry to be an operation type that can actually be emitted.

The shared adapter contract that covers this had asserted only that the result was not None — an empty result satisfied that, which is why ten plan shapes across nineteen driver configurations stayed green while returning nothing. It now requires the rows the database actually produced.

How you use it

plan = await driver.select(query.explain(analyze=True))
for row in plan:
    print(row)

Fixes #655.

@codecov-commenter

codecov-commenter commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.65248% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.13%. Comparing base (6c5c3b4) to head (a745aaa).

Files with missing lines Patch % Lines
sqlspec/adapters/oracledb/driver.py 83.05% 10 Missing ⚠️
sqlspec/core/compiler.py 87.50% 2 Missing and 2 partials ⚠️
sqlspec/builder/_explain.py 97.56% 1 Missing ⚠️
sqlspec/migrations/commands.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #657      +/-   ##
==========================================
+ Coverage   77.10%   77.13%   +0.02%     
==========================================
  Files         475      475              
  Lines       67422    67529     +107     
  Branches     9226     9241      +15     
==========================================
+ Hits        51988    52088     +100     
- Misses      12033    12038       +5     
- Partials     3401     3403       +2     
Flag Coverage Δ
integration 60.74% <73.04%> (+<0.01%) ⬆️
py3.10 75.43% <88.65%> (+0.02%) ⬆️
py3.11 75.46% <88.65%> (+0.03%) ⬆️
py3.12 75.45% <88.65%> (+11.18%) ⬆️
py3.13 75.45% <88.65%> (+0.02%) ⬆️
py3.14 76.34% <88.65%> (+0.04%) ⬆️
unit 64.58% <70.21%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sqlspec/core/explain.py 97.46% <100.00%> (+0.06%) ⬆️
sqlspec/core/statement.py 85.42% <100.00%> (+0.05%) ⬆️
sqlspec/data_dictionary/_types.py 92.25% <100.00%> (ø)
sqlspec/driver/_async.py 78.13% <ø> (ø)
sqlspec/driver/_common.py 81.55% <ø> (ø)
sqlspec/driver/_sync.py 78.93% <ø> (ø)
sqlspec/migrations/base.py 92.20% <ø> (ø)
sqlspec/builder/_explain.py 98.52% <97.56%> (+0.10%) ⬆️
sqlspec/migrations/commands.py 66.32% <0.00%> (ø)
sqlspec/core/compiler.py 91.01% <87.50%> (+0.19%) ⬆️
... and 1 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

cofin added 5 commits July 25, 2026 01:57
SQLGlot falls back to `exp.Command` for `EXPLAIN` on every dialect except
MySQL, where it parses as `exp.Describe`. Both map to the `COMMAND`
operation type, and the operation profile left `returns_rows` False, so
`SQL.returns_rows()` reported False for every EXPLAIN artifact.

Every adapter picks its fetch-vs-execute primitive from that flag, so the
database ran the EXPLAIN and the plan rows were thrown away. Four adapters
(adbc, duckdb, oracledb, bigquery) escaped through the `_should_force_select`
cursor-description probe; the other fifteen did not.

Classify command-shaped statements in the operation profile instead: the
`EXPLAIN`, `SHOW`, `DESC`, and `DESCRIBE` keywords return rows, except for
the `EXPLAIN PLAN FOR` form used by Oracle and Db2, which populates a plan
table and returns nothing. The operation type stays `COMMAND` so the
existing driver-side safety net still covers other commands.

The check runs once per unique statement and its result is memoized in the
parse cache, so execution adds no work.
`Explain.build()` synthesized a fresh `StatementConfig` carrying only the
dialect, discarding everything else the source statement was configured
with. An explained PostgreSQL statement therefore compiled with `?`
placeholders instead of `$n`, and a named parameter used twice was emitted
twice instead of being deduplicated.

The builder now carries the source statement's configuration and applies
only the dialect as an override. Raw strings and builders, which have no
configuration to inherit, behave as before.

`SQL.copy(statement_config=...)` also raised `TypeError` because the
configuration was passed both explicitly and through `**kwargs`; it now
accepts an override.
The shared adapter EXPLAIN contract asserted only that the result data was
not None. An empty list satisfies that, so ten EXPLAIN artifact shapes
across nineteen driver cases stayed green while every plan row was being
discarded.

The contract now requires the plan rows the database actually produced,
with two exemptions established by live probe rather than assumption:
Oracle, whose `EXPLAIN PLAN FOR` populates a plan table and is read back
through DBMS_XPLAN by the modifier contract, and SQLite's plan for an
INSERT with literal VALUES, which reports no rows because there is no scan
to describe.

Verified as a real net: with the classification reverted, eighteen cases
fail across the SQLite drivers, while DuckDB and ADBC stay green because
they already recovered the plan through their cursor-description probe.
`RETURNS_ROWS_OPERATIONS` listed WITH, VALUES, TABLE, SHOW and DESCRIBE,
but none of those are operation types the compiler can produce: WITH parses
to a SELECT, VALUES and TABLE are classified by the operation profile, and
SHOW and DESCRIBE both map to COMMAND. All five were unreachable.

SELECT and PRAGMA remain. PRAGMA is the load-bearing one, because the
operation profile does not classify `exp.Pragma`.

A test now asserts every entry is a member of `OperationType`, so an entry
that cannot be emitted cannot be added back unnoticed.
Reduce the docstrings added alongside the EXPLAIN classification to what the
code currently does. The reasoning about which dialects parse EXPLAIN which
way, and about what the row-returning set used to contain, belongs in the
history rather than in the source.
@cofin
cofin force-pushed the fix/explain-plan-rows branch from b3fbecd to f872411 Compare July 25, 2026 01:59
cofin added 6 commits July 25, 2026 01:59
PostgreSQL, DuckDB and MySQL accept "TABLE t" as shorthand for
"SELECT * FROM t", but the statement was classified as non-row-returning,
so drivers ran it and discarded the rows.

sqlglot has no expression for the shorthand. "TABLE t" parses as a column
named TABLE aliased to t, and the PostgreSQL form with trailing clauses
("TABLE t ORDER BY a LIMIT 5") fails to parse at all. Both paths reached
the generic COMMAND classification with an empty operation profile.

Classify the statement from its leading keyword when the parsed expression
yields no result shape. The check runs once per unique statement alongside
the existing parse memoization, and covers the parse-failure path as well.
An upstream sqlglot fix that produces a Table expression needs no change
here, since that shape is already classified as row-returning.
@cofin
cofin merged commit 6f7872c into main Jul 25, 2026
27 checks passed
@cofin
cofin deleted the fix/explain-plan-rows branch July 25, 2026 02:59
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.

Bug: SQL.explain() is classified as non-row-returning and asyncpg discards the plan

2 participants