dsh-db-tool 1.6.0 → 1.7.1
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/client/client.js +812 -29
- package/dist/adapters/dmdb/index.js +51 -20
- package/dist/adapters/gaussdb/index.js +2 -0
- package/dist/adapters/mongodb/index.js +47 -8
- package/dist/adapters/mysql/index.js +31 -5
- package/dist/adapters/oracle/index.js +61 -24
- package/dist/adapters/postgresql/index.js +4 -0
- package/dist/adapters/sql-shared/pg-like.js +61 -16
- package/dist/adapters/sqlite/index.js +57 -19
- package/dist/adapters/transfer/kinds/mongo.js +378 -0
- package/dist/adapters/transfer/kinds/mysql.js +331 -0
- package/dist/adapters/transfer/kinds/oralike.js +437 -0
- package/dist/adapters/transfer/kinds/pg.js +311 -0
- package/dist/adapters/transfer/kinds/sqlite.js +250 -0
- package/dist/adapters/transfer/model.js +92 -0
- package/dist/http/index.js +53 -0
- package/dist/manager.js +183 -1
- package/dist/store/index.js +5 -0
- package/dist/store/transfer-history.js +114 -0
- package/dist/transfer.js +287 -0
- package/package.json +1 -1
- package/docs/connection-export-import.md +0 -172
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite 适配器(同步 API 包装 Promise)。
|
|
3
|
+
* fields.database / fields.path 为文件路径;ro 模式 readonly 打开。
|
|
4
|
+
*
|
|
5
|
+
* 驱动双路径(离线环境友好):
|
|
6
|
+
* 1. node:sqlite(Node ≥ 22.5 内置,零原生依赖,离线/无编译工具链环境直接可用)
|
|
7
|
+
* 2. better-sqlite3(optionalDependency;预构建缺失或本地编译失败时 pnpm 会
|
|
8
|
+
* 自动排除该包,不影响插件整体安装,动态加载失败时回退到明确报错)
|
|
9
|
+
*/
|
|
10
|
+
import { basename } from 'node:path';
|
|
1
11
|
import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from '../sql-shared/common.js';
|
|
2
12
|
let cached = null;
|
|
3
13
|
let cachedFor = '';
|
|
@@ -57,30 +67,57 @@ function resolveFile(conn) {
|
|
|
57
67
|
}
|
|
58
68
|
throw new Error('sqlite 连接缺少文件路径(fields.database / fields.path 或 url)');
|
|
59
69
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
export async function createSqliteAdapter(conn, opts) {
|
|
70
|
+
/**
|
|
71
|
+
* 打开原始数据库句柄(不包适配器规范化)。数据传输读写端用:
|
|
72
|
+
* 必须绕过 normalizeCell(BLOB 截断 / bigint 降位)保真搬运原始值。
|
|
73
|
+
*/
|
|
74
|
+
export async function openRaw(conn, readOnly) {
|
|
68
75
|
const file = resolveFile(conn);
|
|
69
|
-
const readOnly = opts?.mode === 'ro';
|
|
70
76
|
const { ctor, driver } = await loadDriver();
|
|
71
|
-
let db;
|
|
72
77
|
try {
|
|
73
|
-
// 选项名按驱动区分:node:sqlite 用 readOnly
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
78
|
+
// 选项名按驱动区分:node:sqlite 用 readOnly(打开不存在文件即报错;官方签名
|
|
79
|
+
// new DatabaseSync(location[, options]),options 为可选参数,空对象与不传/undefined
|
|
80
|
+
// 等价,open 默认 true —— https://nodejs.org/docs/latest-v24.x/api/sqlite.html);
|
|
81
|
+
// better-sqlite3 用 readonly + fileMustExist。rw 模式两驱动均传空对象。
|
|
82
|
+
const db = driver === 'node:sqlite'
|
|
77
83
|
? new ctor(file, readOnly ? { readOnly: true } : {})
|
|
78
84
|
: new ctor(file, readOnly ? { readonly: true, fileMustExist: true } : {});
|
|
85
|
+
return { db, driver };
|
|
79
86
|
}
|
|
80
87
|
catch (e) {
|
|
81
88
|
const msg = e instanceof Error ? e.message : String(e);
|
|
82
|
-
|
|
89
|
+
// 只带文件名不带完整路径:错误会经驱动错误通道外发(传输失败快照等),避免内网路径泄露
|
|
90
|
+
throw new Error(`sqlite 打开失败 (${basename(file)}, 驱动 ${driver}): ${msg}`);
|
|
83
91
|
}
|
|
92
|
+
}
|
|
93
|
+
function toQueryResult(raw, columns) {
|
|
94
|
+
return {
|
|
95
|
+
columns,
|
|
96
|
+
rows: raw.map((r) => columns.map((c) => normalizeCell(r[c]))),
|
|
97
|
+
rowCount: raw.length,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 取 SELECT 列名。stmt.columns() 在 node:sqlite 需 Node ≥ 22.16.0(官方文档
|
|
102
|
+
* "Added in: v22.16.0",https://nodejs.org/docs/latest-v22.x/api/sqlite.html),
|
|
103
|
+
* 而本适配器放行的最低版本是 22.5——22.13.0–22.15.x 上 columns 为 undefined,
|
|
104
|
+
* 无条件调用即 TypeError(浏览主路径 query/previewRows 每次必崩)。旧版本回退
|
|
105
|
+
* 到结果首行的 keys(对象字符串键保序 = SELECT 列序;纯数字列名会被当作整数
|
|
106
|
+
* 索引键排前,属降级路径已知盲区)。空结果集无行可推,返回空列头。
|
|
107
|
+
*/
|
|
108
|
+
export function stmtColumns(stmt, raw) {
|
|
109
|
+
if (typeof stmt.columns === 'function')
|
|
110
|
+
return stmt.columns().map((c) => c.name);
|
|
111
|
+
const first = raw[0];
|
|
112
|
+
return first ? Object.keys(first) : [];
|
|
113
|
+
}
|
|
114
|
+
export async function createSqliteAdapter(conn, opts) {
|
|
115
|
+
const readOnly = opts?.mode === 'ro';
|
|
116
|
+
const { db, driver } = await openRaw(conn, readOnly);
|
|
117
|
+
/** close 幂等:node:sqlite 的 DatabaseSync.close() 二次调用抛 "database is not open",
|
|
118
|
+
* 而 manager 的 runScript 超时路径会对同一会话适配器重复 close
|
|
119
|
+
* (onTimeout 与 finally 各一次)——重复 close 必须为 no-op。 */
|
|
120
|
+
let closed = false;
|
|
84
121
|
return {
|
|
85
122
|
kind: 'sqlite',
|
|
86
123
|
connId: conn.meta.id,
|
|
@@ -90,9 +127,8 @@ export async function createSqliteAdapter(conn, opts) {
|
|
|
90
127
|
}),
|
|
91
128
|
query: (sql, params) => humanize('sqlite 查询失败', async () => {
|
|
92
129
|
const stmt = db.prepare(sql);
|
|
93
|
-
const columns = stmt.columns().map((c) => c.name);
|
|
94
130
|
const raw = stmt.all(...(params ?? []));
|
|
95
|
-
return toQueryResult(raw,
|
|
131
|
+
return toQueryResult(raw, stmtColumns(stmt, raw));
|
|
96
132
|
}),
|
|
97
133
|
execute: (statement, params) => humanize('sqlite 执行失败', async () => {
|
|
98
134
|
const stmt = db.prepare(statement);
|
|
@@ -139,11 +175,13 @@ export async function createSqliteAdapter(conn, opts) {
|
|
|
139
175
|
const lim = clampLimit(limit);
|
|
140
176
|
const off = clampOffset(offset);
|
|
141
177
|
const stmt = db.prepare(`SELECT * FROM ${tbl} LIMIT ? OFFSET ?`);
|
|
142
|
-
const columns = stmt.columns().map((c) => c.name);
|
|
143
178
|
const raw = stmt.all(lim, off);
|
|
144
|
-
return toQueryResult(raw,
|
|
179
|
+
return toQueryResult(raw, stmtColumns(stmt, raw));
|
|
145
180
|
}),
|
|
146
181
|
close: () => humanize('sqlite 关闭连接', async () => {
|
|
182
|
+
if (closed)
|
|
183
|
+
return; // 幂等:重复 close 为 no-op(见 closed 声明处注释)
|
|
184
|
+
closed = true;
|
|
147
185
|
db.close();
|
|
148
186
|
}),
|
|
149
187
|
};
|
|
@@ -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
|
+
}
|