UNPKG

@bytebase/dbhub

Version:

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

973 lines (968 loc) • 36.2 kB
import { isDriverNotInstalled } from "./chunk-WVVMH6FJ.js"; import { computeHitRatioPct, toNullableNumber } from "./chunk-FU2ZJE4E.js"; import { SQLRowLimiter, closeQuietly } from "./chunk-X5OCFRNL.js"; import { sqlServerDynamicSqlKeywords, sqlServerDynamicSqlPattern, sqlServerPassThroughKeywords, sqlServerPassThroughPattern } from "./chunk-55X2VY5D.js"; import { ConnectorRegistry, LEADING_SQL_NOISE, SafeURL, obfuscateDSNPassword, splitSQLStatements, stripCommentsAndStrings } from "./chunk-IXC4NQGC.js"; // src/connectors/sqlserver/index.ts import sql3 from "mssql"; // src/connectors/sqlserver/request-helpers.ts import sql from "mssql"; function 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)); } }); } function 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` ); } } // src/connectors/sqlserver/explain.ts import sql2 from "mssql"; var EXPLAIN_ON = /^(?:true|on|1)$/i; var EXPLAIN_OFF = /^(?:false|off|0)$/i; var EXPLAIN_OPTION = /^([A-Za-z_]+)(?:(?:\s*=\s*|\s+)(\S+))?$/; var EXPLAIN_BARE_DISABLED = /^(?:=\s*)?(?:false|off|0)\b/i; async function explainQuery(config, innerQuery, readonly, parameters) { const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim(); if (!cleaned) { throw new Error("EXPLAIN requires a statement to analyze"); } if (readonly) { assertNoReadOnlyEscapes(innerQuery); } if (/\bset\s+showplan/i.test(cleaned)) { throw new Error("EXPLAIN does not support SET SHOWPLAN statements"); } const explainPool = new sql2.ConnectionPool({ ...config, pool: { ...config.pool, max: 1, min: 1 } }); try { await explainPool.connect(); await explainPool.request().batch("SET SHOWPLAN_XML ON"); const planRequest = explainPool.request(); 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(); } } function 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 = 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 || EXPLAIN_ON.test(value)) { analyze = true; } else if (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 = 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 }; } async function explainAnalyzeQuery(config, 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) { assertNoReadOnlyEscapes(innerQuery, { transactionControl: true }); } const explainPool = new sql2.ConnectionPool({ ...config, pool: { ...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 batchRolledBack( explainPool, innerQuery, parameters ); } else { const planRequest = explainPool.request(); bindParameters(planRequest, parameters); planResult = await planRequest.batch(innerQuery); } const planXml = 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(); } } async function batchRolledBack(pool, innerQuery, parameters) { const transaction = new sql2.Transaction(pool); await transaction.begin(); let queryFailed = false; try { const request = new sql2.Request(transaction); 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}` ); } } } } function 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; } // src/connectors/sqlserver/index.ts 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 queryStart = dsn.indexOf("?"); const sslmodes = new URLSearchParams(queryStart === -1 ? "" : dsn.substring(queryStart + 1)).getAll("sslmode"); if (sslmodes.length > 1 || sslmodes.length === 1 && !["disable", "require", "verify-full"].includes(sslmodes[0])) { throw new Error("Invalid sslmode. Specify exactly one value: disable, require, verify-full"); } const url = new SafeURL(dsn); const options = { sslmode: sslmodes[0] }; url.forEachSearchParam((value, key) => { if (key === "authentication") { options.authentication = 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; } else if (options.sslmode === "verify-full") { options.encrypt = true; options.trustServerCertificate = false; } } 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 sql3.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", sql3.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", sql3.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", sql3.VarChar, tableName).input("schema", sql3.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", sql3.VarChar, tableName).input("schema", sql3.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", sql3.VarChar, tableName).input("schema", sql3.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", sql3.VarChar, tableName).input("schema", sql3.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", sql3.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", sql3.VarChar, procedureName).input("schema", sql3.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 || !this.config) { 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 } = parseExplainPrefix( afterNoise.slice("explain".length).trim() ); return analyze ? explainAnalyzeQuery(this.config, query, options, parameters) : explainQuery(this.config, query, options.readonly, parameters); } try { const statements = splitSQLStatements(sqlQuery, "sqlserver"); const isSingleStatement = statements.length === 1; let processedSQL = sqlQuery; if (options.maxRows) { const maxRows = options.maxRows; if (isSingleStatement) { processedSQL = SQLRowLimiter.applyMaxRowsForSQLServerWithTruncationProbe(sqlQuery, maxRows).sql; } else { const terminator = sqlQuery.trimEnd().endsWith(";") ? "\n;" : ""; const moduleStart = statements.findIndex( (statement) => _SQLServerConnector.MODULE_DEFINITION.test(stripCommentsAndStrings(statement, "sqlserver")) ); const rewritable = moduleStart === -1 ? statements.length : moduleStart; processedSQL = statements.map( (statement, i) => i < rewritable ? SQLRowLimiter.applyMaxRowsForSQLServerWithTruncationProbe(statement, maxRows).sql : statement ).join("\n;\n") + terminator; } } if (options.readonly) { return await this.executeReadOnly( processedSQL, parameters, isSingleStatement ? sqlQuery : void 0, options.maxRows ); } 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 }); } ); bindParameters(request, parameters); const result = await request.query(processedSQL); const resultSets = _SQLServerConnector.buildResultSets( result.recordsets, result.rowsAffected, isSingleStatement ? sqlQuery : void 0, options.maxRows ); return { resultSets, ...messages.length > 0 ? { messages } : {} }; } catch (error) { throw new Error(`Failed to execute query: ${error.message}`); } } /** * Builds one result set per recordset. With `maxRows`, every set is capped: * a statement the TOP/FETCH probe rewrite reached returns at most * maxRows + 1 rows, and more than maxRows rows means the cap fired, so the * probe row is dropped and the set flagged truncated. The same check also * bounds result sets the rewrite could not reach (a stored procedure's * output, say): those are trimmed to maxRows and flagged the same way. A * statement whose own TOP/FETCH is within the cap never exceeds maxRows * rows, so it is never flagged. Recordsets do not map 1:1 onto statements * (a SELECT ... INTO returns none, an EXEC may return several), which is * why the check is per result set rather than per rewritten statement. */ static buildResultSets(recordsets, rowsAffected, sourceSql, maxRows) { 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; for (const set of sets) { SQLRowLimiter.flagTruncation(set, maxRows, true); } 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; } /** * 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) { assertNoReadOnlyEscapes(processedSQL, { transactionControl: true }); const transaction = new sql3.Transaction(this.connection); await transaction.begin(); const request = new sql3.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 }); } ); 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, maxRows ); return { resultSets, ...messages.length > 0 ? { messages } : {} }; } }; /** * 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. */ /** A segment that opens a module whose body extends to the end of the batch. */ _SQLServerConnector.MODULE_DEFINITION = /^\s*(?:create|alter)\s+(?:or\s+alter\s+)?(?:proc|procedure|function|trigger)\b/i; var SQLServerConnector = _SQLServerConnector; var sqlServerConnector = new SQLServerConnector(); ConnectorRegistry.register(sqlServerConnector); export { SQLServerConnector, SQLServerDSNParser };