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
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Server 适配器(SQL Server 2008/2008 R2 – 2022 与 Azure SQL;mssql@12,底层 tedious 纯 JS TDS)。
|
|
3
|
+
*
|
|
4
|
+
* 驱动加载:mssql 只在 devDependencies。它是唯一会把 buffer/events/process/string_decoder
|
|
5
|
+
* 四个核心模块同名包带进生产依赖树的驱动(mssql → tedious → bl → readable-stream),
|
|
6
|
+
* 会触发宿主 @deepseek-ai/dsh-app-boot 的 require.resolve.paths bug 使插件整体加载失败
|
|
7
|
+
* (约束由 tests/guard/deps-core-collision.spec.ts 守护)。故运行时经 createRequire 相对
|
|
8
|
+
* 加载 vendor/sqlserver-driver.cjs(scripts/build-sqlserver.mjs 产物),失败不缓存并给中文
|
|
9
|
+
* 修复指引。mssql 12.7.4 不随包发 .d.ts(package.json 无 types 字段),类型在本文件自声明
|
|
10
|
+
* 最小接口——与 pg-like.ts 的 PgLikeDriver 同一手法。
|
|
11
|
+
*
|
|
12
|
+
* 占位符:对外只接受 `?`(客户端 dialectOf('sqlserver') 返回 'q'),内部经词法扫描顺序重写为
|
|
13
|
+
* `@p1..@pN` 并 request.input('pN', v)。扫描跳过单引号串(含 N'...' 与 '' 转义)、[方括号]、
|
|
14
|
+
* "双引号"、行注释(--)与块注释内的 `?`;个数与 params 不一致即抛中文错误。
|
|
15
|
+
*
|
|
16
|
+
* 版本降级:SERVERPROPERTY('ProductMajorVersion') 探测(NULL 时回退 ProductVersion 前缀),
|
|
17
|
+
* 按池缓存。>=11(2012+,含 Azure SQL 的 12)用 OFFSET/FETCH(T-SQL 强制要求 ORDER BY,
|
|
18
|
+
* 用常量排序 ORDER BY (SELECT NULL) 保持「不指定顺序」的语义);<11(2008/2008 R2)降级为
|
|
19
|
+
* ROW_NUMBER() OVER (...) 子查询,并把内部序号列从结果集中剔除。
|
|
20
|
+
*
|
|
21
|
+
* 只读(ro)语义诚实降级:SQL Server 没有等价的会话级只读语句(不像 MySQL 的
|
|
22
|
+
* SET SESSION TRANSACTION READ ONLY 或 PG 的 SET SESSION CHARACTERISTICS AS TRANSACTION
|
|
23
|
+
* READ ONLY),无法在服务器侧强制只读。故 ro 强度弱于 mysql/pg,只有三层:
|
|
24
|
+
* 服务层拦 execute + 适配器侧 query 首词白名单(复用 guard 的 SQL_READ_HEADS)+
|
|
25
|
+
* 拒绝多语句(防 `SELECT 1; UPDATE ...` 堆叠写)。这里是应用层防线,不是服务器级保证。
|
|
26
|
+
*/
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { SQL_READ_HEADS, sqlHead } from '../../guard/index.js';
|
|
29
|
+
import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell } from '../sql-shared/common.js';
|
|
30
|
+
let vendorCache;
|
|
31
|
+
/** 容错加载 vendor bundle:缺失返回 undefined,成功后缓存;失败不缓存 → 构建完成即可恢复 */
|
|
32
|
+
function vendorOrNull() {
|
|
33
|
+
if (vendorCache === undefined) {
|
|
34
|
+
try {
|
|
35
|
+
// 相对路径从本模块出发:dist/adapters/sqlserver/ 或 lib/adapters/sqlserver/ 均指向包根 vendor/
|
|
36
|
+
vendorCache = createRequire(import.meta.url)('../../../vendor/sqlserver-driver.cjs');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return vendorCache;
|
|
43
|
+
}
|
|
44
|
+
/** 严格加载:缺失时抛带修复指引的中文错误 */
|
|
45
|
+
function vendor() {
|
|
46
|
+
const m = vendorOrNull();
|
|
47
|
+
if (m === undefined) {
|
|
48
|
+
throw new Error('sqlserver 驱动不可用:缺少 vendor/sqlserver-driver.cjs。请在插件目录运行 npm run build:sqlserver 构建后重试');
|
|
49
|
+
}
|
|
50
|
+
return m;
|
|
51
|
+
}
|
|
52
|
+
/* ============================ 配置解析 ============================ */
|
|
53
|
+
/** tedious 支持的 TDS 协议版本(默认 7_4) */
|
|
54
|
+
const TDS_VERSIONS = new Set(['7_4', '7_3_A', '7_3_B', '7_2', '7_1']);
|
|
55
|
+
/** URLSearchParams 取值(参数名大小写不敏感,URL 里常见 camelCase 与全小写混写) */
|
|
56
|
+
function queryParam(sp, name) {
|
|
57
|
+
const lower = name.toLowerCase();
|
|
58
|
+
for (const [k, v] of sp) {
|
|
59
|
+
if (k.toLowerCase() === lower)
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/** 布尔 query 参数:非法值抛中文错误而非静默回退(连错加密策略是安全事故) */
|
|
65
|
+
function boolParam(sp, name, dflt) {
|
|
66
|
+
const v = queryParam(sp, name);
|
|
67
|
+
if (v === undefined || v === '')
|
|
68
|
+
return dflt;
|
|
69
|
+
const s = v.trim().toLowerCase();
|
|
70
|
+
if (s === 'true' || s === '1' || s === 'yes')
|
|
71
|
+
return true;
|
|
72
|
+
if (s === 'false' || s === '0' || s === 'no')
|
|
73
|
+
return false;
|
|
74
|
+
throw new Error(`sqlserver 连接 URL 的 ${name} 取值非法:${JSON.stringify(v)}(只接受 true/false)`);
|
|
75
|
+
}
|
|
76
|
+
function decodeCred(raw) {
|
|
77
|
+
try {
|
|
78
|
+
return decodeURIComponent(raw);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error('sqlserver 连接 URL 的用户名或密码 percent 编码非法(如含未编码的 %),请 URL 编码后重试');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function validPort(v, dflt) {
|
|
85
|
+
const n = Number(v);
|
|
86
|
+
return Number.isFinite(n) && n > 0 && n <= 65535 ? Math.floor(n) : dflt;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* URL 脱敏(错误消息可能进 audit.jsonl 与模型上下文,绝不能回显明文密码):
|
|
90
|
+
* 保留 scheme 与 host(:port),userinfo 一律替换为 ***,query/fragment 丢弃。
|
|
91
|
+
* 纯文本处理、不依赖 WHATWG URL —— 本函数正是在 new URL() 失败时使用。
|
|
92
|
+
*/
|
|
93
|
+
function redactUrl(url) {
|
|
94
|
+
const schemeEnd = url.indexOf('://');
|
|
95
|
+
const head = schemeEnd >= 0 ? url.slice(0, schemeEnd + 3) : '';
|
|
96
|
+
const rest = url.slice(head.length);
|
|
97
|
+
const authEnd = /[/?#]/.exec(rest)?.index ?? rest.length;
|
|
98
|
+
const authority = rest.slice(0, authEnd);
|
|
99
|
+
const at = authority.lastIndexOf('@');
|
|
100
|
+
const host = at >= 0 ? authority.slice(at + 1) : authority;
|
|
101
|
+
return `${head}${at >= 0 ? '***@' : ''}${host}`;
|
|
102
|
+
}
|
|
103
|
+
/** ResolvedConnection → mssql 池配置(浏览适配器与传输端共用,保证连接语义一致) */
|
|
104
|
+
function poolConfig(conn) {
|
|
105
|
+
const options = {
|
|
106
|
+
encrypt: true,
|
|
107
|
+
trustServerCertificate: false,
|
|
108
|
+
tdsVersion: '7_4',
|
|
109
|
+
useUTC: true,
|
|
110
|
+
};
|
|
111
|
+
const cfg = { options };
|
|
112
|
+
if (conn.url) {
|
|
113
|
+
let u;
|
|
114
|
+
try {
|
|
115
|
+
u = new URL(conn.url);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// 脱敏:错误消息会进 audit.jsonl 与模型上下文,绝不回显 userinfo 明文密码
|
|
119
|
+
throw new Error(`sqlserver 连接 URL 无法解析:${redactUrl(conn.url)}(期望形态 sqlserver://user:pass@host:1433/db?encrypt=true;请检查端口是否在 1-65535、是否有多余字符)`);
|
|
120
|
+
}
|
|
121
|
+
if (!u.hostname)
|
|
122
|
+
throw new Error('sqlserver 连接 URL 缺少主机名(期望形态 sqlserver://user:pass@host:1433/db)');
|
|
123
|
+
cfg.server = u.hostname;
|
|
124
|
+
cfg.port = u.port ? validPort(u.port, 1433) : 1433;
|
|
125
|
+
if (u.username)
|
|
126
|
+
cfg.user = decodeCred(u.username);
|
|
127
|
+
if (u.password)
|
|
128
|
+
cfg.password = decodeCred(u.password);
|
|
129
|
+
const path = u.pathname.replace(/^\//, '');
|
|
130
|
+
if (path)
|
|
131
|
+
cfg.database = decodeCred(path);
|
|
132
|
+
options.encrypt = boolParam(u.searchParams, 'encrypt', true);
|
|
133
|
+
options.trustServerCertificate = boolParam(u.searchParams, 'trustServerCertificate', false);
|
|
134
|
+
options.useUTC = boolParam(u.searchParams, 'useUTC', true);
|
|
135
|
+
const tds = queryParam(u.searchParams, 'tdsVersion');
|
|
136
|
+
if (tds !== undefined && tds !== '') {
|
|
137
|
+
if (!TDS_VERSIONS.has(tds)) {
|
|
138
|
+
throw new Error(`sqlserver 连接 URL 的 tdsVersion 取值非法:${JSON.stringify(tds)}(可选 ${[...TDS_VERSIONS].join('/')},默认 7_4)`);
|
|
139
|
+
}
|
|
140
|
+
options.tdsVersion = tds;
|
|
141
|
+
}
|
|
142
|
+
const instance = queryParam(u.searchParams, 'instanceName');
|
|
143
|
+
// 指定命名实例时 tedious 会忽略 port,改由 SQL Server Browser(UDP 1434)解析
|
|
144
|
+
if (instance !== undefined && instance !== '')
|
|
145
|
+
options.instanceName = instance;
|
|
146
|
+
// fields.database 覆盖 URL 库名:传输端跨库定位(sqlserverLocate)按 pg/mysql 惯例、
|
|
147
|
+
// 用浅拷贝在 fields 上写 database 而不改写 URL——此处让该覆盖生效,避免静默连回原库。
|
|
148
|
+
const fdb = conn.fields?.['database'];
|
|
149
|
+
if (fdb != null && String(fdb) !== '')
|
|
150
|
+
cfg.database = String(fdb);
|
|
151
|
+
}
|
|
152
|
+
else if (conn.fields) {
|
|
153
|
+
const f = conn.fields;
|
|
154
|
+
cfg.server = String(f.host ?? 'localhost');
|
|
155
|
+
cfg.port = validPort(f.port, 1433);
|
|
156
|
+
if (f.user != null && String(f.user) !== '')
|
|
157
|
+
cfg.user = String(f.user);
|
|
158
|
+
else if (f.username != null && String(f.username) !== '')
|
|
159
|
+
cfg.user = String(f.username);
|
|
160
|
+
if (f.password != null)
|
|
161
|
+
cfg.password = String(f.password);
|
|
162
|
+
if (f.database != null && String(f.database) !== '')
|
|
163
|
+
cfg.database = String(f.database);
|
|
164
|
+
if (f.instanceName != null && String(f.instanceName) !== '')
|
|
165
|
+
options.instanceName = String(f.instanceName);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
throw new Error('sqlserver 连接缺少 url 或 fields 配置');
|
|
169
|
+
}
|
|
170
|
+
// conn.ssl 是通用字段(mysql/pg 适配器同样处理):语义上等价要求加密信道
|
|
171
|
+
if (conn.ssl)
|
|
172
|
+
options.encrypt = true;
|
|
173
|
+
cfg.connectionTimeout = 15000;
|
|
174
|
+
cfg.requestTimeout = 30000;
|
|
175
|
+
cfg.pool = { max: 10, min: 0, idleTimeoutMillis: 30000 };
|
|
176
|
+
return cfg;
|
|
177
|
+
}
|
|
178
|
+
function resolveDatabase(conn) {
|
|
179
|
+
if (conn.fields && conn.fields.database != null && String(conn.fields.database) !== '') {
|
|
180
|
+
return String(conn.fields.database);
|
|
181
|
+
}
|
|
182
|
+
if (conn.url) {
|
|
183
|
+
const p = new URL(conn.url).pathname.replace(/^\//, '');
|
|
184
|
+
if (p)
|
|
185
|
+
return decodeCred(p);
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
/** T-SQL token 字符(标识符/关键字/变量名;`@` `#` `$` 均可在标识符内) */
|
|
190
|
+
const WORD_CHAR = /[A-Za-z0-9_@$#]/;
|
|
191
|
+
/**
|
|
192
|
+
* 单遍词法扫描:识别并跳过字符串/标识符/注释,统计顶层 `?`、顶层 token 与顶层分号。
|
|
193
|
+
* 跳过规则(T-SQL 词法):
|
|
194
|
+
* - '...':单引号串(N'...' 的 N 是普通字符,无需特殊处理),内部 '' 为转义;
|
|
195
|
+
* - [..]:方括号标识符,内部 ]] 为转义;
|
|
196
|
+
* - "...":双引号(QUOTED_IDENTIFIER ON 下是标识符),内部 "" 为转义;
|
|
197
|
+
* - -- 行注释(到行尾)、块注释。
|
|
198
|
+
* 已知边界(不实现,保持失败可见):T-SQL 允许嵌套块注释(块注释里再开一个块注释),
|
|
199
|
+
* 本扫描器按首个注释结束符收尾 → 会把注释里剩余的 `?` 计入占位符,最终报「个数不一致」
|
|
200
|
+
* (fail-closed,不会误放行;提示文案可能略显跳脱)。需要精确支持时再扩展为深度计数。
|
|
201
|
+
*/
|
|
202
|
+
function scanSql(sql) {
|
|
203
|
+
const placeholders = [];
|
|
204
|
+
const words = [];
|
|
205
|
+
let hasNamedParam = false;
|
|
206
|
+
let multi = false;
|
|
207
|
+
let sawSemi = false;
|
|
208
|
+
let i = 0;
|
|
209
|
+
const n = sql.length;
|
|
210
|
+
while (i < n) {
|
|
211
|
+
const ch = sql[i];
|
|
212
|
+
// 注释与空白整体跳过:它们不构成语句,也不参与「分号之后是否还有语句」的判定
|
|
213
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
214
|
+
i += 2;
|
|
215
|
+
while (i < n && sql[i] !== '\n')
|
|
216
|
+
i++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
220
|
+
i += 2;
|
|
221
|
+
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/'))
|
|
222
|
+
i++;
|
|
223
|
+
i = Math.min(n, i + 2);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (/\s/.test(ch)) {
|
|
227
|
+
i++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// 多语句判定必须先于各跳过分支:顶层分号之后只要还有任何内容(word / `?` / 标点 / 字符串起手),
|
|
231
|
+
// 就是第二条语句。原实现把该判定放在循环末尾、且被 word 分支的 continue 绕过 —— 于是
|
|
232
|
+
// `SELECT 1;DROP TABLE t`、`SELECT 1;EXEC xp_cmdshell 'x'` 这类「第二条全为标识符」的堆叠写被放行。
|
|
233
|
+
if (sawSemi)
|
|
234
|
+
multi = true;
|
|
235
|
+
if (ch === "'" || ch === '"') {
|
|
236
|
+
i++;
|
|
237
|
+
while (i < n) {
|
|
238
|
+
if (sql[i] === ch) {
|
|
239
|
+
if (sql[i + 1] === ch) {
|
|
240
|
+
i += 2;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
i++;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === '[') {
|
|
251
|
+
i++;
|
|
252
|
+
while (i < n) {
|
|
253
|
+
if (sql[i] === ']') {
|
|
254
|
+
if (sql[i + 1] === ']') {
|
|
255
|
+
i += 2;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
i++;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
i++;
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
266
|
+
i += 2;
|
|
267
|
+
while (i < n && sql[i] !== '\n')
|
|
268
|
+
i++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
272
|
+
i += 2;
|
|
273
|
+
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/'))
|
|
274
|
+
i++;
|
|
275
|
+
i = Math.min(n, i + 2);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (WORD_CHAR.test(ch)) {
|
|
279
|
+
let j = i + 1;
|
|
280
|
+
while (j < n && WORD_CHAR.test(sql[j]))
|
|
281
|
+
j++;
|
|
282
|
+
const tok = sql.slice(i, j).toLowerCase();
|
|
283
|
+
words.push(tok);
|
|
284
|
+
// 只认精确的 @p1/@P1 形态(T-SQL 变量名大小写不敏感):@p1x、@p、@@p1 都是普通标识符/系统变量,
|
|
285
|
+
// 把它们当占位符会误报「混用」,让用户无法使用以 @p 开头的变量名。
|
|
286
|
+
if (/^@p\d+$/i.test(tok))
|
|
287
|
+
hasNamedParam = true;
|
|
288
|
+
i = j;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ch === '?')
|
|
292
|
+
placeholders.push(i);
|
|
293
|
+
else if (ch === ';')
|
|
294
|
+
sawSemi = true;
|
|
295
|
+
i++;
|
|
296
|
+
}
|
|
297
|
+
return { placeholders, words, multi, hasNamedParam };
|
|
298
|
+
}
|
|
299
|
+
/** 占位符/参数一致性校验(在建池建请求之前调用:参数写错不该先触发网络 IO) */
|
|
300
|
+
function assertPlaceholderCount(scan, params) {
|
|
301
|
+
// 混用会让 @pN 之外的位置也被绑定成同一个参数(静默错值),必须直接拒绝
|
|
302
|
+
if (scan.hasNamedParam && scan.placeholders.length > 0) {
|
|
303
|
+
throw new Error('不允许在同一条语句中混用 ? 与 @pN 占位符:请统一使用 ?(适配器会自动重写为 @p1..@pN 并按顺序绑定)');
|
|
304
|
+
}
|
|
305
|
+
if (scan.placeholders.length !== params.length) {
|
|
306
|
+
throw new Error(`SQL 中 ? 占位符个数(${scan.placeholders.length})与传入参数个数(${params.length})不一致:请补齐/减少参数以匹配 ? 的个数` +
|
|
307
|
+
'(本适配器只接受 ? 占位符并自动重写为 @pN 绑定,不支持在 SQL 里直接写 @pN 再单独传参)');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** 把顶层 `?` 顺序重写为 @p1..@pN 并绑定到 request(调用前须已 assertPlaceholderCount) */
|
|
311
|
+
function rewritePlaceholders(req, sql, scan, params) {
|
|
312
|
+
let out = '';
|
|
313
|
+
let prev = 0;
|
|
314
|
+
scan.placeholders.forEach((pos, idx) => {
|
|
315
|
+
out += sql.slice(prev, pos) + `@p${idx + 1}`;
|
|
316
|
+
prev = pos + 1;
|
|
317
|
+
});
|
|
318
|
+
out += sql.slice(prev);
|
|
319
|
+
params.forEach((v, idx) => req.input(`p${idx + 1}`, v));
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* ro 模式下 query 的只读闸门(应用层防线,见文件头注释):
|
|
324
|
+
* 1) 首词白名单(SQL_READ_HEADS);
|
|
325
|
+
* 2) 拒绝多语句;
|
|
326
|
+
* 3) SQL Server 专属写形态——通用 guard 遮不住的三类,而 SQL Server 没有服务器级只读可兜底:
|
|
327
|
+
* - `SELECT ... INTO <目标>`:无确认建表并复制数据(guard 的 into 只匹配 MySQL 的 OUTFILE/DUMPFILE);
|
|
328
|
+
* - `WITH ... INSERT|UPDATE|DELETE|MERGE`:CTE 直接跟写语句(guard 的 writeBody 能识别,但 ro 只查首词);
|
|
329
|
+
* - `OPENQUERY(...)/OPENROWSET(...)/OPENDATASOURCE(...)`:其字符串参数在远端/外部数据源执行任意语句
|
|
330
|
+
* (含写与 xp_cmdshell),ro 连接上无法审计其内容,一律拒绝(保守:只读场景罕见,误拒代价远小于放行写)。
|
|
331
|
+
* 所有判定都走 scanSql 的顶层 token(已跳过字符串/方括号/双引号标识符/注释),
|
|
332
|
+
* 因此字符串里的 into、方括号标识符(如 [into])、以及块注释里的 into 都不会误伤。
|
|
333
|
+
* 局限(安全取舍):裸标识符恰好叫 into/delete 等保留字的极端场景会被误拒(fail-closed)。
|
|
334
|
+
*/
|
|
335
|
+
function assertReadOnlyQuery(sql, scan) {
|
|
336
|
+
if (!SQL_READ_HEADS.has(sqlHead(sql))) {
|
|
337
|
+
throw new Error('DRIVER_ERROR: 只读(ro)模式下 query 仅允许读语句(select/with/show/describe/desc/explain/use/set/help/table),写操作请使用可写连接');
|
|
338
|
+
}
|
|
339
|
+
if (scan.multi) {
|
|
340
|
+
throw new Error('DRIVER_ERROR: 只读(ro)模式下 query 不允许一次提交多条语句(分号后仍有语句),请拆分为独立查询');
|
|
341
|
+
}
|
|
342
|
+
const words = new Set(scan.words);
|
|
343
|
+
if (words.has('into')) {
|
|
344
|
+
throw new Error('DRIVER_ERROR: 只读(ro)模式下不允许 SELECT ... INTO(会创建新表并复制数据),请使用可写连接');
|
|
345
|
+
}
|
|
346
|
+
if (scan.words[0] === 'with' &&
|
|
347
|
+
(words.has('insert') || words.has('update') || words.has('delete') || words.has('merge'))) {
|
|
348
|
+
throw new Error('DRIVER_ERROR: 只读(ro)模式下不允许 WITH 子句后跟写操作(insert/update/delete/merge),请使用可写连接');
|
|
349
|
+
}
|
|
350
|
+
if (words.has('openquery') || words.has('openrowset') || words.has('opendatasource')) {
|
|
351
|
+
throw new Error('DRIVER_ERROR: 只读(ro)模式下不允许 OPENQUERY/OPENROWSET/OPENDATASOURCE(其字符串参数会在远端/外部数据源执行任意语句,无法审计),请使用可写连接');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function errText(e) {
|
|
355
|
+
return e instanceof Error ? e.message : String(e);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* mssql 错误码 → 中文可读提示。ETIMEOUT 在 ConnectionError(建连超时)与 RequestError
|
|
359
|
+
* (语句执行超时)下语义不同,故结合 err.name 判断而非只看 code;originalError/precedingErrors
|
|
360
|
+
* 附加在末尾用于追溯。无法识别时保留原始 message(不吞信息)。
|
|
361
|
+
*/
|
|
362
|
+
function mssqlReason(e) {
|
|
363
|
+
const err = (e ?? {});
|
|
364
|
+
const code = typeof err.code === 'string' ? err.code : '';
|
|
365
|
+
const name = typeof err.name === 'string' ? err.name : '';
|
|
366
|
+
const detail = typeof err.message === 'string' ? err.message : String(e);
|
|
367
|
+
const num = typeof err.number === 'number' ? err.number : undefined;
|
|
368
|
+
const sev = typeof err.severity === 'number' ? err.severity : undefined;
|
|
369
|
+
const line = typeof err.lineNumber === 'number' ? err.lineNumber : undefined;
|
|
370
|
+
// TLS/证书类失败(自签证书、链不可验证、协议版本不符)在 SQL Server 2008/2008 R2 上很常见。
|
|
371
|
+
// 默认 encrypt=true + trustServerCertificate=false 与 mssql 官方一致(不改默认值),但必须:
|
|
372
|
+
// 1) 不把「TCP 已连通、TLS 校验失败」误报成网络不可达(会把用户引向错误排查方向);
|
|
373
|
+
// 2) 把「怎么连上」写清楚(URL 加 ?trustServerCertificate=true / ?encrypt=false)。
|
|
374
|
+
const tlsText = `${detail} ${errText(err.originalError)}`;
|
|
375
|
+
const tlsish = /self[ -]?signed|certificate|unable to verify|wrong version number|ERR_SSL|EPROTO|CERT_/i.test(tlsText);
|
|
376
|
+
let base;
|
|
377
|
+
switch (code) {
|
|
378
|
+
case 'ELOGIN':
|
|
379
|
+
base = `登录失败:用户名或密码错误,或账号不允许从当前主机登录(${detail})`;
|
|
380
|
+
break;
|
|
381
|
+
case 'ETIMEOUT':
|
|
382
|
+
base =
|
|
383
|
+
name === 'ConnectionError'
|
|
384
|
+
? `连接超时:未能在超时时间内建立连接,请检查主机/端口/防火墙(${detail})`
|
|
385
|
+
: `请求超时:语句执行超过 requestTimeout,请拆小查询或调大超时(${detail})`;
|
|
386
|
+
break;
|
|
387
|
+
case 'ESOCKET':
|
|
388
|
+
// 保留「网络不可达」字样(既有断言与用户检索习惯依赖),但 TLS 特征时先说明 TCP 已连通
|
|
389
|
+
base = tlsish
|
|
390
|
+
? `TCP 已连通但 TLS 握手/证书校验失败(不是网络不可达:网络不可达指主机/端口不可达或连接被拒,此处 TCP 已建立)(${detail})`
|
|
391
|
+
: `网络不可达:无法与服务器建立 TCP 连接,请检查主机名、端口与网络(${detail})`;
|
|
392
|
+
break;
|
|
393
|
+
// 服务器强制加密(SQL Server 2022 / Azure SQL 默认)而客户端 encrypt=false:tedious 抛英文原文,
|
|
394
|
+
// 不映射则中文界面里只剩英文,故单列一条给出可直接照做的改法。
|
|
395
|
+
case 'EENCRYPT':
|
|
396
|
+
base = `服务器要求加密连接:当前 encrypt=false 被服务器拒绝,请改用 ?encrypt=true 后重试(${detail})`;
|
|
397
|
+
break;
|
|
398
|
+
case 'EINSTLOOKUP':
|
|
399
|
+
base = `实例名解析失败:SQL Server Browser 服务可能未运行或 UDP 1434 不可达(命名实例可改用「主机:端口」直连,如 host,1433)(${detail})`;
|
|
400
|
+
break;
|
|
401
|
+
case 'ENOTOPEN':
|
|
402
|
+
case 'ECONNCLOSED':
|
|
403
|
+
base = `连接已关闭或未打开:请重新连接后重试(${detail})`;
|
|
404
|
+
break;
|
|
405
|
+
case 'ECANCEL':
|
|
406
|
+
base = `请求已取消(${detail})`;
|
|
407
|
+
break;
|
|
408
|
+
case 'EREQUEST':
|
|
409
|
+
base = `SQL 执行错误${num !== undefined ? `(错误号 ${num}${sev !== undefined ? `、严重级 ${sev}` : ''}${line !== undefined ? `、第 ${line} 行` : ''})` : ''}:${detail}`;
|
|
410
|
+
break;
|
|
411
|
+
default:
|
|
412
|
+
base = detail;
|
|
413
|
+
}
|
|
414
|
+
const trail = [];
|
|
415
|
+
if (tlsish) {
|
|
416
|
+
trail.push('若目标是 SQL Server 2008/2008 R2 或使用自签名证书的实例,可在连接 URL 追加 ?trustServerCertificate=true(或 ?encrypt=false,前提是服务器未强制加密)');
|
|
417
|
+
}
|
|
418
|
+
if (err.originalError !== undefined)
|
|
419
|
+
trail.push(`原始错误: ${errText(err.originalError)}`);
|
|
420
|
+
if (Array.isArray(err.precedingErrors) && err.precedingErrors.length > 0) {
|
|
421
|
+
trail.push(`前序错误: ${err.precedingErrors.map(errText).join('; ')}`);
|
|
422
|
+
}
|
|
423
|
+
return trail.length > 0 ? `${base}。${trail.join(';')}` : base;
|
|
424
|
+
}
|
|
425
|
+
/** 映射错误并把结构化信息(code/number/…)透传给上层,便于日志与守卫判断 */
|
|
426
|
+
function mappedError(e) {
|
|
427
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
428
|
+
const reason = mssqlReason(e);
|
|
429
|
+
const out = new Error(reason);
|
|
430
|
+
out.name = err.name;
|
|
431
|
+
const src = err;
|
|
432
|
+
Object.assign(out, {
|
|
433
|
+
code: src.code,
|
|
434
|
+
number: src.number,
|
|
435
|
+
severity: src.severity,
|
|
436
|
+
lineNumber: src.lineNumber,
|
|
437
|
+
originalError: err,
|
|
438
|
+
});
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
/** humanize + 错误码映射的组合包装 */
|
|
442
|
+
function wrap(label, fn) {
|
|
443
|
+
return humanize(label, async () => {
|
|
444
|
+
try {
|
|
445
|
+
return await fn();
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
throw mappedError(e);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/* ============================ 类型规范化 ============================ */
|
|
453
|
+
/** 字符/二进制类(max_length 即字符数);N 前缀类型 max_length 是字节数(2 倍字符数) */
|
|
454
|
+
const CHAR_TYPES = new Set(['char', 'varchar', 'binary', 'varbinary']);
|
|
455
|
+
const NCHAR_TYPES = new Set(['nchar', 'nvarchar']);
|
|
456
|
+
const DECIMAL_TYPES = new Set(['decimal', 'numeric']);
|
|
457
|
+
const SCALED_TYPES = new Set(['datetime2', 'datetimeoffset', 'time']);
|
|
458
|
+
function formatDataType(name, maxLength, precision, scale) {
|
|
459
|
+
const t = name.toLowerCase();
|
|
460
|
+
if (CHAR_TYPES.has(t) || NCHAR_TYPES.has(t)) {
|
|
461
|
+
if (maxLength < 0)
|
|
462
|
+
return `${name}(max)`;
|
|
463
|
+
const len = NCHAR_TYPES.has(t) ? Math.floor(maxLength / 2) : maxLength;
|
|
464
|
+
return `${name}(${len})`;
|
|
465
|
+
}
|
|
466
|
+
if (DECIMAL_TYPES.has(t))
|
|
467
|
+
return `${name}(${precision}, ${scale})`;
|
|
468
|
+
if (SCALED_TYPES.has(t))
|
|
469
|
+
return `${name}(${scale})`;
|
|
470
|
+
return name;
|
|
471
|
+
}
|
|
472
|
+
/** 版本探测 SQL(ProductMajorVersion 为 NULL 时用 ProductVersion 前缀回退) */
|
|
473
|
+
const VERSION_SQL = `SELECT CAST(SERVERPROPERTY('ProductMajorVersion') AS int) AS major,
|
|
474
|
+
CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS version`;
|
|
475
|
+
function parseMajor(row) {
|
|
476
|
+
const m = row?.['major'];
|
|
477
|
+
if (typeof m === 'number' && Number.isFinite(m) && m >= 1)
|
|
478
|
+
return m;
|
|
479
|
+
const v = row?.['version'];
|
|
480
|
+
if (typeof v === 'string') {
|
|
481
|
+
const p = Number.parseInt(v, 10);
|
|
482
|
+
if (Number.isFinite(p) && p >= 1)
|
|
483
|
+
return p;
|
|
484
|
+
}
|
|
485
|
+
return 0; // 探测失败按 <11 降级(ROW_NUMBER 在任意版本可用,保证能出结果)
|
|
486
|
+
}
|
|
487
|
+
/* ============================ 池包装 ============================ */
|
|
488
|
+
/** 结果行视图(元素形态在类型上放宽为 unknown,读取时统一按 Record 处理) */
|
|
489
|
+
function rowsOf(res) {
|
|
490
|
+
return (res.recordset ?? res.recordsets?.[0] ?? []);
|
|
491
|
+
}
|
|
492
|
+
function makePoolBox(Driver, cfg) {
|
|
493
|
+
const pool = new Driver.ConnectionPool(cfg);
|
|
494
|
+
// 无条件兜底:mssql 的 ConnectionPool 在 acquire 失败(空闲连接被服务器断开等)时会
|
|
495
|
+
// emit('error', err),EventEmitter 无监听器时直接抛 → 崩掉宿主进程。
|
|
496
|
+
pool.on('error', () => { });
|
|
497
|
+
let connecting;
|
|
498
|
+
const ready = () => {
|
|
499
|
+
if (connecting === undefined) {
|
|
500
|
+
connecting = Promise.resolve(pool.connect()).then(() => undefined, (e) => {
|
|
501
|
+
connecting = undefined; // 失败不缓存:允许下次重试
|
|
502
|
+
throw e;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return connecting;
|
|
506
|
+
};
|
|
507
|
+
let majorPromise;
|
|
508
|
+
const major = () => {
|
|
509
|
+
if (majorPromise === undefined) {
|
|
510
|
+
// 探测失败(SERVERPROPERTY 不可用 / 无权限 / 网络抖动)一律按 0 → 走 ROW_NUMBER 降级路径:
|
|
511
|
+
// 探测本身不该把整个 previewRows 打挂,ROW_NUMBER 在任意版本都可用(与传输端 propOf 的容错对称)。
|
|
512
|
+
majorPromise = (async () => {
|
|
513
|
+
try {
|
|
514
|
+
await ready();
|
|
515
|
+
const res = await pool.request().query(VERSION_SQL);
|
|
516
|
+
return parseMajor(rowsOf(res)[0]);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
})();
|
|
522
|
+
}
|
|
523
|
+
return majorPromise;
|
|
524
|
+
};
|
|
525
|
+
return { pool, ready, major };
|
|
526
|
+
}
|
|
527
|
+
/* ============================ 适配器 ============================ */
|
|
528
|
+
const LIST_DATABASES_SQL = `SELECT name FROM sys.databases WHERE database_id > 4 ORDER BY name`;
|
|
529
|
+
// 系统 schema 过滤:guest/sys/INFORMATION_SCHEMA 与固定数据库角色 schema(schema_id >= 16384)
|
|
530
|
+
const LIST_SCHEMAS_SQL = `SELECT s.name AS name FROM sys.schemas s
|
|
531
|
+
WHERE s.name NOT IN ('guest', 'sys', 'INFORMATION_SCHEMA') AND s.schema_id < 16384
|
|
532
|
+
ORDER BY s.name`;
|
|
533
|
+
// sys.objects 同时覆盖表(U)与视图(V);is_ms_shipped 排除系统附带对象
|
|
534
|
+
const LIST_TABLES_SQL = `SELECT o.name AS table_name, o.type AS obj_type
|
|
535
|
+
FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id
|
|
536
|
+
WHERE s.name = @p1 AND o.type IN ('U', 'V') AND o.is_ms_shipped = 0
|
|
537
|
+
ORDER BY o.name`;
|
|
538
|
+
// 元数据一律用 object_id/column_id 整数键关联(避免 collation/大小写差异);
|
|
539
|
+
// 扩展属性 MS_Description 作列注释(2005+ 通用写法,无 STRING_AGG/CONCAT_WS 等新语法)
|
|
540
|
+
const DESCRIBE_COLUMNS_SQL = `SELECT c.column_id, c.name AS column_name, t.name AS type_name,
|
|
541
|
+
c.max_length, c.precision, c.scale, c.is_nullable,
|
|
542
|
+
dc.definition AS default_def, ep.value AS comment
|
|
543
|
+
FROM sys.columns c
|
|
544
|
+
JOIN sys.types t ON t.user_type_id = c.user_type_id
|
|
545
|
+
LEFT JOIN sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
|
|
546
|
+
LEFT JOIN sys.extended_properties ep ON ep.class = 1 AND ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = N'MS_Description'
|
|
547
|
+
WHERE c.object_id = OBJECT_ID(@p1)
|
|
548
|
+
ORDER BY c.column_id`;
|
|
549
|
+
const DESCRIBE_PK_SQL = `SELECT ic.column_id
|
|
550
|
+
FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
|
551
|
+
WHERE i.object_id = OBJECT_ID(@p1) AND i.is_primary_key = 1`;
|
|
552
|
+
const TEST_CONNECT_SQL = `SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS version,
|
|
553
|
+
CAST(SERVERPROPERTY('ProductMajorVersion') AS int) AS major,
|
|
554
|
+
CAST(SERVERPROPERTY('EngineEdition') AS int) AS engine`;
|
|
555
|
+
const PRODUCT_NAMES = {
|
|
556
|
+
9: '2005',
|
|
557
|
+
10: '2008',
|
|
558
|
+
11: '2012',
|
|
559
|
+
12: '2014',
|
|
560
|
+
13: '2016',
|
|
561
|
+
14: '2017',
|
|
562
|
+
15: '2019',
|
|
563
|
+
16: '2022',
|
|
564
|
+
};
|
|
565
|
+
/** 版本串展示:'Microsoft SQL Server 2019 - 15.0.2000.5'(Azure SQL 按 EngineEdition=5 单独标注) */
|
|
566
|
+
function serverInfo(version, major, engine) {
|
|
567
|
+
if (engine === 5)
|
|
568
|
+
return `Azure SQL Database - ${version}`;
|
|
569
|
+
const m = typeof major === 'number' ? major : Number.parseInt(version, 10);
|
|
570
|
+
let name = PRODUCT_NAMES[m];
|
|
571
|
+
if (m === 10 && version.startsWith('10.5'))
|
|
572
|
+
name = '2008 R2';
|
|
573
|
+
return `Microsoft SQL Server ${name ?? m} - ${version}`;
|
|
574
|
+
}
|
|
575
|
+
export async function createSqlserverAdapter(conn, opts) {
|
|
576
|
+
// 配置先于驱动解析:配置错误不依赖 vendor 是否构建,用户能立刻拿到正确指引
|
|
577
|
+
const cfg = poolConfig(conn);
|
|
578
|
+
const Driver = opts?.driver ?? vendor();
|
|
579
|
+
const readOnly = opts?.mode === 'ro';
|
|
580
|
+
const mainDb = resolveDatabase(conn);
|
|
581
|
+
// 类型钉死:decimal/numeric/bigint/uniqueidentifier 一律转字符串。SQL Server 官方类型表里
|
|
582
|
+
// decimal/numeric 返回 JS Number、bigint 返回 Number/String,超过 2^53 会静默丢精度;
|
|
583
|
+
// 注意驱动在解析阶段已按数值解析过(精度损失发生在 tedious 内部),此处只能防止后续
|
|
584
|
+
// 再被当数值处理——需要逐位保真的场景请在 SQL 里显式 CONVERT(nvarchar(...), col)。
|
|
585
|
+
const TYPES = Driver.TYPES ?? {};
|
|
586
|
+
const pinnedTypes = new Set([TYPES['Decimal'], TYPES['Numeric'], TYPES['BigInt'], TYPES['UniqueIdentifier']].filter((t) => t !== undefined));
|
|
587
|
+
const cellOf = (v, meta) => {
|
|
588
|
+
if (v === null || v === undefined)
|
|
589
|
+
return null;
|
|
590
|
+
if (meta?.type !== undefined && pinnedTypes.has(meta.type)) {
|
|
591
|
+
return typeof v === 'string' ? v : typeof v === 'bigint' ? v.toString() : String(v);
|
|
592
|
+
}
|
|
593
|
+
return normalizeCell(v);
|
|
594
|
+
};
|
|
595
|
+
const toQueryResult = (res, dropColumns) => {
|
|
596
|
+
const recordset = (res.recordset ?? res.recordsets?.[0] ?? []);
|
|
597
|
+
const meta = recordset.columns;
|
|
598
|
+
const first = recordset[0];
|
|
599
|
+
let names = meta ? Object.keys(meta) : first ? Object.keys(first) : [];
|
|
600
|
+
if (dropColumns && dropColumns.length > 0) {
|
|
601
|
+
const drop = new Set(dropColumns.map((c) => c.toLowerCase()));
|
|
602
|
+
names = names.filter((c) => !drop.has(c.toLowerCase()));
|
|
603
|
+
}
|
|
604
|
+
const rows = recordset.map((r) => {
|
|
605
|
+
const row = r;
|
|
606
|
+
return names.map((c) => cellOf(row[c], meta?.[c]));
|
|
607
|
+
});
|
|
608
|
+
return { columns: names, rows, rowCount: rows.length };
|
|
609
|
+
};
|
|
610
|
+
const mainPool = makePoolBox(Driver, cfg);
|
|
611
|
+
// 跨库(Navicat 式):按库名缓存独立池,ro 时同样受首词白名单约束(query 路径统一校验)。
|
|
612
|
+
/**
|
|
613
|
+
* 关闭幂等标志:manager 在授权复核失败路径会关两次(pg-pool 二次 end() 会抛的教训)。
|
|
614
|
+
* 关闭后一切访问都必须在此处被拦下——否则 mssql 池会以 ENOTOPEN 报英文错,
|
|
615
|
+
* 且跨库访问还会新建池(关不掉的连接泄漏)。
|
|
616
|
+
*/
|
|
617
|
+
let closed = false;
|
|
618
|
+
const assertOpen = () => {
|
|
619
|
+
if (closed)
|
|
620
|
+
throw new Error('sqlserver 连接已关闭:请重新连接后重试');
|
|
621
|
+
};
|
|
622
|
+
const dbPools = new Map();
|
|
623
|
+
const poolOf = (db) => {
|
|
624
|
+
assertOpen();
|
|
625
|
+
if (!db)
|
|
626
|
+
return mainPool;
|
|
627
|
+
// SQL Server 库名默认不区分大小写:池键归一为小写,避免 MyDB / mydb 各建一个池(连接泄漏)。
|
|
628
|
+
const key = db.toLowerCase();
|
|
629
|
+
if (mainDb !== undefined && key === mainDb.toLowerCase())
|
|
630
|
+
return mainPool;
|
|
631
|
+
let box = dbPools.get(key);
|
|
632
|
+
if (!box) {
|
|
633
|
+
box = makePoolBox(Driver, { ...cfg, database: db });
|
|
634
|
+
dbPools.set(key, box);
|
|
635
|
+
}
|
|
636
|
+
return box;
|
|
637
|
+
};
|
|
638
|
+
// 事务激活期间 query/execute 必须走同一连接(mssql 的事务请求自带该连接)
|
|
639
|
+
let txObj = null;
|
|
640
|
+
const defaultSchema = () => {
|
|
641
|
+
const f = conn.fields;
|
|
642
|
+
return f && typeof f.schema === 'string' && f.schema ? f.schema : 'dbo';
|
|
643
|
+
};
|
|
644
|
+
/** 浏览目标:'db.schema'(跨库) | 'schema'(当前库) | 空(当前库默认 schema) */
|
|
645
|
+
const parseBrowseTarget = (ref, what) => {
|
|
646
|
+
if (!ref)
|
|
647
|
+
return { db: undefined, schema: defaultSchema() };
|
|
648
|
+
const i = ref.indexOf('.');
|
|
649
|
+
if (i < 0)
|
|
650
|
+
return { db: undefined, schema: assertIdent(ref, what) };
|
|
651
|
+
return { db: assertIdent(ref.slice(0, i), '数据库'), schema: assertIdent(ref.slice(i + 1), what) };
|
|
652
|
+
};
|
|
653
|
+
/** 取执行用的 request:事务中走事务连接(跨库请求不参与事务,与 pg-like 同语义) */
|
|
654
|
+
const requestFor = async (db) => {
|
|
655
|
+
assertOpen();
|
|
656
|
+
if (txObj && db === undefined)
|
|
657
|
+
return { req: txObj.request(), inTx: true };
|
|
658
|
+
const box = poolOf(db);
|
|
659
|
+
await box.ready();
|
|
660
|
+
return { req: box.pool.request(), inTx: false };
|
|
661
|
+
};
|
|
662
|
+
const tx = {
|
|
663
|
+
begin: () => wrap('sqlserver 事务开启', async () => {
|
|
664
|
+
if (txObj)
|
|
665
|
+
throw new Error('事务已开启,请勿重复 begin');
|
|
666
|
+
await mainPool.ready();
|
|
667
|
+
const t = new Driver.Transaction(mainPool.pool);
|
|
668
|
+
await t.begin();
|
|
669
|
+
txObj = t;
|
|
670
|
+
}),
|
|
671
|
+
commit: () => wrap('sqlserver 事务提交', async () => {
|
|
672
|
+
const t = txObj;
|
|
673
|
+
if (!t)
|
|
674
|
+
throw new Error('没有进行中的事务');
|
|
675
|
+
txObj = null;
|
|
676
|
+
await t.commit();
|
|
677
|
+
}),
|
|
678
|
+
rollback: () => wrap('sqlserver 事务回滚', async () => {
|
|
679
|
+
const t = txObj;
|
|
680
|
+
if (!t)
|
|
681
|
+
throw new Error('没有进行中的事务');
|
|
682
|
+
txObj = null;
|
|
683
|
+
await t.rollback();
|
|
684
|
+
}),
|
|
685
|
+
};
|
|
686
|
+
return {
|
|
687
|
+
kind: 'sqlserver',
|
|
688
|
+
connId: conn.meta.id,
|
|
689
|
+
tx,
|
|
690
|
+
testConnect: () => wrap('sqlserver 连接测试', async () => {
|
|
691
|
+
assertOpen();
|
|
692
|
+
await mainPool.ready();
|
|
693
|
+
const res = await mainPool.pool.request().query(TEST_CONNECT_SQL);
|
|
694
|
+
const row = rowsOf(res)[0];
|
|
695
|
+
const version = typeof row?.['version'] === 'string' ? row['version'] : '';
|
|
696
|
+
if (version === '')
|
|
697
|
+
return { ok: true, serverInfo: 'SQL Server(版本信息不可读)' };
|
|
698
|
+
return { ok: true, serverInfo: serverInfo(version, row?.['major'], row?.['engine']) };
|
|
699
|
+
}),
|
|
700
|
+
query: (sql, params, database) => wrap('sqlserver 查询失败', async () => {
|
|
701
|
+
const scan = scanSql(sql);
|
|
702
|
+
if (readOnly)
|
|
703
|
+
assertReadOnlyQuery(sql, scan);
|
|
704
|
+
const binds = params ?? [];
|
|
705
|
+
assertPlaceholderCount(scan, binds);
|
|
706
|
+
const db = database ? assertIdent(database, '数据库') : undefined;
|
|
707
|
+
const { req } = await requestFor(db);
|
|
708
|
+
const text = rewritePlaceholders(req, sql, scan, binds);
|
|
709
|
+
const res = await req.query(text);
|
|
710
|
+
return toQueryResult(res);
|
|
711
|
+
}),
|
|
712
|
+
execute: (statement, params, database) => wrap('sqlserver 执行失败', async () => {
|
|
713
|
+
// ro 连接由服务层拦截(本适配器不重复拦 execute:控制台事务通道也会用 execute 发读语句)
|
|
714
|
+
const scan = scanSql(statement);
|
|
715
|
+
const binds = params ?? [];
|
|
716
|
+
assertPlaceholderCount(scan, binds);
|
|
717
|
+
const db = database ? assertIdent(database, '数据库') : undefined;
|
|
718
|
+
const { req } = await requestFor(db);
|
|
719
|
+
const text = rewritePlaceholders(req, statement, scan, binds);
|
|
720
|
+
const res = await req.query(text);
|
|
721
|
+
const arr = res.rowsAffected ?? [];
|
|
722
|
+
let sum = 0;
|
|
723
|
+
let known = false;
|
|
724
|
+
for (const n of arr) {
|
|
725
|
+
if (typeof n === 'number' && Number.isFinite(n)) {
|
|
726
|
+
sum += n;
|
|
727
|
+
known = true;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
affectedRows: known ? sum : undefined,
|
|
732
|
+
message: known ? `执行成功,受影响 ${sum} 行` : '执行成功',
|
|
733
|
+
};
|
|
734
|
+
}),
|
|
735
|
+
listDatabases: () => wrap('sqlserver 列出数据库', async () => {
|
|
736
|
+
const box = poolOf(undefined); // 走主池并经 assertOpen(关闭后抛中文错误而非驱动英文错)
|
|
737
|
+
await box.ready();
|
|
738
|
+
const res = await box.pool.request().query(LIST_DATABASES_SQL);
|
|
739
|
+
return rowsOf(res).map((r) => String(r['name']));
|
|
740
|
+
}),
|
|
741
|
+
listSchemas: (database) => wrap('sqlserver 列出模式', async () => {
|
|
742
|
+
const box = poolOf(database ? assertIdent(database, '数据库') : undefined);
|
|
743
|
+
await box.ready();
|
|
744
|
+
const res = await box.pool.request().query(LIST_SCHEMAS_SQL);
|
|
745
|
+
return rowsOf(res).map((r) => String(r['name']));
|
|
746
|
+
}),
|
|
747
|
+
listTables: (database) => wrap('sqlserver 列出表', async () => {
|
|
748
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
749
|
+
const box = poolOf(db);
|
|
750
|
+
await box.ready();
|
|
751
|
+
const req = box.pool.request();
|
|
752
|
+
req.input('p1', schema);
|
|
753
|
+
const res = await req.query(LIST_TABLES_SQL);
|
|
754
|
+
return rowsOf(res).map((r) => {
|
|
755
|
+
const raw = String(r['obj_type']);
|
|
756
|
+
return {
|
|
757
|
+
name: String(r['table_name']),
|
|
758
|
+
type: raw === 'U' ? 'TABLE' : raw === 'V' ? 'VIEW' : raw,
|
|
759
|
+
database: db ? `${db}.${schema}` : schema,
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
}),
|
|
763
|
+
describeTable: (table, database) => wrap('sqlserver 查看表结构', async () => {
|
|
764
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
765
|
+
const tbl = assertIdent(table, '表名');
|
|
766
|
+
const box = poolOf(db);
|
|
767
|
+
await box.ready();
|
|
768
|
+
const ref = `[${schema.replaceAll(']', ']]')}].[${tbl.replaceAll(']', ']]')}]`;
|
|
769
|
+
const colReq = box.pool.request();
|
|
770
|
+
colReq.input('p1', ref);
|
|
771
|
+
const cols = await colReq.query(DESCRIBE_COLUMNS_SQL);
|
|
772
|
+
const columnRows = rowsOf(cols);
|
|
773
|
+
if (columnRows.length === 0)
|
|
774
|
+
throw new Error(`表不存在: ${schema}.${tbl}`);
|
|
775
|
+
const pkReq = box.pool.request();
|
|
776
|
+
pkReq.input('p1', ref);
|
|
777
|
+
const pkRes = await pkReq.query(DESCRIBE_PK_SQL);
|
|
778
|
+
const pkIds = new Set(rowsOf(pkRes).map((r) => Number(r['column_id'])));
|
|
779
|
+
return columnRows.map((r) => {
|
|
780
|
+
const typeName = String(r['type_name']);
|
|
781
|
+
const comment = r['comment'];
|
|
782
|
+
const dflt = r['default_def'];
|
|
783
|
+
const id = Number(r['column_id']);
|
|
784
|
+
return {
|
|
785
|
+
name: String(r['column_name']),
|
|
786
|
+
dataType: formatDataType(typeName, Number(r['max_length'] ?? 0), Number(r['precision'] ?? 0), Number(r['scale'] ?? 0)),
|
|
787
|
+
nullable: r['is_nullable'] === true || r['is_nullable'] === 1,
|
|
788
|
+
key: pkIds.has(id) ? 'PRI' : undefined,
|
|
789
|
+
default: dflt == null ? null : String(dflt),
|
|
790
|
+
comment: comment == null || comment === '' ? undefined : String(comment),
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
}),
|
|
794
|
+
previewRows: (table, limit, database, offset) => wrap('sqlserver 预览行', async () => {
|
|
795
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
796
|
+
const tbl = assertIdent(table, '表名');
|
|
797
|
+
const lim = clampLimit(limit);
|
|
798
|
+
const off = clampOffset(offset);
|
|
799
|
+
const box = poolOf(db);
|
|
800
|
+
const major = await box.major();
|
|
801
|
+
const q = (name) => `[${name.replaceAll(']', ']]')}]`;
|
|
802
|
+
const target = `${q(schema)}.${q(tbl)}`;
|
|
803
|
+
if (major >= 11) {
|
|
804
|
+
// T-SQL 的 OFFSET/FETCH 强制要求 ORDER BY;(SELECT NULL) 是常量排序,等价于不指定顺序。
|
|
805
|
+
// 绑定:@p1 = 偏移,@p2 = 行数(FETCH NEXT @p2)。
|
|
806
|
+
const req = box.pool.request();
|
|
807
|
+
req.input('p1', off);
|
|
808
|
+
req.input('p2', lim);
|
|
809
|
+
const res = await req.query(`SELECT * FROM ${target} ORDER BY (SELECT NULL) OFFSET @p1 ROWS FETCH NEXT @p2 ROWS ONLY`);
|
|
810
|
+
return toQueryResult(res);
|
|
811
|
+
}
|
|
812
|
+
// 2008/2008 R2:无 OFFSET/FETCH,用 ROW_NUMBER 子查询降级;内部序号列在投影后剔除。
|
|
813
|
+
// 绑定:@p1 = 偏移,@p2 = 上界(off + lim,不是 lim!否则 offset>0 时第 2 页起恒为空)。
|
|
814
|
+
const req = box.pool.request();
|
|
815
|
+
req.input('p1', off);
|
|
816
|
+
req.input('p2', off + lim);
|
|
817
|
+
const res = await req.query(`SELECT t.* FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS [__dsh_rn] FROM ${target}) AS t WHERE t.[__dsh_rn] > @p1 AND t.[__dsh_rn] <= @p2`);
|
|
818
|
+
return toQueryResult(res, ['__dsh_rn']);
|
|
819
|
+
}),
|
|
820
|
+
close: () => wrap('sqlserver 关闭连接', async () => {
|
|
821
|
+
if (closed)
|
|
822
|
+
return;
|
|
823
|
+
closed = true;
|
|
824
|
+
await Promise.all([mainPool.pool.close(), ...[...dbPools.values()].map((b) => b.pool.close())]);
|
|
825
|
+
}),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
/** 传输层等内部调用用的工厂(注意:会丢弃 opts.mode,服务层注册表不得使用) */
|
|
829
|
+
export const factory = (conn) => createSqlserverAdapter(conn);
|
|
830
|
+
/**
|
|
831
|
+
* 数据传输端复用的独立原始池(绕过展示化转换,保真搬运)。
|
|
832
|
+
* 库名与主连接一致(跨库由调用方在 conn 上覆盖 database)。
|
|
833
|
+
*/
|
|
834
|
+
export async function openSqlserverRaw(conn, driver) {
|
|
835
|
+
const Driver = driver ?? vendor();
|
|
836
|
+
const cfg = poolConfig(conn);
|
|
837
|
+
const box = makePoolBox(Driver, cfg);
|
|
838
|
+
const database = typeof cfg.database === 'string' ? cfg.database : '';
|
|
839
|
+
return { pool: box.pool, ready: box.ready, database, driver: Driver };
|
|
840
|
+
}
|