dsh-db-tool 1.7.1 → 1.8.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 +28 -9
- package/README_EN.md +28 -9
- package/client/client.js +113 -18
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/mysql/index.js +52 -12
- package/dist/adapters/sql-shared/common.js +2 -1
- package/dist/adapters/sqlserver/index.js +840 -0
- package/dist/adapters/transfer/kinds/sqlserver.js +562 -0
- package/dist/adapters/transfer/model.js +8 -0
- package/dist/guard/index.js +97 -3
- package/dist/store/connections.js +23 -7
- package/package.json +5 -2
- package/scripts/build-codemirror.mjs +5 -2
- package/scripts/build-sqlserver.mjs +53 -0
- package/scripts/fix-dsh-install.ps1 +155 -0
- package/skills/db-admin/SKILL.md +25 -5
- package/vendor/codemirror-sql.cjs +798 -6
- package/vendor/sqlserver-driver.cjs +74182 -0
package/README.md
CHANGED
|
@@ -6,26 +6,45 @@ DSH 社区插件:在聊天中安全操作数据库,配套侧边栏管理台
|
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **9 种数据库**:MySQL、PostgreSQL、GaussDB、SQLite、Redis、MongoDB、Oracle、达梦(DM)、SQL Server
|
|
10
10
|
- **DatabaseManager 单工具多 action**:`list_connections / query / execute / schema / preview / run_script`(`run_script` 在 node:vm 沙箱中执行,60s 超时,仅注入受限 `db.{query,execute}` 句柄)
|
|
11
11
|
- **分级权限**:连接级只读(ro)/读写(rw)+ 项目级授权(`grants.json`:projectPathKey → 连接 → 模式);未授权项目一律拒绝
|
|
12
12
|
- **危险操作确认**:DDL / FLUSHALL / dropDatabase 等先返回 `NEEDS_CONFIRMATION`,对话内(模型经 ask)或 SQL 控制台(弹窗)确认后携一次性 `challengeId`(绑定语句 SHA256、5 分钟过期)重试
|
|
13
13
|
- **审计**:全部执行落 `audit.jsonl`(语句、危险级、是否确认、结果)
|
|
14
14
|
- **侧边栏 4 面板**(dsh-better-sidebar,zh/en):连接管理、项目授权、数据浏览、SQL 控制台
|
|
15
|
-
- **db-admin skill**:随插件分发,覆盖
|
|
15
|
+
- **db-admin skill**:随插件分发,覆盖 9 库方言速查、安全规范、确认流程
|
|
16
16
|
|
|
17
17
|
### SQL 控制台
|
|
18
18
|
|
|
19
19
|
侧边栏 SQL 控制台面板已升级为 Navicat 式多标签编辑器(CodeMirror 6):
|
|
20
20
|
|
|
21
21
|
- **多标签**:新建/关闭/切换查询标签,每个标签独立保存 SQL、结果与历史(历史上限 50 条,可展开点击回填)
|
|
22
|
-
- **语法高亮与补全**:按方言切换——mysql→MySQL、postgresql/gaussdb→PostgreSQL、sqlite→SQLite、oracle/dmdb→PL/SQL
|
|
22
|
+
- **语法高亮与补全**:按方言切换——mysql→MySQL、postgresql/gaussdb→PostgreSQL、sqlite→SQLite、oracle/dmdb→PL/SQL、sqlserver→T-SQL(`@codemirror/lang-sql` 的 MSSQL 方言);Redis/Mongo 为关键字/方法补全的纯文本编辑;表列元数据自动拉取注入补全(上限 50 表)。编辑器 bundle(`vendor/codemirror-sql.cjs`,`npm run build:codemirror` 生成,`npm run build` 自动串接)加载失败时静默降级 textarea
|
|
23
23
|
- **快捷键**:`Ctrl/Cmd+Enter` 执行全部,`Ctrl/Cmd+Shift+Enter` 执行选中
|
|
24
|
-
- **格式化**:sql-formatter
|
|
24
|
+
- **格式化**:sql-formatter 按方言格式化(sqlserver→`transactsql`),解析失败回退原文
|
|
25
25
|
- **逐条执行**:客户端预分类读/写语句分别路由 `/api/query` 与 `/api/execute`,只读授权下写语句自动跳过并标注;出错可一键定位到对应语句起始处
|
|
26
|
+
- **批分隔符 GO**:支持 `GO` 与 `GO n`——按 sqlcmd 语义把上一批重复执行 n 次(`GO 0` / `GO 1` 均等同 1 次),`GO` 行本身不产生语句;重复计数上限 **1000**,超出按 1000 截断,防止一次执行被放大成天量请求
|
|
26
27
|
- **事务**:rw 授权下可开始/提交/回滚(服务端粘性连接会话;空闲 5 分钟超时自动回滚并审计,授权回收/降级即时生效)
|
|
27
28
|
- **结果网格**:表头三态排序、50 行/页分页、单元格点击复制、CSV/JSON 导出
|
|
28
29
|
|
|
30
|
+
## SQL Server 支持范围与边界
|
|
31
|
+
|
|
32
|
+
覆盖 SQL Server 2008/2008R2 → 2022 与 Azure SQL Database;方言与元数据只使用 SQL Server 2005+ 的通用写法。
|
|
33
|
+
|
|
34
|
+
- **连接形态**:唯一形态为 `sqlserver://user:password@host:port/db`,支持 query 参数 `encrypt` / `trustServerCertificate` / `tdsVersion` / `instanceName` / `useUTC`(参数名大小写不敏感)。`tdsVersion` 默认 `7_4`,可配 `7_3_A` / `7_3_B` / `7_2` / `7_1`;端口默认 1433,指定 `instanceName` 时按 tedious 语义忽略端口、走 SQL Browser(UDP 1434);最低 TLS 版本经 `cryptoCredentialsDetails.minVersion` 配置,`ssl=true` 等价于 `encrypt=true`。**不接受** ADO.NET 键值连接串(`Server=...;Database=...`)。
|
|
35
|
+
默认 `encrypt=true` + `trustServerCertificate=false`(与 mssql 官方默认一致)。**连 SQL Server 2008/2008R2 时通常要在 URL 追加 `?trustServerCertificate=true`**:旧实例多为自签名证书且可能只支持 TLS 1.0,而 Node 默认最低 TLSv1.2,直接按默认值会握手失败;服务器未强制加密时也可用 `?encrypt=false`,并建议显式写 `?tdsVersion=7_3_A`(2008)/ `7_3_B`(2008R2)。
|
|
36
|
+
- **认证边界**:仅 SQL 认证(`user` / `password`)。**不支持 Windows 集成认证**,**不支持 Azure AD 认证**。
|
|
37
|
+
- **方言**:参数占位符对外只写 `?`,适配器内部按顺序重写为 `@p1..@pN`;标识符用 `[x]`,名字中的 `]` 双写为 `]]`;库/表/列元数据只走 `sys.*` catalog views(`sys.databases` / `sys.schemas` / `sys.objects` / `sys.columns` / `sys.types` / `sys.indexes` / `sys.extended_properties`),只用 2005+ 通用写法。
|
|
38
|
+
- **分页按版本分流**:连接后查 `SERVERPROPERTY('ProductMajorVersion')`(取不到时回退解析 `ProductVersion` 前缀)并缓存;major ≥ 11(2012+,含 Azure SQL 的 12)用 `ORDER BY (SELECT NULL) OFFSET n ROWS FETCH NEXT m ROWS ONLY`,< 11(2008/2008R2)降级 `ROW_NUMBER() OVER (ORDER BY (SELECT NULL))` 子查询;探测失败同样走降级分支。
|
|
39
|
+
- **decimal 精度分侧口径**:浏览侧(`query` / `preview` / 数据浏览网格)**不保证 decimal/numeric 逐位保真**——tedious 在解析阶段已把 decimal 转成 JS number(>15 有效位即丢精度),适配器转字符串只能防后续数值化,且 >1e21 会出现科学计数法;需要逐位保真时请在 SQL 里显式 `CONVERT(nvarchar(...), col)`。数据传输(transfer)读端已内建该转换(`CONVERT(nvarchar(64), col)`),写入时按字符串绑定,精度不经过 JS 浮点。
|
|
40
|
+
- **类型搬运口径(传输侧)**:`hierarchyid` / `geometry` / `geography` 按二进制(bytes)保真搬运,目标端建 `VARBINARY(MAX)`——**UDT 类型语义不保留**(目标端没有 `ST_*` / `hierarchyid` 方法);`sql_variant` 读端 `CONVERT(nvarchar(max), col)` 转文本,**原类型信息丢失**(驱动按值的实际类型返回标量,无法统一搬运);`text` / `ntext` / `xml` → `NVARCHAR(MAX)`,源 `varchar` / `char` 长度 4001–8000 也映射为 `NVARCHAR(MAX)` 类(保容量,不做隐式收窄),`varbinary` / `image` / `rowversion` → `VARBINARY(MAX)`,`decimal` / `numeric` / `money` → `DECIMAL`;`datetime2` 读端 `CONVERT(nvarchar(33), col, 126)`、`time` 用 `CONVERT(nvarchar(16), col, 126)` 保真。
|
|
41
|
+
- **ro 只读强度弱于 MySQL/PG**:SQL Server 没有等价的会话级只读语句。ro 授权下适配器 query 闸门共五道:① 首词白名单(`select / with / show / describe / desc / explain / use / set / help / table`);② 拒绝多语句(词法级:顶层分号之后出现任何内容即拒);③ 拒绝 `SELECT ... INTO`(词法 token 级判定,字符串、方括号标识符与注释内的 `into` 不误伤);④ 拒绝 `WITH ... (INSERT|UPDATE|DELETE|MERGE)`;⑤ 拒绝 `OPENQUERY` / `OPENROWSET` / `OPENDATASOURCE`(其字符串参数会在远端/外部数据源执行任意语句、不可审计,连合法的只读远端查询也一律拒)。服务层另拦 `execute`,guard 层对同样两类「读头写体」还会要求确认。**这仍是应用层拦截,不等同于数据库侧的只读会话**;需要强隔离时请给连接配数据库侧只读账号。
|
|
42
|
+
- **数据传输**:写入走参数化多行 `VALUES` INSERT(TDS 单语句参数上限 2100、`VALUES` 表值构造器单语句最多 1000 行,每语句行数取两者最小值;replace/ignore 走 `MERGE`)。传输分页同样按目标库版本分流:2012+ 用 `OFFSET/FETCH`,2008/2008R2 降级——有序游标(keyset/range)用 `SELECT TOP (n) ... ORDER BY key`,无分片键的 offset 全序与 `keyAt` 切块用 `ROW_NUMBER() OVER (...)` 子查询;版本探测失败时按 0 走保守降级,因此**传输在 SQL Server 2008/2008R2 同样可用**。已知限制:目标表已存在且该列为 IDENTITY 时,写入显式值会报错 544(当前不自动 `SET IDENTITY_INSERT ON`);传输自动建表不生成 IDENTITY 列。建表 DDL 在显式事务中可回滚,不需要 Oracle/DM 那样的隐式提交处理。
|
|
43
|
+
- **验证状态**:本轮只交付离线单测(Fake 驱动),SQL Server 路径**未经真机验证**;真机冒烟开关 `DBT_TEST_SQLSERVER_URL`(形如 `sqlserver://sa:pw@host:1433/db`)。
|
|
44
|
+
- **驱动形态**:`mssql@12` 仅在 devDependencies,运行时加载 `npm run build:sqlserver` 生成的 `vendor/sqlserver-driver.cjs`;原因是 mssql → tedious → bl → readable-stream 会把 buffer/events/process/string_decoder 带进生产依赖树,触发宿主 `dsh-app-boot` 的 `require.resolve.paths` bug,导致插件整体加载失败。
|
|
45
|
+
- **错误消息脱敏**:连接 URL 解析失败时,错误文案里的 userinfo 已脱敏,不回显明文密码(错误会进 `audit.jsonl` 与模型上下文,故必须脱敏)。
|
|
46
|
+
- **不支持**:SQL Server 2005(9.x)及更早版本,未支持也未验证;`tdsVersion=8_0`(TDS 8.0)与 `encrypt='strict'`(严格加密)亦未支持——SQL Server 2022 用默认 `7_4` 即可正常连接,TDS 8.0 强加密属后续增强。
|
|
47
|
+
|
|
29
48
|
## 数据布局
|
|
30
49
|
|
|
31
50
|
`$DSH_HOME/db-tool/`(0700):
|
|
@@ -39,7 +58,7 @@ DSH 社区插件:在聊天中安全操作数据库,配套侧边栏管理台
|
|
|
39
58
|
|
|
40
59
|
## 安装
|
|
41
60
|
|
|
42
|
-
|
|
61
|
+
9 种数据库驱动随 npm 包分发(SQLite 优先用 Node 内置 `node:sqlite`,可选 `better-sqlite3`;GaussDB 驱动为华为云官方 npm 包 `gaussdb-node`),无需任何构建步骤。其中 SQL Server 驱动(`mssql@12`)只在 devDependencies,运行时加载发布包内置的 `vendor/sqlserver-driver.cjs`(`npm run build:sqlserver` 生成)——mssql 的生产依赖树会带入 buffer/events/process/string_decoder,触发宿主 resolve.paths bug。GaussDB 认证支持 sha256 与 md5,md5-sha256 混合与 SM3 暂不支持。
|
|
43
62
|
|
|
44
63
|
唯一的一次性交互来自 oracledb:其 install 脚本会被 pnpm 默认拦截并使首次安装报告失败——在 DSH 插件安装界面点 **"Allow these scripts and retry"** 即可完成安装(该脚本仅做 Node 版本检查与横幅打印,批准无风险;批准持久化到 profile,后续升级不再提示)。
|
|
45
64
|
|
|
@@ -78,19 +97,19 @@ npx tsc --noEmit
|
|
|
78
97
|
npx stryker run # 变异测试(范围 lib/guard + lib/manager + lib/store,报告 reports/mutation/)
|
|
79
98
|
```
|
|
80
99
|
|
|
81
|
-
真机冒烟(设了才跑):`DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT
|
|
100
|
+
真机冒烟(设了才跑):`DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT / DBT_TEST_SQLSERVER_URL`(SQL Server 形如 `sqlserver://sa:pw@host:1433/db`)。GaussDB、Oracle/Mongo 与 SQL Server 的要求均按官方文档实现,未真机验证处以代码内标注为准;**本轮 SQL Server 只有离线单测(Fake 驱动),未经真机验证**。
|
|
82
101
|
|
|
83
102
|
## 目录结构
|
|
84
103
|
|
|
85
104
|
```
|
|
86
|
-
lib/ host 插件(store / adapters×
|
|
105
|
+
lib/ host 插件(store / adapters×9 / guard / manager / http / index)
|
|
87
106
|
client/ 侧边栏单文件产物(client.js,即源码)
|
|
88
107
|
skills/ db-admin skill
|
|
89
|
-
scripts/ 构建与工具脚本(mongodb
|
|
108
|
+
scripts/ 构建与工具脚本(mongodb / sqlserver 驱动、CodeMirror 编辑器 bundle、DSH 宿主热修复 patch:dsh 等)
|
|
90
109
|
patches/ DSH 宿主 dsh-app-boot 热修复补丁(patch:dsh 按宿主版本选用)
|
|
91
110
|
docs/ 预留(当前为空)
|
|
92
111
|
tests/ vitest(离线 mock + DBT_TEST_* 门控真机)
|
|
93
|
-
vendor/ mongodb-driver.cjs 与 codemirror-sql.cjs bundle(gitignore,发布经 files 白名单收录)
|
|
112
|
+
vendor/ mongodb-driver.cjs、sqlserver-driver.cjs 与 codemirror-sql.cjs bundle(gitignore,发布经 files 白名单收录)
|
|
94
113
|
```
|
|
95
114
|
|
|
96
115
|
## 许可证
|
package/README_EN.md
CHANGED
|
@@ -6,26 +6,45 @@ A DSH community plugin for operating databases safely from chat, with a sidebar
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **9 databases**: MySQL, PostgreSQL, GaussDB, SQLite, Redis, MongoDB, Oracle, DM (Dameng), and SQL Server
|
|
10
10
|
- **One tool, six actions**: `DatabaseManager` exposes `list_connections / query / execute / schema / preview / run_script` (`run_script` executes in a `node:vm` sandbox with a 60s timeout and only restricted `db.{query,execute}` handles)
|
|
11
11
|
- **Tiered permissions**: per-connection read-only (ro) / read-write (rw) plus project-scoped grants (`grants.json`: projectPathKey → connection → mode); unauthorized projects are always rejected
|
|
12
12
|
- **Dangerous-operation confirmation**: DDL / FLUSHALL / dropDatabase and the like first return `NEEDS_CONFIRMATION`; after confirmation in chat (model via ask) or in the SQL console (dialog), the retry carries a one-time `challengeId` (bound to the statement SHA256, 5-minute TTL)
|
|
13
13
|
- **Auditing**: every execution is appended to `audit.jsonl` (statement, danger level, confirmed or not, outcome)
|
|
14
14
|
- **Sidebar with 4 panels** (dsh-better-sidebar, zh/en): connections, project grants, data browser, SQL console
|
|
15
|
-
- **db-admin skill**: shipped with the plugin, covering dialect cheat sheets for all
|
|
15
|
+
- **db-admin skill**: shipped with the plugin, covering dialect cheat sheets for all 9 databases, safety rules, and the confirmation flow
|
|
16
16
|
|
|
17
17
|
### SQL Console
|
|
18
18
|
|
|
19
19
|
The sidebar SQL console panel has been upgraded to a Navicat-style multi-tab editor (CodeMirror 6):
|
|
20
20
|
|
|
21
21
|
- **Multi-tab**: create/close/switch query tabs; each tab keeps its own SQL, result, and history (capped at 50 entries, expandable and click-to-refill)
|
|
22
|
-
- **Syntax highlighting & completion**: switches with the dialect — mysql→MySQL, postgresql/gaussdb→PostgreSQL, sqlite→SQLite, oracle/dmdb→PL/SQL; Redis/Mongo get keyword/method completion over plain text; table/column metadata is fetched to feed completion (up to 50 tables). If the editor bundle (`vendor/codemirror-sql.cjs`, built by `npm run build:codemirror`, chained after `npm run build`) fails to load, it silently falls back to a textarea
|
|
22
|
+
- **Syntax highlighting & completion**: switches with the dialect — mysql→MySQL, postgresql/gaussdb→PostgreSQL, sqlite→SQLite, oracle/dmdb→PL/SQL, sqlserver→T-SQL (the MSSQL dialect from `@codemirror/lang-sql`); Redis/Mongo get keyword/method completion over plain text; table/column metadata is fetched to feed completion (up to 50 tables). If the editor bundle (`vendor/codemirror-sql.cjs`, built by `npm run build:codemirror`, chained after `npm run build`) fails to load, it silently falls back to a textarea
|
|
23
23
|
- **Shortcuts**: `Ctrl/Cmd+Enter` runs everything, `Ctrl/Cmd+Shift+Enter` runs the selection
|
|
24
|
-
- **Formatting**: dialect-aware formatting via sql-formatter, falling back to the original text on parse failure
|
|
24
|
+
- **Formatting**: dialect-aware formatting via sql-formatter (sqlserver→`transactsql`), falling back to the original text on parse failure
|
|
25
25
|
- **Per-statement execution**: statements are pre-classified client-side as read/write and routed to `/api/query` vs `/api/execute`; write statements are skipped and flagged under read-only grants; errors can jump to the offending statement
|
|
26
|
+
- **`GO` batch separator**: `GO` and `GO n` follow sqlcmd semantics — the preceding batch is repeated n times (`GO 0` / `GO 1` both mean once) and the `GO` line itself produces no statement; the repeat count is capped at **1000** (values above it are truncated) so one run cannot be amplified into an unbounded number of requests
|
|
26
27
|
- **Transactions**: begin/commit/rollback under rw grants (server-side sticky-connection session; idle sessions roll back after a 5-minute TTL and are audited; grant revocation takes effect immediately)
|
|
27
28
|
- **Result grid**: tri-state column sorting, pagination at 50 rows/page, click-to-copy cells, CSV/JSON export
|
|
28
29
|
|
|
30
|
+
## SQL Server Support Scope and Boundaries
|
|
31
|
+
|
|
32
|
+
Coverage: SQL Server 2008/2008R2 → 2022 and Azure SQL Database; the dialect and metadata queries only use SQL Server 2005+ constructs.
|
|
33
|
+
|
|
34
|
+
- **Connection form**: exclusively `sqlserver://user:password@host:port/db`, with query parameters `encrypt` / `trustServerCertificate` / `tdsVersion` / `instanceName` / `useUTC` (names are case-insensitive). `tdsVersion` defaults to `7_4` and accepts `7_3_A` / `7_3_B` / `7_2` / `7_1`; the port defaults to 1433, and when `instanceName` is set the port is ignored per tedious semantics (SQL Browser over UDP 1434); the minimum TLS version is set via `cryptoCredentialsDetails.minVersion`, and `ssl=true` is equivalent to `encrypt=true`. ADO.NET key/value connection strings (`Server=...;Database=...`) are **not** accepted.
|
|
35
|
+
The defaults are `encrypt=true` + `trustServerCertificate=false` (the same as mssql's own defaults). **Connecting to SQL Server 2008/2008R2 usually requires appending `?trustServerCertificate=true`**: those instances typically use self-signed certificates and may only speak TLS 1.0, while Node's default minimum is TLSv1.2, so the defaults fail the handshake; use `?encrypt=false` when the server does not force encryption, and set `?tdsVersion=7_3_A` (2008) / `7_3_B` (2008R2) explicitly.
|
|
36
|
+
- **Authentication**: SQL authentication only (`user` / `password`). **Windows integrated authentication is not supported**, and neither is **Azure AD authentication**.
|
|
37
|
+
- **Dialect**: write `?` as the parameter placeholder — the adapter rewrites them in order to `@p1..@pN`; identifiers use `[x]` with `]` doubled as `]]`; database/table/column metadata goes through `sys.*` catalog views only (`sys.databases` / `sys.schemas` / `sys.objects` / `sys.columns` / `sys.types` / `sys.indexes` / `sys.extended_properties`), using 2005+ constructs only.
|
|
38
|
+
- **Paging is version-split**: after connecting it reads `SERVERPROPERTY('ProductMajorVersion')` (falling back to parsing the `ProductVersion` prefix) and caches the result; major ≥ 11 (2012+, including Azure SQL's 12) uses `ORDER BY (SELECT NULL) OFFSET n ROWS FETCH NEXT m ROWS ONLY`, while < 11 (2008/2008R2) degrades to a `ROW_NUMBER() OVER (ORDER BY (SELECT NULL))` subquery; a failed probe also takes the degraded branch.
|
|
39
|
+
- **decimal precision is side-dependent**: on the browsing side (`query` / `preview` / data-browser grid) **decimal/numeric is not guaranteed digit-exact** — tedious has already turned decimal into a JS number during parsing (>15 significant digits lose precision), and converting it to a string afterwards only prevents later numeric coercion (>1e21 also renders in scientific notation); ask for `CONVERT(nvarchar(...), col)` in SQL when you need digit-exact values. The transfer reader already does that conversion (`CONVERT(nvarchar(64), col)`) and binds strings on write, so precision never passes through JS floats.
|
|
40
|
+
- **Type mapping on the transfer side**: `hierarchyid` / `geometry` / `geography` move as binary (bytes) into `VARBINARY(MAX)` on the target — the values are preserved but **UDT type semantics are not** (no `ST_*` / `hierarchyid` methods on the target); `sql_variant` is read as `CONVERT(nvarchar(max), col)` text, so **the original type information is lost** (the driver returns scalars of the value's actual type, which cannot be moved uniformly); `text` / `ntext` / `xml` → `NVARCHAR(MAX)`, source `varchar` / `char` of length 4001–8000 also maps to `NVARCHAR(MAX)` class (capacity preserved, never silently narrowed), `varbinary` / `image` / `rowversion` → `VARBINARY(MAX)`, `decimal` / `numeric` / `money` → `DECIMAL`; `datetime2` is read via `CONVERT(nvarchar(33), col, 126)` and `time` via `CONVERT(nvarchar(16), col, 126)` for exactness.
|
|
41
|
+
- **ro read-only is weaker than MySQL/PG**: SQL Server has no equivalent session-level read-only statement. Under an ro grant the adapter's query gate has five checks: ① a first-word allowlist (`select / with / show / describe / desc / explain / use / set / help / table`); ② rejection of multiple statements (lexical: the moment anything follows a top-level semicolon); ③ rejection of `SELECT ... INTO` (token-level, so `into` inside strings, bracket identifiers, or comments is not a false positive); ④ rejection of `WITH ... (INSERT|UPDATE|DELETE|MERGE)`; ⑤ rejection of `OPENQUERY` / `OPENROWSET` / `OPENDATASOURCE` (their string arguments execute arbitrary statements remotely/unattended and cannot be audited, so even legitimate read-only remote queries are refused). The service layer also blocks `execute`, and the guard layer still requires confirmation for the same two "read head, write body" forms. **This is still application-level interception, not the same as a database-side read-only session**; use a database-side read-only account when you need strong isolation.
|
|
42
|
+
- **Data transfer**: writes use parameterized multi-row `VALUES` INSERTs (TDS caps a statement at 2100 parameters and a `VALUES` table constructor at 1000 rows, so rows per statement is the smaller of the two; replace/ignore go through `MERGE`). Transfer paging is version-split as well: 2012+ uses `OFFSET/FETCH`, while 2008/2008R2 degrades — ordered cursors (keyset/range) use `SELECT TOP (n) ... ORDER BY key`, and the offset full ordering plus `keyAt` chunking use `ROW_NUMBER() OVER (...)` subqueries; a failed version probe falls back to 0 and takes the conservative branch, so **transfer works on SQL Server 2008/2008R2 too**. Known limits: when the target table already exists and a column is IDENTITY, writing explicit values fails with error 544 (the plugin does not issue `SET IDENTITY_INSERT ON`); tables created by transfer do not generate IDENTITY columns. CREATE TABLE DDL can be rolled back inside an explicit transaction, so no Oracle/DM-style implicit-commit handling is needed.
|
|
43
|
+
- **Verification status**: this round ships offline unit tests only (Fake driver); the SQL Server path is **not verified against a live database**. The live smoke switch is `DBT_TEST_SQLSERVER_URL` (e.g. `sqlserver://sa:pw@host:1433/db`).
|
|
44
|
+
- **Driver packaging**: `mssql@12` lives in devDependencies only; at runtime the plugin loads `vendor/sqlserver-driver.cjs` built by `npm run build:sqlserver`. Reason: mssql → tedious → bl → readable-stream drags buffer/events/process/string_decoder into the production dependency tree, tripping the host's `dsh-app-boot` `require.resolve.paths` bug and failing the whole plugin load.
|
|
45
|
+
- **Error-message redaction**: when a connection URL fails to parse, the error text has the userinfo redacted and never echoes the plaintext password (errors reach `audit.jsonl` and the model context, so redaction is mandatory).
|
|
46
|
+
- **Not supported**: SQL Server 2005 (9.x) and earlier — neither supported nor verified; `tdsVersion=8_0` (TDS 8.0) and `encrypt='strict'` are also unsupported — SQL Server 2022 connects fine with the default `7_4`, and TDS 8.0 strict encryption is a later enhancement.
|
|
47
|
+
|
|
29
48
|
## Data Layout
|
|
30
49
|
|
|
31
50
|
`$DSH_HOME/db-tool/` (0700):
|
|
@@ -39,7 +58,7 @@ The sidebar SQL console panel has been upgraded to a Navicat-style multi-tab edi
|
|
|
39
58
|
|
|
40
59
|
## Installation
|
|
41
60
|
|
|
42
|
-
Drivers for all
|
|
61
|
+
Drivers for all 9 databases ship with the npm package (SQLite prefers the built-in `node:sqlite`, optionally `better-sqlite3`; the GaussDB driver is the official Huawei Cloud npm package `gaussdb-node`) — no build steps of any kind. The SQL Server driver is the exception in form: `mssql@12` lives in devDependencies only, and the runtime loads the bundled `vendor/sqlserver-driver.cjs` built by `npm run build:sqlserver` — mssql's production dependency tree pulls in buffer/events/process/string_decoder, which trips the host's resolve.paths bug. GaussDB authentication supports sha256 and md5; md5-sha256 hybrid and SM3 are not supported yet.
|
|
43
62
|
|
|
44
63
|
The only one-time interaction comes from oracledb: pnpm blocks its install script by default, which makes the first install report a failure — click **"Allow these scripts and retry"** in the DSH plugin installation UI to finish (the script only checks the Node version and prints a banner, so approving is risk-free; the approval is persisted to the profile and upgrades won't prompt again).
|
|
45
64
|
|
|
@@ -78,19 +97,19 @@ npx tsc --noEmit
|
|
|
78
97
|
npx stryker run # mutation testing (lib/guard + lib/manager + lib/store, reports in reports/mutation/)
|
|
79
98
|
```
|
|
80
99
|
|
|
81
|
-
Live-database smoke tests (run only when set): `DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT
|
|
100
|
+
Live-database smoke tests (run only when set): `DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT / DBT_TEST_SQLSERVER_URL` (SQL Server in the form `sqlserver://sa:pw@host:1433/db`). The GaussDB, Oracle/Mongo, and SQL Server requirements are implemented per their official docs; anything not verified against live databases is annotated in code. **This round ships offline unit tests only (Fake driver) for SQL Server — no live-database verification.**
|
|
82
101
|
|
|
83
102
|
## Project Structure
|
|
84
103
|
|
|
85
104
|
```
|
|
86
|
-
lib/ host plugin (store / adapters×
|
|
105
|
+
lib/ host plugin (store / adapters×9 / guard / manager / http / index)
|
|
87
106
|
client/ sidebar single-file artifact (client.js — this is the source)
|
|
88
107
|
skills/ db-admin skill
|
|
89
|
-
scripts/ build & tooling scripts (MongoDB driver
|
|
108
|
+
scripts/ build & tooling scripts (MongoDB / SQL Server driver and CodeMirror editor bundles, DSH host hotfix patch:dsh, etc.)
|
|
90
109
|
patches/ DSH host dsh-app-boot hotfix patches (selected by host version for patch:dsh)
|
|
91
110
|
docs/ reserved (currently empty)
|
|
92
111
|
tests/ vitest (offline mocks + DBT_TEST_*-gated live runs)
|
|
93
|
-
vendor/ mongodb-driver.cjs and codemirror-sql.cjs bundles (gitignored; shipped via the files whitelist)
|
|
112
|
+
vendor/ mongodb-driver.cjs, sqlserver-driver.cjs and codemirror-sql.cjs bundles (gitignored; shipped via the files whitelist)
|
|
94
113
|
```
|
|
95
114
|
|
|
96
115
|
## License
|
package/client/client.js
CHANGED
|
@@ -468,7 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
468
468
|
|
|
469
469
|
const TAB_ID = "dsh-db-tool";
|
|
470
470
|
const API = "/dsh-db-tool/api";
|
|
471
|
-
const DB_KINDS = ["mysql", "postgresql", "gaussdb", "sqlite", "redis", "mongodb", "oracle", "dmdb"];
|
|
471
|
+
const DB_KINDS = ["mysql", "postgresql", "gaussdb", "sqlite", "redis", "mongodb", "oracle", "dmdb", "sqlserver"];
|
|
472
472
|
const PAGE_SIZE = 50; // 契约:preview limit≤50 服务端强制
|
|
473
473
|
|
|
474
474
|
// --- HTTP 封装:保留 code / challengeId 供确认通道使用 ---
|
|
@@ -876,9 +876,10 @@ window.__ModuleLoader__.load({
|
|
|
876
876
|
// 统一返回形状:{ ok:true, ops:[{statement, params?, database?}] }(多条顺序执行,如 redis 两步)
|
|
877
877
|
// 或 { ok:false, error:"<i18n key>" }(error 为 key 名,组件侧 t() 本地化;null 表示通用错误)
|
|
878
878
|
|
|
879
|
-
// 方言判定:q=问号占位符(mysql/sqlite
|
|
879
|
+
// 方言判定:q=问号占位符(mysql/sqlite/sqlserver,sqlserver 的 @pN 由适配器内部重写)、
|
|
880
|
+
// dollar=$n(postgresql/gaussdb)、colon=:n(oracle/dmdb)
|
|
880
881
|
function dialectOf(kind) {
|
|
881
|
-
if (kind === "mysql" || kind === "sqlite") return "q";
|
|
882
|
+
if (kind === "mysql" || kind === "sqlite" || kind === "sqlserver") return "q";
|
|
882
883
|
if (kind === "postgresql" || kind === "gaussdb") return "dollar";
|
|
883
884
|
if (kind === "oracle" || kind === "dmdb") return "colon";
|
|
884
885
|
if (kind === "redis") return "redis";
|
|
@@ -886,8 +887,10 @@ window.__ModuleLoader__.load({
|
|
|
886
887
|
return null;
|
|
887
888
|
}
|
|
888
889
|
|
|
889
|
-
// 标识符引用:mysql
|
|
890
|
+
// 标识符引用:mysql 用反引号(内部双写转义),sqlserver 用方括号(] 双写转义),
|
|
891
|
+
// 其余统一双引号(内部双写转义)
|
|
890
892
|
function quoteIdent(kind, name) {
|
|
893
|
+
if (kind === "sqlserver") return "[" + String(name).split("]").join("]]") + "]";
|
|
891
894
|
const q = kind === "mysql" ? "`" : "\"";
|
|
892
895
|
return q + String(name).split(q).join(q + q) + q;
|
|
893
896
|
}
|
|
@@ -920,7 +923,9 @@ window.__ModuleLoader__.load({
|
|
|
920
923
|
return { ok: false, error: "noPkHint" };
|
|
921
924
|
}
|
|
922
925
|
// 表全限定:pg/gauss 用 schema 前缀(库由 execute 的 database 参数路由,语句内不写库名);
|
|
923
|
-
// mysql
|
|
926
|
+
// mysql 表名同样带库名(execute 的 database 参数已按库路由,全限定在目标库上执行等价,双保险);
|
|
927
|
+
// oracle/dmdb 的 dbRef 即 OWNER;sqlite 仅表名;
|
|
928
|
+
// sqlserver 有 schema 时写 [库].[schema].[表] 三段
|
|
924
929
|
// execute 的 database 参数只传库名(db.schema 形态取点前段),连接目标只能是库,schema 靠语句内全限定
|
|
925
930
|
let table;
|
|
926
931
|
if (kind === "postgresql" || kind === "gaussdb") {
|
|
@@ -929,6 +934,13 @@ window.__ModuleLoader__.load({
|
|
|
929
934
|
: quoteIdent(kind, tableName);
|
|
930
935
|
} else if (kind === "mysql" || kind === "oracle" || kind === "dmdb") {
|
|
931
936
|
table = quoteIdent(kind, dbRef) + "." + quoteIdent(kind, tableName);
|
|
937
|
+
} else if (kind === "sqlserver") {
|
|
938
|
+
// 无 schema 时只写表名:T-SQL 两段式是 schema.object(不是 database.object),
|
|
939
|
+
// 写 [库].[表] 会被解析成 schema=库名 → 报 208 对象不存在;库由 database 参数路由
|
|
940
|
+
// (适配器按库名切池),与 pg/gauss 分支同构。
|
|
941
|
+
table = schemaName
|
|
942
|
+
? quoteIdent(kind, String(dbRef).split(".")[0]) + "." + quoteIdent(kind, schemaName) + "." + quoteIdent(kind, tableName)
|
|
943
|
+
: quoteIdent(kind, tableName);
|
|
932
944
|
} else {
|
|
933
945
|
table = quoteIdent(kind, tableName);
|
|
934
946
|
}
|
|
@@ -1029,23 +1041,29 @@ window.__ModuleLoader__.load({
|
|
|
1029
1041
|
if (kind === "postgresql" || kind === "gaussdb") return "postgresql";
|
|
1030
1042
|
if (kind === "sqlite") return "sqlite";
|
|
1031
1043
|
if (kind === "oracle" || kind === "dmdb") return "plsql";
|
|
1044
|
+
if (kind === "sqlserver") return "transactsql";
|
|
1032
1045
|
return null;
|
|
1033
1046
|
}
|
|
1034
1047
|
|
|
1035
1048
|
// SQL 文本 → 语句列表 [{ text, start }]:text 为原文切片(保留内部注释/空白),start 为语句
|
|
1036
1049
|
// 首字符偏移(跳过前导空白/注释,错误就地显示后定位光标用)。词法跳过与 lang-sql 方言 spec
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1050
|
+
// 一一对应:单/双引号字符串(双写转义;反斜杠转义是 MySQL 专有语义,其它 kind 按标准 SQL)、
|
|
1051
|
+
// -- 行注释(slashComments)、/* */ 块注释、$$...$$ dollar-quoting(doubleDollarQuotedStrings,仅
|
|
1039
1052
|
// postgresql/gaussdb)、MySQL DELIMITER 自定义分隔符(客户端指令:仅语句缓冲为空时切换后续
|
|
1040
1053
|
// 分隔符,指令行不产生语句)、Oracle/达梦 BEGIN..END 块(块内分号不切分;CREATE
|
|
1041
|
-
// PROCEDURE/FUNCTION
|
|
1054
|
+
// PROCEDURE/FUNCTION 体的分号由该块覆盖)、SQL Server [方括号] 标识符与 GO 批分隔符
|
|
1055
|
+
// (GO 支持重复计数与行尾 -- 注释;GO n 按总数语义展开上一批)。
|
|
1056
|
+
// 空/纯注释语句被过滤;无尾分隔符的最后一条也返回。
|
|
1042
1057
|
// ponytail: depth 计数不识别 CASE..END/LOOP/IF 等(块内混用可能提前切分);需要时换完整 PL/SQL 词法
|
|
1058
|
+
// GO n 的重复上限:GO 是客户端批分隔符,n 次展开会成倍放大执行请求数,钳制防手滑/恶意写出天量请求
|
|
1059
|
+
const GO_REPEAT_MAX = 1000;
|
|
1043
1060
|
function splitSqlStatements(sql, kind) {
|
|
1044
1061
|
const src = String(sql == null ? "" : sql);
|
|
1045
1062
|
const out = [];
|
|
1046
1063
|
const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
1047
1064
|
let sep = ";";
|
|
1048
1065
|
let i = 0, stmtStart = 0, contentStart = 0, hasContent = false, depth = 0; // depth: BEGIN..END 嵌套计数
|
|
1066
|
+
let batchFrom = 0; // 当前批(GO 之间)在 out 中的起始下标,供 GO n 整批重复用
|
|
1049
1067
|
while (i < src.length) {
|
|
1050
1068
|
const c = src[i];
|
|
1051
1069
|
if (c === "-" && src[i + 1] === "-") { // -- 行注释至行尾
|
|
@@ -1058,12 +1076,14 @@ window.__ModuleLoader__.load({
|
|
|
1058
1076
|
i = Math.min(src.length, i + 2);
|
|
1059
1077
|
continue;
|
|
1060
1078
|
}
|
|
1061
|
-
if (c === "'" || c === '"' || (kind === "mysql" && c === "`")) { //
|
|
1079
|
+
if (c === "'" || c === '"' || (kind === "mysql" && c === "`")) { // 引号串:双写转义;反斜杠转义仅 MySQL 语义
|
|
1062
1080
|
if (!hasContent) contentStart = i;
|
|
1063
1081
|
hasContent = true;
|
|
1064
1082
|
i++;
|
|
1065
1083
|
while (i < src.length) {
|
|
1066
|
-
|
|
1084
|
+
// 反斜杠转义是 MySQL 专有语义;其它 kind(pg/oracle/sqlite/sqlserver)按标准 SQL,
|
|
1085
|
+
// \ 是普通字符(T-SQL 下 'C:\temp\' 正常闭合,否则会误吞后续语句)
|
|
1086
|
+
if (kind === "mysql" && src[i] === "\\") { i += 2; continue; }
|
|
1067
1087
|
if (src[i] === c) {
|
|
1068
1088
|
if (src[i + 1] === c) { i += 2; continue; } // 双写转义
|
|
1069
1089
|
i++;
|
|
@@ -1073,6 +1093,21 @@ window.__ModuleLoader__.load({
|
|
|
1073
1093
|
}
|
|
1074
1094
|
continue;
|
|
1075
1095
|
}
|
|
1096
|
+
// [方括号] 标识符(sqlserver):内部 ; 不切分,]] 为转义(方括号内反斜杠无转义语义)
|
|
1097
|
+
if (kind === "sqlserver" && c === "[") {
|
|
1098
|
+
if (!hasContent) contentStart = i;
|
|
1099
|
+
hasContent = true;
|
|
1100
|
+
i++;
|
|
1101
|
+
while (i < src.length) {
|
|
1102
|
+
if (src[i] === "]") {
|
|
1103
|
+
if (src[i + 1] === "]") { i += 2; continue; }
|
|
1104
|
+
i++;
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
i++;
|
|
1108
|
+
}
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1076
1111
|
if ((kind === "postgresql" || kind === "gaussdb") && c === "$" && src[i + 1] === "$") {
|
|
1077
1112
|
if (!hasContent) contentStart = i; // $$dollar 串至下一 $$(未闭合则吞到结尾)
|
|
1078
1113
|
hasContent = true;
|
|
@@ -1091,6 +1126,50 @@ window.__ModuleLoader__.load({
|
|
|
1091
1126
|
while (j < e && (src[j] === " " || src[j] === "\t")) j++;
|
|
1092
1127
|
if (j < e) { sep = src.slice(j, e); stmtStart = e; i = e; continue; }
|
|
1093
1128
|
}
|
|
1129
|
+
// GO 批分隔符(sqlserver):独占一行——行首可缩进、行尾可跟空白与 -- 行注释,
|
|
1130
|
+
// 可带重复计数(GO 3 = 上一批执行 3 次,总数语义,与 sqlcmd 一致);大小写不敏感;
|
|
1131
|
+
// GO 行本身不产生语句。计数超 GO_REPEAT_MAX 时截断(防天量请求)。
|
|
1132
|
+
if (kind === "sqlserver" && depth === 0 && (i === 0 || src[i - 1] === "\n")) {
|
|
1133
|
+
let j = i;
|
|
1134
|
+
while (j < src.length && (src[j] === " " || src[j] === "\t")) j++;
|
|
1135
|
+
if (src.slice(j, j + 2).toUpperCase() === "GO" && !isWord(src[j + 2] || " ")) {
|
|
1136
|
+
let k = j + 2, count = 1;
|
|
1137
|
+
while (k < src.length && (src[k] === " " || src[k] === "\t")) k++;
|
|
1138
|
+
if (src[k] >= "0" && src[k] <= "9") { // 可选重复计数:GO 0 / GO 1 均等同 1 次
|
|
1139
|
+
let n = 0;
|
|
1140
|
+
while (k < src.length && src[k] >= "0" && src[k] <= "9") {
|
|
1141
|
+
n = Math.min(n * 10 + (src.charCodeAt(k) - 48), GO_REPEAT_MAX);
|
|
1142
|
+
k++;
|
|
1143
|
+
}
|
|
1144
|
+
count = Math.max(1, n);
|
|
1145
|
+
}
|
|
1146
|
+
while (k < src.length && (src[k] === " " || src[k] === "\t")) k++;
|
|
1147
|
+
if (src[k] === "-" && src[k + 1] === "-") { // 行尾注释:GO -- 说明
|
|
1148
|
+
while (k < src.length && src[k] !== "\n") k++;
|
|
1149
|
+
}
|
|
1150
|
+
if (k >= src.length || src[k] === "\n" || src[k] === "\r") { // 确认行尾:行内 GO 不算
|
|
1151
|
+
if (hasContent) {
|
|
1152
|
+
// 语句切到 GO 行首前一行的行尾换行之前(text 不带 GO 行前导换行)
|
|
1153
|
+
let end = i;
|
|
1154
|
+
if (end > stmtStart && src[end - 1] === "\n") end -= src[end - 2] === "\r" ? 2 : 1;
|
|
1155
|
+
out.push({ text: src.slice(stmtStart, end), start: contentStart });
|
|
1156
|
+
}
|
|
1157
|
+
// 批重复:把刚切出的当前批再追加 count-1 遍(总数语义;复制对象,不共享引用)
|
|
1158
|
+
if (count > 1 && out.length > batchFrom) {
|
|
1159
|
+
const batch = out.slice(batchFrom);
|
|
1160
|
+
for (let r = 1; r < count; r++) {
|
|
1161
|
+
for (const s of batch) out.push({ text: s.text, start: s.start });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
batchFrom = out.length; // 下一批从后续新语句开始
|
|
1165
|
+
i = k;
|
|
1166
|
+
stmtStart = k;
|
|
1167
|
+
contentStart = k;
|
|
1168
|
+
hasContent = false;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1094
1173
|
// BEGIN/END 词计数(oracle/dmdb):块内分号不切分;END 不把 depth 减到负(顶层 CASE..END 不受影响)
|
|
1095
1174
|
if ((kind === "oracle" || kind === "dmdb") && (i === 0 || !isWord(src[i - 1]))) {
|
|
1096
1175
|
if (src.slice(i, i + 5).toUpperCase() === "BEGIN" && !isWord(src[i + 5] || " ")) {
|
|
@@ -1172,6 +1251,7 @@ window.__ModuleLoader__.load({
|
|
|
1172
1251
|
mongodb: { host: "127.0.0.1", port: 27017, database: "test" },
|
|
1173
1252
|
oracle: { host: "127.0.0.1", port: 1521, user: "system" },
|
|
1174
1253
|
dmdb: { host: "127.0.0.1", port: 5236, user: "SYSDBA" },
|
|
1254
|
+
sqlserver: { host: "127.0.0.1", port: 1433, user: "sa" },
|
|
1175
1255
|
};
|
|
1176
1256
|
|
|
1177
1257
|
/**
|
|
@@ -1732,8 +1812,8 @@ window.__ModuleLoader__.load({
|
|
|
1732
1812
|
if (!open["c:" + c.id]) toggleConn(c);
|
|
1733
1813
|
}
|
|
1734
1814
|
}, [conns, projectPath, autoExpand]); // eslint-disable-line
|
|
1735
|
-
// PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
|
|
1736
|
-
const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
|
|
1815
|
+
// PG/GaussDB/SQL Server 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
|
|
1816
|
+
const HAS_SCHEMAS = { postgresql: true, gaussdb: true, sqlserver: true };
|
|
1737
1817
|
function toggleDb(c, d) {
|
|
1738
1818
|
if (!projectPath) return;
|
|
1739
1819
|
toggle("d:" + c.id + "/" + d, () => loadByKey("d:" + c.id + "/" + d));
|
|
@@ -2397,7 +2477,7 @@ window.__ModuleLoader__.load({
|
|
|
2397
2477
|
};
|
|
2398
2478
|
}
|
|
2399
2479
|
|
|
2400
|
-
const CONSOLE_SQL_KINDS = { mysql: true, postgresql: true, gaussdb: true, sqlite: true, oracle: true, dmdb: true };
|
|
2480
|
+
const CONSOLE_SQL_KINDS = { mysql: true, postgresql: true, gaussdb: true, sqlite: true, oracle: true, dmdb: true, sqlserver: true };
|
|
2401
2481
|
|
|
2402
2482
|
function ConsoleView(props) {
|
|
2403
2483
|
const { conns, grants, projectPath, askConfirm, onError } = props;
|
|
@@ -2408,6 +2488,9 @@ window.__ModuleLoader__.load({
|
|
|
2408
2488
|
// Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
|
|
2409
2489
|
const [dbList, setDbList] = React.useState([]);
|
|
2410
2490
|
const [db, setDb] = React.useState("");
|
|
2491
|
+
// 列库失败/加载中必须就地可见(对齐 BrowseTree 错误行;此前静默吞错,下拉在失败时无声消失)
|
|
2492
|
+
const [dbsErr, setDbsErr] = React.useState("");
|
|
2493
|
+
const [dbsLoading, setDbsLoading] = React.useState(false);
|
|
2411
2494
|
// 多标签(内存态,不落盘):tabs[{id,title,sql,result,history}]
|
|
2412
2495
|
const tabSeq = React.useRef(1);
|
|
2413
2496
|
const [tabs, setTabs] = React.useState(() => [{ id: "t1", title: t("queryTab", { n: 1 }), sql: "", result: null, history: [] }]);
|
|
@@ -2433,11 +2516,19 @@ window.__ModuleLoader__.load({
|
|
|
2433
2516
|
const activeTab = tabs[activeIdx] || tabs[0];
|
|
2434
2517
|
const activeTabId = activeTab ? activeTab.id : "";
|
|
2435
2518
|
|
|
2436
|
-
//
|
|
2519
|
+
// 跨库下拉(保留既有行为:换连接重置);列库失败可见+可重试(词条与 TransferView 复用)
|
|
2520
|
+
function loadDbs() {
|
|
2521
|
+
setDbsErr(""); // 发起即清:换连接/重试成功/重试失败三路径由此闭合
|
|
2522
|
+
if (!connId || !projectPath) return;
|
|
2523
|
+
setDbsLoading(true);
|
|
2524
|
+
api("databases" + qs({ project: projectPath, connId })).then(
|
|
2525
|
+
(l) => { setDbList(l || []); setDbsLoading(false); },
|
|
2526
|
+
(e) => { setDbsErr(String(e && e.message ? e.message : e)); setDbsLoading(false); },
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2437
2529
|
React.useEffect(() => {
|
|
2438
2530
|
setDbList([]); setDb("");
|
|
2439
|
-
|
|
2440
|
-
api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
|
|
2531
|
+
loadDbs();
|
|
2441
2532
|
}, [connId, projectPath]);
|
|
2442
2533
|
|
|
2443
2534
|
// vendor bundle 异步加载(失败降级 textarea,静默)
|
|
@@ -2716,9 +2807,13 @@ window.__ModuleLoader__.load({
|
|
|
2716
2807
|
const suffix = g && g.mode ? " · " + g.mode : "";
|
|
2717
2808
|
return React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + suffix + ")");
|
|
2718
2809
|
})),
|
|
2810
|
+
dbsErr && connId ? React.createElement("span", { className: "dbt-err", role: "alert" },
|
|
2811
|
+
t("trxLoadDbsFail") + ":" + dbsErr + " ",
|
|
2812
|
+
React.createElement("button", { className: "dbt-btn", onClick: loadDbs }, t("retry"))) : null,
|
|
2719
2813
|
dbList.length > 1 ? React.createElement("select", { value: db, disabled: !!txToken, onChange: (e) => setDb(e.target.value) },
|
|
2720
2814
|
React.createElement("option", { value: "" }, t("defaultDb")),
|
|
2721
2815
|
dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
|
|
2816
|
+
dbsLoading ? React.createElement("span", { className: "dbt-muted" }, "…") : null,
|
|
2722
2817
|
),
|
|
2723
2818
|
),
|
|
2724
2819
|
// 多标签条(内存态):标签 + 关闭 + 新建
|
|
@@ -2896,8 +2991,8 @@ window.__ModuleLoader__.load({
|
|
|
2896
2991
|
const srcConn = conns.find((c) => c.id === srcId);
|
|
2897
2992
|
const dstConn = conns.find((c) => c.id === dstId);
|
|
2898
2993
|
const dstGrant = (props.grants || []).find((x) => x.connId === dstId);
|
|
2899
|
-
// PG/GaussDB 三层(库→模式→表),与 BrowsePane 同一张表;sqlite 无库层(连接默认上下文)
|
|
2900
|
-
const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
|
|
2994
|
+
// PG/GaussDB/SQL Server 三层(库→模式→表),与 BrowsePane 同一张表;sqlite 无库层(连接默认上下文)
|
|
2995
|
+
const HAS_SCHEMAS = { postgresql: true, gaussdb: true, sqlserver: true };
|
|
2901
2996
|
const NO_DB_LEVEL = { sqlite: true };
|
|
2902
2997
|
|
|
2903
2998
|
// 项目切换清层级缓存(对齐 BrowsePane:避免陈旧授权下的旧数据)
|
package/dist/adapters/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const LOADERS = {
|
|
|
7
7
|
mongodb: () => import('./mongodb/index.js').then((m) => m.createMongoAdapter),
|
|
8
8
|
oracle: () => import('./oracle/index.js').then((m) => m.createOracleAdapter),
|
|
9
9
|
dmdb: () => import('./dmdb/index.js').then((m) => m.createDmAdapter),
|
|
10
|
+
sqlserver: () => import('./sqlserver/index.js').then((m) => m.createSqlserverAdapter),
|
|
10
11
|
};
|
|
11
12
|
/** 驱动缺失时的安装指引 */
|
|
12
13
|
const INSTALL_HINTS = {
|
|
@@ -18,6 +19,7 @@ const INSTALL_HINTS = {
|
|
|
18
19
|
mongodb: 'npm run build:mongodb(构建 vendor/mongodb-driver.cjs;发布包已内置产物,仅在缺失时需要)',
|
|
19
20
|
oracle: 'npm install oracledb',
|
|
20
21
|
dmdb: 'npm install dmdb',
|
|
22
|
+
sqlserver: 'npm run build:sqlserver(重建 vendor/sqlserver-driver.cjs;mssql 仅在 devDependencies)',
|
|
21
23
|
};
|
|
22
24
|
/** 生成带安装指引的驱动缺失错误 */
|
|
23
25
|
export function driverMissingError(kind, cause) {
|
|
@@ -78,16 +78,46 @@ function connOptions(conn) {
|
|
|
78
78
|
return base;
|
|
79
79
|
}
|
|
80
80
|
export async function createMysqlAdapter(conn, opts) {
|
|
81
|
-
const
|
|
81
|
+
const baseOpts = connOptions(conn);
|
|
82
|
+
const pool = mysql.createPool(baseOpts);
|
|
82
83
|
const readOnly = opts?.mode === 'ro';
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
// 跨库(Navicat 式):按库名缓存独立池。mysql2 官方多库方案即多池管理——池上
|
|
85
|
+
// changeUser/USE 只影响被调用的那条池内连接,池保持建池配置,切库不可靠。
|
|
86
|
+
const mainDb = typeof baseOpts.database === 'string' ? baseOpts.database : '';
|
|
87
|
+
const dbPools = new Map();
|
|
88
|
+
/**
|
|
89
|
+
* 关闭幂等标志(对齐 pg-like/sqlserver):关闭后一切访问必须被拦下——
|
|
90
|
+
* 否则跨库访问会新建池(关不掉的连接泄漏),主池请求则报英文驱动错。
|
|
91
|
+
*/
|
|
92
|
+
let closed = false;
|
|
93
|
+
const assertOpen = () => {
|
|
94
|
+
if (closed)
|
|
95
|
+
throw new Error('mysql 连接已关闭:请重新连接后重试');
|
|
96
|
+
};
|
|
97
|
+
/** ro 模式:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
|
|
98
|
+
* 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
|
|
99
|
+
* 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。主池与跨库池共用同一注册。 */
|
|
100
|
+
const attachReadOnly = (p) => {
|
|
101
|
+
if (!readOnly)
|
|
102
|
+
return;
|
|
103
|
+
p.on('connection', (c) => {
|
|
88
104
|
swallowSetError(c.query('SET SESSION TRANSACTION READ ONLY'));
|
|
89
105
|
});
|
|
90
|
-
}
|
|
106
|
+
};
|
|
107
|
+
attachReadOnly(pool);
|
|
108
|
+
const poolFor = (db) => {
|
|
109
|
+
assertOpen();
|
|
110
|
+
if (db === mainDb)
|
|
111
|
+
return pool;
|
|
112
|
+
let p = dbPools.get(db);
|
|
113
|
+
if (!p) {
|
|
114
|
+
// connOptions 已把 URL/fields 拆成纯字段,覆盖 database 即建出目标库的池
|
|
115
|
+
p = mysql.createPool({ ...baseOpts, database: db });
|
|
116
|
+
dbPools.set(db, p);
|
|
117
|
+
attachReadOnly(p);
|
|
118
|
+
}
|
|
119
|
+
return p;
|
|
120
|
+
};
|
|
91
121
|
/** 事务激活期间路由到同一连接,保证 BEGIN/COMMIT 生效 */
|
|
92
122
|
let txConn = null;
|
|
93
123
|
// PoolConnection 与 Pool 的联合会让 mysql2 的 query/execute 重载解析失败,收窄为 Pool 视图
|
|
@@ -160,18 +190,23 @@ export async function createMysqlAdapter(conn, opts) {
|
|
|
160
190
|
const [rows] = await pool.query('SELECT VERSION() AS v');
|
|
161
191
|
return { ok: true, serverInfo: `MySQL ${String(rows[0]?.v ?? '')}` };
|
|
162
192
|
}),
|
|
163
|
-
query: (sql, params) => humanize('mysql 查询失败', async () => {
|
|
193
|
+
query: (sql, params, database) => humanize('mysql 查询失败', async () => {
|
|
164
194
|
if (readOnly && !RO_READ_PREFIX.test(sql.trimStart())) {
|
|
165
195
|
throw new Error('DRIVER_ERROR: 只读(ro)模式下 query 仅允许读语句(select/show/desc/describe/explain/use/help/table),写操作请使用可写连接');
|
|
166
196
|
}
|
|
167
|
-
|
|
197
|
+
// 跨库请求路由到该库自己的池(白名单校验在前,拒绝发生在建池之前;
|
|
198
|
+
// 缺省判断用 != null:空串等非法值必须进 assertIdent 被拒,不能落回主池);
|
|
199
|
+
// 无 database 时维持事务粘主池语义(服务层已拒绝事务携带 database)
|
|
200
|
+
const target = database != null ? poolFor(assertIdent(database, '库名')) : runner();
|
|
201
|
+
const [rows, fields] = await target.query(sql, params ?? []);
|
|
168
202
|
if (!Array.isArray(rows))
|
|
169
203
|
return { columns: [], rows: [], rowCount: 0 };
|
|
170
204
|
return toQueryResult(rows, fields);
|
|
171
205
|
}),
|
|
172
|
-
execute: (statement, params) => humanize('mysql 执行失败', async () => {
|
|
206
|
+
execute: (statement, params, database) => humanize('mysql 执行失败', async () => {
|
|
173
207
|
// mysql2 的 ExecuteValues 不接受 unknown[],此处值均为绑定参数(string|number|null)
|
|
174
|
-
const
|
|
208
|
+
const target = database != null ? poolFor(assertIdent(database, '库名')) : runner();
|
|
209
|
+
const [result] = await target.execute(statement, (params ?? []));
|
|
175
210
|
const header = (Array.isArray(result) ? result[0] : result);
|
|
176
211
|
const n = header?.affectedRows;
|
|
177
212
|
return {
|
|
@@ -231,7 +266,12 @@ export async function createMysqlAdapter(conn, opts) {
|
|
|
231
266
|
return { columns: [], rows: [], rowCount: 0 };
|
|
232
267
|
return toQueryResult(rows, fields);
|
|
233
268
|
}),
|
|
234
|
-
close: () => humanize('mysql 关闭连接', () =>
|
|
269
|
+
close: () => humanize('mysql 关闭连接', async () => {
|
|
270
|
+
if (closed)
|
|
271
|
+
return; // 幂等:重复 close 为 no-op(pg-pool 二次 end() 会抛的教训)
|
|
272
|
+
closed = true;
|
|
273
|
+
await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
|
|
274
|
+
}),
|
|
235
275
|
};
|
|
236
276
|
}
|
|
237
277
|
export const factory = (conn) => createMysqlAdapter(conn);
|
|
@@ -44,8 +44,9 @@ export function normalizeCell(v) {
|
|
|
44
44
|
? Number(b)
|
|
45
45
|
: b.toString();
|
|
46
46
|
}
|
|
47
|
+
// Invalid Date(如驱动返回无法解析的日期)不能抛 RangeError:单格异常会放大成整查询失败
|
|
47
48
|
if (v instanceof Date)
|
|
48
|
-
return v.toISOString();
|
|
49
|
+
return Number.isNaN(v.getTime()) ? String(v) : v.toISOString();
|
|
49
50
|
if (v instanceof Uint8Array)
|
|
50
51
|
return `[BLOB ${v.byteLength} bytes]`;
|
|
51
52
|
let s;
|