dsh-sql 0.4.4 → 0.4.6
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/CHANGELOG.md +23 -0
- package/README.en.md +24 -87
- package/README.md +24 -87
- package/docs/USAGE.en.md +102 -0
- package/docs/USAGE.md +103 -0
- package/docs/VALIDATION.md +11 -0
- package/lib/adapters.d.ts +16 -2
- package/lib/adapters.js +82 -39
- package/lib/index.js +4 -2
- package/lib/tools.d.ts +17 -2
- package/lib/tools.js +144 -17
- package/package.json +5 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
[返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
|
|
4
|
+
|
|
5
|
+
## 0.4.6 (2026-10-20)
|
|
6
|
+
|
|
7
|
+
- 修复 sql_query 只读校验与 PostgreSQL 反斜杠语义不一致的问题:词法去噪改为按引擎处理反斜杠(MySQL 默认转义 / PostgreSQL 只有 `E'…'`、`U&'…'` 转义 / SQLite 与引号标识符里都不转义)。`SELECT '\'; DELETE FROM users; --'` 这类双语句载荷会被拒绝,不再能在 readOnly=true 下删表
|
|
8
|
+
- 修复 PRAGMA 函数式写法绕过只读校验的问题:`PRAGMA foreign_keys(0)`、`writable_schema(1)`、`user_version(4242)` 等与 `name=value` 等价,现在改为只读 PRAGMA 白名单,只放行 table_info / index_list / page_count 等内省语句
|
|
9
|
+
- readOnly=true 时把只读约束下沉到数据库自身:SQLite 以只读方式打开文件、PostgreSQL 加 `-c default_transaction_read_only=on`、MySQL 每个新连接执行 `SET SESSION TRANSACTION READ ONLY`。词法校验被绕过时服务器仍会拒绝写入
|
|
10
|
+
- sql_exec 审批文本不再截断到 200 字符:逐条列出每条语句并附语句类型统计;超过 800 字符时把完整 SQL 指回工具参数,200 字符之后的 DROP TABLE 不会再从审批卡里消失
|
|
11
|
+
- 新增 `test/readonly-hardening.test.mjs`(10 项回归),`tools.test.mjs` 的 PRAGMA 断言同步更新
|
|
12
|
+
|
|
13
|
+
## 0.4.5 (2026-10-05)
|
|
14
|
+
|
|
15
|
+
- 保留 SQLite 64 位整数与三种数据库的同名列;零行结果保留列名。同名列 JSON 导出使用 columns / rows,避免后列覆盖前列。
|
|
16
|
+
|
|
17
|
+
## 0.4.4 (2026-09-28)
|
|
18
|
+
|
|
19
|
+
- 更新官方 Harness 0.2.0-rc.1 的兼容声明和共同加载验证;运行时代码未变。验证范围见[验证记录](docs/VALIDATION.md)。
|
|
20
|
+
|
|
21
|
+
## 更早的改动
|
|
22
|
+
|
|
23
|
+
完整历史可查阅 [GitHub 提交记录](https://github.com/STARDUSTLC666/dsh-sql/commits/master)。
|
package/README.en.md
CHANGED
|
@@ -1,111 +1,48 @@
|
|
|
1
|
-
[中文](README.md)
|
|
2
|
-
|
|
3
1
|
# dsh-sql
|
|
4
2
|
|
|
5
|
-
|
|
3
|
+
[中文](README.md)
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+

|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
Connect SQLite, MySQL or PostgreSQL to query data and inspect schemas.
|
|
10
8
|
|
|
11
|
-
[](https://www.npmjs.com/package/dsh-sql) [](https://www.npmjs.com/package/dsh-sql)
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-sql/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-sql/pulls).
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
## What it does
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
- Manage connections and inspect schemas or database summaries.
|
|
16
|
+
- Limit rows and timeouts for read-only queries.
|
|
17
|
+
- Keep writes under host approval and check connections.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
dsh plugin --profile web add dsh-sql
|
|
21
|
-
```
|
|
19
|
+
## Install
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
In DSH Desktop, install `dsh-sql` from the Plugins panel. If the bundled dsh command is available:
|
|
24
22
|
|
|
25
23
|
```bash
|
|
26
|
-
dsh plugin --profile
|
|
24
|
+
dsh plugin --profile desktop add dsh-sql
|
|
27
25
|
```
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## Configuration
|
|
33
|
-
|
|
34
|
-
```yaml
|
|
35
|
-
- id: sql
|
|
36
|
-
name: 'dsh-sql'
|
|
37
|
-
config:
|
|
38
|
-
connections:
|
|
39
|
-
- name: local
|
|
40
|
-
engine: sqlite
|
|
41
|
-
file: E:\data\app.db # or :memory:
|
|
42
|
-
- name: prod
|
|
43
|
-
engine: postgres
|
|
44
|
-
host: db.internal
|
|
45
|
-
database: app
|
|
46
|
-
# password: xxx # prefer env var DSH_SQL_PASSWORD_PROD
|
|
47
|
-
- name: legacy
|
|
48
|
-
engine: mysql
|
|
49
|
-
host: 127.0.0.1
|
|
50
|
-
port: 3306
|
|
51
|
-
user: root
|
|
52
|
-
database: legacy
|
|
53
|
-
maxRows: 1000 # query row cap (1-10000)
|
|
54
|
-
queryTimeoutMs: 60000 # per-query timeout (default 60s, 5s - 10min)
|
|
55
|
-
execTimeoutMs: 120000 # per-write timeout (default 120s, 5s - 10min)
|
|
56
|
-
readOnly: false # true disables sql_exec
|
|
57
|
-
writeApproval: true # approve write operations first (default true)
|
|
58
|
-
```
|
|
27
|
+
For the web version, replace `desktop` with `web`. Restart DSH after installation.
|
|
59
28
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
## Tools
|
|
63
|
-
|
|
64
|
-
| Tool | Purpose | Safety |
|
|
65
|
-
| :-- | :-- | :-- |
|
|
66
|
-
| `sql_list` | List connections + connectivity test | — |
|
|
67
|
-
| `sql_query` | Read-only queries (SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH) | Keyword whitelist + rejects multi-statement |
|
|
68
|
-
| `sql_exec` | Writes / DDL (multi-statement scripts allowed) | readOnly lock + approval gate |
|
|
69
|
-
| `sql_schema` | Table list / table structure | Identifier whitelist validation |
|
|
70
|
-
| `sql_stats` | Table counts, row estimates, and database size | Quoted identifiers + isolated query failures |
|
|
71
|
-
| `sql_health` | Connection and safety-configuration checks | Per-connection probe; passwords are never returned |
|
|
72
|
-
|
|
73
|
-
### Examples
|
|
74
|
-
|
|
75
|
-
```text
|
|
76
|
-
sql_list {}
|
|
77
|
-
sql_schema {} # list all tables
|
|
78
|
-
sql_schema { table: users } # inspect the users table
|
|
79
|
-
sql_stats {} # inspect the default connection's data size
|
|
80
|
-
sql_health {} # check connections and safety settings
|
|
81
|
-
sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
|
|
82
|
-
sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
83
|
-
```
|
|
29
|
+
## Start using it
|
|
84
30
|
|
|
85
|
-
|
|
31
|
+
Configure a connection, then ask to inspect a table schema and its ten latest rows using read-only queries.
|
|
86
32
|
|
|
87
|
-
|
|
88
|
-
- **Write approval gate**: sql_exec asks for approval by default (mirroring dsh-email's send approval); headless environments without an approval channel are denied
|
|
89
|
-
- **readOnly mode**: lock out writes entirely for production databases
|
|
90
|
-
- **Streaming row cap**: SQLite iterators, MySQL Readables, and PostgreSQL Query row events collect at most maxRows+1 rows and flag overflow with truncated. MySQL and PostgreSQL close the query's dedicated connection at the cap; smaller results return the connection to the pool, without materializing the full result in memory
|
|
91
|
-
- **Cancellation-aware execution**: queries and writes observe Harness `exec.signal`; cancellation stops waiting and destroys the active dedicated MySQL/PostgreSQL connection
|
|
92
|
-
- **Lossless large integers**: bigint values within JavaScript's safe integer range are returned as numbers; larger values are returned as decimal strings instead of silently losing precision
|
|
93
|
-
- **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
|
|
94
|
-
- **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
|
|
33
|
+
## Requirements and configuration
|
|
95
34
|
|
|
96
|
-
|
|
35
|
+
SQLite requires an accessible database file. MySQL and PostgreSQL require a reachable service and credentials.
|
|
97
36
|
|
|
98
|
-
|
|
99
|
-
- **MySQL**: mysql2 pool
|
|
100
|
-
- **PostgreSQL**: pg pool
|
|
37
|
+
Detailed configuration, tool arguments and troubleshooting are in the [usage guide](docs/USAGE.en.md). For standalone development, follow the Node requirement in [package.json](package.json).
|
|
101
38
|
|
|
102
|
-
##
|
|
39
|
+
## Documentation
|
|
103
40
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
41
|
+
- [Usage and troubleshooting](docs/USAGE.en.md)
|
|
42
|
+
- [Changelog](CHANGELOG.md)
|
|
43
|
+
- [Validation scope and history](docs/VALIDATION.md)
|
|
44
|
+
- [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-sql/issues)
|
|
108
45
|
|
|
109
46
|
## License
|
|
110
47
|
|
|
111
|
-
MIT
|
|
48
|
+
[MIT](LICENSE)
|
package/README.md
CHANGED
|
@@ -1,111 +1,48 @@
|
|
|
1
|
-
[English](README.en.md)
|
|
2
|
-
|
|
3
1
|
# dsh-sql
|
|
4
2
|
|
|
5
|
-
|
|
3
|
+
[English](README.en.md)
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+

|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
连接 SQLite、MySQL 或 PostgreSQL,查询数据并检查数据库结构。
|
|
10
8
|
|
|
11
|
-
[](https://www.npmjs.com/package/dsh-sql) [](https://www.npmjs.com/package/dsh-sql)
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-sql/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-sql/pulls)。
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
## 功能
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
- 管理连接,查询结构和数据概览。
|
|
16
|
+
- 只读查询带行数与超时限制。
|
|
17
|
+
- 写操作通过宿主审批,支持连接自检。
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
dsh plugin --profile web add dsh-sql
|
|
21
|
-
```
|
|
19
|
+
## 安装
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
桌面版可在「插件」面板按包名 `dsh-sql` 安装。已配置 dsh 命令时也可使用:
|
|
24
22
|
|
|
25
23
|
```bash
|
|
26
|
-
dsh plugin --profile
|
|
24
|
+
dsh plugin --profile desktop add dsh-sql
|
|
27
25
|
```
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## 配置
|
|
33
|
-
|
|
34
|
-
```yaml
|
|
35
|
-
- id: sql
|
|
36
|
-
name: 'dsh-sql'
|
|
37
|
-
config:
|
|
38
|
-
connections:
|
|
39
|
-
- name: local
|
|
40
|
-
engine: sqlite
|
|
41
|
-
file: E:\data\app.db # 或 :memory:
|
|
42
|
-
- name: prod
|
|
43
|
-
engine: postgres
|
|
44
|
-
host: db.internal
|
|
45
|
-
database: app
|
|
46
|
-
# password: xxx # 推荐环境变量 DSH_SQL_PASSWORD_PROD
|
|
47
|
-
- name: legacy
|
|
48
|
-
engine: mysql
|
|
49
|
-
host: 127.0.0.1
|
|
50
|
-
port: 3306
|
|
51
|
-
user: root
|
|
52
|
-
database: legacy
|
|
53
|
-
maxRows: 1000 # 查询返回行数上限(1-10000)
|
|
54
|
-
queryTimeoutMs: 60000 # 单次查询超时(默认 60 秒,5 秒 - 10 分钟)
|
|
55
|
-
execTimeoutMs: 120000 # 单次写操作超时(默认 120 秒,5 秒 - 10 分钟)
|
|
56
|
-
readOnly: false # true 时禁用 sql_exec
|
|
57
|
-
writeApproval: true # 写操作先弹审批(默认 true)
|
|
58
|
-
```
|
|
27
|
+
网页版把命令中的 `desktop` 改为 `web`。安装后重启 DSH。
|
|
59
28
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
## 工具一览
|
|
63
|
-
|
|
64
|
-
| 工具 | 作用 | 安全 |
|
|
65
|
-
| :-- | :-- | :-- |
|
|
66
|
-
| `sql_list` | 列出连接 + 连通性测试 | — |
|
|
67
|
-
| `sql_query` | 只读查询(SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH)| 关键字白名单 + 拒绝多语句 |
|
|
68
|
-
| `sql_exec` | 写操作/DDL(可多语句脚本)| readOnly 禁用 + 审批门 |
|
|
69
|
-
| `sql_schema` | 表清单 / 表结构 | 标识符白名单校验 |
|
|
70
|
-
| `sql_stats` | 表数量、行数与库体积概览 | 表名引用 + 查询失败隔离 |
|
|
71
|
-
| `sql_health` | 连接与安全配置自检 | 逐连接探活,不回显密码 |
|
|
72
|
-
|
|
73
|
-
### 示例
|
|
74
|
-
|
|
75
|
-
```text
|
|
76
|
-
sql_list {}
|
|
77
|
-
sql_schema {} # 列出所有表
|
|
78
|
-
sql_schema { table: users } # 看 users 表结构
|
|
79
|
-
sql_stats {} # 查看默认连接的数据规模
|
|
80
|
-
sql_health {} # 检查连接和安全配置
|
|
81
|
-
sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
|
|
82
|
-
sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
83
|
-
```
|
|
29
|
+
## 开始使用
|
|
84
30
|
|
|
85
|
-
|
|
31
|
+
配置数据库连接后,可说:“查看这张表的结构和最近十条记录,先只读查询。”
|
|
86
32
|
|
|
87
|
-
|
|
88
|
-
- **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
|
|
89
|
-
- **readOnly 模式**:生产库可整体禁用写
|
|
90
|
-
- **流式行数钳制**:SQLite 迭代器 / MySQL Readable / PostgreSQL Query 行事件最多收集 maxRows+1 行,超量标记 truncated;MySQL 和 PostgreSQL 在达到上限时关闭该查询的专用连接,未达上限则正常归还连接池,避免全量结果驻留内存
|
|
91
|
-
- **可取消执行**:查询与写操作遵守 Harness 的 `exec.signal`;取消时会中止等待并销毁正在工作的 MySQL/PostgreSQL 专用连接
|
|
92
|
-
- **大整数无损**:数据库返回的 bigint 在 JavaScript 安全整数范围内输出 number,超出范围则输出十进制字符串,避免静默丢精度
|
|
93
|
-
- **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
|
|
94
|
-
- **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
|
|
33
|
+
## 依赖与配置
|
|
95
34
|
|
|
96
|
-
|
|
35
|
+
SQLite 需可访问的数据库文件;MySQL / PostgreSQL 需对应服务与登录信息。
|
|
97
36
|
|
|
98
|
-
|
|
99
|
-
- **MySQL**:mysql2 连接池
|
|
100
|
-
- **PostgreSQL**:pg 连接池
|
|
37
|
+
详细配置、工具参数与排错见[使用说明](docs/USAGE.md)。从源码独立开发时,Node 要求以 [package.json](package.json) 为准。
|
|
101
38
|
|
|
102
|
-
##
|
|
39
|
+
## 文档
|
|
103
40
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
41
|
+
- [使用与排错](docs/USAGE.md)
|
|
42
|
+
- [更新记录](CHANGELOG.md)
|
|
43
|
+
- [验证范围与历史记录](docs/VALIDATION.md)
|
|
44
|
+
- [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-sql/issues)
|
|
108
45
|
|
|
109
46
|
## License
|
|
110
47
|
|
|
111
|
-
MIT
|
|
48
|
+
[MIT](LICENSE)
|
package/docs/USAGE.en.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# dsh-sql usage guide
|
|
2
|
+
|
|
3
|
+
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
|
+
|
|
5
|
+
## Current improvements
|
|
6
|
+
|
|
7
|
+
Safe integers remain numbers; larger integers are decimal strings. JSON with duplicate column names uses { columns, rows }; other JSON remains an object array. SQLite currently executes synchronously: row limits bound returned data but cannot interrupt an already-running expensive statement. Avoid unbounded complex queries on unknown data.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
dsh plugin --profile web add dsh-sql
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Uninstall
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
dsh plugin --profile web remove dsh-sql
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
- id: sql
|
|
27
|
+
name: 'dsh-sql'
|
|
28
|
+
config:
|
|
29
|
+
connections:
|
|
30
|
+
- name: local
|
|
31
|
+
engine: sqlite
|
|
32
|
+
file: E:\data\app.db # or :memory:
|
|
33
|
+
- name: prod
|
|
34
|
+
engine: postgres
|
|
35
|
+
host: db.internal
|
|
36
|
+
database: app
|
|
37
|
+
# password: xxx # prefer env var DSH_SQL_PASSWORD_PROD
|
|
38
|
+
- name: legacy
|
|
39
|
+
engine: mysql
|
|
40
|
+
host: 127.0.0.1
|
|
41
|
+
port: 3306
|
|
42
|
+
user: root
|
|
43
|
+
database: legacy
|
|
44
|
+
maxRows: 1000 # query row cap (1-10000)
|
|
45
|
+
queryTimeoutMs: 60000 # per-query timeout (default 60s, 5s - 10min)
|
|
46
|
+
execTimeoutMs: 120000 # per-write timeout (default 120s, 5s - 10min)
|
|
47
|
+
readOnly: false # true disables sql_exec
|
|
48
|
+
writeApproval: true # approve write operations first (default true)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With no connection configuration, the plugin provides a `:memory:` SQLite connection. If configuration is present but invalid, the plugin fails to load with the validation error instead of silently falling back to the in-memory database.
|
|
52
|
+
|
|
53
|
+
## Tools
|
|
54
|
+
|
|
55
|
+
| Tool | Purpose | Safety |
|
|
56
|
+
| :-- | :-- | :-- |
|
|
57
|
+
| `sql_list` | List connections + connectivity test | — |
|
|
58
|
+
| `sql_query` | Read-only queries (SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH) | Keyword whitelist + rejects multi-statement |
|
|
59
|
+
| `sql_exec` | Writes / DDL (multi-statement scripts allowed) | readOnly lock + approval gate |
|
|
60
|
+
| `sql_schema` | Table list / table structure | Identifier whitelist validation |
|
|
61
|
+
| `sql_stats` | Table counts, row estimates, and database size | Quoted identifiers + isolated query failures |
|
|
62
|
+
| `sql_health` | Connection and safety-configuration checks | Per-connection probe; passwords are never returned |
|
|
63
|
+
|
|
64
|
+
### Examples
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
sql_list {}
|
|
68
|
+
sql_schema {} # list all tables
|
|
69
|
+
sql_schema { table: users } # inspect the users table
|
|
70
|
+
sql_stats {} # inspect the default connection's data size
|
|
71
|
+
sql_health {} # check connections and safety settings
|
|
72
|
+
sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
|
|
73
|
+
sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Safety
|
|
77
|
+
|
|
78
|
+
- **Lexer-grade read-only guard**: sql_query strips strings/comments before validation, then rejects data-modifying CTEs (WITH…DELETE/UPDATE), SELECT INTO, FOR UPDATE/FOR SHARE, PRAGMA assignment, and multi-statement input
|
|
79
|
+
- **Write approval gate**: sql_exec asks for approval by default (mirroring dsh-email's send approval); headless environments without an approval channel are denied
|
|
80
|
+
- **readOnly mode**: lock out writes entirely for production databases
|
|
81
|
+
- **Streaming row cap**: SQLite iterators, MySQL Readables, and PostgreSQL Query row events collect at most maxRows+1 rows and flag overflow with truncated. MySQL and PostgreSQL close the query's dedicated connection at the cap; smaller results return the connection to the pool, without materializing the full result in memory
|
|
82
|
+
- **Cancellation-aware execution**: queries and writes observe Harness `exec.signal`; cancellation stops waiting and destroys the active dedicated MySQL/PostgreSQL connection
|
|
83
|
+
- **Lossless large integers**: bigint values within JavaScript's safe integer range are returned as numbers; larger values are returned as decimal strings instead of silently losing precision
|
|
84
|
+
- **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
|
|
85
|
+
- **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
|
|
86
|
+
|
|
87
|
+
## Engines
|
|
88
|
+
|
|
89
|
+
- **SQLite**: built-in `node:sqlite` (Node 22.13+), zero dependencies
|
|
90
|
+
- **MySQL**: mysql2 pool
|
|
91
|
+
- **PostgreSQL**: pg pool
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pnpm install
|
|
97
|
+
pnpm test # build + full test suite, including a real SQLite integration suite
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/docs/USAGE.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# dsh-sql 使用说明
|
|
2
|
+
|
|
3
|
+
[返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
|
|
4
|
+
|
|
5
|
+
## 本次改进
|
|
6
|
+
|
|
7
|
+
安全范围内整数保持 number,超出范围按十进制字符串输出。同名列 JSON 使用 { columns, rows },其他 JSON 仍为对象数组。SQLite 当前使用同步执行:行数上限限制返回数据,不能中断已运行的耗时 SQL;请避免对未知数据执行无界复杂查询。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
dsh plugin --profile web add dsh-sql
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 卸载
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
dsh plugin --profile web remove dsh-sql
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
- id: sql
|
|
27
|
+
name: 'dsh-sql'
|
|
28
|
+
config:
|
|
29
|
+
connections:
|
|
30
|
+
- name: local
|
|
31
|
+
engine: sqlite
|
|
32
|
+
file: E:\data\app.db # 或 :memory:
|
|
33
|
+
- name: prod
|
|
34
|
+
engine: postgres
|
|
35
|
+
host: db.internal
|
|
36
|
+
database: app
|
|
37
|
+
# password: xxx # 推荐环境变量 DSH_SQL_PASSWORD_PROD
|
|
38
|
+
- name: legacy
|
|
39
|
+
engine: mysql
|
|
40
|
+
host: 127.0.0.1
|
|
41
|
+
port: 3306
|
|
42
|
+
user: root
|
|
43
|
+
database: legacy
|
|
44
|
+
maxRows: 1000 # 查询返回行数上限(1-10000)
|
|
45
|
+
queryTimeoutMs: 60000 # 单次查询超时(默认 60 秒,5 秒 - 10 分钟)
|
|
46
|
+
execTimeoutMs: 120000 # 单次写操作超时(默认 120 秒,5 秒 - 10 分钟)
|
|
47
|
+
readOnly: false # true 时禁用 sql_exec,并让数据库自身以只读方式连接
|
|
48
|
+
writeApproval: true # 写操作先弹审批(默认 true)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
配置缺省时会提供一个 `:memory:` SQLite 连接;配置一旦给出但格式无效,插件会直接报错并停止加载,不会静默回退到内存库。
|
|
52
|
+
|
|
53
|
+
## 工具一览
|
|
54
|
+
|
|
55
|
+
| 工具 | 作用 | 安全 |
|
|
56
|
+
| :-- | :-- | :-- |
|
|
57
|
+
| `sql_list` | 列出连接 + 连通性测试 | — |
|
|
58
|
+
| `sql_query` | 只读查询(SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH)| 按引擎去噪的关键字白名单 + 拒绝多语句 + PRAGMA 白名单 |
|
|
59
|
+
| `sql_exec` | 写操作/DDL(可多语句脚本)| readOnly 禁用 + 审批门 |
|
|
60
|
+
| `sql_schema` | 表清单 / 表结构 | 标识符白名单校验 |
|
|
61
|
+
| `sql_stats` | 表数量、行数与库体积概览 | 表名引用 + 查询失败隔离 |
|
|
62
|
+
| `sql_health` | 连接与安全配置自检 | 逐连接探活,不回显密码 |
|
|
63
|
+
|
|
64
|
+
### 示例
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
sql_list {}
|
|
68
|
+
sql_schema {} # 列出所有表
|
|
69
|
+
sql_schema { table: users } # 看 users 表结构
|
|
70
|
+
sql_stats {} # 查看默认连接的数据规模
|
|
71
|
+
sql_health {} # 检查连接和安全配置
|
|
72
|
+
sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
|
|
73
|
+
sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 安全设计
|
|
77
|
+
|
|
78
|
+
- **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、多语句,以及不在只读白名单里的 PRAGMA。去噪按引擎处理反斜杠(MySQL 默认转义、PostgreSQL 只有 `E'…'`/`U&'…'` 转义、SQLite 与引号标识符里都不转义),避免「校验看到一条、服务器执行两条」
|
|
79
|
+
- **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
|
|
80
|
+
- **readOnly 模式**:生产库可整体禁用写。除了禁用 sql_exec,还会把只读约束下沉到数据库自身:SQLite 以只读方式打开文件,PostgreSQL 用 `-c default_transaction_read_only=on` 启动参数,MySQL 在每个新连接上执行 `SET SESSION TRANSACTION READ ONLY`(需要 MySQL 5.6.5+ / MariaDB 10.0+)。即使词法校验被绕过,服务器也会拒绝写入
|
|
81
|
+
- **审批卡不藏语句**:sql_exec 的审批文本逐条列出每条语句,并给出一行语句类型统计;超过 800 字符时把完整 SQL 指回工具参数,第二条 DROP / DELETE 不会因为截断而从审批卡里消失
|
|
82
|
+
- **流式行数钳制**:SQLite 迭代器 / MySQL Readable / PostgreSQL Query 行事件最多收集 maxRows+1 行,超量标记 truncated;MySQL 和 PostgreSQL 在达到上限时关闭该查询的专用连接,未达上限则正常归还连接池,避免全量结果驻留内存
|
|
83
|
+
- **可取消执行**:查询与写操作遵守 Harness 的 `exec.signal`;取消时会中止等待并销毁正在工作的 MySQL/PostgreSQL 专用连接
|
|
84
|
+
- **大整数无损**:数据库返回的 bigint 在 JavaScript 安全整数范围内输出 number,超出范围则输出十进制字符串,避免静默丢精度
|
|
85
|
+
- **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
|
|
86
|
+
- **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
|
|
87
|
+
|
|
88
|
+
## 引擎
|
|
89
|
+
|
|
90
|
+
- **SQLite**:Node 22.13+ 内置 `node:sqlite`,零依赖
|
|
91
|
+
- **MySQL**:mysql2 连接池
|
|
92
|
+
- **PostgreSQL**:pg 连接池
|
|
93
|
+
|
|
94
|
+
## 开发
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pnpm install
|
|
98
|
+
pnpm test # 构建 + 完整测试套件(含真实 SQLite 集成)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-sql 验证记录
|
|
2
|
+
|
|
3
|
+
本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
|
|
4
|
+
|
|
5
|
+
## 原中文记录
|
|
6
|
+
|
|
7
|
+
验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。53 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 6 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
|
|
8
|
+
|
|
9
|
+
## Original English record
|
|
10
|
+
|
|
11
|
+
Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 53 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 6 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
|
package/lib/adapters.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
import pg from 'pg';
|
|
1
3
|
import { type SqlConnectionConfig } from './config.js';
|
|
2
4
|
/** 查询结果:列名 + 行(值数组,无损 JSON 友好)。 */
|
|
3
5
|
export interface QueryResult {
|
|
@@ -21,5 +23,17 @@ export interface DatabaseAdapter {
|
|
|
21
23
|
ping(signal?: AbortSignal): Promise<void>;
|
|
22
24
|
close(): Promise<void>;
|
|
23
25
|
}
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
26
|
+
/** 适配器额外选项:readOnly 时把只读约束下沉到数据库自身。 */
|
|
27
|
+
export interface AdapterOptions {
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** MySQL 连接池配置(导出以便测试断言,不含连接)。 */
|
|
31
|
+
export declare function mysqlPoolConfig(connection: SqlConnectionConfig): mysql.PoolOptions;
|
|
32
|
+
/**
|
|
33
|
+
* PostgreSQL 连接池配置。readOnly 时用启动参数把会话默认事务设为只读:
|
|
34
|
+
* 即使词法校验被绕过(多语句、反斜杠、注释技巧),服务器也会以
|
|
35
|
+
* "cannot execute ... in a read-only transaction" 拒绝写操作。
|
|
36
|
+
*/
|
|
37
|
+
export declare function postgresPoolConfig(connection: SqlConnectionConfig, readOnly?: boolean): pg.PoolConfig;
|
|
38
|
+
/** 按连接配置创建适配器;readOnly 会下沉到引擎自身的只读约束。 */
|
|
39
|
+
export declare function createAdapter(connection: SqlConnectionConfig, options?: AdapterOptions): DatabaseAdapter;
|
package/lib/adapters.js
CHANGED
|
@@ -43,7 +43,7 @@ function streamMysqlQuery(corePool, sql, limit, discard) {
|
|
|
43
43
|
let settled = false;
|
|
44
44
|
let columns = [];
|
|
45
45
|
const rows = [];
|
|
46
|
-
const stream = corePool.query(sql).stream({ highWaterMark: 64 });
|
|
46
|
+
const stream = corePool.query({ sql, rowsAsArray: true }).stream({ highWaterMark: 64 });
|
|
47
47
|
const finish = () => {
|
|
48
48
|
if (settled)
|
|
49
49
|
return;
|
|
@@ -59,7 +59,7 @@ function streamMysqlQuery(corePool, sql, limit, discard) {
|
|
|
59
59
|
return;
|
|
60
60
|
if (columns.length === 0)
|
|
61
61
|
columns = Object.keys(row);
|
|
62
|
-
rows.push(columns.map((name) => toValue(row[name])));
|
|
62
|
+
rows.push(Array.isArray(row) ? row.map(toValue) : columns.map((name) => toValue(row[name])));
|
|
63
63
|
if (rows.length >= limit) {
|
|
64
64
|
finish();
|
|
65
65
|
stream.destroy();
|
|
@@ -83,13 +83,14 @@ function streamPostgresQuery(client, sql, limit, discard) {
|
|
|
83
83
|
let settled = false;
|
|
84
84
|
let columns = [];
|
|
85
85
|
const rows = [];
|
|
86
|
-
const
|
|
86
|
+
const queryConfig = { text: sql, rowMode: 'array' };
|
|
87
|
+
const query = new pg.Query(queryConfig);
|
|
87
88
|
query.on('row', (row, result) => {
|
|
88
89
|
if (settled)
|
|
89
90
|
return;
|
|
90
91
|
if (columns.length === 0)
|
|
91
92
|
columns = result?.fields.map((field) => field.name) ?? Object.keys(row);
|
|
92
|
-
rows.push(columns.map((name) => toValue(row[name])));
|
|
93
|
+
rows.push(Array.isArray(row) ? row.map(toValue) : columns.map((name) => toValue(row[name])));
|
|
93
94
|
if (rows.length >= limit) {
|
|
94
95
|
// Closing this dedicated connection stops server work and prevents reuse.
|
|
95
96
|
settled = true;
|
|
@@ -116,12 +117,56 @@ function streamPostgresQuery(client, sql, limit, discard) {
|
|
|
116
117
|
client.query(query);
|
|
117
118
|
});
|
|
118
119
|
}
|
|
120
|
+
/** MySQL 连接池配置(导出以便测试断言,不含连接)。 */
|
|
121
|
+
export function mysqlPoolConfig(connection) {
|
|
122
|
+
return {
|
|
123
|
+
host: connection.host ?? 'localhost',
|
|
124
|
+
port: connection.port ?? 3306,
|
|
125
|
+
user: connection.user ?? '',
|
|
126
|
+
password: connection.password ?? '',
|
|
127
|
+
database: connection.database ?? '',
|
|
128
|
+
connectionLimit: 5,
|
|
129
|
+
enableKeepAlive: true,
|
|
130
|
+
supportBigNumbers: true,
|
|
131
|
+
bigNumberStrings: true,
|
|
132
|
+
// 显式关掉多语句:即使词法校验被绕过,服务器也不会把第二句当命令执行。
|
|
133
|
+
multipleStatements: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* PostgreSQL 连接池配置。readOnly 时用启动参数把会话默认事务设为只读:
|
|
138
|
+
* 即使词法校验被绕过(多语句、反斜杠、注释技巧),服务器也会以
|
|
139
|
+
* "cannot execute ... in a read-only transaction" 拒绝写操作。
|
|
140
|
+
*/
|
|
141
|
+
export function postgresPoolConfig(connection, readOnly = false) {
|
|
142
|
+
const config = {
|
|
143
|
+
host: connection.host ?? 'localhost',
|
|
144
|
+
port: connection.port ?? 5432,
|
|
145
|
+
user: connection.user ?? '',
|
|
146
|
+
password: connection.password ?? '',
|
|
147
|
+
database: connection.database ?? '',
|
|
148
|
+
max: 5,
|
|
149
|
+
};
|
|
150
|
+
if (readOnly)
|
|
151
|
+
config.options = '-c default_transaction_read_only=on';
|
|
152
|
+
return config;
|
|
153
|
+
}
|
|
119
154
|
/** SQLite 适配器(node:sqlite,零依赖)。 */
|
|
120
155
|
class SqliteAdapter {
|
|
121
156
|
engine = 'sqlite';
|
|
122
157
|
db;
|
|
123
|
-
constructor(file) {
|
|
124
|
-
|
|
158
|
+
constructor(file, readOnly = false) {
|
|
159
|
+
const memory = file === ':memory:';
|
|
160
|
+
// 内存库没有落盘风险,也不支持只读打开;文件库在 readOnly 下由 SQLite
|
|
161
|
+
// 自己拒绝任何写入(PRAGMA 写、DDL、DML 都会报 attempt to write a readonly database)。
|
|
162
|
+
const options = readOnly && !memory ? { readOnly: true } : {};
|
|
163
|
+
try {
|
|
164
|
+
this.db = new DatabaseSync(memory ? ':memory:' : file, options);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
168
|
+
throw new Error('无法打开 SQLite 文件 ' + file + '(readOnly=' + readOnly + '):' + detail);
|
|
169
|
+
}
|
|
125
170
|
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
126
171
|
}
|
|
127
172
|
async listTables(signal) {
|
|
@@ -145,16 +190,17 @@ class SqliteAdapter {
|
|
|
145
190
|
async query(sql, limit, signal) {
|
|
146
191
|
signal?.throwIfAborted();
|
|
147
192
|
const statement = this.db.prepare(sql);
|
|
193
|
+
statement.setReadBigInts(true);
|
|
194
|
+
statement.setReturnArrays(true);
|
|
195
|
+
const columns = statement.columns().map((column) => column.name);
|
|
148
196
|
if (limit === undefined || limit <= 0) {
|
|
149
|
-
const rows = statement.all();
|
|
197
|
+
const rows = statement.all().map(row => row.map(toValue));
|
|
150
198
|
signal?.throwIfAborted();
|
|
151
|
-
return
|
|
199
|
+
return { columns, rows };
|
|
152
200
|
}
|
|
153
|
-
const columns = statement.columns().map((column) => column.name);
|
|
154
201
|
const rows = [];
|
|
155
202
|
for (const raw of statement.iterate()) {
|
|
156
|
-
|
|
157
|
-
rows.push(columns.map((name) => toValue(row[name])));
|
|
203
|
+
rows.push(raw.map(toValue));
|
|
158
204
|
signal?.throwIfAborted();
|
|
159
205
|
if (rows.length >= limit)
|
|
160
206
|
break;
|
|
@@ -186,16 +232,15 @@ class SqliteAdapter {
|
|
|
186
232
|
class MysqlAdapter {
|
|
187
233
|
engine = 'mysql';
|
|
188
234
|
pool;
|
|
189
|
-
constructor(connection) {
|
|
190
|
-
this.pool = mysql.createPool(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
});
|
|
235
|
+
constructor(connection, readOnly = false) {
|
|
236
|
+
this.pool = mysql.createPool(mysqlPoolConfig(connection));
|
|
237
|
+
if (readOnly) {
|
|
238
|
+
// 会话级只读:MySQL 5.6.5+ 的 SET SESSION TRANSACTION READ ONLY 会让
|
|
239
|
+
// 该连接上后续任何 DML/DDL 被服务器拒绝,不依赖词法校验。
|
|
240
|
+
this.pool.on('connection', (session) => {
|
|
241
|
+
void session.query('SET SESSION TRANSACTION READ ONLY').catch(() => { });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
199
244
|
}
|
|
200
245
|
async withSignalConnection(signal, work) {
|
|
201
246
|
signal?.throwIfAborted();
|
|
@@ -226,8 +271,8 @@ class MysqlAdapter {
|
|
|
226
271
|
}
|
|
227
272
|
async queryRows(sql, signal) {
|
|
228
273
|
if (signal === undefined)
|
|
229
|
-
return await this.pool.query(sql);
|
|
230
|
-
return await this.withSignalConnection(signal, async (connection) => await connection.query(sql));
|
|
274
|
+
return typeof sql === 'string' ? await this.pool.query(sql) : await this.pool.query(sql);
|
|
275
|
+
return await this.withSignalConnection(signal, async (connection) => typeof sql === 'string' ? await connection.query(sql) : await connection.query(sql));
|
|
231
276
|
}
|
|
232
277
|
async listTables(signal) {
|
|
233
278
|
const [rows] = await this.queryRows('SHOW TABLES', signal);
|
|
@@ -245,7 +290,9 @@ class MysqlAdapter {
|
|
|
245
290
|
}
|
|
246
291
|
async query(sql, limit, signal) {
|
|
247
292
|
if (limit === undefined || limit <= 0) {
|
|
248
|
-
const [rows] = await this.queryRows(sql, signal);
|
|
293
|
+
const [rows, fields] = await this.queryRows({ sql, rowsAsArray: true }, signal);
|
|
294
|
+
if (fields?.length)
|
|
295
|
+
return { columns: fields.map(field => field.name), rows: rows.map(row => Array.isArray(row) ? row.map(toValue) : fields.map(field => toValue(row[field.name]))) };
|
|
249
296
|
return rowsToColumns(rows);
|
|
250
297
|
}
|
|
251
298
|
return await this.withSignalConnection(signal, async (connection, discard) => {
|
|
@@ -268,15 +315,8 @@ class MysqlAdapter {
|
|
|
268
315
|
class PostgresAdapter {
|
|
269
316
|
engine = 'postgres';
|
|
270
317
|
pool;
|
|
271
|
-
constructor(connection) {
|
|
272
|
-
this.pool = new pg.Pool(
|
|
273
|
-
host: connection.host ?? 'localhost',
|
|
274
|
-
port: connection.port ?? 5432,
|
|
275
|
-
user: connection.user ?? '',
|
|
276
|
-
password: connection.password ?? '',
|
|
277
|
-
database: connection.database ?? '',
|
|
278
|
-
max: 5,
|
|
279
|
-
});
|
|
318
|
+
constructor(connection, readOnly = false) {
|
|
319
|
+
this.pool = new pg.Pool(postgresPoolConfig(connection, readOnly));
|
|
280
320
|
}
|
|
281
321
|
async withSignalClient(signal, work) {
|
|
282
322
|
signal?.throwIfAborted();
|
|
@@ -344,7 +384,9 @@ class PostgresAdapter {
|
|
|
344
384
|
}
|
|
345
385
|
async query(sql, limit, signal) {
|
|
346
386
|
if (limit === undefined || limit <= 0) {
|
|
347
|
-
const result = await this.queryWithSignal(sql, undefined, signal);
|
|
387
|
+
const result = await this.queryWithSignal({ text: sql, rowMode: 'array' }, undefined, signal);
|
|
388
|
+
if (result.fields?.length)
|
|
389
|
+
return { columns: result.fields.map((field) => field.name), rows: result.rows.map((row) => row.map(toValue)) };
|
|
348
390
|
const rows = result.rows;
|
|
349
391
|
return rowsToColumns(rows);
|
|
350
392
|
}
|
|
@@ -363,11 +405,12 @@ class PostgresAdapter {
|
|
|
363
405
|
await this.pool.end();
|
|
364
406
|
}
|
|
365
407
|
}
|
|
366
|
-
/**
|
|
367
|
-
export function createAdapter(connection) {
|
|
408
|
+
/** 按连接配置创建适配器;readOnly 会下沉到引擎自身的只读约束。 */
|
|
409
|
+
export function createAdapter(connection, options = {}) {
|
|
410
|
+
const readOnly = options.readOnly === true;
|
|
368
411
|
if (connection.engine === 'sqlite')
|
|
369
|
-
return new SqliteAdapter(connection.file ?? ':memory:');
|
|
412
|
+
return new SqliteAdapter(connection.file ?? ':memory:', readOnly);
|
|
370
413
|
if (connection.engine === 'mysql')
|
|
371
|
-
return new MysqlAdapter(connection);
|
|
372
|
-
return new PostgresAdapter(connection);
|
|
414
|
+
return new MysqlAdapter(connection, readOnly);
|
|
415
|
+
return new PostgresAdapter(connection, readOnly);
|
|
373
416
|
}
|
package/lib/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module dsh-sql
|
|
10
10
|
*/
|
|
11
11
|
import { resolveConfig } from './config.js';
|
|
12
|
-
import { buildSqlTools } from './tools.js';
|
|
12
|
+
import { buildSqlTools, summarizeWriteScript } from './tools.js';
|
|
13
13
|
/** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明。 */
|
|
14
14
|
export const name = 'sql';
|
|
15
15
|
export const inject = ['tools'];
|
|
@@ -27,7 +27,9 @@ export function apply(ctx, config) {
|
|
|
27
27
|
const sql = typeof args.sql === 'string' ? args.sql : '';
|
|
28
28
|
return {
|
|
29
29
|
kind: 'ask',
|
|
30
|
-
|
|
30
|
+
// 不截断整段 SQL:先逐条列出每条语句与语句类型统计,再附完整 SQL。
|
|
31
|
+
// 只截 200 字符会把第二条 DROP / DELETE 挡在审批卡之外。
|
|
32
|
+
reason: 'sql_exec 写操作需要确认:\n' + summarizeWriteScript(sql),
|
|
31
33
|
};
|
|
32
34
|
});
|
|
33
35
|
}
|
package/lib/tools.d.ts
CHANGED
|
@@ -26,8 +26,22 @@ export interface SqlToolDefinition {
|
|
|
26
26
|
execute(args: unknown, exec: unknown): Promise<unknown>;
|
|
27
27
|
timeoutMs?: number;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** 词法校验需要区分的三种引擎。 */
|
|
30
|
+
type SqlEngine = 'sqlite' | 'mysql' | 'postgres';
|
|
31
|
+
/**
|
|
32
|
+
* 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句 + PRAGMA 白名单。
|
|
33
|
+
*
|
|
34
|
+
* `engine` 决定反斜杠转义规则(见 stripSqlNoise);不传时按最保守的方式把
|
|
35
|
+
* 反斜杠当字面量,宁可误拒也不放过第二条语句。
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertReadQuery(sql: string, engine?: SqlEngine): string;
|
|
38
|
+
/**
|
|
39
|
+
* 把一段写操作脚本压成审批卡可读的摘要。
|
|
40
|
+
*
|
|
41
|
+
* 关键点:逐条列出每条语句,并额外给一行语句类型统计。即使脚本体被省略,
|
|
42
|
+
* 第二条、第 N 条语句的 DROP / DELETE 也不会从审批文本里消失。
|
|
43
|
+
*/
|
|
44
|
+
export declare function summarizeWriteScript(sql: string): string;
|
|
31
45
|
/** 查询结果转 CSV 文本(RFC 4180 风格转义)。 */
|
|
32
46
|
export declare function toCsv(columns: string[], rows: unknown[][]): string;
|
|
33
47
|
/** 审批执行上下文的最小面。 */
|
|
@@ -42,3 +56,4 @@ export declare function buildSqlTools(config: ResolvedSqlConfig): {
|
|
|
42
56
|
tools: SqlToolDefinition[];
|
|
43
57
|
adapters: Map<string, DatabaseAdapter>;
|
|
44
58
|
};
|
|
59
|
+
export {};
|
package/lib/tools.js
CHANGED
|
@@ -40,8 +40,30 @@ function executionSignal(exec) {
|
|
|
40
40
|
}
|
|
41
41
|
/** 只读语句关键字白名单。 */
|
|
42
42
|
const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* PostgreSQL 的 E'…' / U&'…' 会把反斜杠当转义;普通 '…' 在
|
|
45
|
+
* standard_conforming_strings=on(9.1 起的默认值)下反斜杠是普通字符。
|
|
46
|
+
*/
|
|
47
|
+
function isPostgresEscapeString(sql, quoteIndex) {
|
|
48
|
+
const prev = sql[quoteIndex - 1];
|
|
49
|
+
const isWordChar = (ch) => ch !== undefined && /[A-Za-z0-9_$]/.test(ch);
|
|
50
|
+
if (prev === 'E' || prev === 'e')
|
|
51
|
+
return !isWordChar(sql[quoteIndex - 2]);
|
|
52
|
+
if (prev === '&') {
|
|
53
|
+
const u = sql[quoteIndex - 2];
|
|
54
|
+
return (u === 'U' || u === 'u') && !isWordChar(sql[quoteIndex - 3]);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 去掉字符串、引号标识符与注释,保留真实 SQL 关键字与分号。
|
|
60
|
+
*
|
|
61
|
+
* 反斜杠规则按引擎对齐,否则去噪结果会和服务器看到的分割方式不一致:
|
|
62
|
+
* MySQL 默认把 \ 当转义;PostgreSQL 只有 E'…' / U&'…' 才转义;
|
|
63
|
+
* SQLite 字符串里根本没有反斜杠转义。引号标识符("…" 与 `…`)在三种
|
|
64
|
+
* 引擎里都不把反斜杠当转义,永远按字面量处理。
|
|
65
|
+
*/
|
|
66
|
+
function stripSqlNoise(sql, engine) {
|
|
45
67
|
let out = '';
|
|
46
68
|
let i = 0;
|
|
47
69
|
while (i < sql.length) {
|
|
@@ -74,6 +96,7 @@ function stripSqlNoise(sql) {
|
|
|
74
96
|
if (ch === "'") {
|
|
75
97
|
out += ' ';
|
|
76
98
|
i += 1;
|
|
99
|
+
const backslashEscapes = engine === 'mysql' || (engine === 'postgres' && isPostgresEscapeString(sql, i - 1));
|
|
77
100
|
while (i < sql.length) {
|
|
78
101
|
if (sql[i] === "'" && sql[i + 1] === "'") {
|
|
79
102
|
i += 2;
|
|
@@ -83,7 +106,7 @@ function stripSqlNoise(sql) {
|
|
|
83
106
|
i += 1;
|
|
84
107
|
break;
|
|
85
108
|
}
|
|
86
|
-
if (sql[i] === '\\') {
|
|
109
|
+
if (backslashEscapes && sql[i] === '\\') {
|
|
87
110
|
i += 2;
|
|
88
111
|
continue;
|
|
89
112
|
}
|
|
@@ -99,10 +122,6 @@ function stripSqlNoise(sql) {
|
|
|
99
122
|
i += 1;
|
|
100
123
|
break;
|
|
101
124
|
}
|
|
102
|
-
if (sql[i] === '\\') {
|
|
103
|
-
i += 2;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
125
|
i += 1;
|
|
107
126
|
}
|
|
108
127
|
continue;
|
|
@@ -132,10 +151,74 @@ function stripSqlNoise(sql) {
|
|
|
132
151
|
}
|
|
133
152
|
/** 写操作关键字:出在 SELECT/EXPLAIN/WITH 语句里即拒绝。 */
|
|
134
153
|
const WRITE_KEYWORDS = /\b(insert|update|delete|replace|merge|drop|alter|create|truncate|call|execute|copy|grant|revoke|attach|detach|vacuum|reindex|refresh|set|reset|begin|commit|rollback|savepoint|release|analyze|load_extension)\b/i;
|
|
135
|
-
/**
|
|
136
|
-
|
|
154
|
+
/** 允许不带参数的只读 PRAGMA(含查询型与会话状态查询)。 */
|
|
155
|
+
const READONLY_PRAGMAS = new Set([
|
|
156
|
+
'application_id',
|
|
157
|
+
'auto_vacuum',
|
|
158
|
+
'cell_size_check',
|
|
159
|
+
'collation_list',
|
|
160
|
+
'compile_options',
|
|
161
|
+
'data_version',
|
|
162
|
+
'database_list',
|
|
163
|
+
'encoding',
|
|
164
|
+
'freelist_count',
|
|
165
|
+
'function_list',
|
|
166
|
+
'journal_mode',
|
|
167
|
+
'legacy_file_format',
|
|
168
|
+
'mmap_size',
|
|
169
|
+
'module_list',
|
|
170
|
+
'page_count',
|
|
171
|
+
'page_size',
|
|
172
|
+
'pragma_list',
|
|
173
|
+
'schema_version',
|
|
174
|
+
'sqlite_version',
|
|
175
|
+
'user_version',
|
|
176
|
+
]);
|
|
177
|
+
/** 允许带一个标识符参数的只读内省 PRAGMA。 */
|
|
178
|
+
const READONLY_PRAGMAS_WITH_ARG = new Set([
|
|
179
|
+
'foreign_key_check',
|
|
180
|
+
'foreign_key_list',
|
|
181
|
+
'index_info',
|
|
182
|
+
'index_list',
|
|
183
|
+
'index_xinfo',
|
|
184
|
+
'integrity_check',
|
|
185
|
+
'quick_check',
|
|
186
|
+
'table_info',
|
|
187
|
+
'table_list',
|
|
188
|
+
'table_xinfo',
|
|
189
|
+
]);
|
|
190
|
+
/**
|
|
191
|
+
* PRAGMA 白名单:只放行只读内省。
|
|
192
|
+
*
|
|
193
|
+
* SQLite 里 `PRAGMA name(value)` 与 `PRAGMA name = value` 完全等价,
|
|
194
|
+
* 只拦改号会让 `PRAGMA foreign_keys(0)` / `PRAGMA writable_schema(1)`
|
|
195
|
+
* 这类会改连接或库文件状态的调用漏过去,所以按名字白名单放行。
|
|
196
|
+
*/
|
|
197
|
+
function assertReadOnlyPragma(statement) {
|
|
198
|
+
const match = /^pragma\s+(?:[a-z_][a-z0-9_]*\.)?([a-z_][a-z0-9_]*)\s*(.*)$/i.exec(statement);
|
|
199
|
+
if (match === null)
|
|
200
|
+
throw new Error('无法解析这条 PRAGMA,sql_query 只接受只读的 PRAGMA 内省语句。');
|
|
201
|
+
const pragmaName = match[1].toLowerCase();
|
|
202
|
+
const rest = match[2].trim();
|
|
203
|
+
if (rest !== '' && /^[=(]/.test(rest)) {
|
|
204
|
+
if (!READONLY_PRAGMAS_WITH_ARG.has(pragmaName)) {
|
|
205
|
+
throw new Error('sql_query 不接受带参数的 PRAGMA ' + pragmaName.toUpperCase() + '(PRAGMA name(value) 与 name=value 等价,会改连接或数据库状态)。请用 sql_exec。');
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!READONLY_PRAGMAS.has(pragmaName) && !READONLY_PRAGMAS_WITH_ARG.has(pragmaName)) {
|
|
210
|
+
throw new Error('sql_query 只放行只读 PRAGMA 白名单(table_info / index_list / page_count / user_version 等),' + pragmaName.toUpperCase() + ' 不在白名单内。需要修改请用 sql_exec。');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句 + PRAGMA 白名单。
|
|
215
|
+
*
|
|
216
|
+
* `engine` 决定反斜杠转义规则(见 stripSqlNoise);不传时按最保守的方式把
|
|
217
|
+
* 反斜杠当字面量,宁可误拒也不放过第二条语句。
|
|
218
|
+
*/
|
|
219
|
+
export function assertReadQuery(sql, engine) {
|
|
137
220
|
const trimmed = sql.trim();
|
|
138
|
-
const clean = stripSqlNoise(trimmed);
|
|
221
|
+
const clean = stripSqlNoise(trimmed, engine);
|
|
139
222
|
const first = /^[a-z]+/i.exec(clean.trim())?.[0]?.toLowerCase() ?? '';
|
|
140
223
|
if (!READ_KEYWORDS.test(clean.trim())) {
|
|
141
224
|
throw new Error('sql_query 只接受只读语句(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。写操作请用 sql_exec。');
|
|
@@ -145,8 +228,7 @@ export function assertReadQuery(sql) {
|
|
|
145
228
|
throw new Error('sql_query 一次只允许一条语句。');
|
|
146
229
|
const single = statements[0]?.trim() ?? '';
|
|
147
230
|
if (first === 'pragma') {
|
|
148
|
-
|
|
149
|
-
throw new Error('sql_query 不接受带赋值参数的 PRAGMA 写操作(如 PRAGMA journal_mode=WAL),请用 sql_exec。');
|
|
231
|
+
assertReadOnlyPragma(single);
|
|
150
232
|
}
|
|
151
233
|
else if (first === 'show' || first === 'describe' || first === 'desc') {
|
|
152
234
|
// SHOW / DESCRIBE 本身只读,不再扫写关键字(避免误伤 SHOW CREATE TABLE)。
|
|
@@ -163,6 +245,40 @@ export function assertReadQuery(sql) {
|
|
|
163
245
|
}
|
|
164
246
|
return trimmed.replace(/;+\s*$/, '').trim();
|
|
165
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* 把一段写操作脚本压成审批卡可读的摘要。
|
|
250
|
+
*
|
|
251
|
+
* 关键点:逐条列出每条语句,并额外给一行语句类型统计。即使脚本体被省略,
|
|
252
|
+
* 第二条、第 N 条语句的 DROP / DELETE 也不会从审批文本里消失。
|
|
253
|
+
*/
|
|
254
|
+
export function summarizeWriteScript(sql) {
|
|
255
|
+
const trimmed = sql.trim();
|
|
256
|
+
const clean = stripSqlNoise(trimmed);
|
|
257
|
+
const statements = clean
|
|
258
|
+
.split(';')
|
|
259
|
+
.map((part) => part.replace(/\s+/g, ' ').trim())
|
|
260
|
+
.filter((part) => part !== '');
|
|
261
|
+
const effective = statements.length === 0 ? ['(空语句)'] : statements;
|
|
262
|
+
const tally = new Map();
|
|
263
|
+
for (const statement of effective) {
|
|
264
|
+
const keyword = /^[a-z]+/i.exec(statement)?.[0]?.toUpperCase() ?? '?';
|
|
265
|
+
tally.set(keyword, (tally.get(keyword) ?? 0) + 1);
|
|
266
|
+
}
|
|
267
|
+
const lines = ['共 ' + effective.length + ' 条语句:'];
|
|
268
|
+
effective.slice(0, 100).forEach((statement, index) => {
|
|
269
|
+
lines.push(' ' + (index + 1) + '. ' + (statement.length > 120 ? statement.slice(0, 120) + '…' : statement));
|
|
270
|
+
});
|
|
271
|
+
if (effective.length > 100)
|
|
272
|
+
lines.push(' …另有 ' + (effective.length - 100) + ' 条语句');
|
|
273
|
+
lines.push('语句类型:' + [...tally.entries()].map(([keyword, count]) => keyword + '×' + count).join(','));
|
|
274
|
+
if (trimmed.length <= 800) {
|
|
275
|
+
lines.push('', '完整 SQL:' + trimmed);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
lines.push('', '完整 SQL 共 ' + trimmed.length + ' 个字符,见本次工具调用参数。');
|
|
279
|
+
}
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|
|
166
282
|
const querySchema = {
|
|
167
283
|
type: 'object',
|
|
168
284
|
properties: {
|
|
@@ -289,15 +405,20 @@ const healthSchema = {
|
|
|
289
405
|
export function buildSqlTools(config) {
|
|
290
406
|
const cfg = config;
|
|
291
407
|
const adapters = new Map();
|
|
292
|
-
|
|
408
|
+
/** 只解析连接配置(不建连接),用于在打开数据库前挑选词法规则。 */
|
|
409
|
+
const findConnection = (name) => {
|
|
293
410
|
const target = name ?? cfg.connections[0].name;
|
|
294
411
|
const connection = cfg.connections.find((item) => item.name.toLowerCase() === target.toLowerCase());
|
|
295
412
|
if (connection === undefined) {
|
|
296
413
|
throw new Error('未找到名为 ' + target + ' 的数据库连接。可用 sql_list 查看连接清单。');
|
|
297
414
|
}
|
|
415
|
+
return connection;
|
|
416
|
+
};
|
|
417
|
+
const getAdapter = (name) => {
|
|
418
|
+
const connection = findConnection(name);
|
|
298
419
|
let adapter = adapters.get(connection.name);
|
|
299
420
|
if (adapter === undefined) {
|
|
300
|
-
adapter = createAdapter(connection);
|
|
421
|
+
adapter = createAdapter(connection, { readOnly: cfg.readOnly });
|
|
301
422
|
adapters.set(connection.name, adapter);
|
|
302
423
|
}
|
|
303
424
|
return { adapter, name: connection.name };
|
|
@@ -352,7 +473,7 @@ export function buildSqlTools(config) {
|
|
|
352
473
|
};
|
|
353
474
|
const sqlQuery = {
|
|
354
475
|
name: 'sql_query',
|
|
355
|
-
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH
|
|
476
|
+
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验按引擎处理反斜杠转义,拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、非白名单 PRAGMA 与多语句;readOnly=true 时还会让数据库自身以只读方式连接。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
|
|
356
477
|
parameters: compileParameters({
|
|
357
478
|
sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
|
|
358
479
|
connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
|
|
@@ -378,8 +499,11 @@ export function buildSqlTools(config) {
|
|
|
378
499
|
},
|
|
379
500
|
async execute(rawArgs, exec) {
|
|
380
501
|
const args = asRecord(rawArgs);
|
|
381
|
-
const
|
|
382
|
-
const
|
|
502
|
+
const rawSql = requiredString(args, 'sql', 'SQL 语句');
|
|
503
|
+
const connectionName = optionalString(args, 'connection');
|
|
504
|
+
// 先按目标连接的引擎做词法校验,再打开数据库:被拒绝的 SQL 不该去建连接或建库文件。
|
|
505
|
+
const sql = assertReadQuery(rawSql, findConnection(connectionName).engine);
|
|
506
|
+
const { adapter, name } = getAdapter(connectionName);
|
|
383
507
|
const result = await adapter.query(sql, cfg.maxRows + 1, executionSignal(exec));
|
|
384
508
|
const total = result.rows.length;
|
|
385
509
|
const rows = result.rows.slice(0, cfg.maxRows);
|
|
@@ -395,6 +519,9 @@ export function buildSqlTools(config) {
|
|
|
395
519
|
if (format === 'csv')
|
|
396
520
|
return { ...base, format, formatted: toCsv(result.columns, rows) };
|
|
397
521
|
if (format === 'json') {
|
|
522
|
+
if (new Set(result.columns).size !== result.columns.length) {
|
|
523
|
+
return { ...base, format, formatted: JSON.stringify({ columns: result.columns, rows }, null, 2) };
|
|
524
|
+
}
|
|
398
525
|
const objects = rows.map((row) => Object.fromEntries(result.columns.map((column, i) => [column, row[i]])));
|
|
399
526
|
return { ...base, format, formatted: JSON.stringify(objects, null, 2) };
|
|
400
527
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sql",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.6",
|
|
4
|
+
"description": "连接 SQLite、MySQL 或 PostgreSQL,查询数据并检查数据库结构。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"lib",
|
|
18
18
|
"cordis.patch.yml",
|
|
19
19
|
"README.md",
|
|
20
|
-
"README.en.md"
|
|
20
|
+
"README.en.md",
|
|
21
|
+
"docs",
|
|
22
|
+
"CHANGELOG.md"
|
|
21
23
|
],
|
|
22
24
|
"scripts": {
|
|
23
25
|
"build": "tsc -p tsconfig.json",
|