dsh-db-tool 1.5.91 → 1.7.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,378 @@
1
+ import { openMongoRaw } from '../../mongodb/index.js';
2
+ const SAMPLE_LIMIT = 50;
3
+ /** BSON 值 → 中间类型(抽样推断用;null 不参与类型判定) */
4
+ function bsonToMid(v, M) {
5
+ if (v === null || v === undefined)
6
+ return null;
7
+ if (typeof v === 'string')
8
+ return 'string';
9
+ if (typeof v === 'boolean')
10
+ return 'boolean';
11
+ if (typeof v === 'number')
12
+ return Number.isInteger(v) ? 'int' : 'double';
13
+ if (typeof v === 'bigint')
14
+ return 'bigint';
15
+ if (v instanceof Date)
16
+ return 'datetime';
17
+ if (v instanceof M.ObjectId)
18
+ return 'string';
19
+ if (v instanceof M.Long)
20
+ return 'bigint';
21
+ if (v instanceof M.Decimal128)
22
+ return 'decimal';
23
+ if (v instanceof M.Binary)
24
+ return 'bytes';
25
+ if (Array.isArray(v) || typeof v === 'object')
26
+ return 'json';
27
+ return 'string';
28
+ }
29
+ /** 行值保真转换(读端):按列 midType 目标形态输出 */
30
+ function bsonToCell(v, midType, M) {
31
+ if (v === null || v === undefined)
32
+ return null;
33
+ if (v instanceof M.ObjectId)
34
+ return v.toHexString();
35
+ if (v instanceof Date)
36
+ return v.toISOString();
37
+ if (v instanceof M.Long)
38
+ return v.toBigInt();
39
+ if (v instanceof M.Decimal128)
40
+ return v.toString();
41
+ if (v instanceof M.Binary)
42
+ return new Uint8Array(v.buffer.buffer, v.buffer.byteOffset, v.buffer.byteLength);
43
+ if (Array.isArray(v) || (typeof v === 'object' && v !== null))
44
+ return JSON.stringify(v);
45
+ if (typeof v === 'bigint')
46
+ return v;
47
+ if (typeof v === 'number') {
48
+ // 混类型列推断为 int 但实际 double:值原样(number 涵盖 int)
49
+ return midType === 'bigint' ? BigInt(Math.trunc(v)) : v;
50
+ }
51
+ return String(v);
52
+ }
53
+ /** 中间类型 → $jsonSchema bsonType(json 列接受 object/array 两形态,写端 parse 复原) */
54
+ function midToBsonType(t) {
55
+ switch (t) {
56
+ case 'int': return 'int';
57
+ case 'bigint': return 'long';
58
+ case 'decimal': return 'decimal';
59
+ case 'float':
60
+ case 'double': return 'double';
61
+ case 'boolean': return 'bool';
62
+ case 'bytes': return 'binData';
63
+ case 'json': return ['object', 'array'];
64
+ case 'date':
65
+ case 'datetime':
66
+ case 'timestamptz': return 'date';
67
+ default: return 'string'; // string/text/time
68
+ }
69
+ }
70
+ /** 写端值转换:SQL 形态 → BSON 形态。null 返回 SKIP(省略字段,评审 #2) */
71
+ const SKIP = Symbol('skip');
72
+ /** 无时区日期串补 UTC 语义:空格墙钟('2024-01-01 10:00:00')与 ISO 无时区
73
+ * ('2024-01-01T10:00:00')在 V8 均按插件进程本地时区解析——跨库传输的墙钟统一
74
+ * UTC 语义(mysql 读端 time_zone=+00:00、sqlite TEXT 约定),已带时区的原样解析 */
75
+ export function toUtcParse(s) {
76
+ const t = s.trim();
77
+ if (/[Zz]$/.test(t) || /[+-]\d{2}:?\d{2}$/.test(t))
78
+ return t;
79
+ if (/^\d{4}-\d{2}-\d{2}$/.test(t))
80
+ return `${t}T00:00:00Z`;
81
+ return `${t.replace(' ', 'T')}Z`;
82
+ }
83
+ export function cellToBson(c, midType, M) {
84
+ if (c === null)
85
+ return SKIP;
86
+ if (c instanceof Uint8Array)
87
+ return new M.Binary(c);
88
+ if (typeof c === 'bigint')
89
+ return M.Long.fromString(c.toString());
90
+ if (typeof c === 'boolean' || typeof c === 'number')
91
+ return c;
92
+ if (midType === 'json') {
93
+ if (typeof c !== 'string')
94
+ return c;
95
+ try {
96
+ return JSON.parse(c);
97
+ }
98
+ catch {
99
+ return c; // 非法 JSON 原样字符串(bsonType 校验会拒绝并进失败清单,不静默)
100
+ }
101
+ }
102
+ if (midType === 'date' || midType === 'datetime' || midType === 'timestamptz') {
103
+ const d = new Date(typeof c === 'string' ? toUtcParse(c) : c);
104
+ if (Number.isNaN(d.getTime())) {
105
+ throw new Error(`非法日期值「${c}」(${midType} 列):无法解析为时间`);
106
+ }
107
+ return d;
108
+ }
109
+ if (midType === 'decimal' && typeof c === 'string')
110
+ return M.Decimal128.fromString(c);
111
+ return c;
112
+ }
113
+ /** 读 own 属性值:BSON 反序列化允许 __proto__ 等保留名为 own property,
114
+ * 普通 doc[k] 访问会命中原型 getter 读出错误值 */
115
+ function ownValue(doc, k) {
116
+ return Object.hasOwn(doc, k) ? Object.getOwnPropertyDescriptor(doc, k)?.value : undefined;
117
+ }
118
+ export function createMongoTransferReader(raw) {
119
+ const { db, M } = raw;
120
+ const describe = async (name) => {
121
+ const coll = db.collection(name);
122
+ const sample = (await coll.find({}).limit(SAMPLE_LIMIT).toArray());
123
+ // 字段并集(首次出现顺序)+ 类型推断(首个非 null 类型)
124
+ const cols = new Map();
125
+ const present = new Map();
126
+ for (const doc of sample) {
127
+ for (const [k, v] of Object.entries(doc)) {
128
+ if (!cols.has(k)) {
129
+ cols.set(k, bsonToMid(v, M) ?? 'string');
130
+ present.set(k, 0);
131
+ }
132
+ if (v !== null && v !== undefined)
133
+ present.set(k, (present.get(k) ?? 0) + 1);
134
+ }
135
+ }
136
+ if (!cols.has('_id')) {
137
+ cols.set('_id', 'string');
138
+ present.set('_id', 0);
139
+ }
140
+ const columns = [...cols.entries()].map(([fieldName, midType]) => ({
141
+ name: fieldName,
142
+ midType,
143
+ sourceType: midType === 'json' ? 'object|array (inferred)' : `${midType} (inferred)`,
144
+ // 抽样缺失 = 可空;样本为空时按可空(required 从无样本推断不安全);_id 恒非空
145
+ nullable: fieldName === '_id' ? false : sample.length === 0 || (present.get(fieldName) ?? 0) < sample.length,
146
+ primaryKey: fieldName === '_id',
147
+ }));
148
+ return { name, columns };
149
+ };
150
+ // _id 的原始 BSON 形态(keyset 游标 after 还原用);混类型 = null(不分片)
151
+ const idKindCache = new Map();
152
+ return {
153
+ kind: 'mongodb',
154
+ listTables: async () => {
155
+ const infos = (await db.listCollections().toArray());
156
+ return infos.filter((c) => c.type !== 'view').map((c) => c.name);
157
+ },
158
+ describe,
159
+ count: async (name) => {
160
+ return db.collection(name).countDocuments({});
161
+ },
162
+ shardKey: async (name) => {
163
+ // _id 是 mongo 唯一可靠的分片键;类型唯一才可用(评审 #4)
164
+ const coll = db.collection(name);
165
+ const sample = (await coll.find({}).limit(SAMPLE_LIMIT).toArray());
166
+ const kinds = new Set();
167
+ for (const doc of sample) {
168
+ const v = doc._id;
169
+ if (v instanceof M.ObjectId)
170
+ kinds.add('ObjectId');
171
+ else if (typeof v === 'string')
172
+ kinds.add('string');
173
+ else if (typeof v === 'number')
174
+ kinds.add('number');
175
+ else if (v instanceof M.Long)
176
+ kinds.add('Long');
177
+ else if (v !== null && v !== undefined)
178
+ kinds.add('other');
179
+ }
180
+ const kindsArr = [...kinds];
181
+ const kind = kindsArr.length === 1 ? kindsArr[0] : null;
182
+ idKindCache.set(name, kind);
183
+ switch (kind) {
184
+ case 'ObjectId':
185
+ case 'string':
186
+ return 'string';
187
+ case 'number':
188
+ return 'int';
189
+ case 'Long':
190
+ return 'bigint';
191
+ default:
192
+ return null; // 混类型/空集合:单 worker offset 全序
193
+ }
194
+ },
195
+ readBatch: async (name, cursor, limit) => {
196
+ const t = await describe(name);
197
+ const coll = db.collection(name);
198
+ if (cursor.mode === 'keyset' || cursor.mode === 'range') {
199
+ const idKind = idKindCache.get(name) ?? 'ObjectId';
200
+ const restore = (cell) => {
201
+ let v = cell;
202
+ if (idKind === 'ObjectId' && typeof v === 'string')
203
+ v = new M.ObjectId(v);
204
+ if (idKind === 'Long' && typeof v === 'bigint')
205
+ v = M.Long.fromString(v.toString());
206
+ return v;
207
+ };
208
+ if (cursor.mode === 'keyset') {
209
+ const filter = cursor.after === null ? {} : { _id: { $gt: restore(cursor.after) } };
210
+ const docs = (await coll.find(filter).sort({ _id: 1 }).limit(limit).toArray());
211
+ return docs.map((doc) => t.columns.map((c) => bsonToCell(ownValue(doc, c.name), c.midType, M)));
212
+ }
213
+ // range:hi 端恒开区间;lo 按 loInclusive 取 $gte/$gt(分片首批含下界)
214
+ const cond = {};
215
+ if (cursor.lo !== null)
216
+ cond[cursor.loInclusive ? '$gte' : '$gt'] = restore(cursor.lo);
217
+ if (cursor.hi !== null)
218
+ cond.$lt = restore(cursor.hi);
219
+ const docs = (await coll
220
+ .find(Object.keys(cond).length > 0 ? { _id: cond } : {})
221
+ .sort({ _id: 1 })
222
+ .limit(limit)
223
+ .toArray());
224
+ return docs.map((doc) => t.columns.map((c) => bsonToCell(ownValue(doc, c.name), c.midType, M)));
225
+ }
226
+ const docs = (await coll
227
+ .find({})
228
+ .sort({ _id: 1 })
229
+ .skip(cursor.offset)
230
+ .limit(limit)
231
+ .toArray());
232
+ return docs.map((doc) => t.columns.map((c) => bsonToCell(ownValue(doc, c.name), c.midType, M)));
233
+ },
234
+ keyAt: async (name, column, offset) => {
235
+ const coll = db.collection(name);
236
+ const docs = (await coll.find({}).sort({ _id: 1 }).skip(offset).limit(1).toArray());
237
+ if (docs.length === 0)
238
+ return null;
239
+ return bsonToCell(ownValue(docs[0], column), 'string', M);
240
+ },
241
+ close: async () => {
242
+ await raw.client.close();
243
+ },
244
+ };
245
+ }
246
+ export function createMongoTransferWriter(raw, writeMode = 'insert') {
247
+ const { db, M } = raw;
248
+ /** 单列主键列(写为 _id);复合/无主键返回 undefined(mongo 自动 _id,原主键列按普通字段存) */
249
+ function idColumn(t) {
250
+ const pks = t.columns.filter((c) => c.primaryKey);
251
+ const pk = pks.length === 1 ? pks[0] : undefined;
252
+ return pk && !pk.name.includes('.') ? pk : undefined;
253
+ }
254
+ /** 行 → 文档:先全量转换(非法值在写入前抛出,避免 unordered 部分成功) */
255
+ function rowToDoc(t, r) {
256
+ const idCol = idColumn(t);
257
+ const doc = {};
258
+ t.columns.forEach((c, i) => {
259
+ const v = cellToBson(r[i] ?? null, c.midType, M);
260
+ if (v === SKIP)
261
+ return;
262
+ if (idCol && c.name === idCol.name) {
263
+ doc._id = v;
264
+ }
265
+ else {
266
+ // defineProperty 而非赋值:源字段名可能为 __proto__ 等保留名,按名赋值走
267
+ // setter 改原型——该列数据静默丢失且无告警
268
+ Object.defineProperty(doc, c.name, { value: v, enumerable: true, writable: true, configurable: true });
269
+ }
270
+ });
271
+ return doc;
272
+ }
273
+ return {
274
+ kind: 'mongodb',
275
+ tableExists: async (name) => {
276
+ const infos = (await db.listCollections({ name }).toArray());
277
+ return infos.length > 0;
278
+ },
279
+ dropTable: async (name) => {
280
+ await db.dropCollection(name).catch(() => false);
281
+ },
282
+ // truncate 模式前置清空:deleteMany({}) 全集合删除(mongo 无 TRUNCATE 等价物)
283
+ beforeLoad: async (t) => {
284
+ await db.collection(t.name).deleteMany({});
285
+ },
286
+ createTable: async (t) => {
287
+ // $jsonSchema 承载结构与字段属性:required = 非 nullable 列;NULL 语义 = 字段缺失
288
+ //(bsonType 精确匹配不含 null,写 {col:null} 会被 strict 拒绝——评审 #2)
289
+ const properties = {};
290
+ const required = [];
291
+ for (const c of t.columns) {
292
+ if (c.primaryKey)
293
+ continue; // _id 不进 properties(主键值类型不约束)
294
+ // defineProperty 防 __proto__ 保留名改原型致 validator properties 失真
295
+ Object.defineProperty(properties, c.name, {
296
+ value: { bsonType: midToBsonType(c.midType) },
297
+ enumerable: true,
298
+ writable: true,
299
+ configurable: true,
300
+ });
301
+ if (!c.nullable)
302
+ required.push(c.name);
303
+ }
304
+ // MongoDB 官方限制:admin/config/local 等内部库的集合不允许文档校验器
305
+ // (实测报「Document validators are not allowed ... in the admin internal database」)——
306
+ // 内部库直接建集合,结构与唯一性约束由写入端 bulkWrite 语义承担
307
+ const internal = ['admin', 'config', 'local'].includes(raw.db.databaseName);
308
+ await db.createCollection(t.name, internal
309
+ ? {}
310
+ : {
311
+ validator: {
312
+ $jsonSchema: {
313
+ bsonType: 'object',
314
+ ...(Object.keys(properties).length > 0 ? { properties } : {}),
315
+ ...(required.length > 0 ? { required } : {}),
316
+ },
317
+ },
318
+ validationLevel: 'strict',
319
+ });
320
+ },
321
+ writeBatch: async (t, rows) => {
322
+ if (rows.length === 0)
323
+ return 0;
324
+ const width = t.columns.length;
325
+ for (const r of rows) {
326
+ if (r.length !== width) {
327
+ throw new Error(`行宽不匹配:期望 ${width} 列,实际 ${r.length} 列(表 ${t.name})`);
328
+ }
329
+ }
330
+ const idCol = idColumn(t);
331
+ if (writeMode === 'replace' && !idCol) {
332
+ throw new Error(`replace 模式需要单列主键(映射 _id):表 ${t.name} 无主键`);
333
+ }
334
+ // 先全量转换再写(转换抛错 → 一条都没写,规避 unordered 部分成功不可回滚)
335
+ const docs = rows.map((r) => rowToDoc(t, r));
336
+ const coll = db.collection(t.name);
337
+ if (writeMode === 'replace') {
338
+ const ops = docs.map((doc) => {
339
+ const { _id, ...rest } = doc;
340
+ return { replaceOne: { filter: { _id }, replacement: { _id, ...rest }, upsert: true } };
341
+ });
342
+ const res = await coll.bulkWrite(ops, { ordered: false });
343
+ return res.insertedCount + res.upsertedCount + res.modifiedCount;
344
+ }
345
+ const ops = docs.map((doc) => ({ insertOne: { document: doc } }));
346
+ try {
347
+ const res = await coll.bulkWrite(ops, { ordered: false });
348
+ return res.insertedCount;
349
+ }
350
+ catch (e) {
351
+ // ignore 模式:唯一键冲突(E11000,实际即 _id 重复)跳过;其余错误上抛
352
+ if (writeMode === 'ignore' && e != null && typeof e === 'object' && 'writeErrors' in e) {
353
+ const errors = e.writeErrors ?? [];
354
+ if (errors.every((w) => w.code === 11000)) {
355
+ return docs.length - errors.length;
356
+ }
357
+ }
358
+ throw e;
359
+ }
360
+ },
361
+ close: async () => {
362
+ await raw.client.close();
363
+ },
364
+ };
365
+ }
366
+ /** 定位:database 覆盖 meta.database(openMongoRaw 按 meta.database → URL path →
367
+ * fields 顺序取库,覆盖入口即全链生效);schema 忽略 */
368
+ function mongoLocate(rc, loc) {
369
+ if (!loc?.database)
370
+ return rc;
371
+ return { ...rc, meta: { ...rc.meta, database: loc.database } };
372
+ }
373
+ export async function createMongoTransferReaderFromConn(rc, loc) {
374
+ return createMongoTransferReader(await openMongoRaw(mongoLocate(rc, loc)));
375
+ }
376
+ export async function createMongoTransferWriterFromConn(rc, writeMode, loc) {
377
+ return createMongoTransferWriter(await openMongoRaw(mongoLocate(rc, loc)), writeMode);
378
+ }