UNPKG

@bytebase/dbhub

Version:

Token-efficient database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite

1,033 lines (1,031 loc) • 39.6 kB
import { isDriverNotInstalled } from "./chunk-WVVMH6FJ.js"; import { computeHitRatioPct, toNullableNumber } from "./chunk-FU2ZJE4E.js"; import { SQLRowLimiter, closeQuietly } from "./chunk-YIGJHEU6.js"; import { sqlServerDynamicSqlKeywords, sqlServerDynamicSqlPattern, sqlServerPassThroughKeywords, sqlServerPassThroughPattern } from "./chunk-ABTVMHD6.js"; import { ConnectorRegistry, LEADING_SQL_NOISE, SafeURL, obfuscateDSNPassword, splitSQLStatements, stripCommentsAndStrings } from "./chunk-IXC4NQGC.js"; // src/connectors/sqlserver/index.ts import sql from "mssql"; var SQLServerDSNParser = class { async parse(dsn, config) { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; const queryTimeoutSeconds = config?.queryTimeoutSeconds; if (!this.isValidDSN(dsn)) { const obfuscatedDSN = obfuscateDSNPassword(dsn); const expectedFormat = this.getSampleDSN(); throw new Error( `Invalid SQL Server DSN format. Provided: ${obfuscatedDSN} Expected: ${expectedFormat}` ); } try { const url = new SafeURL(dsn); const options = {}; url.forEachSearchParam((value, key) => { if (key === "authentication") { options.authentication = value; } else if (key === "sslmode") { options.sslmode = value; } else if (key === "instanceName") { options.instanceName = value; } else if (key === "domain") { options.domain = value; } }); if (options.authentication === "ntlm" && !options.domain) { throw new Error("NTLM authentication requires 'domain' parameter"); } if (options.domain && options.authentication !== "ntlm") { throw new Error("Parameter 'domain' requires 'authentication=ntlm'"); } if (options.sslmode) { if (options.sslmode === "disable") { options.encrypt = false; options.trustServerCertificate = false; } else if (options.sslmode === "require") { options.encrypt = true; options.trustServerCertificate = true; } } const config2 = { server: url.hostname, port: url.port ? parseInt(url.port) : 1433, // Default SQL Server port database: url.pathname ? url.pathname.substring(1) : "", // Remove leading slash options: { encrypt: options.encrypt ?? false, // Default to unencrypted for development trustServerCertificate: options.trustServerCertificate ?? false, ...connectionTimeoutSeconds !== void 0 && { connectTimeout: connectionTimeoutSeconds * 1e3 }, ...queryTimeoutSeconds !== void 0 && { requestTimeout: queryTimeoutSeconds * 1e3 }, instanceName: options.instanceName // Add named instance support } }; switch (options.authentication) { case "azure-active-directory-access-token": { let DefaultAzureCredential; try { ({ DefaultAzureCredential } = await import("@azure/identity")); } catch (importError) { if (isDriverNotInstalled(importError, "@azure/identity")) { throw new Error( 'Azure AD authentication requires the "@azure/identity" package. Install it with: pnpm add @azure/identity' ); } throw importError; } try { const credential = new DefaultAzureCredential(); const token = await credential.getToken("https://database.windows.net/"); config2.authentication = { type: "azure-active-directory-access-token", options: { token: token.token } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get Azure AD token: ${errorMessage}`); } break; } case "ntlm": config2.authentication = { type: "ntlm", options: { domain: options.domain, userName: url.username, password: url.password } }; break; default: config2.user = url.username; config2.password = url.password; break; } return config2; } catch (error) { throw new Error( `Failed to parse SQL Server DSN: ${error instanceof Error ? error.message : String(error)}` ); } } getSampleDSN() { return "sqlserver://username:password@localhost:1433/database?sslmode=disable&instanceName=INSTANCE1"; } isValidDSN(dsn) { try { return dsn.startsWith("sqlserver://"); } catch (error) { return false; } } }; var _SQLServerConnector = class _SQLServerConnector { constructor() { this.id = "sqlserver"; this.name = "SQL Server"; this.dsnParser = new SQLServerDSNParser(); // Source ID is set by ConnectorManager after cloning this.sourceId = "default"; } getId() { return this.sourceId; } clone() { return new _SQLServerConnector(); } async connect(dsn, initScript, config) { try { this.config = await this.dsnParser.parse(dsn, config); if (!this.config.options) { this.config.options = {}; } this.connection = new sql.ConnectionPool(this.config); await this.connection.connect(); } catch (error) { if (this.connection) { const connection = this.connection; this.connection = void 0; await closeQuietly(() => connection.close()); } throw error; } } async disconnect() { if (this.connection) { await this.connection.close(); this.connection = void 0; } } async getSchemas() { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const result = await this.connection.request().query(` SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME `); return result.recordset.map((row) => row.SCHEMA_NAME); } catch (error) { throw new Error(`Failed to get schemas: ${error.message}`); } } async getTables(schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("schema", sql.VarChar, schemaToUse); const query = ` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME `; const result = await request.query(query); return result.recordset.map((row) => row.TABLE_NAME); } catch (error) { throw new Error(`Failed to get tables: ${error.message}`); } } async getViews(schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("schema", sql.VarChar, schemaToUse); const query = ` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_TYPE = 'VIEW' ORDER BY TABLE_NAME `; const result = await request.query(query); return result.recordset.map((row) => row.TABLE_NAME); } catch (error) { throw new Error(`Failed to get views: ${error.message}`); } } async tableExists(tableName, schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse); const query = ` SELECT COUNT(*) as count FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schema `; const result = await request.query(query); return result.recordset[0].count > 0; } catch (error) { throw new Error(`Failed to check if table exists: ${error.message}`); } } async getTableIndexes(tableName, schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse); const query = ` SELECT i.name AS index_name, i.is_unique, i.is_primary_key, c.name AS column_name, ic.key_ordinal FROM sys.indexes i INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id INNER JOIN sys.tables t ON i.object_id = t.object_id INNER JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE t.name = @tableName AND s.name = @schema ORDER BY i.name, ic.key_ordinal `; const result = await request.query(query); const indexMap = /* @__PURE__ */ new Map(); for (const row of result.recordset) { const indexName = row.index_name; const columnName = row.column_name; const isUnique = !!row.is_unique; const isPrimary = !!row.is_primary_key; if (!indexMap.has(indexName)) { indexMap.set(indexName, { columns: [], is_unique: isUnique, is_primary: isPrimary }); } const indexInfo = indexMap.get(indexName); indexInfo.columns.push(columnName); } const indexes = []; indexMap.forEach((info, name) => { indexes.push({ index_name: name, column_names: info.columns, is_unique: info.is_unique, is_primary: info.is_primary }); }); return indexes; } catch (error) { throw new Error(`Failed to get indexes for table ${tableName}: ${error.message}`); } } async getTableSchema(tableName, schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse); const query = ` SELECT c.COLUMN_NAME as column_name, c.DATA_TYPE as data_type, c.IS_NULLABLE as is_nullable, c.COLUMN_DEFAULT as column_default, ep.value as description FROM INFORMATION_SCHEMA.COLUMNS c LEFT JOIN sys.columns sc ON sc.name = c.COLUMN_NAME AND sc.object_id = OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME)) LEFT JOIN sys.extended_properties ep ON ep.major_id = sc.object_id AND ep.minor_id = sc.column_id AND ep.name = 'MS_Description' WHERE c.TABLE_NAME = @tableName AND c.TABLE_SCHEMA = @schema ORDER BY c.ORDINAL_POSITION `; const result = await request.query(query); return result.recordset.map((row) => ({ ...row, description: row.description || null })); } catch (error) { throw new Error(`Failed to get schema for table ${tableName}: ${error.message}`); } } async getTableComment(tableName, schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse); const query = ` SELECT ep.value as table_comment FROM sys.extended_properties ep JOIN sys.tables t ON ep.major_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE ep.minor_id = 0 AND ep.name = 'MS_Description' AND t.name = @tableName AND s.name = @schema `; const result = await request.query(query); if (result.recordset.length > 0) { return result.recordset[0].table_comment || null; } return null; } catch (error) { return null; } } async getHealthCheck() { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } const notes = []; const result = {}; try { const [connResult, maxConnResult] = await Promise.all([ this.connection.request().query(` SELECT COUNT(*) AS total, SUM(CASE WHEN r.session_id IS NOT NULL THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN r.session_id IS NULL THEN 1 ELSE 0 END) AS idle, SUM(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction, MAX(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL THEN DATEDIFF(SECOND, s.last_request_end_time, SYSDATETIME()) ELSE NULL END) AS longest_idle_in_transaction_seconds, MAX(CASE WHEN r.session_id IS NOT NULL THEN DATEDIFF(SECOND, r.start_time, SYSDATETIME()) ELSE NULL END) AS longest_active_query_seconds FROM sys.dm_exec_sessions s LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id LEFT JOIN (SELECT DISTINCT session_id FROM sys.dm_tran_session_transactions) t ON t.session_id = s.session_id WHERE s.is_user_process = 1 AND s.session_id <> @@SPID `), this.connection.request().query(` SELECT CAST(value_in_use AS INT) AS max_connections FROM sys.configurations WHERE name = 'user connections' `) ]); const conn = connResult.recordset[0]; const maxConnections = maxConnResult.recordset.length > 0 && maxConnResult.recordset[0].max_connections > 0 ? maxConnResult.recordset[0].max_connections : null; result.connections = { total: conn.total ?? 0, active: conn.active ?? 0, idle: conn.idle ?? 0, idleInTransaction: conn.idle_in_transaction ?? 0, // SQL Server has no equivalent of Postgres's "idle in transaction // (aborted)" state - a failed statement doesn't leave the session's // transaction in a distinct aborted-but-open state the way Postgres does. maxConnections, longestIdleInTransactionSeconds: toNullableNumber(conn.longest_idle_in_transaction_seconds), longestActiveQuerySeconds: toNullableNumber(conn.longest_active_query_seconds) }; } catch { notes.push( "Connection pool metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)." ); } try { const bufferResult = await this.connection.request().query(` SELECT counter_name, CAST(cntr_value AS BIGINT) AS cntr_value FROM sys.dm_os_performance_counters WHERE object_name LIKE '%Buffer Manager%' AND counter_name IN ('Page lookups/sec', 'Page reads/sec') `); const counters = Object.fromEntries( bufferResult.recordset.map((row) => [row.counter_name.trim(), Number(row.cntr_value)]) ); const pageLookups = counters["Page lookups/sec"] ?? 0; const pageReads = counters["Page reads/sec"] ?? 0; result.bufferCache = { hitRatioPct: computeHitRatioPct(pageLookups, pageReads), blocksHit: pageLookups - pageReads, blocksRead: pageReads }; } catch { notes.push( "Buffer cache metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)." ); } if (notes.length > 0) { result.notes = notes; } return result; } async getStoredProcedures(schema, routineType) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("schema", sql.VarChar, schemaToUse); let typeFilter; if (routineType === "function") { typeFilter = "AND ROUTINE_TYPE = 'FUNCTION'"; } else if (routineType === "procedure") { typeFilter = "AND ROUTINE_TYPE = 'PROCEDURE'"; } else { typeFilter = "AND (ROUTINE_TYPE = 'PROCEDURE' OR ROUTINE_TYPE = 'FUNCTION')"; } const query = ` SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = @schema ${typeFilter} ORDER BY ROUTINE_NAME `; const result = await request.query(query); return result.recordset.map((row) => row.ROUTINE_NAME); } catch (error) { throw new Error(`Failed to get stored procedures: ${error.message}`); } } async getStoredProcedureDetail(procedureName, schema) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { const schemaToUse = schema || "dbo"; const request = this.connection.request().input("procedureName", sql.VarChar, procedureName).input("schema", sql.VarChar, schemaToUse); const routineQuery = ` SELECT ROUTINE_NAME as procedure_name, ROUTINE_TYPE, DATA_TYPE as return_data_type FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_NAME = @procedureName AND ROUTINE_SCHEMA = @schema `; const routineResult = await request.query(routineQuery); if (routineResult.recordset.length === 0) { throw new Error(`Stored procedure '${procedureName}' not found in schema '${schemaToUse}'`); } const routine = routineResult.recordset[0]; const parameterQuery = ` SELECT PARAMETER_NAME, PARAMETER_MODE, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, ORDINAL_POSITION FROM INFORMATION_SCHEMA.PARAMETERS WHERE SPECIFIC_NAME = @procedureName AND SPECIFIC_SCHEMA = @schema ORDER BY ORDINAL_POSITION `; const parameterResult = await request.query(parameterQuery); let parameterList = ""; if (parameterResult.recordset.length > 0) { parameterList = parameterResult.recordset.map( (param) => { const lengthStr = param.CHARACTER_MAXIMUM_LENGTH > 0 ? `(${param.CHARACTER_MAXIMUM_LENGTH})` : ""; return `${param.PARAMETER_NAME} ${param.PARAMETER_MODE} ${param.DATA_TYPE}${lengthStr}`; } ).join(", "); } const definitionQuery = ` SELECT definition FROM sys.sql_modules sm JOIN sys.objects o ON sm.object_id = o.object_id JOIN sys.schemas s ON o.schema_id = s.schema_id WHERE o.name = @procedureName AND s.name = @schema `; const definitionResult = await request.query(definitionQuery); let definition = void 0; if (definitionResult.recordset.length > 0) { definition = definitionResult.recordset[0].definition; } return { procedure_name: routine.procedure_name, procedure_type: routine.ROUTINE_TYPE === "PROCEDURE" ? "procedure" : "function", language: "sql", // SQL Server procedures are typically in T-SQL parameter_list: parameterList, return_type: routine.ROUTINE_TYPE === "FUNCTION" ? routine.return_data_type : void 0, definition }; } catch (error) { throw new Error(`Failed to get stored procedure details: ${error.message}`); } } async executeSQL(sqlQuery, options, parameters) { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } const afterNoise = sqlQuery.replace(LEADING_SQL_NOISE, ""); if (/^explain\b/i.test(afterNoise)) { const { analyze, query } = _SQLServerConnector.parseExplainPrefix( afterNoise.slice("explain".length).trim() ); return analyze ? this.explainAnalyzeQuery(query, options, parameters) : this.explainQuery(query, options.readonly, parameters); } try { let processedSQL = sqlQuery; let probeApplied = false; if (options.maxRows) { const rewrite = SQLRowLimiter.applyMaxRowsForSQLServerWithTruncationProbe( sqlQuery, options.maxRows ); processedSQL = rewrite.sql; probeApplied = rewrite.probeApplied; } const isSingleStatement = splitSQLStatements(processedSQL, "sqlserver").length === 1; if (options.readonly) { return await this.executeReadOnly( processedSQL, parameters, isSingleStatement ? sqlQuery : void 0, options.maxRows, probeApplied ); } const request = this.connection.request(); const messages = []; request.on( "info", (info) => { messages.push({ text: info.message, // SQL Server reports severity as a numeric class; info messages are < 10. severity: info.class !== void 0 ? String(info.class) : void 0, code: info.number, line: info.lineNumber }); } ); _SQLServerConnector.bindParameters(request, parameters); const result = await request.query(processedSQL); const resultSets = _SQLServerConnector.buildResultSets( result.recordsets, result.rowsAffected, isSingleStatement ? sqlQuery : void 0 ); if (resultSets.length > 0) { SQLRowLimiter.flagTruncation(resultSets[0], options.maxRows, probeApplied); } return { resultSets, ...messages.length > 0 ? { messages } : {} }; } catch (error) { throw new Error(`Failed to execute query: ${error.message}`); } } /** * Builds one result set per SELECT-producing statement in the batch, plus * (if any) a trailing result set summarizing the write-only statements. * * node-mssql only pushes a `recordsets` entry for statements that produce * columns (SELECT); INSERT/UPDATE/DELETE statements in the same batch * contribute to `rowsAffected` only, with no way to recover which * `rowsAffected` entry belongs to which statement from the driver's final * arrays (`recordsets`/`rowsAffected` aren't index-aligned - see the * tedious `doneHandler`, which always pushes to `rowsAffected` but only * conditionally to `recordsets`). So a batch of pure writes collapses to a * single summed result set, and a batch mixing writes with selects gets * one result set per select plus one trailing "writes" set for the rest - * not a truly per-statement breakdown, but no read statement's rows are * ever merged with another's, which is what mattered for #380. * * `sourceSql`, when given, is attributed to the single resulting set - it * must be omitted (pass `undefined`) unless the caller has already * confirmed the batch is unambiguously one statement, since for a genuine * multi-statement batch there's no reliable way to say which source * statement a given recordset (or the trailing writes set) came from. */ static buildResultSets(recordsets, rowsAffected, sourceSql) { const sets = (recordsets ?? []).map((recordset) => { const rows = recordset ?? []; return { rows, rowCount: rows.length }; }); const totalAffected = (rowsAffected ?? []).reduce((total, count) => total + (count ?? 0), 0); const accountedFor = sets.reduce((total, set) => total + set.rowCount, 0); const writesOnly = totalAffected - accountedFor; if (sets.length === 0) { sets.push({ rows: [], rowCount: totalAffected }); } else if (writesOnly > 0) { sets.push({ rows: [], rowCount: writesOnly }); } if (sets.length === 1 && sourceSql !== void 0) { sets[0].sql = sourceSql; } return sets; } /** * Bind positional parameters as @p1, @p2, ... inferring the SQL Server type * from each JavaScript value. Shared by every execution path so they cannot * drift in how a given value is typed. * * Works for `batch` as well as `query`: node-mssql prepends the matching * DECLARE/SET statements when the request is a batch. */ static bindParameters(request, parameters) { if (!parameters || parameters.length === 0) { return; } parameters.forEach((param, index) => { const paramName = `p${index + 1}`; if (typeof param === "string") { request.input(paramName, sql.VarChar, param); } else if (typeof param === "number") { if (Number.isInteger(param)) { request.input(paramName, sql.Int, param); } else { request.input(paramName, sql.Float, param); } } else if (typeof param === "boolean") { request.input(paramName, sql.Bit, param); } else if (param === null || param === void 0) { request.input(paramName, sql.VarChar, param); } else if (Array.isArray(param)) { request.input(paramName, sql.VarChar, JSON.stringify(param)); } else { request.input(paramName, sql.VarChar, JSON.stringify(param)); } }); } /** * Reject the constructs that escape SQL Server's read-only guards, for use by * both read-only execution paths. * * - Dynamic SQL (sqlServerDynamicSqlKeywords): can carry hidden COMMIT/ROLLBACK * inside string literals that stripCommentsAndStrings removes * - Pass-through data sources (sqlServerPassThroughKeywords): execute on a * remote or ad-hoc source, so a local rollback never reaches them * - COMMIT/ROLLBACK, when `transactionControl` is set: would end the wrapping * transaction, letting writes persist. Only meaningful for the transaction * path; the EXPLAIN path opens no transaction of its own. * * Both keyword lists are imported from the read-only classifier rather than * redeclared, so the classifier and these backstops cannot drift apart. * * Note the COMMIT/ROLLBACK check is SQL Server-only by design. MySQL/MariaDB * wrap batches in a transaction too, but there `commit`, `prepare` and * `execute` are absent from their allow-lists in allowedKeywords, and * execute-sql.ts requires every split statement to pass the classifier — so a * transaction-control statement can never reach their backstop. */ assertNoReadOnlyEscapes(sqlText, { transactionControl = false } = {}) { const cleaned = stripCommentsAndStrings(sqlText, "sqlserver").toLowerCase(); if (transactionControl && /\b(?:commit|rollback)\b/.test(cleaned)) { throw new Error( "Read-only mode: transaction control statements (COMMIT, ROLLBACK) are not allowed" ); } if (sqlServerDynamicSqlPattern.test(cleaned)) { throw new Error( `Read-only mode: dynamic SQL execution (${sqlServerDynamicSqlKeywords.map((k) => k.toUpperCase()).join(", ")}) is not allowed` ); } if (sqlServerPassThroughPattern.test(cleaned)) { throw new Error( `Read-only mode: pass-through data sources (${sqlServerPassThroughKeywords.map((k) => k.toUpperCase()).join(", ")}) are not allowed` ); } } /** * Execute a query inside a transaction that always rolls back, preventing * any modifications from persisting. SQL Server has no native READ ONLY * transaction mode, so this is the defense-in-depth backstop behind the * keyword classifier. * * Dangerous constructs are rejected before the transaction opens; see * assertNoReadOnlyEscapes. */ async executeReadOnly(processedSQL, parameters, sourceSql, maxRows, probeApplied) { this.assertNoReadOnlyEscapes(processedSQL, { transactionControl: true }); const transaction = new sql.Transaction(this.connection); await transaction.begin(); const request = new sql.Request(transaction); const messages = []; request.on( "info", (info) => { messages.push({ text: info.message, severity: info.class !== void 0 ? String(info.class) : void 0, code: info.number, line: info.lineNumber }); } ); _SQLServerConnector.bindParameters(request, parameters); let result; let queryFailed = false; try { result = await request.query(processedSQL); } catch (error) { queryFailed = true; throw error; } finally { try { await transaction.rollback(); } catch (rollbackError) { if (!queryFailed) { throw new Error( `Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}` ); } } } const resultSets = _SQLServerConnector.buildResultSets( result.recordsets, result.rowsAffected, sourceSql ); if (resultSets.length > 0) { SQLRowLimiter.flagTruncation(resultSets[0], maxRows, probeApplied); } return { resultSets, ...messages.length > 0 ? { messages } : {} }; } /** * Return the estimated execution plan for a query using SHOWPLAN_XML. * * SHOWPLAN_XML compiles the statement and returns its plan without executing * it, but it has two constraints: `SET SHOWPLAN_XML ON` must be the only * statement in its batch, and the setting is session scoped. The shared pool * hands out a fresh connection per request() and an open transaction * suppresses SHOWPLAN, so neither can carry the setting to a follow-up query. * * We therefore run the SET / query pair on a short-lived, single-connection * pool built from the same config. The dedicated session keeps SHOWPLAN state * off the shared pool, so a concurrent query can never land on a connection * with SHOWPLAN enabled (which would return a plan instead of its results). */ async explainQuery(innerQuery, readonly, parameters) { const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim(); if (!cleaned) { throw new Error("EXPLAIN requires a statement to analyze"); } if (readonly) { this.assertNoReadOnlyEscapes(innerQuery); } if (/\bset\s+showplan/i.test(cleaned)) { throw new Error("EXPLAIN does not support SET SHOWPLAN statements"); } if (!this.config) { throw new Error("Not connected to SQL Server database"); } const explainPool = new sql.ConnectionPool({ ...this.config, pool: { ...this.config.pool, max: 1, min: 1 } }); try { await explainPool.connect(); await explainPool.request().batch("SET SHOWPLAN_XML ON"); const planRequest = explainPool.request(); _SQLServerConnector.bindParameters(planRequest, parameters); const planResult = await planRequest.batch(innerQuery); const planRow = planResult.recordset?.[0]; const planXml = planRow ? Object.values(planRow)[0] : null; return { resultSets: [ { rows: planXml != null ? [{ plan: planXml }] : [], rowCount: planXml != null ? 1 : 0 } ] }; } catch (error) { throw new Error(`Failed to explain query: ${error.message}`); } finally { await explainPool.close(); } } /** * Splits the modifiers between EXPLAIN and its statement, covering the same * forms the read-only classifier recognises (see utils/allowed-keywords.ts): * `ANALYZE`, `ANALYZE VERBOSE`, `(ANALYZE)`, `(ANALYZE, BUFFERS)`, * `(ANALYZE false)`. * * Only ANALYZE carries a meaning here — it selects STATISTICS XML over * SHOWPLAN_XML. The rest are PostgreSQL planner knobs with no SQL Server * counterpart, so they are refused by name: silently dropping an option would * quietly hand back something other than what was asked for. */ static parseExplainPrefix(afterExplain) { if (afterExplain.startsWith("(")) { const close = afterExplain.indexOf(")"); if (close < 0) { throw new Error("EXPLAIN option list is missing its closing ')'"); } let analyze = false; for (const part of afterExplain.slice(1, close).split(",")) { const token = part.trim(); if (!token) continue; const parsed = _SQLServerConnector.EXPLAIN_OPTION.exec(token); if (!parsed) { throw new Error( `EXPLAIN option '${token}' is not supported on SQL Server \u2014 only ANALYZE is.` ); } const name = parsed[1]; if (!/^analyze$/i.test(name)) { throw new Error( `EXPLAIN option '${name}' is not supported on SQL Server \u2014 only ANALYZE is.` ); } const value = parsed[2]; if (value === void 0 || _SQLServerConnector.EXPLAIN_ON.test(value)) { analyze = true; } else if (_SQLServerConnector.EXPLAIN_OFF.test(value)) { analyze = false; } else { throw new Error(`EXPLAIN option 'ANALYZE' expects a boolean, got '${value}'.`); } } return { analyze, query: afterExplain.slice(close + 1).trim() }; } const analyzeKeyword = /^analyze\b/i.exec(afterExplain); if (!analyzeKeyword) { return { analyze: false, query: afterExplain }; } const rest = afterExplain.slice(analyzeKeyword[0].length).trim(); const disabled = _SQLServerConnector.EXPLAIN_BARE_DISABLED.exec(rest); if (disabled) { return { analyze: false, query: rest.slice(disabled[0].length).trim() }; } const trailing = /^([A-Za-z_]+)\b/.exec(rest); if (trailing && /^verbose$/i.test(trailing[1])) { throw new Error( "EXPLAIN option 'VERBOSE' is not supported on SQL Server \u2014 only ANALYZE is." ); } return { analyze: true, query: rest }; } /** * Run a statement under SET STATISTICS XML and return its *actual* execution * plan — the Postgres `EXPLAIN ANALYZE` contract. * * SHOWPLAN_XML (plain EXPLAIN) compiles without executing, so its plan carries * only estimates. STATISTICS XML runs the statement, so the plan reports real * row counts and execution counts. The flip side is that this path is *not* * inherently read-only the way explainQuery is, so under `options.readonly` the * statement runs inside a transaction that always rolls back. * * The dedicated single-connection pool serves the same purpose as in * explainQuery: the STATISTICS XML session toggle must never leak onto a * shared pool connection, where a concurrent query would inherit it. */ async explainAnalyzeQuery(innerQuery, options, parameters) { const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim(); if (!cleaned) { throw new Error("EXPLAIN ANALYZE requires a statement to analyze"); } if (/\bset\s+statistics\b/i.test(cleaned)) { throw new Error("EXPLAIN ANALYZE does not support SET STATISTICS statements"); } if (/\bset\s+showplan\b/i.test(cleaned)) { throw new Error("EXPLAIN ANALYZE does not support SET SHOWPLAN statements"); } if (options.readonly) { this.assertNoReadOnlyEscapes(innerQuery, { transactionControl: true }); } if (!this.config) { throw new Error("Not connected to SQL Server database"); } const explainPool = new sql.ConnectionPool({ ...this.config, pool: { ...this.config.pool, max: 1, min: 1 } }); try { await explainPool.connect(); await explainPool.request().batch("SET STATISTICS XML ON"); let planResult; if (options.readonly) { planResult = await _SQLServerConnector.batchRolledBack( explainPool, innerQuery, parameters ); } else { const planRequest = explainPool.request(); _SQLServerConnector.bindParameters(planRequest, parameters); planResult = await planRequest.batch(innerQuery); } const planXml = _SQLServerConnector.extractPlanXml(planResult); return { resultSets: [ { rows: planXml != null ? [{ plan: planXml }] : [], rowCount: planXml != null ? 1 : 0 } ] }; } catch (error) { throw new Error(`Failed to explain analyze query: ${error.message}`); } finally { await explainPool.close(); } } /** * Run a batch inside a transaction that is always rolled back, so EXPLAIN * ANALYZE can report a real plan without letting the statement's writes stick. */ static async batchRolledBack(pool, innerQuery, parameters) { const transaction = new sql.Transaction(pool); await transaction.begin(); let queryFailed = false; try { const request = new sql.Request(transaction); _SQLServerConnector.bindParameters(request, parameters); return await request.batch(innerQuery); } catch (error) { queryFailed = true; throw error; } finally { try { await transaction.rollback(); } catch (rollbackError) { if (!queryFailed) { throw new Error( `Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}` ); } } } } /** * Pull the ShowPlanXML document out of a STATISTICS XML result. * * STATISTICS XML interleaves each statement's plan with that statement's own * result sets, so the plan sits at no fixed index — and `recordset` (singular) * would hand back the statement's data instead. Scan from the end for the * first single-column row holding a plan document. */ static extractPlanXml(result) { const recordsets = result.recordsets ?? []; for (let i = recordsets.length - 1; i >= 0; i--) { const firstRow = recordsets[i]?.[0]; if (!firstRow) continue; const values = Object.values(firstRow); if (values.length !== 1) continue; const value = values[0]; if (typeof value === "string" && value.includes("<ShowPlanXML")) { return value; } } return null; } }; /** Boolean spellings PostgreSQL accepts for an EXPLAIN option. */ _SQLServerConnector.EXPLAIN_ON = /^(?:true|on|1)$/i; _SQLServerConnector.EXPLAIN_OFF = /^(?:false|off|0)$/i; /** * One option inside `EXPLAIN (...)`: a name, then optionally a value written * either space-separated as PostgreSQL spells it (`ANALYZE false`) or with an * equals sign, which the read-only classifier also accepts (`ANALYZE = 0`). */ _SQLServerConnector.EXPLAIN_OPTION = /^([A-Za-z_]+)(?:(?:\s*=\s*|\s+)(\S+))?$/; /** A disabling boolean directly after a bare `EXPLAIN ANALYZE`. */ _SQLServerConnector.EXPLAIN_BARE_DISABLED = /^(?:=\s*)?(?:false|off|0)\b/i; var SQLServerConnector = _SQLServerConnector; var sqlServerConnector = new SQLServerConnector(); ConnectorRegistry.register(sqlServerConnector); export { SQLServerConnector, SQLServerDSNParser };