Skip to content

Commit a3ab94d

Browse files
tianzhouclaude
andauthored
Fix: enable query plans on SQL Server via EXPLAIN (#331)
* Fix: enable query plans on SQL Server via EXPLAIN SQL Server has no native EXPLAIN statement, and the bogus `explain`/ `showplan` entries in the read-only allow-list did nothing. The actual mechanism, SET SHOWPLAN_XML ON, is session scoped, must be the only statement in its batch, and is suppressed inside a transaction — so it could not be used through the pooled, stateless connector. Translate a leading `EXPLAIN <query>` into a SHOWPLAN_XML request run on a short-lived single-connection pool, giving SQL Server a Postgres/MySQL -like EXPLAIN. SHOWPLAN_XML compiles without executing, so it is read-only safe; the isolated session keeps SHOWPLAN state off the shared pool. Drop the dead `showplan` keyword. Closes #310 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden SQL Server EXPLAIN translation (PR review) Address Copilot review feedback on the EXPLAIN/SHOWPLAN flow: - Skip leading whitespace and SQL comments when detecting EXPLAIN, so a comment-prefixed EXPLAIN that passes the (comment-stripping) read-only validator is also translated instead of reaching the server untranslated. - Trim the extracted inner query. - Reject empty EXPLAIN and any SET SHOWPLAN inside the explained statement. SQL Server already blocks SET SHOWPLAN alongside other statements in a batch (verified: the DELETE does not execute), but this keeps the read-only guarantee self-contained rather than relying on server behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Treat comment-only EXPLAIN as empty (PR review) Validate the explained statement against comment/string-stripped SQL so `EXPLAIN /* comment */` raises the "requires a statement" error instead of running an empty batch. Reuses the strip already done for the SET SHOWPLAN guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9ffd041 commit a3ab94d

4 files changed

Lines changed: 171 additions & 1 deletion

File tree

src/connectors/__tests__/sqlserver.integration.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,81 @@ describe('SQL Server Connector Integration Tests', () => {
264264
});
265265

266266
describe('SQL Server-specific Features', () => {
267+
it('should return an execution plan for EXPLAIN <query> (SHOWPLAN_XML)', async () => {
268+
const result = await sqlServerTest.connector.executeSQL(
269+
'EXPLAIN SELECT * FROM users WHERE age > 30',
270+
{}
271+
);
272+
273+
expect(result.rows).toHaveLength(1);
274+
const planXml = result.rows[0].plan as string;
275+
expect(typeof planXml).toBe('string');
276+
// SHOWPLAN_XML output is a ShowPlanXML document referencing the query.
277+
expect(planXml).toContain('ShowPlanXML');
278+
expect(planXml).toContain('users');
279+
});
280+
281+
it('should not execute the statement under EXPLAIN', async () => {
282+
const before = await sqlServerTest.connector.executeSQL(
283+
"SELECT COUNT(*) as count FROM users WHERE email = 'explain-noexec@example.com'",
284+
{}
285+
);
286+
expect(Number(before.rows[0].count)).toBe(0);
287+
288+
// SHOWPLAN_XML compiles without executing, so this INSERT must not run.
289+
await sqlServerTest.connector.executeSQL(
290+
"EXPLAIN INSERT INTO users (name, email, age) VALUES ('NoExec', 'explain-noexec@example.com', 99)",
291+
{}
292+
);
293+
294+
const after = await sqlServerTest.connector.executeSQL(
295+
"SELECT COUNT(*) as count FROM users WHERE email = 'explain-noexec@example.com'",
296+
{}
297+
);
298+
expect(Number(after.rows[0].count)).toBe(0);
299+
});
300+
301+
it('should translate EXPLAIN even when preceded by a comment', async () => {
302+
const result = await sqlServerTest.connector.executeSQL(
303+
'/* inspect plan */ EXPLAIN SELECT * FROM users',
304+
{}
305+
);
306+
expect(result.rows).toHaveLength(1);
307+
expect(result.rows[0].plan as string).toContain('ShowPlanXML');
308+
});
309+
310+
it('should reject SET SHOWPLAN smuggled into an EXPLAIN query', async () => {
311+
const before = await sqlServerTest.connector.executeSQL(
312+
'SELECT COUNT(*) as count FROM users',
313+
{}
314+
);
315+
316+
await expect(
317+
sqlServerTest.connector.executeSQL(
318+
'EXPLAIN SET SHOWPLAN_XML OFF DELETE FROM users',
319+
{}
320+
)
321+
).rejects.toThrow(/SET SHOWPLAN/i);
322+
323+
const after = await sqlServerTest.connector.executeSQL(
324+
'SELECT COUNT(*) as count FROM users',
325+
{}
326+
);
327+
expect(Number(after.rows[0].count)).toBe(Number(before.rows[0].count));
328+
});
329+
330+
it('should reject an empty EXPLAIN', async () => {
331+
await expect(
332+
sqlServerTest.connector.executeSQL('EXPLAIN ', {})
333+
).rejects.toThrow(/requires a statement/i);
334+
});
335+
336+
it('should reject a comment-only EXPLAIN', async () => {
337+
await expect(
338+
sqlServerTest.connector.executeSQL('EXPLAIN /* just a comment */', {})
339+
).rejects.toThrow(/requires a statement/i);
340+
});
341+
267342
it('should handle SQL Server IDENTITY columns', async () => {
268343
await sqlServerTest.connector.executeSQL(`
269344
CREATE TABLE identity_test (

src/connectors/sqlserver/index.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { isDriverNotInstalled } from "../../utils/module-loader.js";
1616
import { SafeURL } from "../../utils/safe-url.js";
1717
import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js";
1818
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
19+
import { stripCommentsAndStrings } from "../../utils/sql-parser.js";
1920

2021
/**
2122
* SQL Server DSN parser
@@ -172,6 +173,14 @@ export class SQLServerConnector implements Connector {
172173
// Source ID is set by ConnectorManager after cloning
173174
private sourceId: string = "default";
174175

176+
/**
177+
* Leading whitespace and SQL comments to skip before looking for a keyword.
178+
* The read-only validator strips comments before checking the first keyword,
179+
* so the connector must skip them too; otherwise an EXPLAIN preceded by a
180+
* comment passes validation but reaches the server untranslated.
181+
*/
182+
private static readonly LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
183+
175184
getId(): string {
176185
return this.sourceId;
177186
}
@@ -602,6 +611,17 @@ export class SQLServerConnector implements Connector {
602611
throw new Error("Not connected to SQL Server database");
603612
}
604613

614+
// SQL Server has no native EXPLAIN statement. Translate a leading `EXPLAIN`
615+
// into a SHOWPLAN_XML request so callers get a Postgres/MySQL-like
616+
// experience. SHOWPLAN_XML compiles the statement without executing it, so
617+
// this is read-only safe (further enforced in explainQuery).
618+
const afterNoise = sqlQuery.slice(
619+
sqlQuery.match(SQLServerConnector.LEADING_NOISE)![0].length
620+
);
621+
if (/^explain\b/i.test(afterNoise)) {
622+
return this.explainQuery(afterNoise.slice("explain".length).trim());
623+
}
624+
605625
try {
606626
// Apply maxRows limit to SELECT queries if specified
607627
let processedSQL = sqlQuery;
@@ -673,6 +693,65 @@ export class SQLServerConnector implements Connector {
673693
throw new Error(`Failed to execute query: ${(error as Error).message}`);
674694
}
675695
}
696+
697+
/**
698+
* Return the estimated execution plan for a query using SHOWPLAN_XML.
699+
*
700+
* SHOWPLAN_XML compiles the statement and returns its plan without executing
701+
* it, but it has two constraints: `SET SHOWPLAN_XML ON` must be the only
702+
* statement in its batch, and the setting is session scoped. The shared pool
703+
* hands out a fresh connection per request() and an open transaction
704+
* suppresses SHOWPLAN, so neither can carry the setting to a follow-up query.
705+
*
706+
* We therefore run the SET / query pair on a short-lived, single-connection
707+
* pool built from the same config. The dedicated session keeps SHOWPLAN state
708+
* off the shared pool, so a concurrent query can never land on a connection
709+
* with SHOWPLAN enabled (which would return a plan instead of its results).
710+
*/
711+
private async explainQuery(innerQuery: string): Promise<SQLResult> {
712+
// Validate against comment/string-stripped SQL so comment-only input counts
713+
// as empty and a SET SHOWPLAN can't hide behind comments.
714+
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
715+
if (!cleaned) {
716+
throw new Error("EXPLAIN requires a statement to analyze");
717+
}
718+
719+
// Defense in depth: the SET SHOWPLAN session toggle is what makes EXPLAIN
720+
// non-executing, so the explained statement must not disable it. SQL Server
721+
// already rejects `SET SHOWPLAN_* OFF` alongside other statements in a
722+
// batch, but enforcing it here keeps the read-only guarantee self-contained.
723+
if (/\bset\s+showplan/i.test(cleaned)) {
724+
throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
725+
}
726+
727+
if (!this.config) {
728+
throw new Error("Not connected to SQL Server database");
729+
}
730+
731+
const explainPool = new sql.ConnectionPool({
732+
...this.config,
733+
pool: { ...this.config.pool, max: 1, min: 1 },
734+
});
735+
736+
try {
737+
await explainPool.connect();
738+
// max:1 + sequential awaits guarantee both batches hit the same session.
739+
await explainPool.request().batch("SET SHOWPLAN_XML ON");
740+
const planResult = await explainPool.request().batch(innerQuery);
741+
742+
// The plan is returned as the single column of the first row.
743+
const planRow = planResult.recordset?.[0];
744+
const planXml = planRow ? Object.values(planRow)[0] : null;
745+
return {
746+
rows: planXml != null ? [{ plan: planXml }] : [],
747+
rowCount: planXml != null ? 1 : 0,
748+
};
749+
} catch (error) {
750+
throw new Error(`Failed to explain query: ${(error as Error).message}`);
751+
} finally {
752+
await explainPool.close();
753+
}
754+
}
676755
}
677756

678757
// Create and register the connector

src/utils/__tests__/allowed-keywords.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,20 @@ describe("isReadOnlySQL", () => {
220220
});
221221
});
222222

223+
describe("SQL Server keywords", () => {
224+
it("should allow EXPLAIN (translated to SHOWPLAN_XML by the connector)", () => {
225+
expect(isReadOnlySQL("EXPLAIN SELECT * FROM users", "sqlserver")).toBe(true);
226+
});
227+
228+
it("should reject SHOWPLAN (not a real T-SQL statement)", () => {
229+
expect(isReadOnlySQL("SHOWPLAN SELECT * FROM users", "sqlserver")).toBe(false);
230+
});
231+
232+
it("should reject bare SET SHOWPLAN_XML (session-scoped, handled via EXPLAIN)", () => {
233+
expect(isReadOnlySQL("SET SHOWPLAN_XML ON", "sqlserver")).toBe(false);
234+
});
235+
});
236+
223237
describe("edge cases", () => {
224238
it("should treat empty SQL after comment stripping as not read-only", () => {
225239
expect(isReadOnlySQL("-- just a comment", "postgres")).toBe(false);

src/utils/allowed-keywords.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export const allowedKeywords: Record<ConnectorType, string[]> = {
1111
mysql: ["select", "with", "explain", "show", "describe", "desc"],
1212
mariadb: ["select", "with", "explain", "show", "describe", "desc"],
1313
sqlite: ["select", "with", "explain", "pragma"],
14-
sqlserver: ["select", "with", "explain", "showplan"],
14+
// SQL Server has no native EXPLAIN statement; the connector translates a
15+
// leading `EXPLAIN` into a SET SHOWPLAN_XML request (see SQLServerConnector).
16+
sqlserver: ["select", "with", "explain"],
1517
};
1618

1719
/**

0 commit comments

Comments
 (0)