dsh-db-tool 1.7.2 → 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.
@@ -0,0 +1,562 @@
1
+ import { assertIdent } from '../../sql-shared/common.js';
2
+ import { openSqlserverRaw } from '../../sqlserver/index.js';
3
+ import { safeDefaultExpr } from '../model.js';
4
+ /** TDS 单语句参数上限(SQL Server Maximum Capacity Specifications:2100 个参数/请求) */
5
+ export const SQLSERVER_MAX_PARAMS = 2100;
6
+ /** `VALUES` 表值构造器单语句行数上限(官方限制:1000 行) */
7
+ export const SQLSERVER_MAX_VALUES_ROWS = 1000;
8
+ /** 多行 VALUES 每语句行数:两条上限取小。**不能只按 floor(2100/列数)**——列数 ≤2 时
9
+ * floor 值(2100 / 1050)会超过表值构造器 1000 行硬上限,语句被服务器直接拒绝;
10
+ * 列数非法时兜底 1,不得产出 0 行语句。 */
11
+ export function sqlserverPerStmt(columnCount) {
12
+ const cols = Math.floor(columnCount);
13
+ if (!Number.isFinite(cols) || cols < 1)
14
+ return 1;
15
+ return Math.max(1, Math.min(Math.floor(SQLSERVER_MAX_PARAMS / cols), SQLSERVER_MAX_VALUES_ROWS));
16
+ }
17
+ /** T-SQL 标识符:一律 [x],内部 ] 双写为 ]](引用标识符内唯一需要转义的字符)。
18
+ * sql-shared 的 quoteIdent 只支持 " 与 ` 两种库,此处按 SQL Server 方言单独实现。 */
19
+ export function sqlserverIdent(name) {
20
+ return '[' + assertIdent(name, '标识符').replaceAll(']', ']]') + ']';
21
+ }
22
+ /** information_schema.columns.data_type → 中间类型(maxLength = CHARACTER_MAXIMUM_LENGTH,
23
+ * -1 表示 (max):变长字符类型归 text,避免目标端建成 NVARCHAR(255) 截断超长值) */
24
+ export function sqlserverTypeToMid(dataType, maxLength) {
25
+ const t = dataType.toLowerCase();
26
+ if (t === 'bigint')
27
+ return 'bigint';
28
+ if (t === 'int' || t === 'smallint' || t === 'tinyint')
29
+ return 'int';
30
+ if (t === 'bit')
31
+ return 'boolean';
32
+ if (t === 'decimal' || t === 'numeric' || t === 'money' || t === 'smallmoney')
33
+ return 'decimal';
34
+ if (t === 'float')
35
+ return 'double';
36
+ if (t === 'real')
37
+ return 'float';
38
+ if (t === 'date')
39
+ return 'date';
40
+ if (t === 'time')
41
+ return 'time';
42
+ if (t === 'datetime' || t === 'datetime2' || t === 'smalldatetime')
43
+ return 'datetime';
44
+ if (t === 'datetimeoffset')
45
+ return 'timestamptz';
46
+ if (t === 'binary' || t === 'varbinary' || t === 'image' || t === 'timestamp' || t === 'rowversion')
47
+ return 'bytes';
48
+ // CLR/空间 UDT:tedious 返回 Buffer → 按 bytes 二进制保真搬运(目标端 VARBINARY(MAX))。
49
+ // 不归 string——binary 值按 NVARCHAR 绑定会隐式转换乱码或直接报错(静默错值比明确失败更糟)。
50
+ // 取舍:UDT 类型语义不保留(值仍是原字节流,但目标端没有 ST_*/hierarchyid 方法)。
51
+ // 升级路径:读端按类型显式转文本(geography::STAsText()、hierarchyid::ToString())后归 text。
52
+ if (t === 'hierarchyid' || t === 'geometry' || t === 'geography')
53
+ return 'bytes';
54
+ if (t === 'text' || t === 'ntext' || t === 'xml')
55
+ return 'text';
56
+ // sql_variant 存任意基础类型的值,tedious 按其实际类型返回标量(number/string/Date/Buffer),
57
+ // 无法作为单一标量安全搬运 → 归 text,读端显式 CONVERT(nvarchar(max), col)(官方支持的转换),
58
+ // 值以文本形态保留、不报错、不静默乱码。取舍:原类型信息丢失(写回为 NVARCHAR(MAX))。
59
+ if (t === 'sql_variant')
60
+ return 'text';
61
+ // 字符类型长度矩阵(源 → midType),按各类的容量上限决定是否走 (MAX):
62
+ // nvarchar/nchar:n <= 4000(其 n 上限就是 4000)→ string;-1((max))→ text
63
+ // varchar/char :n <= 4000 → string(目标 NVARCHAR(n) 装得下);
64
+ // 4000 < n <= 8000(varchar/char 的 n 上限 8000)→ text——
65
+ // 收敛到 NVARCHAR(4000) 会让超 4000 字符的源值在目标 INSERT 报 8152 整批失败;
66
+ // -1((max))→ text
67
+ if ((t === 'nvarchar' || t === 'varchar' || t === 'nchar' || t === 'char')
68
+ && (maxLength === -1 || (maxLength != null && maxLength > 4000)))
69
+ return 'text';
70
+ return 'string'; // nvarchar/nchar/varchar/char/sysname/uniqueidentifier
71
+ }
72
+ /** 中间类型 → T-SQL 列类型(长度/精度 clamp 到 SQL Server 上限:NVARCHAR 4000、DECIMAL 精度 38) */
73
+ export function midToSqlserverType(c) {
74
+ switch (c.midType) {
75
+ case 'int': return 'INT';
76
+ case 'bigint': return 'BIGINT';
77
+ case 'decimal': {
78
+ const p = Math.min(Math.max(Math.floor(c.precision ?? 38), 1), 38);
79
+ const s = Math.min(Math.max(Math.floor(c.scale ?? 10), 0), p);
80
+ return `DECIMAL(${p},${s})`;
81
+ }
82
+ case 'float': return 'REAL';
83
+ case 'double': return 'FLOAT';
84
+ case 'boolean': return 'BIT';
85
+ case 'string': return `NVARCHAR(${Math.min(Math.max(Math.floor(c.length ?? 255), 1), 4000)})`;
86
+ case 'text': return 'NVARCHAR(MAX)';
87
+ case 'bytes': return 'VARBINARY(MAX)';
88
+ case 'json': return 'NVARCHAR(MAX)';
89
+ case 'date': return 'DATE';
90
+ case 'time': return 'TIME';
91
+ case 'datetime': return 'DATETIME2';
92
+ case 'timestamptz': return 'DATETIMEOFFSET';
93
+ }
94
+ }
95
+ /** 中间格式日期值 → T-SQL 可解析形态:ISO 带 'Z' 后缀(pg/oralike/mongo 的 materialize
96
+ * 产出)在 T-SQL 里不是合法 datetime 字面量(隐式转换报错)——剥离为 UTC 墙钟,
97
+ * 保留 'T' 分隔(T-SQL 官方接受的 ISO 8601 形态),非日期形态/无 Z 原样。 */
98
+ export function toSqlserverDateTime(s) {
99
+ return s.replace(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(?:\.\d+)?)Z$/i, '$1T$2');
100
+ }
101
+ /** 绑定值形态:bigint→十进制字符串(不经 JS number 通道,防 >2^53 丢精度)、
102
+ * bytes→Buffer、日期列 ISO 'Z' → T-SQL 墙钟;decimal 值本就以字符串流转(读端 CONVERT),
103
+ * 直接绑定由服务器隐式转换回 decimal 列,精度不经过 JS 浮点。 */
104
+ export function toSqlserverBind(c, col) {
105
+ if (c === null)
106
+ return null;
107
+ if (typeof c === 'bigint')
108
+ return c.toString();
109
+ if (typeof c === 'boolean')
110
+ return c;
111
+ if (c instanceof Uint8Array)
112
+ return Buffer.from(c);
113
+ if (typeof c === 'string' && col != null &&
114
+ (col.midType === 'date' || col.midType === 'time' || col.midType === 'datetime' || col.midType === 'timestamptz')) {
115
+ return toSqlserverDateTime(c);
116
+ }
117
+ return c;
118
+ }
119
+ /** 单元格保真:tedious 的 Date → ISO 字符串(与 pg/oralike 读端同一中间格式约定);
120
+ * Buffer 是 Uint8Array 子类,直接进中间模型。 */
121
+ function materialize(v) {
122
+ if (v === null || v === undefined)
123
+ return null;
124
+ if (v instanceof Date)
125
+ return v.toISOString();
126
+ return v;
127
+ }
128
+ /** OFFSET/FETCH 的 n 必须是字面整数(不可绑定):非有限数/越界一律钳到 min,
129
+ * 防 NaN/负数拼进 SQL 破坏语法 */
130
+ function inlineInt(v, min) {
131
+ const n = Math.floor(Number(v));
132
+ return Number.isFinite(n) && n > min ? n : min;
133
+ }
134
+ /** 锁等待类错误号:1222 = Lock request time out period exceeded(官方),
135
+ * 1221 = Lock request time out(兼容旧形态),1205 = 死锁牺牲品。只有这些能安全降级
136
+ * DELETE(表被长时间占用);权限(229)/对象不存在(208)/语法等失败必须上抛。 */
137
+ const SQLSERVER_LOCK_WAIT_NUMBERS = new Set([1222, 1221, 1205]);
138
+ /** 驱动错误号:mssql 的 RequestError.number 在顶层;部分路径(连接级/包装错误)只在
139
+ * originalError 上带 number,故沿 originalError 链找一层(两种形态都要能识别,
140
+ * 否则权限类错误会被当成"无错误号"落到文案兜底 → 误降级)。 */
141
+ function errorNumber(e) {
142
+ const candidates = [e, e?.originalError];
143
+ for (const cand of candidates) {
144
+ const n = cand?.number;
145
+ if (typeof n === 'number' && Number.isFinite(n))
146
+ return n;
147
+ if (typeof n === 'string' && /^\d+$/.test(n))
148
+ return Number(n);
149
+ }
150
+ return undefined;
151
+ }
152
+ /** 是否锁等待失败(可降级 DELETE):优先按错误号;驱动偶尔丢 number 时按文案兜底
153
+ * (文案只认锁超时/死锁,避免把权限等失败误判为可降级) */
154
+ function isLockWaitError(e) {
155
+ const num = errorNumber(e);
156
+ if (num != null)
157
+ return SQLSERVER_LOCK_WAIT_NUMBERS.has(num);
158
+ const inner = e?.originalError;
159
+ const msgs = [
160
+ e instanceof Error ? e.message : String(e),
161
+ inner instanceof Error ? inner.message : '',
162
+ ];
163
+ return msgs.some((m) => /lock request time out|deadlock|死锁/i.test(m));
164
+ }
165
+ /** sql_variant 列:驱动按值的实际类型返回标量(number/string/Date/Buffer),必须显式转文本
166
+ * 才能统一搬运(映射归 text,见 sqlserverTypeToMid) */
167
+ function isVariant(c) {
168
+ return (c.sourceType ?? '').toLowerCase() === 'sql_variant';
169
+ }
170
+ /** 时间类列的保真读表达式(与 decimal 同款「读端显式转换」口径):CONVERT(…, 126) 输出
171
+ * ISO 8601 墙钟文本——datetime2/time 的小数位最多 7 位(100ns),而 tedious 把它们解析成
172
+ * JS Date(只有毫秒),亚毫秒会被静默截断;转文本后原样流转,写端按 NVarChar 绑定回
173
+ * datetime2 由服务器隐式转换,精度不经过 JS Date 通道。顺带避免 Date→toISOString 把墙钟
174
+ * 按插件进程时区解释(文件头提到的时间语义隐患)。
175
+ * nvarchar(33):datetime2 的 126 形态 'YYYY-MM-DDThh:mm:ss.fffffff'(27)+ datetimeoffset
176
+ * 的时区后缀 '+hh:mm'(6)都装得下;time 的 126 形态 'hh:mm:ss.fffffff'(16)。 */
177
+ function timeConvertExpr(c, id) {
178
+ if (c.midType === 'datetime' || c.midType === 'timestamptz')
179
+ return `CONVERT(nvarchar(33), ${id}, 126)`;
180
+ if (c.midType === 'time')
181
+ return `CONVERT(nvarchar(16), ${id}, 126)`;
182
+ return null;
183
+ }
184
+ /** decimal 列读表达式:显式转 nvarchar 保真(tedious 默认把 decimal 解析为 JS number)。
185
+ * nvarchar(64) 足以容纳 decimal(38,38) 的完整十进制表示。 */
186
+ function readExpr(c) {
187
+ const id = sqlserverIdent(c.name);
188
+ if (c.midType === 'decimal')
189
+ return `CONVERT(nvarchar(64), ${id})`;
190
+ if (isVariant(c))
191
+ return `CONVERT(nvarchar(max), ${id})`;
192
+ return timeConvertExpr(c, id) ?? id;
193
+ }
194
+ /** 子查询场景的读表达式:表达式必须带列别名(否则内层结果列无名字,外层无法引用) */
195
+ function aliasedExpr(c) {
196
+ const id = sqlserverIdent(c.name);
197
+ if (c.midType === 'decimal')
198
+ return `CONVERT(nvarchar(64), ${id}) AS ${id}`;
199
+ if (isVariant(c))
200
+ return `CONVERT(nvarchar(max), ${id}) AS ${id}`;
201
+ const t = timeConvertExpr(c, id);
202
+ return t ? `${t} AS ${id}` : id;
203
+ }
204
+ /** OFFSET/FETCH 从 SQL Server 2012(主版本 11)引入;主版本 < 11 或无版本探测结果走降级 */
205
+ const SQLSERVER_MIN_MAJOR_FOR_OFFSET_FETCH = 11;
206
+ /** 2008 降级 offset 分页的排序表达式:按可排序的普通列排序,使分页顺序确定、不重不漏。
207
+ * text/ntext/image/xml/geography/geometry 不能用于 ORDER BY,排除;极端表(全部列都不可
208
+ * 排序)才退回 `(SELECT NULL)`——ROW_NUMBER 的 OVER 子句必须有排序表达式,这是不指定顺序
209
+ * 时的最小占位(2005+ 可用,但与 2012+ 路径同样不保证跨批稳定,故仅作最后兜底)。 */
210
+ function sortOrderExprs(columns) {
211
+ const notSortable = new Set(['text', 'ntext', 'image', 'xml', 'geography', 'geometry']);
212
+ const sortable = columns.filter((c) => !notSortable.has((c.sourceType ?? '').toLowerCase()));
213
+ return sortable.length > 0 ? sortable.map((c) => sqlserverIdent(c.name)).join(', ') : '(SELECT NULL)';
214
+ }
215
+ /** 版本属性 → 主版本号。ProductMajorVersion 是 2012+ 才有的属性(2008 返回 NULL),
216
+ * 回退解析 ProductVersion 前缀('10.50.1600.1' → 10);两者都判不出返回 0(调用方按
217
+ * 0 走保守降级,保证老版本/权限受限时仍可传输)。 */
218
+ export function sqlserverMajorFromProps(majorProp, productVersion) {
219
+ const m = Number(majorProp);
220
+ if (Number.isFinite(m) && m > 0)
221
+ return Math.floor(m);
222
+ const s = typeof productVersion === 'string' ? productVersion : String(productVersion ?? '');
223
+ const hit = /^\s*(\d+)/.exec(s);
224
+ const v = hit ? Number(hit[1]) : Number.NaN;
225
+ return Number.isFinite(v) && v > 0 ? v : 0;
226
+ }
227
+ /** 目标库主版本探测:两次属性查询均失败 → 0(保守降级)。只查一次,结果由 reader 闭包缓存
228
+ * (readBatch/keyAt 高频调用,不能每批重复探测)。 */
229
+ async function detectSqlserverMajor(pool) {
230
+ const propOf = async (name) => {
231
+ try {
232
+ const res = await pool.request().query(`SELECT SERVERPROPERTY('${name}') AS v`);
233
+ const rows = (res.recordset ?? []);
234
+ return rows[0]?.v;
235
+ }
236
+ catch {
237
+ return null; // 属性不存在 / 权限不足 → 交由回退链
238
+ }
239
+ };
240
+ const majorProp = await propOf('ProductMajorVersion');
241
+ const fromMajorProp = sqlserverMajorFromProps(majorProp, null);
242
+ if (fromMajorProp > 0)
243
+ return fromMajorProp;
244
+ return sqlserverMajorFromProps(majorProp, await propOf('ProductVersion'));
245
+ }
246
+ export function createSqlserverTransferReader(pool, schema) {
247
+ const sch = assertIdent(schema, '模式名');
248
+ const tblOf = (table) => `${sqlserverIdent(sch)}.${sqlserverIdent(table)}`;
249
+ // 版本探测结果缓存(Promise 缓存:并发首批只探测一次)
250
+ let majorPromise;
251
+ const major = () => (majorPromise ??= detectSqlserverMajor(pool));
252
+ const q = async (sql, binds) => {
253
+ const req = pool.request();
254
+ for (const [k, v] of Object.entries(binds ?? {}))
255
+ req.input(k, v);
256
+ const res = await req.query(sql);
257
+ return (res.recordset ?? []);
258
+ };
259
+ const describe = async (table) => {
260
+ const tbl = assertIdent(table, '表名');
261
+ const rows = await q(`SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT,
262
+ c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, c.NUMERIC_SCALE,
263
+ COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME)),
264
+ c.COLUMN_NAME, 'IsIdentity') AS IS_IDENTITY
265
+ FROM INFORMATION_SCHEMA.COLUMNS c
266
+ WHERE c.TABLE_SCHEMA = @schema AND c.TABLE_NAME = @table
267
+ ORDER BY c.ORDINAL_POSITION`, { schema: sch, table: tbl });
268
+ if (rows.length === 0)
269
+ throw new Error(`表不存在: ${sch}.${tbl}`);
270
+ const pkRows = await q(`SELECT kcu.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
271
+ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
272
+ ON kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND kcu.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
273
+ AND kcu.TABLE_NAME = tc.TABLE_NAME
274
+ WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.TABLE_SCHEMA = @schema AND tc.TABLE_NAME = @table
275
+ ORDER BY kcu.ORDINAL_POSITION`, { schema: sch, table: tbl });
276
+ const pkSet = new Set(pkRows.map((r) => String(r.COLUMN_NAME)));
277
+ const columns = rows.map((r) => {
278
+ const dataType = String(r.DATA_TYPE ?? '');
279
+ const lower = dataType.toLowerCase();
280
+ const charLen = r.CHARACTER_MAXIMUM_LENGTH == null ? undefined : Number(r.CHARACTER_MAXIMUM_LENGTH);
281
+ const numeric = lower === 'decimal' || lower === 'numeric' || lower === 'money' || lower === 'smallmoney';
282
+ return {
283
+ name: String(r.COLUMN_NAME),
284
+ midType: sqlserverTypeToMid(dataType, charLen),
285
+ sourceType: dataType,
286
+ nullable: r.IS_NULLABLE === true || r.IS_NULLABLE === 'YES' || r.IS_NULLABLE === 1,
287
+ primaryKey: pkSet.has(String(r.COLUMN_NAME)),
288
+ autoIncrement: r.IS_IDENTITY === true || r.IS_IDENTITY === 1,
289
+ length: charLen == null || charLen < 0 ? undefined : charLen,
290
+ precision: numeric && r.NUMERIC_PRECISION != null ? Number(r.NUMERIC_PRECISION) : undefined,
291
+ scale: numeric && r.NUMERIC_SCALE != null ? Number(r.NUMERIC_SCALE) : undefined,
292
+ default: r.COLUMN_DEFAULT == null ? null : String(r.COLUMN_DEFAULT),
293
+ };
294
+ });
295
+ return { name: tbl, columns };
296
+ };
297
+ return {
298
+ kind: 'sqlserver',
299
+ listTables: async () => {
300
+ const rows = await q(`SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
301
+ WHERE TABLE_SCHEMA = @schema AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME`, { schema: sch });
302
+ return rows.map((r) => String(r.TABLE_NAME));
303
+ },
304
+ describe,
305
+ count: async (table) => {
306
+ // COUNT_BIG:COUNT 的 int 返回值在 >21 亿行时溢出
307
+ const rows = await q(`SELECT COUNT_BIG(*) AS n FROM ${tblOf(assertIdent(table, '表名'))}`);
308
+ return Number(rows[0]?.n ?? 0);
309
+ },
310
+ shardKey: async (table) => {
311
+ const t = await describe(table);
312
+ const pks = t.columns.filter((c) => c.primaryKey);
313
+ const pk = pks.length === 1 ? pks[0] : undefined;
314
+ const sharable = ['int', 'bigint', 'decimal', 'string', 'datetime', 'timestamptz'];
315
+ return pk && sharable.includes(pk.midType) ? pk.midType : null;
316
+ },
317
+ readBatch: async (table, cursor, limit) => {
318
+ const t = await describe(table);
319
+ const tbl = tblOf(t.name);
320
+ const cols = t.columns.map(readExpr).join(', ');
321
+ const n = inlineInt(limit, 1);
322
+ const legacy = (await major()) < SQLSERVER_MIN_MAJOR_FOR_OFFSET_FETCH;
323
+ let sql;
324
+ let binds;
325
+ if (cursor.mode === 'keyset') {
326
+ const key = sqlserverIdent(cursor.column);
327
+ binds = cursor.after === null ? {} : { after: toSqlserverBind(cursor.after) };
328
+ const where = cursor.after === null ? '' : ` WHERE ${key} > @after`;
329
+ // 2008 无 OFFSET/FETCH:有序取前 n 行就是 TOP (n),无需 ROW_NUMBER
330
+ sql = legacy
331
+ ? `SELECT TOP (${n}) ${cols} FROM ${tbl}${where} ORDER BY ${key}`
332
+ : `SELECT ${cols} FROM ${tbl}${where} ORDER BY ${key} OFFSET 0 ROWS FETCH NEXT ${n} ROWS ONLY`;
333
+ }
334
+ else if (cursor.mode === 'range') {
335
+ const key = sqlserverIdent(cursor.column);
336
+ const conds = [];
337
+ const b = {};
338
+ if (cursor.lo !== null) {
339
+ const nm = `p${Object.keys(b).length}`;
340
+ conds.push(`${key} ${cursor.loInclusive ? '>=' : '>'} @${nm}`);
341
+ b[nm] = toSqlserverBind(cursor.lo);
342
+ }
343
+ if (cursor.hi !== null) {
344
+ const nm = `p${Object.keys(b).length}`;
345
+ conds.push(`${key} < @${nm}`);
346
+ b[nm] = toSqlserverBind(cursor.hi);
347
+ }
348
+ const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
349
+ sql = legacy
350
+ ? `SELECT TOP (${n}) ${cols} FROM ${tbl}${where} ORDER BY ${key}`
351
+ : `SELECT ${cols} FROM ${tbl}${where} ORDER BY ${key} OFFSET 0 ROWS FETCH NEXT ${n} ROWS ONLY`;
352
+ binds = b;
353
+ }
354
+ else {
355
+ const off = inlineInt(cursor.offset, 0);
356
+ binds = {};
357
+ if (legacy) {
358
+ // 2008 降级:ROW_NUMBER 子查询取 (off, off+n] 行(无 OFFSET/FETCH 可用);
359
+ // 外层按 rn 取列,排序表达式见 sortOrderExprs(不依赖 OFFSET 子句的 (SELECT NULL))
360
+ sql = `SELECT ${t.columns.map((c) => sqlserverIdent(c.name)).join(', ')} FROM (`
361
+ + `SELECT ${t.columns.map(aliasedExpr).join(', ')}, ROW_NUMBER() OVER (ORDER BY ${sortOrderExprs(t.columns)}) AS [__dbt_rn]`
362
+ + ` FROM ${tbl}) AS [__dbt_paged] WHERE [__dbt_rn] > ${off} AND [__dbt_rn] <= ${off + n} ORDER BY [__dbt_rn]`;
363
+ }
364
+ else {
365
+ // 无分片键的 offset 全序:OFFSET/FETCH 强制要求 ORDER BY,(SELECT NULL) 为不排序占位
366
+ sql = `SELECT ${cols} FROM ${tbl} ORDER BY (SELECT NULL)
367
+ OFFSET ${off} ROWS FETCH NEXT ${n} ROWS ONLY`;
368
+ }
369
+ }
370
+ const rows = await q(sql, binds);
371
+ return rows.map((r) => t.columns.map((c) => materialize(r[c.name])));
372
+ },
373
+ keyAt: async (table, column, offset) => {
374
+ const t = await describe(table);
375
+ const keyCol = t.columns.find((c) => c.name === column);
376
+ const key = sqlserverIdent(assertIdent(column, '分片键'));
377
+ const keyExpr = keyCol ? readExpr(keyCol) : key;
378
+ const target = tblOf(assertIdent(table, '表名'));
379
+ const off = inlineInt(offset, 0);
380
+ // 2008 降级:ROW_NUMBER 按分片键排序定位第 off+1 行(keyAt 语义本就是按该键排序取第 offset 行)
381
+ const rows = (await major()) < SQLSERVER_MIN_MAJOR_FOR_OFFSET_FETCH
382
+ ? await q(`SELECT TOP (1) [k] FROM (SELECT ${keyExpr} AS [k], ROW_NUMBER() OVER (ORDER BY ${key}) AS [__dbt_rn]`
383
+ + ` FROM ${target}) AS [__dbt_paged] WHERE [__dbt_rn] = ${off + 1}`)
384
+ : await q(`SELECT ${keyExpr} AS k FROM ${target}
385
+ ORDER BY ${key} OFFSET ${off} ROWS FETCH NEXT 1 ROWS ONLY`);
386
+ return materialize(rows[0]?.k ?? null);
387
+ },
388
+ close: async () => {
389
+ await pool.close();
390
+ },
391
+ };
392
+ }
393
+ export function createSqlserverTransferWriter(pool, openTx, schema, writeMode = 'insert') {
394
+ const sch = assertIdent(schema, '模式名');
395
+ const tblOf = (name) => `${sqlserverIdent(sch)}.${sqlserverIdent(assertIdent(name, '表名'))}`;
396
+ const q = async (sql, binds) => {
397
+ const req = pool.request();
398
+ for (const [k, v] of Object.entries(binds ?? {}))
399
+ req.input(k, v);
400
+ const res = await req.query(sql);
401
+ return (res.recordset ?? []);
402
+ };
403
+ return {
404
+ kind: 'sqlserver',
405
+ tableExists: async (name) => {
406
+ const rows = await q(`SELECT COUNT(*) AS n FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table`, { schema: sch, table: assertIdent(name, '表名') });
407
+ return Number(rows[0]?.n ?? 0) > 0;
408
+ },
409
+ dropTable: async (name) => {
410
+ await q(`DROP TABLE ${tblOf(name)}`);
411
+ },
412
+ // truncate 模式前置清空:TRUNCATE 表级锁被长事务持有时默认无限等待——同一事务(同一连接)
413
+ // 内先 SET LOCK_TIMEOUT 15000(毫秒),**只有锁等待类失败**才降级 DELETE FROM(行级锁,
414
+ // 同一 15s 上限内有限等待)。权限(229)/对象不存在(208) 等失败上抛:静默换 DELETE 会改变
415
+ // 语义(DELETE 不重置 IDENTITY 种子 → truncate 模式名不副实)并掩盖真实错误。
416
+ // SET LOCK_TIMEOUT 是会话级设置,连接回池后不自动复位 → 成功路径结束前显式恢复默认
417
+ // (-1 = 无限等待),避免把 15s 上限带给后续无关请求。
418
+ beforeLoad: async (table) => {
419
+ const tbl = tblOf(table.name);
420
+ const tx = openTx();
421
+ await tx.begin();
422
+ try {
423
+ await tx.request().query('SET LOCK_TIMEOUT 15000');
424
+ try {
425
+ await tx.request().query(`TRUNCATE TABLE ${tbl}`);
426
+ }
427
+ catch (e) {
428
+ if (!isLockWaitError(e)) {
429
+ const num = errorNumber(e);
430
+ throw new Error(`TRUNCATE TABLE ${sch}.${table.name} 失败${num != null ? `(错误号 ${num})` : ''},`
431
+ + `未降级 DELETE(仅锁等待超时才降级):${e instanceof Error ? e.message : String(e)}`);
432
+ }
433
+ await tx.request().query(`DELETE FROM ${tbl}`);
434
+ }
435
+ await tx.request().query('SET LOCK_TIMEOUT -1');
436
+ await tx.commit();
437
+ }
438
+ catch (e) {
439
+ // ponytail: 失败路径下事务对象 rollback 后不可复用,无法在同一连接上复位 LOCK_TIMEOUT
440
+ //(残留 15s 有限上限,不构成无限等待;且这是清空失败本身的异常路径)。
441
+ // 升级路径:若必须严格复位,改用专用连接并在 finally 里无条件 SET -1。
442
+ await tx.rollback().catch(() => { });
443
+ throw e;
444
+ }
445
+ },
446
+ createTable: async (table) => {
447
+ if (table.columns.length === 0) {
448
+ throw new Error(`表 ${table.name} 无列定义,无法建表(源表结构为空?)`);
449
+ }
450
+ const defs = table.columns.map((c) => {
451
+ let d = `${sqlserverIdent(c.name)} ${midToSqlserverType(c)}`;
452
+ if (!c.nullable)
453
+ d += ' NOT NULL';
454
+ if (c.default != null) {
455
+ const expr = safeDefaultExpr(c.default);
456
+ if (expr != null)
457
+ d += ` DEFAULT ${expr}`;
458
+ }
459
+ return d;
460
+ });
461
+ const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => sqlserverIdent(c.name));
462
+ const pk = pkCols.length > 0 ? `, PRIMARY KEY (${pkCols.join(', ')})` : '';
463
+ await q(`CREATE TABLE ${tblOf(table.name)} (${defs.join(', ')}${pk})`);
464
+ },
465
+ writeBatch: async (table, rows) => {
466
+ if (rows.length === 0)
467
+ return 0;
468
+ const cols = table.columns;
469
+ if (cols.length === 0) {
470
+ throw new Error(`表 ${table.name} 无列定义,无法写入(源表结构为空?)`);
471
+ }
472
+ const width = cols.length;
473
+ for (const r of rows) {
474
+ if (r.length !== width) {
475
+ throw new Error(`行宽不匹配:期望 ${width} 列,实际 ${r.length} 列(表 ${table.name})`);
476
+ }
477
+ }
478
+ const colIds = cols.map((c) => sqlserverIdent(c.name));
479
+ const pkIds = cols.filter((c) => c.primaryKey).map((c) => sqlserverIdent(c.name));
480
+ if (writeMode === 'replace' && pkIds.length === 0) {
481
+ throw new Error(`replace 模式需要主键:表 ${table.name} 无主键`);
482
+ }
483
+ // T-SQL 无 INSERT IGNORE / ON CONFLICT:ignore/replace 用 MERGE(官方 upsert 形态)。
484
+ // MERGE 需要冲突判定键;ignore 无主键时无重复可判,退化为纯 INSERT(同 pg 无冲突目标语义)。
485
+ const useMerge = writeMode !== 'insert' && pkIds.length > 0;
486
+ const perStmt = sqlserverPerStmt(width);
487
+ const tbl = tblOf(table.name);
488
+ const colList = colIds.join(', ');
489
+ const tx = openTx();
490
+ await tx.begin();
491
+ try {
492
+ let written = 0;
493
+ for (let i = 0; i < rows.length; i += perStmt) {
494
+ const chunk = rows.slice(i, i + perStmt);
495
+ const holders = chunk
496
+ .map((_, ri) => `(${cols.map((_, ci) => `@p${ri * width + ci}`).join(', ')})`)
497
+ .join(', ');
498
+ let sql;
499
+ if (!useMerge) {
500
+ sql = `INSERT INTO ${tbl} (${colList}) VALUES ${holders}`;
501
+ }
502
+ else {
503
+ const srcList = colIds.map((id) => `src.${id}`).join(', ');
504
+ const on = pkIds.map((k) => `tgt.${k} = src.${k}`).join(' AND ');
505
+ const updaters = cols.filter((c) => !c.primaryKey);
506
+ // 全主键列时 WHEN MATCHED 无列可更新(MERGE 语法要求 UPDATE SET 至少一列)→ 只保留未匹配插入
507
+ const matched = writeMode === 'replace' && updaters.length > 0
508
+ ? ` WHEN MATCHED THEN UPDATE SET ${updaters.map((c) => `tgt.${sqlserverIdent(c.name)} = src.${sqlserverIdent(c.name)}`).join(', ')}`
509
+ : '';
510
+ // WITH (HOLDLOCK):MERGE 在并发下的已知竞态缓解(官方推荐的序列化提示)。
511
+ // ponytail: MERGE 要求同一目标行在本语句内不被匹配多次——源表主键唯一时成立;
512
+ // 源数据批内自重复会报错并整批回滚(不静默丢数据,符合失败可见原则)。
513
+ // 升级路径:改 INSERT + 显式 UPDATE 两步,换更宽松的批内语义。
514
+ sql = `MERGE INTO ${tbl} WITH (HOLDLOCK) AS tgt USING (VALUES ${holders}) AS src (${colList})`
515
+ + ` ON ${on}${matched} WHEN NOT MATCHED THEN INSERT (${colList}) VALUES (${srcList});`;
516
+ }
517
+ const req = tx.request();
518
+ chunk.forEach((r, ri) => {
519
+ r.forEach((v, ci) => {
520
+ req.input(`p${ri * width + ci}`, toSqlserverBind(v, cols[ci]));
521
+ });
522
+ });
523
+ const res = await req.query(sql);
524
+ written += (res.rowsAffected ?? []).reduce((a, b) => a + b, 0);
525
+ }
526
+ await tx.commit();
527
+ return written;
528
+ }
529
+ catch (e) {
530
+ await tx.rollback().catch(() => { });
531
+ throw e;
532
+ }
533
+ },
534
+ close: async () => {
535
+ await pool.close();
536
+ },
537
+ };
538
+ }
539
+ /** 定位解析:schema = loc.schema(assertIdent 拒绝空串/换行)?? 连接 schema ?? dbo;
540
+ * database ≠ 连接库时浅拷贝 rc 仅覆盖 fields.database(与 pgLocate 同款,由 openSqlserverRaw
541
+ * 按该字段决定连接的默认库)。 */
542
+ export function sqlserverLocate(rc, loc) {
543
+ const s = rc.fields?.schema;
544
+ const schema = loc?.schema != null ? assertIdent(loc.schema, '模式名') : (typeof s === 'string' && s ? s : 'dbo');
545
+ const cur = rc.fields?.database != null ? String(rc.fields.database) : undefined;
546
+ if (!loc?.database || loc.database === cur)
547
+ return { rc, schema };
548
+ return { rc: { ...rc, fields: { ...rc.fields, database: loc.database } }, schema };
549
+ }
550
+ export async function createSqlserverTransferReaderFromConn(rc, loc) {
551
+ const located = sqlserverLocate(rc, loc);
552
+ const raw = await openSqlserverRaw(located.rc);
553
+ await raw.ready(); // 首次 request 前必须建连(否则池以 ENOTOPEN 拒绝)
554
+ return createSqlserverTransferReader(raw.pool, located.schema);
555
+ }
556
+ export async function createSqlserverTransferWriterFromConn(rc, writeMode, loc) {
557
+ const located = sqlserverLocate(rc, loc);
558
+ const raw = await openSqlserverRaw(located.rc);
559
+ await raw.ready();
560
+ // 每次写入批新建事务对象:mssql 的事务 commit/rollback 后不可复用
561
+ return createSqlserverTransferWriter(raw.pool, () => new raw.driver.Transaction(raw.pool), located.schema, writeMode);
562
+ }
@@ -50,6 +50,10 @@ export async function createTransferReader(rc, loc) {
50
50
  const { createMongoTransferReaderFromConn } = await import('./kinds/mongo.js');
51
51
  return createMongoTransferReaderFromConn(rc, loc);
52
52
  }
53
+ if (kind === 'sqlserver') {
54
+ const { createSqlserverTransferReaderFromConn } = await import('./kinds/sqlserver.js');
55
+ return createSqlserverTransferReaderFromConn(rc, loc);
56
+ }
53
57
  throw new Error(`数据传输暂不支持读取 ${kind}(待后续版本支持)`);
54
58
  }
55
59
  export async function createTransferWriter(rc, writeMode = 'insert', loc) {
@@ -88,5 +92,9 @@ export async function createTransferWriter(rc, writeMode = 'insert', loc) {
88
92
  const { createMongoTransferWriterFromConn } = await import('./kinds/mongo.js');
89
93
  return createMongoTransferWriterFromConn(rc, writeMode, loc);
90
94
  }
95
+ if (kind === 'sqlserver') {
96
+ const { createSqlserverTransferWriterFromConn } = await import('./kinds/sqlserver.js');
97
+ return createSqlserverTransferWriterFromConn(rc, writeMode, loc);
98
+ }
91
99
  throw new Error(`数据传输暂不支持写入 ${kind}(待后续版本支持)`);
92
100
  }