@zz1996/dbhub-dameng 0.1.7 → 1.2.5-dameng.621
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -137
- package/dbhub.dameng.toml.example +4 -11
- package/dist/{chunk-SQA2ISDE.js → chunk-2JL4KCP5.js} +21 -119
- package/dist/chunk-3RJRH3CN.js +121 -0
- package/dist/chunk-4276LPPU.js +179 -0
- package/dist/chunk-FU2ZJE4E.js +16 -0
- package/dist/chunk-HDPZAIDL.js +155 -0
- package/dist/{chunk-2A2QF3CS.js → chunk-HGU7ILEZ.js} +215 -68
- package/dist/{chunk-IPK7BYBL.js → chunk-JFWX35TB.js} +0 -1
- package/dist/chunk-O3PEWKBB.js +302 -0
- package/dist/dameng-NKF7NZKE.js +250 -0
- package/dist/index.js +461 -251
- package/dist/{mariadb-7F72IRB4.js → mariadb-MMC6WMEG.js} +86 -53
- package/dist/{mysql-A43SL7UM.js → mysql-TESH7KKV.js} +126 -50
- package/dist/{postgres-VFNLBWMF.js → postgres-C24SBQ4I.js} +154 -36
- package/dist/public/assets/index-BJ-1UrcV.css +1 -0
- package/dist/public/assets/index-BdBaFSHd.js +148 -0
- package/dist/public/assets/postgres-BpcazhJg.svg +22 -0
- package/dist/public/assets/sqlserver-ByfFYYpV.svg +11 -0
- package/dist/public/favicon.svg +57 -0
- package/dist/public/index.html +14 -0
- package/dist/public/logo-full-light.svg +58 -0
- package/dist/{registry-XSX2VZTD.js → registry-WVSFVS35.js} +3 -2
- package/dist/{sqlite-IOUAYHGE.js → sqlite-7BNGDMBZ.js} +57 -42
- package/dist/sqlserver-KZPPK67Q.js +1042 -0
- package/package.json +22 -65
- package/dist/chunk-RTB262PR.js +0 -60
- package/dist/chunk-ZNQTMARG.js +0 -127
- package/dist/dameng-5KYI5JBK.js +0 -898
- package/dist/sqlserver-OE4SFH4B.js +0 -587
- package/docs/README.md +0 -14
- package/docs/dameng-connector-plan.md +0 -143
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isDriverNotInstalled
|
|
3
|
+
} from "./chunk-WVVMH6FJ.js";
|
|
4
|
+
import {
|
|
5
|
+
computeHitRatioPct,
|
|
6
|
+
toNullableNumber
|
|
7
|
+
} from "./chunk-FU2ZJE4E.js";
|
|
8
|
+
import {
|
|
9
|
+
obfuscateDSNPassword
|
|
10
|
+
} from "./chunk-3RJRH3CN.js";
|
|
11
|
+
import {
|
|
12
|
+
SQLRowLimiter,
|
|
13
|
+
closeQuietly
|
|
14
|
+
} from "./chunk-O3PEWKBB.js";
|
|
15
|
+
import {
|
|
16
|
+
sqlServerDynamicSqlKeywords,
|
|
17
|
+
sqlServerDynamicSqlPattern,
|
|
18
|
+
sqlServerPassThroughKeywords,
|
|
19
|
+
sqlServerPassThroughPattern
|
|
20
|
+
} from "./chunk-HDPZAIDL.js";
|
|
21
|
+
import {
|
|
22
|
+
ConnectorRegistry,
|
|
23
|
+
SafeURL,
|
|
24
|
+
splitSQLStatements,
|
|
25
|
+
stripCommentsAndStrings
|
|
26
|
+
} from "./chunk-2JL4KCP5.js";
|
|
27
|
+
|
|
28
|
+
// src/connectors/sqlserver/index.ts
|
|
29
|
+
import sql from "mssql";
|
|
30
|
+
var SQLServerDSNParser = class {
|
|
31
|
+
async parse(dsn, config) {
|
|
32
|
+
const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
|
|
33
|
+
const queryTimeoutSeconds = config?.queryTimeoutSeconds;
|
|
34
|
+
if (!this.isValidDSN(dsn)) {
|
|
35
|
+
const obfuscatedDSN = obfuscateDSNPassword(dsn);
|
|
36
|
+
const expectedFormat = this.getSampleDSN();
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid SQL Server DSN format.
|
|
39
|
+
Provided: ${obfuscatedDSN}
|
|
40
|
+
Expected: ${expectedFormat}`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const url = new SafeURL(dsn);
|
|
45
|
+
const options = {};
|
|
46
|
+
url.forEachSearchParam((value, key) => {
|
|
47
|
+
if (key === "authentication") {
|
|
48
|
+
options.authentication = value;
|
|
49
|
+
} else if (key === "sslmode") {
|
|
50
|
+
options.sslmode = value;
|
|
51
|
+
} else if (key === "instanceName") {
|
|
52
|
+
options.instanceName = value;
|
|
53
|
+
} else if (key === "domain") {
|
|
54
|
+
options.domain = value;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (options.authentication === "ntlm" && !options.domain) {
|
|
58
|
+
throw new Error("NTLM authentication requires 'domain' parameter");
|
|
59
|
+
}
|
|
60
|
+
if (options.domain && options.authentication !== "ntlm") {
|
|
61
|
+
throw new Error("Parameter 'domain' requires 'authentication=ntlm'");
|
|
62
|
+
}
|
|
63
|
+
if (options.sslmode) {
|
|
64
|
+
if (options.sslmode === "disable") {
|
|
65
|
+
options.encrypt = false;
|
|
66
|
+
options.trustServerCertificate = false;
|
|
67
|
+
} else if (options.sslmode === "require") {
|
|
68
|
+
options.encrypt = true;
|
|
69
|
+
options.trustServerCertificate = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const config2 = {
|
|
73
|
+
server: url.hostname,
|
|
74
|
+
port: url.port ? parseInt(url.port) : 1433,
|
|
75
|
+
// Default SQL Server port
|
|
76
|
+
database: url.pathname ? url.pathname.substring(1) : "",
|
|
77
|
+
// Remove leading slash
|
|
78
|
+
options: {
|
|
79
|
+
encrypt: options.encrypt ?? false,
|
|
80
|
+
// Default to unencrypted for development
|
|
81
|
+
trustServerCertificate: options.trustServerCertificate ?? false,
|
|
82
|
+
...connectionTimeoutSeconds !== void 0 && {
|
|
83
|
+
connectTimeout: connectionTimeoutSeconds * 1e3
|
|
84
|
+
},
|
|
85
|
+
...queryTimeoutSeconds !== void 0 && {
|
|
86
|
+
requestTimeout: queryTimeoutSeconds * 1e3
|
|
87
|
+
},
|
|
88
|
+
instanceName: options.instanceName
|
|
89
|
+
// Add named instance support
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
switch (options.authentication) {
|
|
93
|
+
case "azure-active-directory-access-token": {
|
|
94
|
+
let DefaultAzureCredential;
|
|
95
|
+
try {
|
|
96
|
+
({ DefaultAzureCredential } = await import("@azure/identity"));
|
|
97
|
+
} catch (importError) {
|
|
98
|
+
if (isDriverNotInstalled(importError, "@azure/identity")) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'Azure AD authentication requires the "@azure/identity" package. Install it with: pnpm add @azure/identity'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
throw importError;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const credential = new DefaultAzureCredential();
|
|
107
|
+
const token = await credential.getToken("https://database.windows.net/");
|
|
108
|
+
config2.authentication = {
|
|
109
|
+
type: "azure-active-directory-access-token",
|
|
110
|
+
options: {
|
|
111
|
+
token: token.token
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
116
|
+
throw new Error(`Failed to get Azure AD token: ${errorMessage}`);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "ntlm":
|
|
121
|
+
config2.authentication = {
|
|
122
|
+
type: "ntlm",
|
|
123
|
+
options: {
|
|
124
|
+
domain: options.domain,
|
|
125
|
+
userName: url.username,
|
|
126
|
+
password: url.password
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
config2.user = url.username;
|
|
132
|
+
config2.password = url.password;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
return config2;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Failed to parse SQL Server DSN: ${error instanceof Error ? error.message : String(error)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
getSampleDSN() {
|
|
143
|
+
return "sqlserver://username:password@localhost:1433/database?sslmode=disable&instanceName=INSTANCE1";
|
|
144
|
+
}
|
|
145
|
+
isValidDSN(dsn) {
|
|
146
|
+
try {
|
|
147
|
+
return dsn.startsWith("sqlserver://");
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
var _SQLServerConnector = class _SQLServerConnector {
|
|
154
|
+
constructor() {
|
|
155
|
+
this.id = "sqlserver";
|
|
156
|
+
this.name = "SQL Server";
|
|
157
|
+
this.dsnParser = new SQLServerDSNParser();
|
|
158
|
+
// Source ID is set by ConnectorManager after cloning
|
|
159
|
+
this.sourceId = "default";
|
|
160
|
+
}
|
|
161
|
+
getId() {
|
|
162
|
+
return this.sourceId;
|
|
163
|
+
}
|
|
164
|
+
clone() {
|
|
165
|
+
return new _SQLServerConnector();
|
|
166
|
+
}
|
|
167
|
+
async connect(dsn, initScript, config) {
|
|
168
|
+
try {
|
|
169
|
+
this.config = await this.dsnParser.parse(dsn, config);
|
|
170
|
+
if (!this.config.options) {
|
|
171
|
+
this.config.options = {};
|
|
172
|
+
}
|
|
173
|
+
this.connection = new sql.ConnectionPool(this.config);
|
|
174
|
+
await this.connection.connect();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (this.connection) {
|
|
177
|
+
const connection = this.connection;
|
|
178
|
+
this.connection = void 0;
|
|
179
|
+
await closeQuietly(() => connection.close());
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async disconnect() {
|
|
185
|
+
if (this.connection) {
|
|
186
|
+
await this.connection.close();
|
|
187
|
+
this.connection = void 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async getSchemas() {
|
|
191
|
+
if (!this.connection) {
|
|
192
|
+
throw new Error("Not connected to SQL Server database");
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const result = await this.connection.request().query(`
|
|
196
|
+
SELECT SCHEMA_NAME
|
|
197
|
+
FROM INFORMATION_SCHEMA.SCHEMATA
|
|
198
|
+
ORDER BY SCHEMA_NAME
|
|
199
|
+
`);
|
|
200
|
+
return result.recordset.map((row) => row.SCHEMA_NAME);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new Error(`Failed to get schemas: ${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async getTables(schema) {
|
|
206
|
+
if (!this.connection) {
|
|
207
|
+
throw new Error("Not connected to SQL Server database");
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const schemaToUse = schema || "dbo";
|
|
211
|
+
const request = this.connection.request().input("schema", sql.VarChar, schemaToUse);
|
|
212
|
+
const query = `
|
|
213
|
+
SELECT TABLE_NAME
|
|
214
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
215
|
+
WHERE TABLE_SCHEMA = @schema
|
|
216
|
+
AND TABLE_TYPE = 'BASE TABLE'
|
|
217
|
+
ORDER BY TABLE_NAME
|
|
218
|
+
`;
|
|
219
|
+
const result = await request.query(query);
|
|
220
|
+
return result.recordset.map((row) => row.TABLE_NAME);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw new Error(`Failed to get tables: ${error.message}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async getViews(schema) {
|
|
226
|
+
if (!this.connection) {
|
|
227
|
+
throw new Error("Not connected to SQL Server database");
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const schemaToUse = schema || "dbo";
|
|
231
|
+
const request = this.connection.request().input("schema", sql.VarChar, schemaToUse);
|
|
232
|
+
const query = `
|
|
233
|
+
SELECT TABLE_NAME
|
|
234
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
235
|
+
WHERE TABLE_SCHEMA = @schema
|
|
236
|
+
AND TABLE_TYPE = 'VIEW'
|
|
237
|
+
ORDER BY TABLE_NAME
|
|
238
|
+
`;
|
|
239
|
+
const result = await request.query(query);
|
|
240
|
+
return result.recordset.map((row) => row.TABLE_NAME);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw new Error(`Failed to get views: ${error.message}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async tableExists(tableName, schema) {
|
|
246
|
+
if (!this.connection) {
|
|
247
|
+
throw new Error("Not connected to SQL Server database");
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const schemaToUse = schema || "dbo";
|
|
251
|
+
const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
|
|
252
|
+
const query = `
|
|
253
|
+
SELECT COUNT(*) as count
|
|
254
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
255
|
+
WHERE TABLE_NAME = @tableName
|
|
256
|
+
AND TABLE_SCHEMA = @schema
|
|
257
|
+
`;
|
|
258
|
+
const result = await request.query(query);
|
|
259
|
+
return result.recordset[0].count > 0;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw new Error(`Failed to check if table exists: ${error.message}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async getTableIndexes(tableName, schema) {
|
|
265
|
+
if (!this.connection) {
|
|
266
|
+
throw new Error("Not connected to SQL Server database");
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const schemaToUse = schema || "dbo";
|
|
270
|
+
const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
|
|
271
|
+
const query = `
|
|
272
|
+
SELECT i.name AS index_name,
|
|
273
|
+
i.is_unique,
|
|
274
|
+
i.is_primary_key,
|
|
275
|
+
c.name AS column_name,
|
|
276
|
+
ic.key_ordinal
|
|
277
|
+
FROM sys.indexes i
|
|
278
|
+
INNER JOIN
|
|
279
|
+
sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
|
280
|
+
INNER JOIN
|
|
281
|
+
sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
|
282
|
+
INNER JOIN
|
|
283
|
+
sys.tables t ON i.object_id = t.object_id
|
|
284
|
+
INNER JOIN
|
|
285
|
+
sys.schemas s ON t.schema_id = s.schema_id
|
|
286
|
+
WHERE t.name = @tableName
|
|
287
|
+
AND s.name = @schema
|
|
288
|
+
ORDER BY i.name,
|
|
289
|
+
ic.key_ordinal
|
|
290
|
+
`;
|
|
291
|
+
const result = await request.query(query);
|
|
292
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
293
|
+
for (const row of result.recordset) {
|
|
294
|
+
const indexName = row.index_name;
|
|
295
|
+
const columnName = row.column_name;
|
|
296
|
+
const isUnique = !!row.is_unique;
|
|
297
|
+
const isPrimary = !!row.is_primary_key;
|
|
298
|
+
if (!indexMap.has(indexName)) {
|
|
299
|
+
indexMap.set(indexName, {
|
|
300
|
+
columns: [],
|
|
301
|
+
is_unique: isUnique,
|
|
302
|
+
is_primary: isPrimary
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const indexInfo = indexMap.get(indexName);
|
|
306
|
+
indexInfo.columns.push(columnName);
|
|
307
|
+
}
|
|
308
|
+
const indexes = [];
|
|
309
|
+
indexMap.forEach((info, name) => {
|
|
310
|
+
indexes.push({
|
|
311
|
+
index_name: name,
|
|
312
|
+
column_names: info.columns,
|
|
313
|
+
is_unique: info.is_unique,
|
|
314
|
+
is_primary: info.is_primary
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
return indexes;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw new Error(`Failed to get indexes for table ${tableName}: ${error.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async getTableSchema(tableName, schema) {
|
|
323
|
+
if (!this.connection) {
|
|
324
|
+
throw new Error("Not connected to SQL Server database");
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const schemaToUse = schema || "dbo";
|
|
328
|
+
const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
|
|
329
|
+
const query = `
|
|
330
|
+
SELECT c.COLUMN_NAME as column_name,
|
|
331
|
+
c.DATA_TYPE as data_type,
|
|
332
|
+
c.IS_NULLABLE as is_nullable,
|
|
333
|
+
c.COLUMN_DEFAULT as column_default,
|
|
334
|
+
ep.value as description
|
|
335
|
+
FROM INFORMATION_SCHEMA.COLUMNS c
|
|
336
|
+
LEFT JOIN sys.columns sc
|
|
337
|
+
ON sc.name = c.COLUMN_NAME
|
|
338
|
+
AND sc.object_id = OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME))
|
|
339
|
+
LEFT JOIN sys.extended_properties ep
|
|
340
|
+
ON ep.major_id = sc.object_id
|
|
341
|
+
AND ep.minor_id = sc.column_id
|
|
342
|
+
AND ep.name = 'MS_Description'
|
|
343
|
+
WHERE c.TABLE_NAME = @tableName
|
|
344
|
+
AND c.TABLE_SCHEMA = @schema
|
|
345
|
+
ORDER BY c.ORDINAL_POSITION
|
|
346
|
+
`;
|
|
347
|
+
const result = await request.query(query);
|
|
348
|
+
return result.recordset.map((row) => ({
|
|
349
|
+
...row,
|
|
350
|
+
description: row.description || null
|
|
351
|
+
}));
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw new Error(`Failed to get schema for table ${tableName}: ${error.message}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async getTableComment(tableName, schema) {
|
|
357
|
+
if (!this.connection) {
|
|
358
|
+
throw new Error("Not connected to SQL Server database");
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const schemaToUse = schema || "dbo";
|
|
362
|
+
const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
|
|
363
|
+
const query = `
|
|
364
|
+
SELECT ep.value as table_comment
|
|
365
|
+
FROM sys.extended_properties ep
|
|
366
|
+
JOIN sys.tables t ON ep.major_id = t.object_id
|
|
367
|
+
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
368
|
+
WHERE ep.minor_id = 0
|
|
369
|
+
AND ep.name = 'MS_Description'
|
|
370
|
+
AND t.name = @tableName
|
|
371
|
+
AND s.name = @schema
|
|
372
|
+
`;
|
|
373
|
+
const result = await request.query(query);
|
|
374
|
+
if (result.recordset.length > 0) {
|
|
375
|
+
return result.recordset[0].table_comment || null;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async getHealthCheck() {
|
|
383
|
+
if (!this.connection) {
|
|
384
|
+
throw new Error("Not connected to SQL Server database");
|
|
385
|
+
}
|
|
386
|
+
const notes = [];
|
|
387
|
+
const result = {};
|
|
388
|
+
try {
|
|
389
|
+
const [connResult, maxConnResult] = await Promise.all([
|
|
390
|
+
this.connection.request().query(`
|
|
391
|
+
SELECT
|
|
392
|
+
COUNT(*) AS total,
|
|
393
|
+
SUM(CASE WHEN r.session_id IS NOT NULL THEN 1 ELSE 0 END) AS active,
|
|
394
|
+
SUM(CASE WHEN r.session_id IS NULL THEN 1 ELSE 0 END) AS idle,
|
|
395
|
+
SUM(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction,
|
|
396
|
+
MAX(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL
|
|
397
|
+
THEN DATEDIFF(SECOND, s.last_request_end_time, SYSDATETIME()) ELSE NULL END) AS longest_idle_in_transaction_seconds,
|
|
398
|
+
MAX(CASE WHEN r.session_id IS NOT NULL
|
|
399
|
+
THEN DATEDIFF(SECOND, r.start_time, SYSDATETIME()) ELSE NULL END) AS longest_active_query_seconds
|
|
400
|
+
FROM sys.dm_exec_sessions s
|
|
401
|
+
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
|
|
402
|
+
LEFT JOIN (SELECT DISTINCT session_id FROM sys.dm_tran_session_transactions) t ON t.session_id = s.session_id
|
|
403
|
+
WHERE s.is_user_process = 1 AND s.session_id <> @@SPID
|
|
404
|
+
`),
|
|
405
|
+
this.connection.request().query(`
|
|
406
|
+
SELECT CAST(value_in_use AS INT) AS max_connections
|
|
407
|
+
FROM sys.configurations
|
|
408
|
+
WHERE name = 'user connections'
|
|
409
|
+
`)
|
|
410
|
+
]);
|
|
411
|
+
const conn = connResult.recordset[0];
|
|
412
|
+
const maxConnections = maxConnResult.recordset.length > 0 && maxConnResult.recordset[0].max_connections > 0 ? maxConnResult.recordset[0].max_connections : null;
|
|
413
|
+
result.connections = {
|
|
414
|
+
total: conn.total ?? 0,
|
|
415
|
+
active: conn.active ?? 0,
|
|
416
|
+
idle: conn.idle ?? 0,
|
|
417
|
+
idleInTransaction: conn.idle_in_transaction ?? 0,
|
|
418
|
+
// SQL Server has no equivalent of Postgres's "idle in transaction
|
|
419
|
+
// (aborted)" state - a failed statement doesn't leave the session's
|
|
420
|
+
// transaction in a distinct aborted-but-open state the way Postgres does.
|
|
421
|
+
maxConnections,
|
|
422
|
+
longestIdleInTransactionSeconds: toNullableNumber(conn.longest_idle_in_transaction_seconds),
|
|
423
|
+
longestActiveQuerySeconds: toNullableNumber(conn.longest_active_query_seconds)
|
|
424
|
+
};
|
|
425
|
+
} catch {
|
|
426
|
+
notes.push(
|
|
427
|
+
"Connection pool metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)."
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const bufferResult = await this.connection.request().query(`
|
|
432
|
+
SELECT counter_name, CAST(cntr_value AS BIGINT) AS cntr_value
|
|
433
|
+
FROM sys.dm_os_performance_counters
|
|
434
|
+
WHERE object_name LIKE '%Buffer Manager%'
|
|
435
|
+
AND counter_name IN ('Page lookups/sec', 'Page reads/sec')
|
|
436
|
+
`);
|
|
437
|
+
const counters = Object.fromEntries(
|
|
438
|
+
bufferResult.recordset.map((row) => [row.counter_name.trim(), Number(row.cntr_value)])
|
|
439
|
+
);
|
|
440
|
+
const pageLookups = counters["Page lookups/sec"] ?? 0;
|
|
441
|
+
const pageReads = counters["Page reads/sec"] ?? 0;
|
|
442
|
+
result.bufferCache = {
|
|
443
|
+
hitRatioPct: computeHitRatioPct(pageLookups, pageReads),
|
|
444
|
+
blocksHit: pageLookups - pageReads,
|
|
445
|
+
blocksRead: pageReads
|
|
446
|
+
};
|
|
447
|
+
} catch {
|
|
448
|
+
notes.push(
|
|
449
|
+
"Buffer cache metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)."
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (notes.length > 0) {
|
|
453
|
+
result.notes = notes;
|
|
454
|
+
}
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
async getStoredProcedures(schema, routineType) {
|
|
458
|
+
if (!this.connection) {
|
|
459
|
+
throw new Error("Not connected to SQL Server database");
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const schemaToUse = schema || "dbo";
|
|
463
|
+
const request = this.connection.request().input("schema", sql.VarChar, schemaToUse);
|
|
464
|
+
let typeFilter;
|
|
465
|
+
if (routineType === "function") {
|
|
466
|
+
typeFilter = "AND ROUTINE_TYPE = 'FUNCTION'";
|
|
467
|
+
} else if (routineType === "procedure") {
|
|
468
|
+
typeFilter = "AND ROUTINE_TYPE = 'PROCEDURE'";
|
|
469
|
+
} else {
|
|
470
|
+
typeFilter = "AND (ROUTINE_TYPE = 'PROCEDURE' OR ROUTINE_TYPE = 'FUNCTION')";
|
|
471
|
+
}
|
|
472
|
+
const query = `
|
|
473
|
+
SELECT ROUTINE_NAME
|
|
474
|
+
FROM INFORMATION_SCHEMA.ROUTINES
|
|
475
|
+
WHERE ROUTINE_SCHEMA = @schema
|
|
476
|
+
${typeFilter}
|
|
477
|
+
ORDER BY ROUTINE_NAME
|
|
478
|
+
`;
|
|
479
|
+
const result = await request.query(query);
|
|
480
|
+
return result.recordset.map((row) => row.ROUTINE_NAME);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
throw new Error(`Failed to get stored procedures: ${error.message}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async getStoredProcedureDetail(procedureName, schema) {
|
|
486
|
+
if (!this.connection) {
|
|
487
|
+
throw new Error("Not connected to SQL Server database");
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const schemaToUse = schema || "dbo";
|
|
491
|
+
const request = this.connection.request().input("procedureName", sql.VarChar, procedureName).input("schema", sql.VarChar, schemaToUse);
|
|
492
|
+
const routineQuery = `
|
|
493
|
+
SELECT ROUTINE_NAME as procedure_name,
|
|
494
|
+
ROUTINE_TYPE,
|
|
495
|
+
DATA_TYPE as return_data_type
|
|
496
|
+
FROM INFORMATION_SCHEMA.ROUTINES
|
|
497
|
+
WHERE ROUTINE_NAME = @procedureName
|
|
498
|
+
AND ROUTINE_SCHEMA = @schema
|
|
499
|
+
`;
|
|
500
|
+
const routineResult = await request.query(routineQuery);
|
|
501
|
+
if (routineResult.recordset.length === 0) {
|
|
502
|
+
throw new Error(`Stored procedure '${procedureName}' not found in schema '${schemaToUse}'`);
|
|
503
|
+
}
|
|
504
|
+
const routine = routineResult.recordset[0];
|
|
505
|
+
const parameterQuery = `
|
|
506
|
+
SELECT PARAMETER_NAME,
|
|
507
|
+
PARAMETER_MODE,
|
|
508
|
+
DATA_TYPE,
|
|
509
|
+
CHARACTER_MAXIMUM_LENGTH,
|
|
510
|
+
ORDINAL_POSITION
|
|
511
|
+
FROM INFORMATION_SCHEMA.PARAMETERS
|
|
512
|
+
WHERE SPECIFIC_NAME = @procedureName
|
|
513
|
+
AND SPECIFIC_SCHEMA = @schema
|
|
514
|
+
ORDER BY ORDINAL_POSITION
|
|
515
|
+
`;
|
|
516
|
+
const parameterResult = await request.query(parameterQuery);
|
|
517
|
+
let parameterList = "";
|
|
518
|
+
if (parameterResult.recordset.length > 0) {
|
|
519
|
+
parameterList = parameterResult.recordset.map(
|
|
520
|
+
(param) => {
|
|
521
|
+
const lengthStr = param.CHARACTER_MAXIMUM_LENGTH > 0 ? `(${param.CHARACTER_MAXIMUM_LENGTH})` : "";
|
|
522
|
+
return `${param.PARAMETER_NAME} ${param.PARAMETER_MODE} ${param.DATA_TYPE}${lengthStr}`;
|
|
523
|
+
}
|
|
524
|
+
).join(", ");
|
|
525
|
+
}
|
|
526
|
+
const definitionQuery = `
|
|
527
|
+
SELECT definition
|
|
528
|
+
FROM sys.sql_modules sm
|
|
529
|
+
JOIN sys.objects o ON sm.object_id = o.object_id
|
|
530
|
+
JOIN sys.schemas s ON o.schema_id = s.schema_id
|
|
531
|
+
WHERE o.name = @procedureName
|
|
532
|
+
AND s.name = @schema
|
|
533
|
+
`;
|
|
534
|
+
const definitionResult = await request.query(definitionQuery);
|
|
535
|
+
let definition = void 0;
|
|
536
|
+
if (definitionResult.recordset.length > 0) {
|
|
537
|
+
definition = definitionResult.recordset[0].definition;
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
procedure_name: routine.procedure_name,
|
|
541
|
+
procedure_type: routine.ROUTINE_TYPE === "PROCEDURE" ? "procedure" : "function",
|
|
542
|
+
language: "sql",
|
|
543
|
+
// SQL Server procedures are typically in T-SQL
|
|
544
|
+
parameter_list: parameterList,
|
|
545
|
+
return_type: routine.ROUTINE_TYPE === "FUNCTION" ? routine.return_data_type : void 0,
|
|
546
|
+
definition
|
|
547
|
+
};
|
|
548
|
+
} catch (error) {
|
|
549
|
+
throw new Error(`Failed to get stored procedure details: ${error.message}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async executeSQL(sqlQuery, options, parameters) {
|
|
553
|
+
if (!this.connection) {
|
|
554
|
+
throw new Error("Not connected to SQL Server database");
|
|
555
|
+
}
|
|
556
|
+
const afterNoise = sqlQuery.slice(
|
|
557
|
+
sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
|
|
558
|
+
);
|
|
559
|
+
if (/^explain\b/i.test(afterNoise)) {
|
|
560
|
+
const { analyze, query } = _SQLServerConnector.parseExplainPrefix(
|
|
561
|
+
afterNoise.slice("explain".length).trim()
|
|
562
|
+
);
|
|
563
|
+
return analyze ? this.explainAnalyzeQuery(query, options, parameters) : this.explainQuery(query, options.readonly, parameters);
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
let processedSQL = sqlQuery;
|
|
567
|
+
let probeApplied = false;
|
|
568
|
+
if (options.maxRows) {
|
|
569
|
+
const rewrite = SQLRowLimiter.applyMaxRowsForSQLServerWithTruncationProbe(
|
|
570
|
+
sqlQuery,
|
|
571
|
+
options.maxRows
|
|
572
|
+
);
|
|
573
|
+
processedSQL = rewrite.sql;
|
|
574
|
+
probeApplied = rewrite.probeApplied;
|
|
575
|
+
}
|
|
576
|
+
const isSingleStatement = splitSQLStatements(processedSQL, "sqlserver").length === 1;
|
|
577
|
+
if (options.readonly) {
|
|
578
|
+
return await this.executeReadOnly(
|
|
579
|
+
processedSQL,
|
|
580
|
+
parameters,
|
|
581
|
+
isSingleStatement ? sqlQuery : void 0,
|
|
582
|
+
options.maxRows,
|
|
583
|
+
probeApplied
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const request = this.connection.request();
|
|
587
|
+
const messages = [];
|
|
588
|
+
request.on(
|
|
589
|
+
"info",
|
|
590
|
+
(info) => {
|
|
591
|
+
messages.push({
|
|
592
|
+
text: info.message,
|
|
593
|
+
// SQL Server reports severity as a numeric class; info messages are < 10.
|
|
594
|
+
severity: info.class !== void 0 ? String(info.class) : void 0,
|
|
595
|
+
code: info.number,
|
|
596
|
+
line: info.lineNumber
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
);
|
|
600
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
601
|
+
const result = await request.query(processedSQL);
|
|
602
|
+
const resultSets = _SQLServerConnector.buildResultSets(
|
|
603
|
+
result.recordsets,
|
|
604
|
+
result.rowsAffected,
|
|
605
|
+
isSingleStatement ? sqlQuery : void 0
|
|
606
|
+
);
|
|
607
|
+
if (resultSets.length > 0) {
|
|
608
|
+
SQLRowLimiter.flagTruncation(resultSets[0], options.maxRows, probeApplied);
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
resultSets,
|
|
612
|
+
...messages.length > 0 ? { messages } : {}
|
|
613
|
+
};
|
|
614
|
+
} catch (error) {
|
|
615
|
+
throw new Error(`Failed to execute query: ${error.message}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Builds one result set per SELECT-producing statement in the batch, plus
|
|
620
|
+
* (if any) a trailing result set summarizing the write-only statements.
|
|
621
|
+
*
|
|
622
|
+
* node-mssql only pushes a `recordsets` entry for statements that produce
|
|
623
|
+
* columns (SELECT); INSERT/UPDATE/DELETE statements in the same batch
|
|
624
|
+
* contribute to `rowsAffected` only, with no way to recover which
|
|
625
|
+
* `rowsAffected` entry belongs to which statement from the driver's final
|
|
626
|
+
* arrays (`recordsets`/`rowsAffected` aren't index-aligned - see the
|
|
627
|
+
* tedious `doneHandler`, which always pushes to `rowsAffected` but only
|
|
628
|
+
* conditionally to `recordsets`). So a batch of pure writes collapses to a
|
|
629
|
+
* single summed result set, and a batch mixing writes with selects gets
|
|
630
|
+
* one result set per select plus one trailing "writes" set for the rest -
|
|
631
|
+
* not a truly per-statement breakdown, but no read statement's rows are
|
|
632
|
+
* ever merged with another's, which is what mattered for #380.
|
|
633
|
+
*
|
|
634
|
+
* `sourceSql`, when given, is attributed to the single resulting set - it
|
|
635
|
+
* must be omitted (pass `undefined`) unless the caller has already
|
|
636
|
+
* confirmed the batch is unambiguously one statement, since for a genuine
|
|
637
|
+
* multi-statement batch there's no reliable way to say which source
|
|
638
|
+
* statement a given recordset (or the trailing writes set) came from.
|
|
639
|
+
*/
|
|
640
|
+
static buildResultSets(recordsets, rowsAffected, sourceSql) {
|
|
641
|
+
const sets = (recordsets ?? []).map((recordset) => {
|
|
642
|
+
const rows = recordset ?? [];
|
|
643
|
+
return { rows, rowCount: rows.length };
|
|
644
|
+
});
|
|
645
|
+
const totalAffected = (rowsAffected ?? []).reduce((total, count) => total + (count ?? 0), 0);
|
|
646
|
+
const accountedFor = sets.reduce((total, set) => total + set.rowCount, 0);
|
|
647
|
+
const writesOnly = totalAffected - accountedFor;
|
|
648
|
+
if (sets.length === 0) {
|
|
649
|
+
sets.push({ rows: [], rowCount: totalAffected });
|
|
650
|
+
} else if (writesOnly > 0) {
|
|
651
|
+
sets.push({ rows: [], rowCount: writesOnly });
|
|
652
|
+
}
|
|
653
|
+
if (sets.length === 1 && sourceSql !== void 0) {
|
|
654
|
+
sets[0].sql = sourceSql;
|
|
655
|
+
}
|
|
656
|
+
return sets;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Bind positional parameters as @p1, @p2, ... inferring the SQL Server type
|
|
660
|
+
* from each JavaScript value. Shared by every execution path so they cannot
|
|
661
|
+
* drift in how a given value is typed.
|
|
662
|
+
*
|
|
663
|
+
* Works for `batch` as well as `query`: node-mssql prepends the matching
|
|
664
|
+
* DECLARE/SET statements when the request is a batch.
|
|
665
|
+
*/
|
|
666
|
+
static bindParameters(request, parameters) {
|
|
667
|
+
if (!parameters || parameters.length === 0) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
parameters.forEach((param, index) => {
|
|
671
|
+
const paramName = `p${index + 1}`;
|
|
672
|
+
if (typeof param === "string") {
|
|
673
|
+
request.input(paramName, sql.VarChar, param);
|
|
674
|
+
} else if (typeof param === "number") {
|
|
675
|
+
if (Number.isInteger(param)) {
|
|
676
|
+
request.input(paramName, sql.Int, param);
|
|
677
|
+
} else {
|
|
678
|
+
request.input(paramName, sql.Float, param);
|
|
679
|
+
}
|
|
680
|
+
} else if (typeof param === "boolean") {
|
|
681
|
+
request.input(paramName, sql.Bit, param);
|
|
682
|
+
} else if (param === null || param === void 0) {
|
|
683
|
+
request.input(paramName, sql.VarChar, param);
|
|
684
|
+
} else if (Array.isArray(param)) {
|
|
685
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
686
|
+
} else {
|
|
687
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Reject the constructs that escape SQL Server's read-only guards, for use by
|
|
693
|
+
* both read-only execution paths.
|
|
694
|
+
*
|
|
695
|
+
* - Dynamic SQL (sqlServerDynamicSqlKeywords): can carry hidden COMMIT/ROLLBACK
|
|
696
|
+
* inside string literals that stripCommentsAndStrings removes
|
|
697
|
+
* - Pass-through data sources (sqlServerPassThroughKeywords): execute on a
|
|
698
|
+
* remote or ad-hoc source, so a local rollback never reaches them
|
|
699
|
+
* - COMMIT/ROLLBACK, when `transactionControl` is set: would end the wrapping
|
|
700
|
+
* transaction, letting writes persist. Only meaningful for the transaction
|
|
701
|
+
* path; the EXPLAIN path opens no transaction of its own.
|
|
702
|
+
*
|
|
703
|
+
* Both keyword lists are imported from the read-only classifier rather than
|
|
704
|
+
* redeclared, so the classifier and these backstops cannot drift apart.
|
|
705
|
+
*
|
|
706
|
+
* Note the COMMIT/ROLLBACK check is SQL Server-only by design. MySQL/MariaDB
|
|
707
|
+
* wrap batches in a transaction too, but there `commit`, `prepare` and
|
|
708
|
+
* `execute` are absent from their allow-lists in allowedKeywords, and
|
|
709
|
+
* execute-sql.ts requires every split statement to pass the classifier — so a
|
|
710
|
+
* transaction-control statement can never reach their backstop.
|
|
711
|
+
*/
|
|
712
|
+
assertNoReadOnlyEscapes(sqlText, { transactionControl = false } = {}) {
|
|
713
|
+
const cleaned = stripCommentsAndStrings(sqlText, "sqlserver").toLowerCase();
|
|
714
|
+
if (transactionControl && /\b(?:commit|rollback)\b/.test(cleaned)) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
"Read-only mode: transaction control statements (COMMIT, ROLLBACK) are not allowed"
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (sqlServerDynamicSqlPattern.test(cleaned)) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`Read-only mode: dynamic SQL execution (${sqlServerDynamicSqlKeywords.map((k) => k.toUpperCase()).join(", ")}) is not allowed`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (sqlServerPassThroughPattern.test(cleaned)) {
|
|
725
|
+
throw new Error(
|
|
726
|
+
`Read-only mode: pass-through data sources (${sqlServerPassThroughKeywords.map((k) => k.toUpperCase()).join(", ")}) are not allowed`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Execute a query inside a transaction that always rolls back, preventing
|
|
732
|
+
* any modifications from persisting. SQL Server has no native READ ONLY
|
|
733
|
+
* transaction mode, so this is the defense-in-depth backstop behind the
|
|
734
|
+
* keyword classifier.
|
|
735
|
+
*
|
|
736
|
+
* Dangerous constructs are rejected before the transaction opens; see
|
|
737
|
+
* assertNoReadOnlyEscapes.
|
|
738
|
+
*/
|
|
739
|
+
async executeReadOnly(processedSQL, parameters, sourceSql, maxRows, probeApplied) {
|
|
740
|
+
this.assertNoReadOnlyEscapes(processedSQL, { transactionControl: true });
|
|
741
|
+
const transaction = new sql.Transaction(this.connection);
|
|
742
|
+
await transaction.begin();
|
|
743
|
+
const request = new sql.Request(transaction);
|
|
744
|
+
const messages = [];
|
|
745
|
+
request.on(
|
|
746
|
+
"info",
|
|
747
|
+
(info) => {
|
|
748
|
+
messages.push({
|
|
749
|
+
text: info.message,
|
|
750
|
+
severity: info.class !== void 0 ? String(info.class) : void 0,
|
|
751
|
+
code: info.number,
|
|
752
|
+
line: info.lineNumber
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
);
|
|
756
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
757
|
+
let result;
|
|
758
|
+
let queryFailed = false;
|
|
759
|
+
try {
|
|
760
|
+
result = await request.query(processedSQL);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
queryFailed = true;
|
|
763
|
+
throw error;
|
|
764
|
+
} finally {
|
|
765
|
+
try {
|
|
766
|
+
await transaction.rollback();
|
|
767
|
+
} catch (rollbackError) {
|
|
768
|
+
if (!queryFailed) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
`Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const resultSets = _SQLServerConnector.buildResultSets(
|
|
776
|
+
result.recordsets,
|
|
777
|
+
result.rowsAffected,
|
|
778
|
+
sourceSql
|
|
779
|
+
);
|
|
780
|
+
if (resultSets.length > 0) {
|
|
781
|
+
SQLRowLimiter.flagTruncation(resultSets[0], maxRows, probeApplied);
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
resultSets,
|
|
785
|
+
...messages.length > 0 ? { messages } : {}
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Return the estimated execution plan for a query using SHOWPLAN_XML.
|
|
790
|
+
*
|
|
791
|
+
* SHOWPLAN_XML compiles the statement and returns its plan without executing
|
|
792
|
+
* it, but it has two constraints: `SET SHOWPLAN_XML ON` must be the only
|
|
793
|
+
* statement in its batch, and the setting is session scoped. The shared pool
|
|
794
|
+
* hands out a fresh connection per request() and an open transaction
|
|
795
|
+
* suppresses SHOWPLAN, so neither can carry the setting to a follow-up query.
|
|
796
|
+
*
|
|
797
|
+
* We therefore run the SET / query pair on a short-lived, single-connection
|
|
798
|
+
* pool built from the same config. The dedicated session keeps SHOWPLAN state
|
|
799
|
+
* off the shared pool, so a concurrent query can never land on a connection
|
|
800
|
+
* with SHOWPLAN enabled (which would return a plan instead of its results).
|
|
801
|
+
*/
|
|
802
|
+
async explainQuery(innerQuery, readonly, parameters) {
|
|
803
|
+
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
|
|
804
|
+
if (!cleaned) {
|
|
805
|
+
throw new Error("EXPLAIN requires a statement to analyze");
|
|
806
|
+
}
|
|
807
|
+
if (readonly) {
|
|
808
|
+
this.assertNoReadOnlyEscapes(innerQuery);
|
|
809
|
+
}
|
|
810
|
+
if (/\bset\s+showplan/i.test(cleaned)) {
|
|
811
|
+
throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
|
|
812
|
+
}
|
|
813
|
+
if (!this.config) {
|
|
814
|
+
throw new Error("Not connected to SQL Server database");
|
|
815
|
+
}
|
|
816
|
+
const explainPool = new sql.ConnectionPool({
|
|
817
|
+
...this.config,
|
|
818
|
+
pool: { ...this.config.pool, max: 1, min: 1 }
|
|
819
|
+
});
|
|
820
|
+
try {
|
|
821
|
+
await explainPool.connect();
|
|
822
|
+
await explainPool.request().batch("SET SHOWPLAN_XML ON");
|
|
823
|
+
const planRequest = explainPool.request();
|
|
824
|
+
_SQLServerConnector.bindParameters(planRequest, parameters);
|
|
825
|
+
const planResult = await planRequest.batch(innerQuery);
|
|
826
|
+
const planRow = planResult.recordset?.[0];
|
|
827
|
+
const planXml = planRow ? Object.values(planRow)[0] : null;
|
|
828
|
+
return {
|
|
829
|
+
resultSets: [
|
|
830
|
+
{
|
|
831
|
+
rows: planXml != null ? [{ plan: planXml }] : [],
|
|
832
|
+
rowCount: planXml != null ? 1 : 0
|
|
833
|
+
}
|
|
834
|
+
]
|
|
835
|
+
};
|
|
836
|
+
} catch (error) {
|
|
837
|
+
throw new Error(`Failed to explain query: ${error.message}`);
|
|
838
|
+
} finally {
|
|
839
|
+
await explainPool.close();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Splits the modifiers between EXPLAIN and its statement, covering the same
|
|
844
|
+
* forms the read-only classifier recognises (see utils/allowed-keywords.ts):
|
|
845
|
+
* `ANALYZE`, `ANALYZE VERBOSE`, `(ANALYZE)`, `(ANALYZE, BUFFERS)`,
|
|
846
|
+
* `(ANALYZE false)`.
|
|
847
|
+
*
|
|
848
|
+
* Only ANALYZE carries a meaning here — it selects STATISTICS XML over
|
|
849
|
+
* SHOWPLAN_XML. The rest are PostgreSQL planner knobs with no SQL Server
|
|
850
|
+
* counterpart, so they are refused by name: silently dropping an option would
|
|
851
|
+
* quietly hand back something other than what was asked for.
|
|
852
|
+
*/
|
|
853
|
+
static parseExplainPrefix(afterExplain) {
|
|
854
|
+
if (afterExplain.startsWith("(")) {
|
|
855
|
+
const close = afterExplain.indexOf(")");
|
|
856
|
+
if (close < 0) {
|
|
857
|
+
throw new Error("EXPLAIN option list is missing its closing ')'");
|
|
858
|
+
}
|
|
859
|
+
let analyze = false;
|
|
860
|
+
for (const part of afterExplain.slice(1, close).split(",")) {
|
|
861
|
+
const token = part.trim();
|
|
862
|
+
if (!token) continue;
|
|
863
|
+
const parsed = _SQLServerConnector.EXPLAIN_OPTION.exec(token);
|
|
864
|
+
if (!parsed) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
`EXPLAIN option '${token}' is not supported on SQL Server \u2014 only ANALYZE is.`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const name = parsed[1];
|
|
870
|
+
if (!/^analyze$/i.test(name)) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`EXPLAIN option '${name}' is not supported on SQL Server \u2014 only ANALYZE is.`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
const value = parsed[2];
|
|
876
|
+
if (value === void 0 || _SQLServerConnector.EXPLAIN_ON.test(value)) {
|
|
877
|
+
analyze = true;
|
|
878
|
+
} else if (_SQLServerConnector.EXPLAIN_OFF.test(value)) {
|
|
879
|
+
analyze = false;
|
|
880
|
+
} else {
|
|
881
|
+
throw new Error(`EXPLAIN option 'ANALYZE' expects a boolean, got '${value}'.`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return { analyze, query: afterExplain.slice(close + 1).trim() };
|
|
885
|
+
}
|
|
886
|
+
const analyzeKeyword = /^analyze\b/i.exec(afterExplain);
|
|
887
|
+
if (!analyzeKeyword) {
|
|
888
|
+
return { analyze: false, query: afterExplain };
|
|
889
|
+
}
|
|
890
|
+
const rest = afterExplain.slice(analyzeKeyword[0].length).trim();
|
|
891
|
+
const disabled = _SQLServerConnector.EXPLAIN_BARE_DISABLED.exec(rest);
|
|
892
|
+
if (disabled) {
|
|
893
|
+
return { analyze: false, query: rest.slice(disabled[0].length).trim() };
|
|
894
|
+
}
|
|
895
|
+
const trailing = /^([A-Za-z_]+)\b/.exec(rest);
|
|
896
|
+
if (trailing && /^verbose$/i.test(trailing[1])) {
|
|
897
|
+
throw new Error(
|
|
898
|
+
"EXPLAIN option 'VERBOSE' is not supported on SQL Server \u2014 only ANALYZE is."
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
return { analyze: true, query: rest };
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Run a statement under SET STATISTICS XML and return its *actual* execution
|
|
905
|
+
* plan — the Postgres `EXPLAIN ANALYZE` contract.
|
|
906
|
+
*
|
|
907
|
+
* SHOWPLAN_XML (plain EXPLAIN) compiles without executing, so its plan carries
|
|
908
|
+
* only estimates. STATISTICS XML runs the statement, so the plan reports real
|
|
909
|
+
* row counts and execution counts. The flip side is that this path is *not*
|
|
910
|
+
* inherently read-only the way explainQuery is, so under `options.readonly` the
|
|
911
|
+
* statement runs inside a transaction that always rolls back.
|
|
912
|
+
*
|
|
913
|
+
* The dedicated single-connection pool serves the same purpose as in
|
|
914
|
+
* explainQuery: the STATISTICS XML session toggle must never leak onto a
|
|
915
|
+
* shared pool connection, where a concurrent query would inherit it.
|
|
916
|
+
*/
|
|
917
|
+
async explainAnalyzeQuery(innerQuery, options, parameters) {
|
|
918
|
+
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
|
|
919
|
+
if (!cleaned) {
|
|
920
|
+
throw new Error("EXPLAIN ANALYZE requires a statement to analyze");
|
|
921
|
+
}
|
|
922
|
+
if (/\bset\s+statistics\b/i.test(cleaned)) {
|
|
923
|
+
throw new Error("EXPLAIN ANALYZE does not support SET STATISTICS statements");
|
|
924
|
+
}
|
|
925
|
+
if (/\bset\s+showplan\b/i.test(cleaned)) {
|
|
926
|
+
throw new Error("EXPLAIN ANALYZE does not support SET SHOWPLAN statements");
|
|
927
|
+
}
|
|
928
|
+
if (options.readonly) {
|
|
929
|
+
this.assertNoReadOnlyEscapes(innerQuery, { transactionControl: true });
|
|
930
|
+
}
|
|
931
|
+
if (!this.config) {
|
|
932
|
+
throw new Error("Not connected to SQL Server database");
|
|
933
|
+
}
|
|
934
|
+
const explainPool = new sql.ConnectionPool({
|
|
935
|
+
...this.config,
|
|
936
|
+
pool: { ...this.config.pool, max: 1, min: 1 }
|
|
937
|
+
});
|
|
938
|
+
try {
|
|
939
|
+
await explainPool.connect();
|
|
940
|
+
await explainPool.request().batch("SET STATISTICS XML ON");
|
|
941
|
+
let planResult;
|
|
942
|
+
if (options.readonly) {
|
|
943
|
+
planResult = await _SQLServerConnector.batchRolledBack(
|
|
944
|
+
explainPool,
|
|
945
|
+
innerQuery,
|
|
946
|
+
parameters
|
|
947
|
+
);
|
|
948
|
+
} else {
|
|
949
|
+
const planRequest = explainPool.request();
|
|
950
|
+
_SQLServerConnector.bindParameters(planRequest, parameters);
|
|
951
|
+
planResult = await planRequest.batch(innerQuery);
|
|
952
|
+
}
|
|
953
|
+
const planXml = _SQLServerConnector.extractPlanXml(planResult);
|
|
954
|
+
return {
|
|
955
|
+
resultSets: [
|
|
956
|
+
{
|
|
957
|
+
rows: planXml != null ? [{ plan: planXml }] : [],
|
|
958
|
+
rowCount: planXml != null ? 1 : 0
|
|
959
|
+
}
|
|
960
|
+
]
|
|
961
|
+
};
|
|
962
|
+
} catch (error) {
|
|
963
|
+
throw new Error(`Failed to explain analyze query: ${error.message}`);
|
|
964
|
+
} finally {
|
|
965
|
+
await explainPool.close();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Run a batch inside a transaction that is always rolled back, so EXPLAIN
|
|
970
|
+
* ANALYZE can report a real plan without letting the statement's writes stick.
|
|
971
|
+
*/
|
|
972
|
+
static async batchRolledBack(pool, innerQuery, parameters) {
|
|
973
|
+
const transaction = new sql.Transaction(pool);
|
|
974
|
+
await transaction.begin();
|
|
975
|
+
let queryFailed = false;
|
|
976
|
+
try {
|
|
977
|
+
const request = new sql.Request(transaction);
|
|
978
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
979
|
+
return await request.batch(innerQuery);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
queryFailed = true;
|
|
982
|
+
throw error;
|
|
983
|
+
} finally {
|
|
984
|
+
try {
|
|
985
|
+
await transaction.rollback();
|
|
986
|
+
} catch (rollbackError) {
|
|
987
|
+
if (!queryFailed) {
|
|
988
|
+
throw new Error(
|
|
989
|
+
`Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Pull the ShowPlanXML document out of a STATISTICS XML result.
|
|
997
|
+
*
|
|
998
|
+
* STATISTICS XML interleaves each statement's plan with that statement's own
|
|
999
|
+
* result sets, so the plan sits at no fixed index — and `recordset` (singular)
|
|
1000
|
+
* would hand back the statement's data instead. Scan from the end for the
|
|
1001
|
+
* first single-column row holding a plan document.
|
|
1002
|
+
*/
|
|
1003
|
+
static extractPlanXml(result) {
|
|
1004
|
+
const recordsets = result.recordsets ?? [];
|
|
1005
|
+
for (let i = recordsets.length - 1; i >= 0; i--) {
|
|
1006
|
+
const firstRow = recordsets[i]?.[0];
|
|
1007
|
+
if (!firstRow) continue;
|
|
1008
|
+
const values = Object.values(firstRow);
|
|
1009
|
+
if (values.length !== 1) continue;
|
|
1010
|
+
const value = values[0];
|
|
1011
|
+
if (typeof value === "string" && value.includes("<ShowPlanXML")) {
|
|
1012
|
+
return value;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
/**
|
|
1019
|
+
* Leading whitespace and SQL comments to skip before looking for a keyword.
|
|
1020
|
+
* The read-only validator strips comments before checking the first keyword,
|
|
1021
|
+
* so the connector must skip them too; otherwise an EXPLAIN preceded by a
|
|
1022
|
+
* comment passes validation but reaches the server untranslated.
|
|
1023
|
+
*/
|
|
1024
|
+
_SQLServerConnector.LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
|
|
1025
|
+
/** Boolean spellings PostgreSQL accepts for an EXPLAIN option. */
|
|
1026
|
+
_SQLServerConnector.EXPLAIN_ON = /^(?:true|on|1)$/i;
|
|
1027
|
+
_SQLServerConnector.EXPLAIN_OFF = /^(?:false|off|0)$/i;
|
|
1028
|
+
/**
|
|
1029
|
+
* One option inside `EXPLAIN (...)`: a name, then optionally a value written
|
|
1030
|
+
* either space-separated as PostgreSQL spells it (`ANALYZE false`) or with an
|
|
1031
|
+
* equals sign, which the read-only classifier also accepts (`ANALYZE = 0`).
|
|
1032
|
+
*/
|
|
1033
|
+
_SQLServerConnector.EXPLAIN_OPTION = /^([A-Za-z_]+)(?:(?:\s*=\s*|\s+)(\S+))?$/;
|
|
1034
|
+
/** A disabling boolean directly after a bare `EXPLAIN ANALYZE`. */
|
|
1035
|
+
_SQLServerConnector.EXPLAIN_BARE_DISABLED = /^(?:=\s*)?(?:false|off|0)\b/i;
|
|
1036
|
+
var SQLServerConnector = _SQLServerConnector;
|
|
1037
|
+
var sqlServerConnector = new SQLServerConnector();
|
|
1038
|
+
ConnectorRegistry.register(sqlServerConnector);
|
|
1039
|
+
export {
|
|
1040
|
+
SQLServerConnector,
|
|
1041
|
+
SQLServerDSNParser
|
|
1042
|
+
};
|