fix(#655): return query plan rows from EXPLAIN statements - #657
Merged
Conversation
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
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
force-pushed
the
fix/explain-plan-rows
branch
from
July 25, 2026 01:59
b3fbecd to
f872411
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
EXPLAINstatements 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.SHOWandDESCRIBEare fixed too. They were misclassified the same way, and MySQL'sEXPLAINis literally parsed asDESCRIBE.EXPLAIN PLAN FOR, which writes a plan table and returns no rows; the plan is read back separately throughDBMS_XPLAN.?placeholders instead of$n, and a named parameter used twice was sent twice instead of being deduplicated.SQL.copy(statement_config=...)no longer raisesTypeError. 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
Fixes #655.