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,331 @@
1
+ import { assertIdent, quoteIdent } from '../../sql-shared/common.js';
2
+ import { openMysqlRaw } from '../../mysql/index.js';
3
+ import { safeDefaultExpr } from '../model.js';
4
+ /** COLUMN_TYPE → 中间类型(base 取首词去显示宽度;unsigned 不影响宽度决策) */
5
+ export function mysqlTypeToMid(columnType) {
6
+ const base = columnType.trim().split(/[\s(]/)[0]?.toLowerCase() ?? '';
7
+ if (base === 'bigint')
8
+ return 'bigint';
9
+ if (base === 'int' || base === 'integer' || base === 'smallint' || base === 'mediumint' || base === 'tinyint' || base === 'year')
10
+ return 'int';
11
+ if (base === 'decimal' || base === 'numeric' || base === 'dec' || base === 'fixed')
12
+ return 'decimal';
13
+ if (base === 'float')
14
+ return 'float';
15
+ if (base === 'double' || base === 'real')
16
+ return 'double';
17
+ if (base === 'boolean' || base === 'bool')
18
+ return 'boolean';
19
+ if (base === 'json')
20
+ return 'json';
21
+ if (base === 'date')
22
+ return 'date';
23
+ if (base === 'time')
24
+ return 'time';
25
+ if (base === 'datetime')
26
+ return 'datetime';
27
+ if (base === 'timestamp')
28
+ return 'timestamptz';
29
+ if (base === 'bit')
30
+ return 'bytes';
31
+ if (base === 'char' || base === 'varchar' || base === 'binary' || base === 'varbinary' || base === 'enum' || base === 'set')
32
+ return 'string';
33
+ if (base.endsWith('text'))
34
+ return 'text';
35
+ if (base.endsWith('blob'))
36
+ return 'bytes';
37
+ // tinyint(1) 布尔惯例仅在 EXTRA 标注时翻转
38
+ return 'string';
39
+ }
40
+ /** 中间类型 → 目标列类型(AUTO_INCREMENT 不在此生成,见文件头) */
41
+ export function midToMysqlType(c) {
42
+ switch (c.midType) {
43
+ case 'int': return 'INT';
44
+ case 'bigint': return 'BIGINT';
45
+ case 'decimal': return `DECIMAL(${c.precision ?? 38},${c.scale ?? 10})`;
46
+ case 'float': return 'FLOAT';
47
+ case 'double': return 'DOUBLE';
48
+ case 'boolean': return 'TINYINT(1)';
49
+ case 'string': return `VARCHAR(${Math.min(Math.max(c.length ?? 255, 1), 16383)})`;
50
+ case 'text': return 'TEXT';
51
+ case 'bytes': return 'BLOB';
52
+ case 'json': return 'JSON';
53
+ case 'date': return 'DATE';
54
+ case 'time': return 'TIME';
55
+ case 'datetime': return 'DATETIME';
56
+ case 'timestamptz': return 'TIMESTAMP';
57
+ }
58
+ }
59
+ /** 中间格式日期值 → MySQL 可解析形态:读端(pg/oralike/mongo)materialize 产出
60
+ * ISO 带 'Z' 后缀与毫秒,而 MySQL 日期字面量不接受 'Z'(strict 整批 1292 报错、
61
+ * 非 strict 静默存零值)——剥离为 UTC 墙钟(写端会话时区已固定 +00:00,语义不变) */
62
+ export function toMysqlDateTime(s) {
63
+ return s.replace(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/i, '$1 $2');
64
+ }
65
+ /** 绑定值形态:bigint→字符串(mysql2 不接受 bigint 绑定)、boolean→0/1(TINYINT(1))、
66
+ * 日期列的 ISO 'Z' 串 → UTC 墙钟(col 缺省时不做日期归一) */
67
+ export function toMysqlBind(c, col) {
68
+ if (c === null)
69
+ return null;
70
+ if (typeof c === 'bigint')
71
+ return c.toString();
72
+ if (typeof c === 'boolean')
73
+ return c ? 1 : 0;
74
+ if (c instanceof Uint8Array)
75
+ return Buffer.from(c);
76
+ if (typeof c === 'string' && col != null &&
77
+ (col.midType === 'date' || col.midType === 'time' || col.midType === 'datetime' || col.midType === 'timestamptz')) {
78
+ return toMysqlDateTime(c);
79
+ }
80
+ return c;
81
+ }
82
+ export function createMysqlTransferReader(raw) {
83
+ const pool = raw.pool;
84
+ const defaultDb = () => {
85
+ if (raw.database === '')
86
+ throw new Error('mysql 传输读端缺少默认库(连接未指定 database)');
87
+ return assertIdent(raw.database, '库名');
88
+ };
89
+ const q = async (sql, params) => {
90
+ // 一律 pool.query(客户端转义):读语句兼容 LIMIT ? 绑定不可用的 5.7/MariaDB
91
+ const [rows] = await pool.query(sql, params ?? []);
92
+ return (Array.isArray(rows) ? rows : []);
93
+ };
94
+ const describe = async (table) => {
95
+ const db = defaultDb();
96
+ const tbl = assertIdent(table, '表名');
97
+ const rows = await q(`SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE AS CT,
98
+ NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, EXTRA
99
+ FROM information_schema.COLUMNS
100
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, [db, tbl]);
101
+ if (rows.length === 0)
102
+ throw new Error(`表不存在: ${db}.${tbl}`);
103
+ const columns = rows.map((r) => {
104
+ const columnType = String(r.COLUMN_TYPE ?? '');
105
+ const precision = r.NUMERIC_PRECISION == null ? undefined : Number(r.NUMERIC_PRECISION);
106
+ const scale = r.NUMERIC_SCALE == null ? undefined : Number(r.NUMERIC_SCALE);
107
+ const length = r.CHARACTER_MAXIMUM_LENGTH == null ? undefined : Number(r.CHARACTER_MAXIMUM_LENGTH);
108
+ const base = columnType.split('(')[0]?.toLowerCase() ?? '';
109
+ return {
110
+ name: String(r.COLUMN_NAME),
111
+ midType: mysqlTypeToMid(columnType),
112
+ sourceType: columnType,
113
+ nullable: r.IS_NULLABLE === 'YES',
114
+ primaryKey: r.COLUMN_KEY === 'PRI',
115
+ autoIncrement: typeof r.EXTRA === 'string' && r.EXTRA.includes('auto_increment'),
116
+ length,
117
+ precision: base === 'decimal' || base === 'numeric' || base === 'dec' || base === 'fixed' ? precision : undefined,
118
+ scale: base === 'decimal' || base === 'numeric' || base === 'dec' || base === 'fixed' ? scale : undefined,
119
+ default: r.COLUMN_DEFAULT == null ? null : String(r.COLUMN_DEFAULT),
120
+ comment: r.COLUMN_COMMENT != null && r.COLUMN_COMMENT !== '' ? String(r.COLUMN_COMMENT) : undefined,
121
+ };
122
+ });
123
+ return { name: tbl, columns };
124
+ };
125
+ return {
126
+ kind: 'mysql',
127
+ listTables: async () => {
128
+ const db = defaultDb();
129
+ const rows = await q(`SELECT TABLE_NAME FROM information_schema.TABLES
130
+ WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME`, [db]);
131
+ return rows.map((r) => String(r.TABLE_NAME));
132
+ },
133
+ describe,
134
+ count: async (table) => {
135
+ const db = defaultDb();
136
+ const rows = await q(`SELECT COUNT(*) AS n FROM ${quoteIdent(db, '`')}.${quoteIdent(assertIdent(table, '表名'), '`')}`);
137
+ return Number(rows[0]?.n ?? 0);
138
+ },
139
+ shardKey: async (table) => {
140
+ const t = await describe(table);
141
+ const pks = t.columns.filter((c) => c.primaryKey);
142
+ const pk = pks.length === 1 ? pks[0] : undefined;
143
+ const sharable = ['int', 'bigint', 'decimal', 'string', 'datetime', 'timestamptz'];
144
+ return pk && sharable.includes(pk.midType) ? pk.midType : null;
145
+ },
146
+ readBatch: async (table, cursor, limit) => {
147
+ const t = await describe(table);
148
+ const db = defaultDb();
149
+ const tbl = `${quoteIdent(db, '`')}.${quoteIdent(t.name, '`')}`;
150
+ const cols = t.columns.map((c) => quoteIdent(c.name, '`')).join(', ');
151
+ let sql;
152
+ let params;
153
+ if (cursor.mode === 'keyset') {
154
+ const key = quoteIdent(cursor.column, '`');
155
+ sql = cursor.after === null
156
+ ? `SELECT ${cols} FROM ${tbl} ORDER BY ${key} LIMIT ?`
157
+ : `SELECT ${cols} FROM ${tbl} WHERE ${key} > ? ORDER BY ${key} LIMIT ?`;
158
+ params = cursor.after === null ? [limit] : [cursor.after, limit];
159
+ }
160
+ else if (cursor.mode === 'range') {
161
+ const key = quoteIdent(cursor.column, '`');
162
+ const conds = [];
163
+ const binds = [];
164
+ if (cursor.lo !== null) {
165
+ conds.push(`${key} ${cursor.loInclusive ? '>=' : '>'} ?`);
166
+ binds.push(cursor.lo);
167
+ }
168
+ if (cursor.hi !== null) {
169
+ conds.push(`${key} < ?`);
170
+ binds.push(cursor.hi);
171
+ }
172
+ const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
173
+ sql = `SELECT ${cols} FROM ${tbl}${where} ORDER BY ${key} LIMIT ?`;
174
+ params = [...binds, limit];
175
+ }
176
+ else {
177
+ sql = `SELECT ${cols} FROM ${tbl} LIMIT ? OFFSET ?`;
178
+ params = [limit, cursor.offset];
179
+ }
180
+ const rows = await q(sql, params);
181
+ return rows.map((r) => t.columns.map((c) => (r[c.name] ?? null)));
182
+ },
183
+ keyAt: async (table, column, offset) => {
184
+ const db = defaultDb();
185
+ const tbl = `${quoteIdent(db, '`')}.${quoteIdent(assertIdent(table, '表名'), '`')}`;
186
+ const key = quoteIdent(assertIdent(column, '分片键'), '`');
187
+ const rows = await q(`SELECT ${key} AS k FROM ${tbl} ORDER BY ${key} LIMIT 1 OFFSET ?`, [offset]);
188
+ return (rows[0]?.k ?? null);
189
+ },
190
+ close: async () => {
191
+ await pool.end();
192
+ },
193
+ };
194
+ }
195
+ export function createMysqlTransferWriter(raw, writeMode = 'insert') {
196
+ const pool = raw.pool;
197
+ const prefix = writeMode === 'ignore' ? 'INSERT IGNORE INTO ' : writeMode === 'replace' ? 'REPLACE INTO ' : 'INSERT INTO ';
198
+ async function withTx(fn) {
199
+ const conn = await pool.getConnection();
200
+ try {
201
+ await conn.beginTransaction();
202
+ try {
203
+ const out = await fn(conn);
204
+ await conn.commit();
205
+ return out;
206
+ }
207
+ catch (e) {
208
+ await conn.rollback().catch(() => { });
209
+ throw e;
210
+ }
211
+ }
212
+ finally {
213
+ conn.release();
214
+ }
215
+ }
216
+ return {
217
+ kind: 'mysql',
218
+ tableExists: async (name) => {
219
+ const db = raw.database;
220
+ if (db === '')
221
+ throw new Error('mysql 传输写端缺少默认库(连接未指定 database)');
222
+ const [rows] = await pool.query(`SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [db, assertIdent(name, '表名')]);
223
+ const list = (Array.isArray(rows) ? rows : []);
224
+ return Number(list[0]?.n ?? 0) > 0;
225
+ },
226
+ dropTable: async (name) => {
227
+ const db = raw.database !== '' ? `${quoteIdent(assertIdent(raw.database, '库名'), '`')}.` : '';
228
+ await pool.query(`DROP TABLE ${db}${quoteIdent(assertIdent(name, '表名'), '`')}`);
229
+ },
230
+ // truncate 模式前置清空(MySQL 8.4 官方手册「Optimizing InnoDB DDL Operations」:清空首选
231
+ // TRUNCATE,有外键时可能需回退 DELETE)。TRUNCATE 要表级独占 MDL,被活跃调度器/长事务
232
+ // 持有时默认等 31536000s(真机实证:进度无限卡死)——先设 lock_wait_timeout=15s 拿不到即
233
+ // 降级 DELETE FROM(行锁由 innodb_lock_wait_timeout 默认 50s 有限等待,可安全完成)
234
+ beforeLoad: async (table) => {
235
+ const db = raw.database !== ''
236
+ ? `${quoteIdent(assertIdent(raw.database, '库名'), '`')}.`
237
+ : '';
238
+ const conn = await pool.getConnection();
239
+ try {
240
+ await conn.query('SET SESSION lock_wait_timeout = 15');
241
+ try {
242
+ await conn.query(`TRUNCATE TABLE ${db}${quoteIdent(table.name, '`')}`);
243
+ }
244
+ catch {
245
+ // MDL 拿不到(表被活跃连接占用):降级 DELETE(行级锁,有限等待)
246
+ await conn.query(`DELETE FROM ${db}${quoteIdent(table.name, '`')}`);
247
+ }
248
+ }
249
+ finally {
250
+ conn.release();
251
+ }
252
+ },
253
+ createTable: async (table) => {
254
+ const db = raw.database !== ''
255
+ ? `${quoteIdent(assertIdent(raw.database, '库名'), '`')}.`
256
+ : '';
257
+ const defs = table.columns.map((c) => {
258
+ let d = `${quoteIdent(c.name, '`')} ${midToMysqlType(c)}`;
259
+ if (!c.nullable)
260
+ d += ' NOT NULL';
261
+ if (c.default != null) {
262
+ const expr = safeDefaultExpr(c.default);
263
+ if (expr != null)
264
+ d += ` DEFAULT ${expr}`;
265
+ }
266
+ return d;
267
+ });
268
+ const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => quoteIdent(c.name, '`'));
269
+ const pk = pkCols.length > 0 ? `, PRIMARY KEY (${pkCols.join(', ')})` : '';
270
+ const head = `CREATE TABLE ${db}${quoteIdent(table.name, '`')}`;
271
+ await pool.query(`${head} (${defs.join(', ')}${pk})`);
272
+ },
273
+ writeBatch: async (table, rows) => {
274
+ if (rows.length === 0)
275
+ return 0;
276
+ const cols = table.columns;
277
+ const width = cols.length;
278
+ for (const r of rows) {
279
+ if (r.length !== width) {
280
+ throw new Error(`行宽不匹配:期望 ${width} 列,实际 ${r.length} 列(表 ${table.name})`);
281
+ }
282
+ }
283
+ // REPLACE INTO 无唯一键时等同纯 INSERT(官方语义)——静默重复必须拦截
284
+ if (writeMode === 'replace' && !table.columns.some((c) => c.primaryKey)) {
285
+ throw new Error(`replace 模式需要主键:表 ${table.name} 无主键`);
286
+ }
287
+ const db = raw.database !== ''
288
+ ? `${quoteIdent(assertIdent(raw.database, '库名'), '`')}.`
289
+ : '';
290
+ // 多行 VALUES 单语句(官方示例形态):一次往返写整批,较逐行快一个数量级;
291
+ // 每语句 ≤1000 行防 max_allowed_packet 溢出
292
+ const colList = cols.map((c) => quoteIdent(c.name, '`')).join(', ');
293
+ const rowHolders = `(${cols.map(() => '?').join(', ')})`;
294
+ return withTx(async (conn) => {
295
+ let written = 0;
296
+ for (let i = 0; i < rows.length; i += 1000) {
297
+ const chunk = rows.slice(i, i + 1000);
298
+ const sql = `${prefix}${db}${quoteIdent(table.name, '`')} (${colList}) VALUES ${chunk.map(() => rowHolders).join(', ')}`;
299
+ const binds = chunk.flatMap((r) => r.map((v, ci) => toMysqlBind(v, cols[ci])));
300
+ const [result] = await conn.query(sql, binds);
301
+ const header = (Array.isArray(result) ? result[0] : result);
302
+ written += header?.affectedRows ?? 0;
303
+ }
304
+ return written;
305
+ });
306
+ },
307
+ close: async () => {
308
+ await pool.end();
309
+ },
310
+ };
311
+ }
312
+ /** 定位:database 覆盖连接库(读写端均已按连接默认库(MysqlRaw.database)限定,覆盖入口即
313
+ * 全链生效);schema 忽略(mysql 无 schema 层级)。
314
+ * URL 形态连接必须改写 URL 的 pathname——connOptions 的 url 分支会忽略 fields.database
315
+ * (与 pg 的 connectionString 覆盖显式字段同类坑,审计已证) */
316
+ function mysqlLocate(rc, loc) {
317
+ if (!loc?.database)
318
+ return rc;
319
+ if (rc.url) {
320
+ const u = new URL(rc.url);
321
+ u.pathname = '/' + encodeURIComponent(loc.database);
322
+ return { ...rc, url: u.toString() };
323
+ }
324
+ return { ...rc, fields: { ...rc.fields, database: loc.database } };
325
+ }
326
+ export async function createMysqlTransferReaderFromConn(rc, loc) {
327
+ return createMysqlTransferReader(await openMysqlRaw(mysqlLocate(rc, loc)));
328
+ }
329
+ export async function createMysqlTransferWriterFromConn(rc, writeMode, loc) {
330
+ return createMysqlTransferWriter(await openMysqlRaw(mysqlLocate(rc, loc)), writeMode);
331
+ }