feat: support EXPLAIN ANALYZE on SQL Server for actual execution plans#361
Open
DANze11 wants to merge 1 commit into
Open
feat: support EXPLAIN ANALYZE on SQL Server for actual execution plans#361DANze11 wants to merge 1 commit into
DANze11 wants to merge 1 commit into
Conversation
A plain EXPLAIN maps to SET SHOWPLAN_XML, which compiles the statement without executing it — so its plan carries estimates only. There was no way to get a plan with real row counts. EXPLAIN ANALYZE now maps to SET STATISTICS XML, which runs the statement and returns a plan carrying ActualRows/ActualExecutions, matching the PostgreSQL contract the connector already emulates for EXPLAIN. This also fixes an existing gap: the read-only classifier already recognises EXPLAIN ANALYZE (allowed-keywords.ts) and correctly refuses it for DML, but the SQL Server connector had no handling for it. Both `EXPLAIN ANALYZE <query>` and `EXPLAIN (ANALYZE) <query>` fell through to explainQuery() and reached the server as `ANALYZE <query>` / `(ANALYZE) <query>` — a syntax error. Details: 1. Plan extraction reads the last recordset, not `recordset` (singular). STATISTICS XML interleaves each statement's plan with that statement's own result sets, so the plan sits at no fixed index and the singular accessor would hand back the query's data instead. 2. Option parsing covers the forms the classifier recognises: ANALYZE, ANALYZE VERBOSE, (ANALYZE), (ANALYZE, ...), (ANALYZE false). PostgreSQL-only options (BUFFERS, VERBOSE, COSTS, ...) have no SQL Server counterpart and are refused by name — silently dropping an option would quietly return something other than what was asked for. A disabled ANALYZE (false/off/0) falls back to the estimated plan, mirroring the classifier's own lookahead. 3. Unlike EXPLAIN, EXPLAIN ANALYZE executes, so it is not inherently read-only. Under options.readonly the statement runs inside a transaction that always rolls back, reusing the reasoning and keyword guards of executeReadOnly(). 4. The SET STATISTICS XML toggle runs on the same short-lived single-connection pool explainQuery() already uses, so the session state cannot leak onto a shared pool connection where a concurrent query would inherit it. Adds 12 integration tests against a real SQL Server container. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds SQL Server support for PostgreSQL-style EXPLAIN ANALYZE by translating it to SET STATISTICS XML, enabling actual execution plans (with real row counts) while preserving read-only safety via rollback-on-complete execution when options.readonly is enabled.
Changes:
- Extend SQL Server
executeSQL()EXPLAIN handling to parseEXPLAINmodifiers and routeEXPLAIN ANALYZEto a newSET STATISTICS XMLexecution path. - Implement plan extraction from
STATISTICS XMLresults by scanning the final recordsets for aShowPlanXMLpayload. - Add SQL Server integration tests covering
EXPLAIN ANALYZEsemantics, readonly rollback behavior, supported/unsupported option forms, and malformed inputs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/connectors/sqlserver/index.ts | Adds EXPLAIN option parsing and EXPLAIN ANALYZE execution-plan support via SET STATISTICS XML, including readonly rollback handling and plan extraction. |
| src/connectors/tests/sqlserver.integration.test.ts | Adds SQL Server integration coverage for actual-plan generation, execution vs. compile-only behavior, and option/error handling. |
Comment on lines
+898
to
+906
| const parsed = /^([A-Za-z_]+)(?:\s+(\S+))?$/.exec(token); | ||
| const name = parsed?.[1]; | ||
| if (!name || !/^analyze$/i.test(name)) { | ||
| throw new Error( | ||
| `EXPLAIN option '${name ?? token}' is not supported on SQL Server — only ANALYZE is.` | ||
| ); | ||
| } | ||
|
|
||
| const value = parsed[2]; |
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.
Problem
On SQL Server,
EXPLAINmaps toSET SHOWPLAN_XML, which compiles a statementwithout executing it. The resulting plan carries estimates only — there is no way
to get a plan with real row counts, which is what you need when the estimates are
the problem.
There is also an existing gap. The read-only classifier already recognises
EXPLAIN ANALYZE(it is a PostgreSQL form, seeutils/allowed-keywords.ts) andcorrectly refuses it for DML, but the SQL Server connector has no handling for it.
Both spellings currently break:
Both are syntax errors.
Solution
EXPLAIN ANALYZEmaps toSET STATISTICS XML, which executes the statement andreturns a plan carrying
ActualRows/ActualExecutions— the PostgreSQLEXPLAIN ANALYZEcontract this connector already emulates for plainEXPLAIN.Notes on the implementation
Plan extraction reads the last recordset, not
recordset(singular).STATISTICS XMLinterleaves each statement's plan with that statement's ownresult sets, so the plan sits at no fixed index — the singular accessor would hand
back the query's data instead of the plan.
Option parsing covers the forms the classifier already recognises:
ANALYZE,ANALYZE VERBOSE,(ANALYZE),(ANALYZE, ...),(ANALYZE false).PostgreSQL-only options (
BUFFERS,VERBOSE,COSTS, …) have no SQL Servercounterpart and are refused by name rather than silently dropped — quietly
ignoring an option would return something other than what was asked for. A
disabled
ANALYZE(false/off/0) falls back to the estimated plan, mirroringthe classifier's own negative lookahead.
Read-only is handled explicitly. Unlike
EXPLAIN,EXPLAIN ANALYZEexecutes,so it is not inherently read-only the way
explainQuery()is. Underoptions.readonlythe statement runs inside a transaction that always rolls back,reusing the reasoning and keyword guards of
executeReadOnly()(#342, #350).Session isolation is preserved. The
SET STATISTICS XMLtoggle runs on thesame short-lived single-connection pool
explainQuery()already uses, so thesession state cannot leak onto a shared pool connection where a concurrent query
would inherit it.
Tests
12 new integration tests against a real SQL Server container, covering: actual
plan contents (
RunTimeInformation/ActualRows), that the statement reallyexecutes (unlike
EXPLAIN), rollback under readonly, the parenthesized forms,disabled
ANALYZEfalling back to an estimated plan, refusal of PostgreSQL-onlyoptions, and the malformed cases.
The full
sqlserver.integration.test.tssuite passes (50 pre-existing + 12 new).No changes to other connectors;
pnpm test:unitis unchanged.