Skip to content

Commit be92397

Browse files
DANze11claude
andcommitted
feat: support EXPLAIN ANALYZE on SQL Server for actual execution plans
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. The classifier accepts an equals sign there (`ANALYZE = false`) and reads the value after a bare ANALYZE too, so both spellings are honoured here. Otherwise the layers disagree in the dangerous direction: the classifier waives its DML check for what it believes is a non-executing statement, while this connector routes to the path that executes. 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, guarded by assertNoReadOnlyEscapes with transactionControl set — the rollback is a real transaction a COMMIT could close, which is not true of explainQuery. Sharing that helper also blocks the pass-through data sources it covers: OPENQUERY and friends execute on the remote source, so a local rollback never reaches them, and this path executes by design. 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 16 integration tests against a real SQL Server container. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93b7b7d commit be92397

2 files changed

Lines changed: 428 additions & 1 deletion

File tree

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

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,197 @@ describe('SQL Server Connector Integration Tests', () => {
339339
).rejects.toThrow(/requires a statement/i);
340340
});
341341

342+
it('should return an actual execution plan for EXPLAIN ANALYZE <query> (STATISTICS XML)', async () => {
343+
const result = await sqlServerTest.connector.executeSQL(
344+
'EXPLAIN ANALYZE SELECT * FROM users WHERE age > 30',
345+
{}
346+
);
347+
348+
expect(result.rows).toHaveLength(1);
349+
const planXml = result.rows[0].plan as string;
350+
expect(typeof planXml).toBe('string');
351+
expect(planXml).toContain('ShowPlanXML');
352+
expect(planXml).toContain('users');
353+
// RunTimeInformation/ActualRows only appear in an *actual* plan
354+
// (SET STATISTICS XML). SHOWPLAN_XML never emits them.
355+
expect(planXml).toContain('RunTimeInformation');
356+
expect(planXml).toMatch(/ActualRows/i);
357+
});
358+
359+
it('should execute the statement under EXPLAIN ANALYZE', async () => {
360+
const before = await sqlServerTest.connector.executeSQL(
361+
"SELECT COUNT(*) as count FROM users WHERE email = 'explain-analyze-exec@example.com'",
362+
{}
363+
);
364+
expect(Number(before.rows[0].count)).toBe(0);
365+
366+
// Unlike EXPLAIN, EXPLAIN ANALYZE really runs the statement.
367+
await sqlServerTest.connector.executeSQL(
368+
"EXPLAIN ANALYZE INSERT INTO users (name, email, age) VALUES ('Exec', 'explain-analyze-exec@example.com', 42)",
369+
{}
370+
);
371+
372+
const after = await sqlServerTest.connector.executeSQL(
373+
"SELECT COUNT(*) as count FROM users WHERE email = 'explain-analyze-exec@example.com'",
374+
{}
375+
);
376+
expect(Number(after.rows[0].count)).toBe(1);
377+
});
378+
379+
it('should roll back writes under EXPLAIN ANALYZE in readonly mode', async () => {
380+
const before = await sqlServerTest.connector.executeSQL(
381+
"SELECT COUNT(*) as count FROM users WHERE email = 'explain-analyze-ro@example.com'",
382+
{}
383+
);
384+
expect(Number(before.rows[0].count)).toBe(0);
385+
386+
const result = await sqlServerTest.connector.executeSQL(
387+
"EXPLAIN ANALYZE INSERT INTO users (name, email, age) VALUES ('RO', 'explain-analyze-ro@example.com', 42)",
388+
{ readonly: true }
389+
);
390+
// The plan still comes back...
391+
expect(result.rows[0].plan as string).toContain('ShowPlanXML');
392+
393+
// ...but the write must not persist.
394+
const after = await sqlServerTest.connector.executeSQL(
395+
"SELECT COUNT(*) as count FROM users WHERE email = 'explain-analyze-ro@example.com'",
396+
{}
397+
);
398+
expect(Number(after.rows[0].count)).toBe(0);
399+
});
400+
401+
it('should translate EXPLAIN ANALYZE even when preceded by a comment', async () => {
402+
const result = await sqlServerTest.connector.executeSQL(
403+
'/* real numbers please */ EXPLAIN ANALYZE SELECT * FROM users',
404+
{}
405+
);
406+
expect(result.rows).toHaveLength(1);
407+
expect(result.rows[0].plan as string).toContain('RunTimeInformation');
408+
});
409+
410+
it('should reject SET STATISTICS smuggled into an EXPLAIN ANALYZE query', async () => {
411+
await expect(
412+
sqlServerTest.connector.executeSQL(
413+
'EXPLAIN ANALYZE SET STATISTICS XML OFF SELECT * FROM users',
414+
{}
415+
)
416+
).rejects.toThrow(/SET STATISTICS/i);
417+
});
418+
419+
it('should reject an empty EXPLAIN ANALYZE', async () => {
420+
await expect(
421+
sqlServerTest.connector.executeSQL('EXPLAIN ANALYZE ', {})
422+
).rejects.toThrow(/requires a statement/i);
423+
});
424+
425+
it('should accept the parenthesized EXPLAIN (ANALYZE) form', async () => {
426+
const result = await sqlServerTest.connector.executeSQL(
427+
'EXPLAIN (ANALYZE) SELECT name FROM users WHERE age > 30',
428+
{}
429+
);
430+
expect(result.rows[0].plan as string).toContain('RunTimeInformation');
431+
});
432+
433+
it('should treat EXPLAIN (ANALYZE false) as a plain estimated EXPLAIN', async () => {
434+
// Matches the read-only classifier, which does not count disabled ANALYZE
435+
// as executing. An estimated plan has no runtime counters.
436+
const result = await sqlServerTest.connector.executeSQL(
437+
'EXPLAIN (ANALYZE false) SELECT name FROM users WHERE age > 30',
438+
{}
439+
);
440+
const plan = result.rows[0].plan as string;
441+
expect(plan).toContain('ShowPlanXML');
442+
expect(plan).not.toContain('RunTimeInformation');
443+
});
444+
445+
it('should accept the equals spelling the read-only classifier recognizes', async () => {
446+
// The classifier reads `ANALYZE = false` as a disabled ANALYZE, i.e. a
447+
// plain EXPLAIN. Erroring here would reject what that layer waves through.
448+
for (const sql of [
449+
'EXPLAIN (ANALYZE = false) SELECT name FROM users WHERE age > 30',
450+
'EXPLAIN (ANALYZE = 0) SELECT name FROM users WHERE age > 30',
451+
'EXPLAIN ANALYZE = false SELECT name FROM users WHERE age > 30',
452+
'EXPLAIN ANALYZE false SELECT name FROM users WHERE age > 30',
453+
]) {
454+
const result = await sqlServerTest.connector.executeSQL(sql, {});
455+
const plan = result.rows[0].plan as string;
456+
expect(plan, sql).toContain('ShowPlanXML');
457+
expect(plan, sql).not.toContain('RunTimeInformation');
458+
}
459+
});
460+
461+
it('should still enable ANALYZE for the equals spelling', async () => {
462+
const result = await sqlServerTest.connector.executeSQL(
463+
'EXPLAIN (ANALYZE = true) SELECT name FROM users WHERE age > 30',
464+
{}
465+
);
466+
expect(result.rows[0].plan as string).toContain('RunTimeInformation');
467+
});
468+
469+
it('should block pass-through data sources under readonly EXPLAIN ANALYZE', async () => {
470+
// OPENQUERY executes on the remote source, so the rollback guard never
471+
// reaches it — the same escape the plain EXPLAIN path blocks.
472+
await expect(
473+
sqlServerTest.connector.executeSQL(
474+
"EXPLAIN ANALYZE SELECT * FROM OPENQUERY(remote, 'SELECT 1')",
475+
{ readonly: true }
476+
)
477+
).rejects.toThrow(/pass-through data sources/i);
478+
});
479+
480+
it('should block dynamic SQL under readonly EXPLAIN ANALYZE', async () => {
481+
await expect(
482+
sqlServerTest.connector.executeSQL('EXPLAIN ANALYZE EXEC GetUserCount', {
483+
readonly: true,
484+
})
485+
).rejects.toThrow(/dynamic SQL/i);
486+
});
487+
488+
it('should not execute the statement under EXPLAIN (ANALYZE off)', async () => {
489+
const before = await sqlServerTest.connector.executeSQL(
490+
"SELECT COUNT(*) as count FROM users WHERE email = 'analyze-off@example.com'",
491+
{}
492+
);
493+
expect(Number(before.rows[0].count)).toBe(0);
494+
495+
await sqlServerTest.connector.executeSQL(
496+
"EXPLAIN (ANALYZE off) INSERT INTO users (name, email, age) VALUES ('Off', 'analyze-off@example.com', 7)",
497+
{}
498+
);
499+
500+
const after = await sqlServerTest.connector.executeSQL(
501+
"SELECT COUNT(*) as count FROM users WHERE email = 'analyze-off@example.com'",
502+
{}
503+
);
504+
expect(Number(after.rows[0].count)).toBe(0);
505+
});
506+
507+
it('should reject PostgreSQL-only EXPLAIN options with a clear message', async () => {
508+
await expect(
509+
sqlServerTest.connector.executeSQL('EXPLAIN (ANALYZE, BUFFERS) SELECT name FROM users', {})
510+
).rejects.toThrow(/BUFFERS/i);
511+
512+
await expect(
513+
sqlServerTest.connector.executeSQL('EXPLAIN ANALYZE VERBOSE SELECT name FROM users', {})
514+
).rejects.toThrow(/VERBOSE/i);
515+
516+
await expect(
517+
sqlServerTest.connector.executeSQL('EXPLAIN (COSTS) SELECT name FROM users', {})
518+
).rejects.toThrow(/COSTS/i);
519+
});
520+
521+
it('should reject an unterminated EXPLAIN option list', async () => {
522+
await expect(
523+
sqlServerTest.connector.executeSQL('EXPLAIN (ANALYZE SELECT name FROM users', {})
524+
).rejects.toThrow(/option list/i);
525+
});
526+
527+
it('should reject an empty EXPLAIN (ANALYZE) with no statement', async () => {
528+
await expect(
529+
sqlServerTest.connector.executeSQL('EXPLAIN (ANALYZE) ', {})
530+
).rejects.toThrow(/requires a statement/i);
531+
});
532+
342533
it('should handle SQL Server IDENTITY columns', async () => {
343534
await sqlServerTest.connector.executeSQL(`
344535
CREATE TABLE identity_test (

0 commit comments

Comments
 (0)