@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
JavaScript
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 =
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 =
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 =
AND TABLE_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 =
AND s.name =
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 =
AND c.TABLE_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 =
AND s.name =
`;
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 <> @
`),
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 =
${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 =
AND ROUTINE_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 =
AND SPECIFIC_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 =
AND s.name =
`;
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
};