@bytebase/dbhub 1.0.0 → 1.2.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  > [!NOTE]
2
- > Brought to you by [Bytebase](https://www.bytebase.com/), open-source database DevSecOps platform.
2
+ > Brought to you by [Bytebase](https://www.bytebase.com/), open-source database governance platform.
3
3
 
4
4
  <p align="center">
5
5
  <a href="https://dbhub.ai/" target="_blank">
@@ -30,9 +30,9 @@
30
30
  MCP Clients MCP Server Databases
31
31
  ```
32
32
 
33
- DBHub is a zero-dependency, token efficient MCP server implementing the Model Context Protocol (MCP) server interface. This lightweight gateway allows MCP-compatible clients to connect to and explore different databases:
33
+ DBHub is a token efficient, zero-dependency MCP server implementing the Model Context Protocol (MCP) server interface. This lightweight gateway allows MCP-compatible clients to connect to and explore different databases:
34
34
 
35
- - **Local Development First**: Zero dependency, token efficient with just two MCP tools to maximize context window
35
+ - **Local Development First**: Zero dependency, token efficient with a minimal set of MCP tools to maximize context window
36
36
  - **Multi-Database**: PostgreSQL, MySQL, MariaDB, SQL Server, and SQLite through a single interface
37
37
  - **Multi-Connection**: Connect to multiple databases simultaneously with TOML configuration
38
38
  - **Guardrails**: Read-only mode, row limiting, and query timeout to prevent runaway operations
@@ -48,6 +48,8 @@ DBHub implements MCP tools for database operations:
48
48
 
49
49
  - **[execute_sql](https://dbhub.ai/tools/execute-sql)**: Execute SQL queries with transaction support and safety controls
50
50
  - **[search_objects](https://dbhub.ai/tools/search-objects)**: Search and explore database schemas, tables, columns, indexes, and procedures with progressive disclosure
51
+ - **[explain_sql](https://dbhub.ai/tools/explain-sql)** (opt-in): Show a query's execution plan without running it
52
+ - **[health_check](https://dbhub.ai/tools/health-check)** (opt-in): Report connection pool state and buffer cache hit ratio
51
53
  - **[Custom Tools](https://dbhub.ai/tools/custom-tools)**: Define reusable, parameterized SQL operations in your `dbhub.toml` configuration file
52
54
 
53
55
  ## Workbench
@@ -58,61 +60,17 @@ DBHub includes a [built-in web interface](https://dbhub.ai/workbench/overview) f
58
60
 
59
61
  ## Installation
60
62
 
61
- See the full [Installation Guide](https://dbhub.ai/installation) for detailed instructions.
62
-
63
- ### Quick Start
64
-
65
- **Docker:**
66
-
67
- ```bash
68
- docker run --rm --init \
69
- --name dbhub \
70
- --publish 8080:8080 \
71
- bytebase/dbhub \
72
- --transport http \
73
- --port 8080 \
74
- --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
75
- ```
76
-
77
- **NPM:** (requires Node.js >= 22.5.0)
78
-
79
63
  ```bash
80
64
  npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
81
65
  ```
82
66
 
83
- **MCP Bundle (one-click install):**
84
-
85
- Download `dbhub-<version>.mcpb` from the [latest release](https://github.com/bytebase/dbhub/releases/latest) and install it in any [MCPB-compatible client](https://github.com/modelcontextprotocol/mcpb) — Claude Desktop (double-click, or drag into Settings → Extensions), Claude Code, or MCP for Windows — then enter your database connection string. The bundle runs locally over stdio, is **read-only by design** (writes are rejected and the database session is set to read-only at the engine level), and needs no remote endpoint or OAuth setup — ideal for giving non-technical teammates curated, read-only database access. Pair it with a least-privilege, read-only database account. See the [MCP Bundle guide](https://dbhub.ai/mcpb) for details and for packaging your own bundle.
86
-
87
- **Claude Code Plugin:**
88
-
89
- For Claude Code, the [DBHub plugin](https://dbhub.ai/claude-code-plugin) registers the MCP server with one install command — Claude Code prompts for your connection string and stores it in secure storage — and adds `/dbhub:setup` and `/dbhub:explore` skills. Read-only by design, like the MCP Bundle.
90
-
91
- **Demo Mode:**
92
-
93
- ```bash
94
- npx @bytebase/dbhub@latest --transport http --port 8080 --demo
95
- ```
96
-
97
- **Restrict to loopback (recommended for production):**
98
-
99
- ```bash
100
- npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
101
- ```
102
-
103
- > The HTTP transport defaults to `--host 0.0.0.0`, exposing DBHub on every network interface. For production, bind to `127.0.0.1` and front DBHub with a reverse proxy (nginx/Caddy) or firewall — DBHub does not authenticate HTTP clients.
104
- >
105
- > The HTTP transport also has built-in DNS-rebinding protection: it only accepts requests whose `Host` is loopback, this machine's own hostname/IPs, or a name you allow via [`--allowed-hosts`](https://dbhub.ai/config/command-line#allowed-hosts). If a client behind a reverse proxy or custom DNS name gets a `403`, add that hostname with `--allowed-hosts`.
106
- >
107
- > DBHub serves both MCP protocol eras on the same `/mcp` endpoint: the stateless **2026-07-28** revision (cacheable `tools/list`, no session state — safe behind round-robin load balancers) and the 2025-era Streamable HTTP protocol for older clients. 2026-era clients send the standard `Mcp-Method` / `Mcp-Name` headers on every request, so a fronting gateway or WAF can route and rate-limit `execute_sql` separately from `search_objects` without parsing JSON bodies.
108
-
109
- See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
110
-
111
- ### Multi-Database Setup
67
+ Also available as:
112
68
 
113
- Connect to multiple databases simultaneously using TOML configuration files. Perfect for managing production, staging, and development databases from a single DBHub instance.
69
+ - [Docker image](https://dbhub.ai/installation#docker)
70
+ - [MCP Bundle](https://dbhub.ai/mcpb) (one-click install, read-only)
71
+ - [Claude Code plugin](https://dbhub.ai/claude-code-plugin)
114
72
 
115
- See [Multi-Database Configuration](https://dbhub.ai/config/toml) for complete setup instructions.
73
+ See the [Installation Guide](https://dbhub.ai/installation) for all options, [Command-Line Options](https://dbhub.ai/config/command-line) for parameters, and [Multi-Database Configuration](https://dbhub.ai/config/toml) for connecting several databases at once.
116
74
 
117
75
  ## Development
118
76
 
@@ -8,15 +8,22 @@ import {
8
8
  getDefaultPortForType,
9
9
  parseConnectionInfoFromDSN,
10
10
  stripCommentsAndStrings
11
- } from "./chunk-JEZZN2YZ.js";
11
+ } from "./chunk-WIQ4DWGY.js";
12
12
 
13
13
  // src/tools/builtin-tools.ts
14
14
  var BUILTIN_TOOL_EXECUTE_SQL = "execute_sql";
15
15
  var BUILTIN_TOOL_SEARCH_OBJECTS = "search_objects";
16
+ var BUILTIN_TOOL_EXPLAIN_SQL = "explain_sql";
17
+ var BUILTIN_TOOL_HEALTH_CHECK = "health_check";
16
18
  var BUILTIN_TOOLS = [
17
19
  BUILTIN_TOOL_EXECUTE_SQL,
18
20
  BUILTIN_TOOL_SEARCH_OBJECTS
19
21
  ];
22
+ var ALL_BUILTIN_TOOL_NAMES = [
23
+ ...BUILTIN_TOOLS,
24
+ BUILTIN_TOOL_EXPLAIN_SQL,
25
+ BUILTIN_TOOL_HEALTH_CHECK
26
+ ];
20
27
 
21
28
  // src/utils/ssh-tunnel.ts
22
29
  import { Client } from "ssh2";
@@ -798,6 +805,21 @@ function resolveAllowedHosts() {
798
805
  function splitHostList(value) {
799
806
  return value.split(",").map((h) => h.trim()).filter((h) => h.length > 0);
800
807
  }
808
+ function resolveAuthTokens() {
809
+ const args = parseCommandLineArgs();
810
+ const cliValue = requireFlagValue("auth-token", args, "secret1,secret2");
811
+ if (cliValue !== void 0) {
812
+ return { tokens: splitTokenList(cliValue), source: "command line argument" };
813
+ }
814
+ const envValue = process.env.DBHUB_AUTH_TOKEN?.trim();
815
+ if (envValue) {
816
+ return { tokens: splitTokenList(envValue), source: "environment variable" };
817
+ }
818
+ return { tokens: [], source: "default" };
819
+ }
820
+ function splitTokenList(value) {
821
+ return value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
822
+ }
801
823
  function redactDSN(dsn) {
802
824
  try {
803
825
  const url = new URL(dsn);
@@ -1130,7 +1152,7 @@ function validateToolsConfig(tools, sources, configPath) {
1130
1152
  `Configuration file ${configPath}: tool '${tool.name}' references unknown source '${tool.source}'`
1131
1153
  );
1132
1154
  }
1133
- const isBuiltin = BUILTIN_TOOLS.includes(tool.name);
1155
+ const isBuiltin = ALL_BUILTIN_TOOL_NAMES.includes(tool.name);
1134
1156
  const isExecuteSql = tool.name === BUILTIN_TOOL_EXECUTE_SQL;
1135
1157
  if (isBuiltin) {
1136
1158
  if (tool.description || tool.statement || tool.parameters) {
@@ -2278,7 +2300,7 @@ var ToolRegistry = class {
2278
2300
  * Check if a tool name is a built-in tool
2279
2301
  */
2280
2302
  isBuiltinTool(toolName) {
2281
- return BUILTIN_TOOLS.includes(toolName);
2303
+ return ALL_BUILTIN_TOOL_NAMES.includes(toolName);
2282
2304
  }
2283
2305
  /**
2284
2306
  * Validate a custom tool parameter definition
@@ -2350,10 +2372,10 @@ var ToolRegistry = class {
2350
2372
  `Tool '${toolConfig.name}' references unknown source '${toolConfig.source}'. Available sources: ${availableSources.join(", ")}`
2351
2373
  );
2352
2374
  }
2353
- for (const builtinName of BUILTIN_TOOLS) {
2375
+ for (const builtinName of ALL_BUILTIN_TOOL_NAMES) {
2354
2376
  if (toolConfig.name === builtinName || toolConfig.name.startsWith(`${builtinName}_`)) {
2355
2377
  throw new Error(
2356
- `Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${BUILTIN_TOOLS.join(", ")}`
2378
+ `Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${ALL_BUILTIN_TOOL_NAMES.join(", ")}`
2357
2379
  );
2358
2380
  }
2359
2381
  }
@@ -2485,9 +2507,12 @@ export {
2485
2507
  resolvePort,
2486
2508
  resolveHost,
2487
2509
  resolveAllowedHosts,
2510
+ resolveAuthTokens,
2488
2511
  resolveSourceConfigs,
2489
2512
  BUILTIN_TOOL_EXECUTE_SQL,
2490
2513
  BUILTIN_TOOL_SEARCH_OBJECTS,
2514
+ BUILTIN_TOOL_EXPLAIN_SQL,
2515
+ BUILTIN_TOOL_HEALTH_CHECK,
2491
2516
  loadTomlConfig,
2492
2517
  resolveTomlConfigPath,
2493
2518
  classifyConnectionError,
@@ -0,0 +1,16 @@
1
+ // src/connectors/health-check-utils.ts
2
+ function toNullableNumber(value) {
3
+ return value === null || value === void 0 ? null : Number(value);
4
+ }
5
+ function computeHitRatioPct(logicalReads, physicalReads) {
6
+ if (!Number.isFinite(logicalReads) || !Number.isFinite(physicalReads) || logicalReads === 0) {
7
+ return null;
8
+ }
9
+ const pct = Math.round((logicalReads - physicalReads) / logicalReads * 1e4) / 100;
10
+ return Math.min(100, Math.max(0, pct));
11
+ }
12
+
13
+ export {
14
+ toNullableNumber,
15
+ computeHitRatioPct
16
+ };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  stripCommentsAndStrings
3
- } from "./chunk-JEZZN2YZ.js";
3
+ } from "./chunk-WIQ4DWGY.js";
4
4
 
5
5
  // src/utils/allowed-keywords.ts
6
6
  var allowedKeywords = {
@@ -52,6 +52,22 @@ var sqlServerPassThroughPattern = new RegExp(
52
52
  `\\b(?:${sqlServerPassThroughKeywords.join("|")})\\s*\\(`,
53
53
  "i"
54
54
  );
55
+ var escapeHatchFunctionKeywords = {
56
+ mysql: ["load_file", "get_lock", "release_lock", "release_all_locks"],
57
+ mariadb: ["load_file", "get_lock", "release_lock", "release_all_locks"],
58
+ postgres: ["pg_read_file", "pg_read_binary_file", "pg_ls_dir"],
59
+ sqlserver: sqlServerPassThroughKeywords
60
+ };
61
+ var escapeHatchFunctionPatterns = Object.fromEntries(
62
+ Object.entries(escapeHatchFunctionKeywords).map(([type, keywords]) => [
63
+ type,
64
+ new RegExp(`\\b(?:${keywords.join("|")})\\s*\\(`, "i")
65
+ ])
66
+ );
67
+ function hasEscapeHatchFunction(strippedSQL, connectorType) {
68
+ const pattern = escapeHatchFunctionPatterns[connectorType];
69
+ return pattern !== void 0 && pattern.test(strippedSQL);
70
+ }
55
71
  var mutatingPatternSqlServer = new RegExp(
56
72
  `\\b(?:${[...mutatingKeywords, ...sqlServerDynamicSqlKeywords].join("|")})\\b`,
57
73
  "i"
@@ -78,6 +94,10 @@ function isReadOnlySQL(sql, connectorType) {
78
94
  connectorType
79
95
  );
80
96
  }
97
+ function getFirstKeyword(sql, connectorType) {
98
+ const cleaned = stripCommentsAndStrings(sql, connectorType).trim();
99
+ return cleaned.match(/\S+/)?.[0]?.toLowerCase() ?? "";
100
+ }
81
101
  function checkReadOnly(cleanedSQL, connectorType) {
82
102
  if (!cleanedSQL) {
83
103
  return false;
@@ -87,7 +107,7 @@ function checkReadOnly(cleanedSQL, connectorType) {
87
107
  if (!keywordList.includes(firstWord)) {
88
108
  return false;
89
109
  }
90
- if (connectorType === "sqlserver" && sqlServerPassThroughPattern.test(cleanedSQL)) {
110
+ if (hasEscapeHatchFunction(cleanedSQL, connectorType)) {
91
111
  return false;
92
112
  }
93
113
  if (firstWord === "with") {
@@ -126,5 +146,7 @@ export {
126
146
  sqlServerDynamicSqlPattern,
127
147
  sqlServerPassThroughKeywords,
128
148
  sqlServerPassThroughPattern,
129
- isReadOnlySQL
149
+ hasEscapeHatchFunction,
150
+ isReadOnlySQL,
151
+ getFirstKeyword
130
152
  };
@@ -0,0 +1,179 @@
1
+ import {
2
+ computeHitRatioPct,
3
+ toNullableNumber
4
+ } from "./chunk-FU2ZJE4E.js";
5
+ import {
6
+ obfuscateDSNPassword
7
+ } from "./chunk-WIQ4DWGY.js";
8
+
9
+ // src/connectors/mysql-family-health-check.ts
10
+ async function getMySQLFamilyHealthCheck(query) {
11
+ const [processlistRows, maxConnRows, bufferPoolRows, trxOutcome] = await Promise.all([
12
+ query(`
13
+ SELECT
14
+ COUNT(*) AS total,
15
+ SUM(CASE WHEN COMMAND != 'Sleep' THEN 1 ELSE 0 END) AS active,
16
+ SUM(CASE WHEN COMMAND = 'Sleep' THEN 1 ELSE 0 END) AS idle,
17
+ MAX(CASE WHEN COMMAND != 'Sleep' THEN TIME ELSE NULL END) AS longest_active_query_seconds
18
+ FROM information_schema.PROCESSLIST
19
+ WHERE ID <> CONNECTION_ID()
20
+ `),
21
+ query(`SHOW VARIABLES LIKE 'max_connections'`),
22
+ query(`
23
+ SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads')
24
+ `),
25
+ // Joining PROCESSLIST against INNODB_TRX to detect idle-in-transaction
26
+ // sessions requires the PROCESS privilege - unlike a plain PROCESSLIST
27
+ // select, which silently narrows to the caller's own session instead of
28
+ // erroring. Run concurrently with the other queries above, but resolve
29
+ // rather than reject on failure so it degrades instead of failing the
30
+ // whole health check.
31
+ query(`
32
+ SELECT
33
+ COUNT(*) AS idle_in_transaction,
34
+ MAX(TIMESTAMPDIFF(SECOND, t.trx_started, NOW())) AS longest_idle_in_transaction_seconds
35
+ FROM information_schema.INNODB_TRX t
36
+ JOIN information_schema.PROCESSLIST p ON p.ID = t.trx_mysql_thread_id
37
+ WHERE p.COMMAND = 'Sleep'
38
+ `).then(
39
+ (rows) => ({ ok: true, rows }),
40
+ () => ({ ok: false })
41
+ )
42
+ ]);
43
+ const notes = [];
44
+ let idleInTransaction = 0;
45
+ let longestIdleInTransactionSeconds = null;
46
+ if (trxOutcome.ok) {
47
+ idleInTransaction = Number(trxOutcome.rows[0].idle_in_transaction);
48
+ longestIdleInTransactionSeconds = toNullableNumber(trxOutcome.rows[0].longest_idle_in_transaction_seconds);
49
+ } else {
50
+ notes.push(
51
+ "Connected user lacks the PROCESS privilege: PROCESSLIST visibility is restricted to the connecting user's own sessions, and this diagnostic session is excluded from the count, so connection counts and active-query duration may under-report (down to 0). Idle-in-transaction detection is unavailable."
52
+ );
53
+ }
54
+ const proc = processlistRows[0];
55
+ const bufferPoolStats = Object.fromEntries(bufferPoolRows.map((row) => [row.Variable_name, row.Value]));
56
+ const readRequests = Number(bufferPoolStats.Innodb_buffer_pool_read_requests ?? 0);
57
+ const reads = Number(bufferPoolStats.Innodb_buffer_pool_reads ?? 0);
58
+ return {
59
+ connections: {
60
+ total: Number(proc.total),
61
+ active: Number(proc.active),
62
+ idle: Number(proc.idle),
63
+ idleInTransaction,
64
+ // MySQL/MariaDB's InnoDB has no equivalent of Postgres's "idle in
65
+ // transaction (aborted)" state - a failed statement doesn't leave the
66
+ // session's transaction in a distinct aborted-but-open state the way
67
+ // Postgres does - so idleInTransactionAborted is left undefined.
68
+ maxConnections: maxConnRows.length > 0 ? Number(maxConnRows[0].Value) : null,
69
+ longestIdleInTransactionSeconds,
70
+ longestActiveQuerySeconds: toNullableNumber(proc.longest_active_query_seconds)
71
+ },
72
+ bufferCache: {
73
+ hitRatioPct: computeHitRatioPct(readRequests, reads),
74
+ blocksHit: readRequests - reads,
75
+ blocksRead: reads
76
+ },
77
+ ...notes.length > 0 ? { notes } : {}
78
+ };
79
+ }
80
+
81
+ // src/utils/dsn-database.ts
82
+ var MissingDatabaseError = class extends Error {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = "MissingDatabaseError";
86
+ }
87
+ };
88
+ function requireDatabaseInDSN(database, dsn, label) {
89
+ if (database) {
90
+ return;
91
+ }
92
+ throw new MissingDatabaseError(
93
+ `${label} DSN must name a database.
94
+ Provided: ${obfuscateDSNPassword(dsn)}
95
+ Add the database to the DSN, e.g. ...:3306/mydb
96
+ To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
97
+ );
98
+ }
99
+
100
+ // src/utils/multi-statement-result-parser.ts
101
+ function isMetadataObject(element) {
102
+ if (!element || typeof element !== "object" || Array.isArray(element)) {
103
+ return false;
104
+ }
105
+ return "affectedRows" in element || "insertId" in element || "fieldCount" in element || "warningStatus" in element;
106
+ }
107
+ function isMultiStatementResult(results) {
108
+ if (!Array.isArray(results) || results.length === 0) {
109
+ return false;
110
+ }
111
+ const firstElement = results[0];
112
+ return isMetadataObject(firstElement) || Array.isArray(firstElement);
113
+ }
114
+ function buildResultSetsFromMultiStatement(results, statementTexts) {
115
+ const sql = statementTexts?.length === results.length ? statementTexts : void 0;
116
+ return results.map((result, index) => {
117
+ if (Array.isArray(result)) {
118
+ return { sql: sql?.[index], rows: result, rowCount: result.length };
119
+ }
120
+ if (isMetadataObject(result)) {
121
+ return { sql: sql?.[index], rows: [], rowCount: result.affectedRows || 0 };
122
+ }
123
+ return { sql: sql?.[index], rows: [], rowCount: 0 };
124
+ });
125
+ }
126
+ function parseQueryResultSets(results, statementTexts) {
127
+ const singleStatementSql = statementTexts?.length === 1 ? statementTexts[0] : void 0;
128
+ if (isMetadataObject(results)) {
129
+ return [{ sql: singleStatementSql, rows: [], rowCount: results.affectedRows || 0 }];
130
+ }
131
+ if (!Array.isArray(results)) {
132
+ return [{ sql: singleStatementSql, rows: [], rowCount: 0 }];
133
+ }
134
+ if (isMultiStatementResult(results)) {
135
+ return buildResultSetsFromMultiStatement(results, statementTexts);
136
+ }
137
+ return [{ sql: singleStatementSql, rows: results, rowCount: results.length }];
138
+ }
139
+
140
+ // src/utils/readonly-transaction.ts
141
+ function isClientSideTimeout(error) {
142
+ return typeof error === "object" && error !== null && error.code === "PROTOCOL_SEQUENCE_TIMEOUT";
143
+ }
144
+ async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
145
+ if (!readonly) {
146
+ return execute();
147
+ }
148
+ try {
149
+ await conn.query(
150
+ supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
151
+ );
152
+ const result = await execute();
153
+ await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
154
+ return result;
155
+ } catch (error) {
156
+ if (!isClientSideTimeout(error)) {
157
+ try {
158
+ await conn.query("ROLLBACK");
159
+ } catch {
160
+ }
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ // src/utils/server-flavor.ts
167
+ function isTiDBVersion(version) {
168
+ return typeof version === "string" && /tidb/i.test(version);
169
+ }
170
+
171
+ export {
172
+ getMySQLFamilyHealthCheck,
173
+ MissingDatabaseError,
174
+ requireDatabaseInDSN,
175
+ parseQueryResultSets,
176
+ isClientSideTimeout,
177
+ withReadOnlyTransaction,
178
+ isTiDBVersion
179
+ };
@@ -1,6 +1,7 @@
1
1
  import {
2
+ blankCommentsAndStrings,
2
3
  stripCommentsAndStrings
3
- } from "./chunk-JEZZN2YZ.js";
4
+ } from "./chunk-WIQ4DWGY.js";
4
5
 
5
6
  // src/utils/sql-row-limiter.ts
6
7
  var SQLRowLimiter = class {
@@ -70,17 +71,77 @@ var SQLRowLimiter = class {
70
71
  LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
71
72
  }
72
73
  }
74
+ /**
75
+ * Scan blanked (comment/string-free, length-preserving) SQL for parenthesis
76
+ * depth, invoking onMatch for every regex hit at depth 0 (i.e. not nested
77
+ * inside a subquery, function call, or window OVER clause).
78
+ */
79
+ static scanTopLevel(blankedSQL, regex, onMatch) {
80
+ let depth = 0;
81
+ let m;
82
+ while ((m = regex.exec(blankedSQL)) !== null) {
83
+ const token = m[0];
84
+ if (token === "(") {
85
+ depth++;
86
+ } else if (token === ")") {
87
+ depth--;
88
+ } else if (depth === 0) {
89
+ onMatch(m);
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Check if a SQL statement combines multiple SELECTs with a set operator
95
+ * (UNION [ALL], INTERSECT, EXCEPT) at the top level — i.e. not nested
96
+ * inside a subquery already wrapped in parentheses. Strips comments and
97
+ * string literals first to avoid false positives.
98
+ */
99
+ static hasSetOperator(sql) {
100
+ const blankedSQL = blankCommentsAndStrings(sql, "sqlserver");
101
+ let found = false;
102
+ this.scanTopLevel(blankedSQL, /\(|\)|\bunion\b|\bintersect\b|\bexcept\b/gi, () => {
103
+ found = true;
104
+ });
105
+ return found;
106
+ }
107
+ /**
108
+ * Find the start index of a top-level trailing ORDER BY clause (not one
109
+ * nested inside a subquery or a window function's OVER (...) clause).
110
+ * Returns -1 if none exists. Operates on the original (non-blanked) SQL
111
+ * length, since blankCommentsAndStrings preserves length/position.
112
+ */
113
+ static findTopLevelOrderByIndex(sql) {
114
+ const blankedSQL = blankCommentsAndStrings(sql, "sqlserver");
115
+ let lastIndex = -1;
116
+ this.scanTopLevel(blankedSQL, /\(|\)|\border\s+by\b/gi, (m) => {
117
+ lastIndex = m.index;
118
+ });
119
+ return lastIndex;
120
+ }
73
121
  /**
74
122
  * Add or modify TOP clause in a SQL statement (SQL Server)
75
123
  */
76
124
  static applyTopToQuery(sql, maxRows) {
125
+ if (this.hasSetOperator(sql)) {
126
+ const trimmed = sql.trim();
127
+ const hasSemicolon = trimmed.endsWith(";");
128
+ const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
129
+ const orderByIndex = this.findTopLevelOrderByIndex(sqlWithoutSemicolon);
130
+ if (orderByIndex !== -1) {
131
+ const innerSql = sqlWithoutSemicolon.slice(0, orderByIndex).trimEnd();
132
+ const orderByClause = sqlWithoutSemicolon.slice(orderByIndex).trim();
133
+ return `SELECT TOP ${maxRows} * FROM (${innerSql}
134
+ ) AS subq ${orderByClause}${hasSemicolon ? ";" : ""}`;
135
+ }
136
+ return `SELECT TOP ${maxRows} * FROM (${sqlWithoutSemicolon}
137
+ ) AS subq${hasSemicolon ? ";" : ""}`;
138
+ }
77
139
  const existingTop = this.extractTopValue(sql);
78
140
  if (existingTop !== null) {
79
141
  const effectiveTop = Math.min(existingTop, maxRows);
80
142
  return sql.replace(/\bselect\s+top\s+\d+/i, `SELECT TOP ${effectiveTop}`);
81
- } else {
82
- return sql.replace(/\bselect\s+/i, `SELECT TOP ${maxRows} `);
83
143
  }
144
+ return sql.replace(/\bselect\s+/i, `SELECT TOP ${maxRows} `);
84
145
  }
85
146
  /**
86
147
  * Check if a LIMIT clause uses a parameter placeholder (not a literal number).
@@ -462,6 +462,21 @@ function stripCommentsAndStrings(sql, dialect) {
462
462
  }
463
463
  return parts.join("");
464
464
  }
465
+ function blankCommentsAndStrings(sql, dialect) {
466
+ const scanToken = getScanner(dialect);
467
+ let result = "";
468
+ let i = 0;
469
+ while (i < sql.length) {
470
+ const token = scanToken(sql, i);
471
+ if (token.type === TokenType.Plain) {
472
+ result += sql[i];
473
+ } else {
474
+ result += " ".repeat(token.end - i);
475
+ }
476
+ i = token.end;
477
+ }
478
+ return result;
479
+ }
465
480
  function splitSQLStatements(sql, dialect) {
466
481
  const scanToken = getScanner(dialect);
467
482
  const statements = [];
@@ -495,5 +510,6 @@ export {
495
510
  getDatabaseTypeFromDSN,
496
511
  getDefaultPortForType,
497
512
  stripCommentsAndStrings,
513
+ blankCommentsAndStrings,
498
514
  splitSQLStatements
499
515
  };