@bytebase/dbhub 0.24.0 → 1.1.0
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 +8 -44
- package/dist/{chunk-JO7YQPBQ.js → chunk-EPOKWSFC.js} +24 -2
- package/dist/chunk-FU2ZJE4E.js +16 -0
- package/dist/{chunk-MQR6VX5F.js → chunk-GSSLHJB6.js} +13 -4
- package/dist/chunk-NIILHXTG.js +190 -0
- package/dist/{chunk-D2BIPC7V.js → chunk-S2V6RZ3C.js} +4 -2
- package/dist/index.js +377 -97
- package/dist/{mariadb-VZMU7LYA.js → mariadb-DUDFBY5W.js} +12 -13
- package/dist/{mysql-MHIZK2UY.js → mysql-265M3KBD.js} +15 -13
- package/dist/{postgres-ZQFXCBYA.js → postgres-5ZOFYV6X.js} +63 -14
- package/dist/{registry-ZU6ZFNQ2.js → registry-JYRRNC3E.js} +1 -1
- package/dist/{sqlite-JH5CNV5E.js → sqlite-5M7SDAQZ.js} +4 -18
- package/dist/{sqlserver-LHWZ7WOP.js → sqlserver-PSAZRH7G.js} +307 -65
- package/package.json +5 -3
- package/dist/chunk-DUCNPMNW.js +0 -113
|
@@ -3,14 +3,18 @@ import {
|
|
|
3
3
|
sqlServerDynamicSqlPattern,
|
|
4
4
|
sqlServerPassThroughKeywords,
|
|
5
5
|
sqlServerPassThroughPattern
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-EPOKWSFC.js";
|
|
7
7
|
import {
|
|
8
8
|
isDriverNotInstalled
|
|
9
9
|
} from "./chunk-WVVMH6FJ.js";
|
|
10
|
+
import {
|
|
11
|
+
computeHitRatioPct,
|
|
12
|
+
toNullableNumber
|
|
13
|
+
} from "./chunk-FU2ZJE4E.js";
|
|
10
14
|
import {
|
|
11
15
|
SQLRowLimiter,
|
|
12
16
|
closeQuietly
|
|
13
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-S2V6RZ3C.js";
|
|
14
18
|
import {
|
|
15
19
|
ConnectorRegistry,
|
|
16
20
|
SafeURL,
|
|
@@ -372,6 +376,81 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
372
376
|
return null;
|
|
373
377
|
}
|
|
374
378
|
}
|
|
379
|
+
async getHealthCheck() {
|
|
380
|
+
if (!this.connection) {
|
|
381
|
+
throw new Error("Not connected to SQL Server database");
|
|
382
|
+
}
|
|
383
|
+
const notes = [];
|
|
384
|
+
const result = {};
|
|
385
|
+
try {
|
|
386
|
+
const [connResult, maxConnResult] = await Promise.all([
|
|
387
|
+
this.connection.request().query(`
|
|
388
|
+
SELECT
|
|
389
|
+
COUNT(*) AS total,
|
|
390
|
+
SUM(CASE WHEN r.session_id IS NOT NULL THEN 1 ELSE 0 END) AS active,
|
|
391
|
+
SUM(CASE WHEN r.session_id IS NULL THEN 1 ELSE 0 END) AS idle,
|
|
392
|
+
SUM(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction,
|
|
393
|
+
MAX(CASE WHEN r.session_id IS NULL AND t.session_id IS NOT NULL
|
|
394
|
+
THEN DATEDIFF(SECOND, s.last_request_end_time, SYSDATETIME()) ELSE NULL END) AS longest_idle_in_transaction_seconds,
|
|
395
|
+
MAX(CASE WHEN r.session_id IS NOT NULL
|
|
396
|
+
THEN DATEDIFF(SECOND, r.start_time, SYSDATETIME()) ELSE NULL END) AS longest_active_query_seconds
|
|
397
|
+
FROM sys.dm_exec_sessions s
|
|
398
|
+
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
|
|
399
|
+
LEFT JOIN (SELECT DISTINCT session_id FROM sys.dm_tran_session_transactions) t ON t.session_id = s.session_id
|
|
400
|
+
WHERE s.is_user_process = 1 AND s.session_id <> @@SPID
|
|
401
|
+
`),
|
|
402
|
+
this.connection.request().query(`
|
|
403
|
+
SELECT CAST(value_in_use AS INT) AS max_connections
|
|
404
|
+
FROM sys.configurations
|
|
405
|
+
WHERE name = 'user connections'
|
|
406
|
+
`)
|
|
407
|
+
]);
|
|
408
|
+
const conn = connResult.recordset[0];
|
|
409
|
+
const maxConnections = maxConnResult.recordset.length > 0 && maxConnResult.recordset[0].max_connections > 0 ? maxConnResult.recordset[0].max_connections : null;
|
|
410
|
+
result.connections = {
|
|
411
|
+
total: conn.total ?? 0,
|
|
412
|
+
active: conn.active ?? 0,
|
|
413
|
+
idle: conn.idle ?? 0,
|
|
414
|
+
idleInTransaction: conn.idle_in_transaction ?? 0,
|
|
415
|
+
// SQL Server has no equivalent of Postgres's "idle in transaction
|
|
416
|
+
// (aborted)" state - a failed statement doesn't leave the session's
|
|
417
|
+
// transaction in a distinct aborted-but-open state the way Postgres does.
|
|
418
|
+
maxConnections,
|
|
419
|
+
longestIdleInTransactionSeconds: toNullableNumber(conn.longest_idle_in_transaction_seconds),
|
|
420
|
+
longestActiveQuerySeconds: toNullableNumber(conn.longest_active_query_seconds)
|
|
421
|
+
};
|
|
422
|
+
} catch {
|
|
423
|
+
notes.push(
|
|
424
|
+
"Connection pool metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)."
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const bufferResult = await this.connection.request().query(`
|
|
429
|
+
SELECT counter_name, CAST(cntr_value AS BIGINT) AS cntr_value
|
|
430
|
+
FROM sys.dm_os_performance_counters
|
|
431
|
+
WHERE object_name LIKE '%Buffer Manager%'
|
|
432
|
+
AND counter_name IN ('Page lookups/sec', 'Page reads/sec')
|
|
433
|
+
`);
|
|
434
|
+
const counters = Object.fromEntries(
|
|
435
|
+
bufferResult.recordset.map((row) => [row.counter_name.trim(), Number(row.cntr_value)])
|
|
436
|
+
);
|
|
437
|
+
const pageLookups = counters["Page lookups/sec"] ?? 0;
|
|
438
|
+
const pageReads = counters["Page reads/sec"] ?? 0;
|
|
439
|
+
result.bufferCache = {
|
|
440
|
+
hitRatioPct: computeHitRatioPct(pageLookups, pageReads),
|
|
441
|
+
blocksHit: pageLookups - pageReads,
|
|
442
|
+
blocksRead: pageReads
|
|
443
|
+
};
|
|
444
|
+
} catch {
|
|
445
|
+
notes.push(
|
|
446
|
+
"Buffer cache metrics unavailable: connecting user lacks the VIEW SERVER STATE permission (VIEW DATABASE STATE on Azure SQL Database)."
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (notes.length > 0) {
|
|
450
|
+
result.notes = notes;
|
|
451
|
+
}
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
375
454
|
async getStoredProcedures(schema, routineType) {
|
|
376
455
|
if (!this.connection) {
|
|
377
456
|
throw new Error("Not connected to SQL Server database");
|
|
@@ -475,7 +554,10 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
475
554
|
sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
|
|
476
555
|
);
|
|
477
556
|
if (/^explain\b/i.test(afterNoise)) {
|
|
478
|
-
|
|
557
|
+
const { analyze, query } = _SQLServerConnector.parseExplainPrefix(
|
|
558
|
+
afterNoise.slice("explain".length).trim()
|
|
559
|
+
);
|
|
560
|
+
return analyze ? this.explainAnalyzeQuery(query, options, parameters) : this.explainQuery(query, options.readonly, parameters);
|
|
479
561
|
}
|
|
480
562
|
try {
|
|
481
563
|
let processedSQL = sqlQuery;
|
|
@@ -499,39 +581,8 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
499
581
|
});
|
|
500
582
|
}
|
|
501
583
|
);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const paramName = `p${index + 1}`;
|
|
505
|
-
if (typeof param === "string") {
|
|
506
|
-
request.input(paramName, sql.VarChar, param);
|
|
507
|
-
} else if (typeof param === "number") {
|
|
508
|
-
if (Number.isInteger(param)) {
|
|
509
|
-
request.input(paramName, sql.Int, param);
|
|
510
|
-
} else {
|
|
511
|
-
request.input(paramName, sql.Float, param);
|
|
512
|
-
}
|
|
513
|
-
} else if (typeof param === "boolean") {
|
|
514
|
-
request.input(paramName, sql.Bit, param);
|
|
515
|
-
} else if (param === null || param === void 0) {
|
|
516
|
-
request.input(paramName, sql.VarChar, param);
|
|
517
|
-
} else if (Array.isArray(param)) {
|
|
518
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
519
|
-
} else {
|
|
520
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
let result;
|
|
525
|
-
try {
|
|
526
|
-
result = await request.query(processedSQL);
|
|
527
|
-
} catch (error) {
|
|
528
|
-
if (parameters && parameters.length > 0) {
|
|
529
|
-
console.error(`[SQL Server executeSQL] ERROR: ${error.message}`);
|
|
530
|
-
console.error(`[SQL Server executeSQL] SQL: ${processedSQL}`);
|
|
531
|
-
console.error(`[SQL Server executeSQL] Parameters: ${JSON.stringify(parameters)}`);
|
|
532
|
-
}
|
|
533
|
-
throw error;
|
|
534
|
-
}
|
|
584
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
585
|
+
const result = await request.query(processedSQL);
|
|
535
586
|
return {
|
|
536
587
|
rows: result.recordset || [],
|
|
537
588
|
rowCount: result.rowsAffected[0] || 0,
|
|
@@ -541,6 +592,39 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
541
592
|
throw new Error(`Failed to execute query: ${error.message}`);
|
|
542
593
|
}
|
|
543
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* Bind positional parameters as @p1, @p2, ... inferring the SQL Server type
|
|
597
|
+
* from each JavaScript value. Shared by every execution path so they cannot
|
|
598
|
+
* drift in how a given value is typed.
|
|
599
|
+
*
|
|
600
|
+
* Works for `batch` as well as `query`: node-mssql prepends the matching
|
|
601
|
+
* DECLARE/SET statements when the request is a batch.
|
|
602
|
+
*/
|
|
603
|
+
static bindParameters(request, parameters) {
|
|
604
|
+
if (!parameters || parameters.length === 0) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
parameters.forEach((param, index) => {
|
|
608
|
+
const paramName = `p${index + 1}`;
|
|
609
|
+
if (typeof param === "string") {
|
|
610
|
+
request.input(paramName, sql.VarChar, param);
|
|
611
|
+
} else if (typeof param === "number") {
|
|
612
|
+
if (Number.isInteger(param)) {
|
|
613
|
+
request.input(paramName, sql.Int, param);
|
|
614
|
+
} else {
|
|
615
|
+
request.input(paramName, sql.Float, param);
|
|
616
|
+
}
|
|
617
|
+
} else if (typeof param === "boolean") {
|
|
618
|
+
request.input(paramName, sql.Bit, param);
|
|
619
|
+
} else if (param === null || param === void 0) {
|
|
620
|
+
request.input(paramName, sql.VarChar, param);
|
|
621
|
+
} else if (Array.isArray(param)) {
|
|
622
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
623
|
+
} else {
|
|
624
|
+
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
544
628
|
/**
|
|
545
629
|
* Reject the constructs that escape SQL Server's read-only guards, for use by
|
|
546
630
|
* both read-only execution paths.
|
|
@@ -606,39 +690,13 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
606
690
|
});
|
|
607
691
|
}
|
|
608
692
|
);
|
|
609
|
-
|
|
610
|
-
parameters.forEach((param, index) => {
|
|
611
|
-
const paramName = `p${index + 1}`;
|
|
612
|
-
if (typeof param === "string") {
|
|
613
|
-
request.input(paramName, sql.VarChar, param);
|
|
614
|
-
} else if (typeof param === "number") {
|
|
615
|
-
if (Number.isInteger(param)) {
|
|
616
|
-
request.input(paramName, sql.Int, param);
|
|
617
|
-
} else {
|
|
618
|
-
request.input(paramName, sql.Float, param);
|
|
619
|
-
}
|
|
620
|
-
} else if (typeof param === "boolean") {
|
|
621
|
-
request.input(paramName, sql.Bit, param);
|
|
622
|
-
} else if (param === null || param === void 0) {
|
|
623
|
-
request.input(paramName, sql.VarChar, param);
|
|
624
|
-
} else if (Array.isArray(param)) {
|
|
625
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
626
|
-
} else {
|
|
627
|
-
request.input(paramName, sql.VarChar, JSON.stringify(param));
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
}
|
|
693
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
631
694
|
let result;
|
|
632
695
|
let queryFailed = false;
|
|
633
696
|
try {
|
|
634
697
|
result = await request.query(processedSQL);
|
|
635
698
|
} catch (error) {
|
|
636
699
|
queryFailed = true;
|
|
637
|
-
console.error(`[SQL Server executeReadOnly] ERROR: ${error.message}`);
|
|
638
|
-
console.error(`[SQL Server executeReadOnly] SQL: ${processedSQL}`);
|
|
639
|
-
if (parameters && parameters.length > 0) {
|
|
640
|
-
console.error(`[SQL Server executeReadOnly] Parameters: ${JSON.stringify(parameters)}`);
|
|
641
|
-
}
|
|
642
700
|
throw error;
|
|
643
701
|
} finally {
|
|
644
702
|
try {
|
|
@@ -671,7 +729,7 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
671
729
|
* off the shared pool, so a concurrent query can never land on a connection
|
|
672
730
|
* with SHOWPLAN enabled (which would return a plan instead of its results).
|
|
673
731
|
*/
|
|
674
|
-
async explainQuery(innerQuery, readonly) {
|
|
732
|
+
async explainQuery(innerQuery, readonly, parameters) {
|
|
675
733
|
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
|
|
676
734
|
if (!cleaned) {
|
|
677
735
|
throw new Error("EXPLAIN requires a statement to analyze");
|
|
@@ -692,7 +750,9 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
692
750
|
try {
|
|
693
751
|
await explainPool.connect();
|
|
694
752
|
await explainPool.request().batch("SET SHOWPLAN_XML ON");
|
|
695
|
-
const
|
|
753
|
+
const planRequest = explainPool.request();
|
|
754
|
+
_SQLServerConnector.bindParameters(planRequest, parameters);
|
|
755
|
+
const planResult = await planRequest.batch(innerQuery);
|
|
696
756
|
const planRow = planResult.recordset?.[0];
|
|
697
757
|
const planXml = planRow ? Object.values(planRow)[0] : null;
|
|
698
758
|
return {
|
|
@@ -705,6 +765,177 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
705
765
|
await explainPool.close();
|
|
706
766
|
}
|
|
707
767
|
}
|
|
768
|
+
/**
|
|
769
|
+
* Splits the modifiers between EXPLAIN and its statement, covering the same
|
|
770
|
+
* forms the read-only classifier recognises (see utils/allowed-keywords.ts):
|
|
771
|
+
* `ANALYZE`, `ANALYZE VERBOSE`, `(ANALYZE)`, `(ANALYZE, BUFFERS)`,
|
|
772
|
+
* `(ANALYZE false)`.
|
|
773
|
+
*
|
|
774
|
+
* Only ANALYZE carries a meaning here — it selects STATISTICS XML over
|
|
775
|
+
* SHOWPLAN_XML. The rest are PostgreSQL planner knobs with no SQL Server
|
|
776
|
+
* counterpart, so they are refused by name: silently dropping an option would
|
|
777
|
+
* quietly hand back something other than what was asked for.
|
|
778
|
+
*/
|
|
779
|
+
static parseExplainPrefix(afterExplain) {
|
|
780
|
+
if (afterExplain.startsWith("(")) {
|
|
781
|
+
const close = afterExplain.indexOf(")");
|
|
782
|
+
if (close < 0) {
|
|
783
|
+
throw new Error("EXPLAIN option list is missing its closing ')'");
|
|
784
|
+
}
|
|
785
|
+
let analyze = false;
|
|
786
|
+
for (const part of afterExplain.slice(1, close).split(",")) {
|
|
787
|
+
const token = part.trim();
|
|
788
|
+
if (!token) continue;
|
|
789
|
+
const parsed = _SQLServerConnector.EXPLAIN_OPTION.exec(token);
|
|
790
|
+
if (!parsed) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`EXPLAIN option '${token}' is not supported on SQL Server \u2014 only ANALYZE is.`
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
const name = parsed[1];
|
|
796
|
+
if (!/^analyze$/i.test(name)) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`EXPLAIN option '${name}' is not supported on SQL Server \u2014 only ANALYZE is.`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
const value = parsed[2];
|
|
802
|
+
if (value === void 0 || _SQLServerConnector.EXPLAIN_ON.test(value)) {
|
|
803
|
+
analyze = true;
|
|
804
|
+
} else if (_SQLServerConnector.EXPLAIN_OFF.test(value)) {
|
|
805
|
+
analyze = false;
|
|
806
|
+
} else {
|
|
807
|
+
throw new Error(`EXPLAIN option 'ANALYZE' expects a boolean, got '${value}'.`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return { analyze, query: afterExplain.slice(close + 1).trim() };
|
|
811
|
+
}
|
|
812
|
+
const analyzeKeyword = /^analyze\b/i.exec(afterExplain);
|
|
813
|
+
if (!analyzeKeyword) {
|
|
814
|
+
return { analyze: false, query: afterExplain };
|
|
815
|
+
}
|
|
816
|
+
const rest = afterExplain.slice(analyzeKeyword[0].length).trim();
|
|
817
|
+
const disabled = _SQLServerConnector.EXPLAIN_BARE_DISABLED.exec(rest);
|
|
818
|
+
if (disabled) {
|
|
819
|
+
return { analyze: false, query: rest.slice(disabled[0].length).trim() };
|
|
820
|
+
}
|
|
821
|
+
const trailing = /^([A-Za-z_]+)\b/.exec(rest);
|
|
822
|
+
if (trailing && /^verbose$/i.test(trailing[1])) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
"EXPLAIN option 'VERBOSE' is not supported on SQL Server \u2014 only ANALYZE is."
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return { analyze: true, query: rest };
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Run a statement under SET STATISTICS XML and return its *actual* execution
|
|
831
|
+
* plan — the Postgres `EXPLAIN ANALYZE` contract.
|
|
832
|
+
*
|
|
833
|
+
* SHOWPLAN_XML (plain EXPLAIN) compiles without executing, so its plan carries
|
|
834
|
+
* only estimates. STATISTICS XML runs the statement, so the plan reports real
|
|
835
|
+
* row counts and execution counts. The flip side is that this path is *not*
|
|
836
|
+
* inherently read-only the way explainQuery is, so under `options.readonly` the
|
|
837
|
+
* statement runs inside a transaction that always rolls back.
|
|
838
|
+
*
|
|
839
|
+
* The dedicated single-connection pool serves the same purpose as in
|
|
840
|
+
* explainQuery: the STATISTICS XML session toggle must never leak onto a
|
|
841
|
+
* shared pool connection, where a concurrent query would inherit it.
|
|
842
|
+
*/
|
|
843
|
+
async explainAnalyzeQuery(innerQuery, options, parameters) {
|
|
844
|
+
const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
|
|
845
|
+
if (!cleaned) {
|
|
846
|
+
throw new Error("EXPLAIN ANALYZE requires a statement to analyze");
|
|
847
|
+
}
|
|
848
|
+
if (/\bset\s+statistics\b/i.test(cleaned)) {
|
|
849
|
+
throw new Error("EXPLAIN ANALYZE does not support SET STATISTICS statements");
|
|
850
|
+
}
|
|
851
|
+
if (/\bset\s+showplan\b/i.test(cleaned)) {
|
|
852
|
+
throw new Error("EXPLAIN ANALYZE does not support SET SHOWPLAN statements");
|
|
853
|
+
}
|
|
854
|
+
if (options.readonly) {
|
|
855
|
+
this.assertNoReadOnlyEscapes(innerQuery, { transactionControl: true });
|
|
856
|
+
}
|
|
857
|
+
if (!this.config) {
|
|
858
|
+
throw new Error("Not connected to SQL Server database");
|
|
859
|
+
}
|
|
860
|
+
const explainPool = new sql.ConnectionPool({
|
|
861
|
+
...this.config,
|
|
862
|
+
pool: { ...this.config.pool, max: 1, min: 1 }
|
|
863
|
+
});
|
|
864
|
+
try {
|
|
865
|
+
await explainPool.connect();
|
|
866
|
+
await explainPool.request().batch("SET STATISTICS XML ON");
|
|
867
|
+
let planResult;
|
|
868
|
+
if (options.readonly) {
|
|
869
|
+
planResult = await _SQLServerConnector.batchRolledBack(
|
|
870
|
+
explainPool,
|
|
871
|
+
innerQuery,
|
|
872
|
+
parameters
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
const planRequest = explainPool.request();
|
|
876
|
+
_SQLServerConnector.bindParameters(planRequest, parameters);
|
|
877
|
+
planResult = await planRequest.batch(innerQuery);
|
|
878
|
+
}
|
|
879
|
+
const planXml = _SQLServerConnector.extractPlanXml(planResult);
|
|
880
|
+
return {
|
|
881
|
+
rows: planXml != null ? [{ plan: planXml }] : [],
|
|
882
|
+
rowCount: planXml != null ? 1 : 0
|
|
883
|
+
};
|
|
884
|
+
} catch (error) {
|
|
885
|
+
throw new Error(`Failed to explain analyze query: ${error.message}`);
|
|
886
|
+
} finally {
|
|
887
|
+
await explainPool.close();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Run a batch inside a transaction that is always rolled back, so EXPLAIN
|
|
892
|
+
* ANALYZE can report a real plan without letting the statement's writes stick.
|
|
893
|
+
*/
|
|
894
|
+
static async batchRolledBack(pool, innerQuery, parameters) {
|
|
895
|
+
const transaction = new sql.Transaction(pool);
|
|
896
|
+
await transaction.begin();
|
|
897
|
+
let queryFailed = false;
|
|
898
|
+
try {
|
|
899
|
+
const request = new sql.Request(transaction);
|
|
900
|
+
_SQLServerConnector.bindParameters(request, parameters);
|
|
901
|
+
return await request.batch(innerQuery);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
queryFailed = true;
|
|
904
|
+
throw error;
|
|
905
|
+
} finally {
|
|
906
|
+
try {
|
|
907
|
+
await transaction.rollback();
|
|
908
|
+
} catch (rollbackError) {
|
|
909
|
+
if (!queryFailed) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`Read-only rollback failed \u2014 data may have been modified: ${rollbackError.message}`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Pull the ShowPlanXML document out of a STATISTICS XML result.
|
|
919
|
+
*
|
|
920
|
+
* STATISTICS XML interleaves each statement's plan with that statement's own
|
|
921
|
+
* result sets, so the plan sits at no fixed index — and `recordset` (singular)
|
|
922
|
+
* would hand back the statement's data instead. Scan from the end for the
|
|
923
|
+
* first single-column row holding a plan document.
|
|
924
|
+
*/
|
|
925
|
+
static extractPlanXml(result) {
|
|
926
|
+
const recordsets = result.recordsets ?? [];
|
|
927
|
+
for (let i = recordsets.length - 1; i >= 0; i--) {
|
|
928
|
+
const firstRow = recordsets[i]?.[0];
|
|
929
|
+
if (!firstRow) continue;
|
|
930
|
+
const values = Object.values(firstRow);
|
|
931
|
+
if (values.length !== 1) continue;
|
|
932
|
+
const value = values[0];
|
|
933
|
+
if (typeof value === "string" && value.includes("<ShowPlanXML")) {
|
|
934
|
+
return value;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
708
939
|
};
|
|
709
940
|
/**
|
|
710
941
|
* Leading whitespace and SQL comments to skip before looking for a keyword.
|
|
@@ -713,6 +944,17 @@ var _SQLServerConnector = class _SQLServerConnector {
|
|
|
713
944
|
* comment passes validation but reaches the server untranslated.
|
|
714
945
|
*/
|
|
715
946
|
_SQLServerConnector.LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
|
|
947
|
+
/** Boolean spellings PostgreSQL accepts for an EXPLAIN option. */
|
|
948
|
+
_SQLServerConnector.EXPLAIN_ON = /^(?:true|on|1)$/i;
|
|
949
|
+
_SQLServerConnector.EXPLAIN_OFF = /^(?:false|off|0)$/i;
|
|
950
|
+
/**
|
|
951
|
+
* One option inside `EXPLAIN (...)`: a name, then optionally a value written
|
|
952
|
+
* either space-separated as PostgreSQL spells it (`ANALYZE false`) or with an
|
|
953
|
+
* equals sign, which the read-only classifier also accepts (`ANALYZE = 0`).
|
|
954
|
+
*/
|
|
955
|
+
_SQLServerConnector.EXPLAIN_OPTION = /^([A-Za-z_]+)(?:(?:\s*=\s*|\s+)(\S+))?$/;
|
|
956
|
+
/** A disabling boolean directly after a bare `EXPLAIN ANALYZE`. */
|
|
957
|
+
_SQLServerConnector.EXPLAIN_BARE_DISABLED = /^(?:=\s*)?(?:false|off|0)\b/i;
|
|
716
958
|
var SQLServerConnector = _SQLServerConnector;
|
|
717
959
|
var sqlServerConnector = new SQLServerConnector();
|
|
718
960
|
ConnectorRegistry.register(sqlServerConnector);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebase/dbhub",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"mcpName": "io.github.bytebase/dbhub",
|
|
5
5
|
"description": "Minimal, token-efficient Database MCP Server for PostgreSQL, MySQL, SQL Server, SQLite, MariaDB",
|
|
6
6
|
"repository": {
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@iarna/toml": "^2.2.5",
|
|
47
|
-
"@modelcontextprotocol/
|
|
47
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
48
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
48
49
|
"dotenv": "^16.4.7",
|
|
49
50
|
"express": "^4.18.2",
|
|
50
51
|
"ssh-config": "^5.0.3",
|
|
51
52
|
"ssh2": "^1.16.0",
|
|
52
|
-
"zod": "^
|
|
53
|
+
"zod": "^4.2.0"
|
|
53
54
|
},
|
|
54
55
|
"optionalDependencies": {
|
|
55
56
|
"@aws-sdk/rds-signer": "^3.1001.0",
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@anthropic-ai/mcpb": "^2.1.2",
|
|
65
|
+
"@modelcontextprotocol/client": "^2.0.0",
|
|
64
66
|
"@testcontainers/mariadb": "^11.0.3",
|
|
65
67
|
"@testcontainers/mssqlserver": "^11.0.3",
|
|
66
68
|
"@testcontainers/mysql": "^11.0.3",
|
package/dist/chunk-DUCNPMNW.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
obfuscateDSNPassword
|
|
3
|
-
} from "./chunk-JEZZN2YZ.js";
|
|
4
|
-
|
|
5
|
-
// src/utils/dsn-database.ts
|
|
6
|
-
var MissingDatabaseError = class extends Error {
|
|
7
|
-
constructor(message) {
|
|
8
|
-
super(message);
|
|
9
|
-
this.name = "MissingDatabaseError";
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
|
-
function requireDatabaseInDSN(database, dsn, label) {
|
|
13
|
-
if (database) {
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
throw new MissingDatabaseError(
|
|
17
|
-
`${label} DSN must name a database.
|
|
18
|
-
Provided: ${obfuscateDSNPassword(dsn)}
|
|
19
|
-
Add the database to the DSN, e.g. ...:3306/mydb
|
|
20
|
-
To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// src/utils/multi-statement-result-parser.ts
|
|
25
|
-
function isMetadataObject(element) {
|
|
26
|
-
if (!element || typeof element !== "object" || Array.isArray(element)) {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
return "affectedRows" in element || "insertId" in element || "fieldCount" in element || "warningStatus" in element;
|
|
30
|
-
}
|
|
31
|
-
function isMultiStatementResult(results) {
|
|
32
|
-
if (!Array.isArray(results) || results.length === 0) {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
const firstElement = results[0];
|
|
36
|
-
return isMetadataObject(firstElement) || Array.isArray(firstElement);
|
|
37
|
-
}
|
|
38
|
-
function extractRowsFromMultiStatement(results) {
|
|
39
|
-
if (!Array.isArray(results)) {
|
|
40
|
-
return [];
|
|
41
|
-
}
|
|
42
|
-
const allRows = [];
|
|
43
|
-
for (const result of results) {
|
|
44
|
-
if (Array.isArray(result)) {
|
|
45
|
-
allRows.push(...result);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return allRows;
|
|
49
|
-
}
|
|
50
|
-
function extractAffectedRows(results) {
|
|
51
|
-
if (isMetadataObject(results)) {
|
|
52
|
-
return results.affectedRows || 0;
|
|
53
|
-
}
|
|
54
|
-
if (!Array.isArray(results)) {
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
if (isMultiStatementResult(results)) {
|
|
58
|
-
let totalAffected = 0;
|
|
59
|
-
for (const result of results) {
|
|
60
|
-
if (isMetadataObject(result)) {
|
|
61
|
-
totalAffected += result.affectedRows || 0;
|
|
62
|
-
} else if (Array.isArray(result)) {
|
|
63
|
-
totalAffected += result.length;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return totalAffected;
|
|
67
|
-
}
|
|
68
|
-
return results.length;
|
|
69
|
-
}
|
|
70
|
-
function parseQueryResults(results) {
|
|
71
|
-
if (!Array.isArray(results)) {
|
|
72
|
-
return [];
|
|
73
|
-
}
|
|
74
|
-
if (isMultiStatementResult(results)) {
|
|
75
|
-
return extractRowsFromMultiStatement(results);
|
|
76
|
-
}
|
|
77
|
-
return results;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// src/utils/readonly-transaction.ts
|
|
81
|
-
async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
|
|
82
|
-
if (!readonly) {
|
|
83
|
-
return execute();
|
|
84
|
-
}
|
|
85
|
-
try {
|
|
86
|
-
await conn.query(
|
|
87
|
-
supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
|
|
88
|
-
);
|
|
89
|
-
const result = await execute();
|
|
90
|
-
await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
|
|
91
|
-
return result;
|
|
92
|
-
} catch (error) {
|
|
93
|
-
try {
|
|
94
|
-
await conn.query("ROLLBACK");
|
|
95
|
-
} catch {
|
|
96
|
-
}
|
|
97
|
-
throw error;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// src/utils/server-flavor.ts
|
|
102
|
-
function isTiDBVersion(version) {
|
|
103
|
-
return typeof version === "string" && /tidb/i.test(version);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export {
|
|
107
|
-
MissingDatabaseError,
|
|
108
|
-
requireDatabaseInDSN,
|
|
109
|
-
extractAffectedRows,
|
|
110
|
-
parseQueryResults,
|
|
111
|
-
withReadOnlyTransaction,
|
|
112
|
-
isTiDBVersion
|
|
113
|
-
};
|