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
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* - DM 事务隐式开始,无需 SET TRANSACTION。
|
|
10
10
|
*
|
|
11
11
|
* 驱动要点:
|
|
12
|
-
* - db.createPool({ connectString: 'dm://user:pass@host:5236',
|
|
13
|
-
* poolTimeout: 60, queueTimeout: 60000 })
|
|
14
|
-
* @napi-rs/snappy 跨平台坑)。
|
|
15
|
-
* -
|
|
12
|
+
* - db.createPool({ connectString: 'dm://user:pass@host:5236', poolAlias: <逐池唯一>,
|
|
13
|
+
* poolMin: 1, poolMax: 10, poolTimeout: 60, queueTimeout: 60000 });不启用压缩
|
|
14
|
+
* (默认 compress=0,规避 @napi-rs/snappy 跨平台坑)。
|
|
15
|
+
* - poolAlias 必须逐池唯一(不传即撞驱动缺省 'default' 并抛 [20006];见 nextPoolAlias 注释)。
|
|
16
|
+
* - execute 必须显式 maxRows(默认 0=无限制);autoCommit 显式传 true 使语义
|
|
17
|
+
* 不依赖驱动默认(驱动连接属性默认已是自动提交:dmdb connection.js
|
|
18
|
+
* conn_prop_autoCommit = !0;d.ts 三级优先链 执行选项 > dmdb.autoCommit > 连接属性)。
|
|
16
19
|
* - 返回 { metaData, rows, rowsAffected } 与 oracledb 兼容。
|
|
17
20
|
*/
|
|
18
21
|
import db from 'dmdb';
|
|
@@ -82,6 +85,39 @@ export function resolveDmConn(conn) {
|
|
|
82
85
|
const password = typeof f.password === 'string' ? f.password : '';
|
|
83
86
|
return { connectString: `dm://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}`, user, password };
|
|
84
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* 连接池别名:dmdb 把池登记在模块级全局 Map(dmdb.pools),key 即 poolAlias。
|
|
90
|
+
* 驱动缺省别名恒为 'default'(driver/pool.js `_poolAlias = "default"`,仅显式传参才覆盖),
|
|
91
|
+
* 且判重无条件执行 → 不传别名时第二个池必抛 [20006] 连接池别名已存在(src/dm.js:236)。
|
|
92
|
+
* 本插件同进程内可并存多个池(连接的 ro/rw 各一份缓存、testConnection/testDraft 的
|
|
93
|
+
* 一次性池、控制台事务会话、数据传输独立池),故必须逐池唯一。
|
|
94
|
+
* 别名同时是 close 的回收键:正常路径 driver/pool.js 会 `pools.delete(alias)`,
|
|
95
|
+
* 但 close 抛错的异常分支会跳过该删除(登记项残留,插件侧无可观测点)。
|
|
96
|
+
*/
|
|
97
|
+
let poolSeq = 0;
|
|
98
|
+
function nextPoolAlias(conn) {
|
|
99
|
+
return `dsh-dm-${conn.meta.id}-${++poolSeq}`;
|
|
100
|
+
}
|
|
101
|
+
/** 建池(连接串解析、池参数与别名单点维护,供适配器与数据传输入口共用) */
|
|
102
|
+
async function createDmPool(conn) {
|
|
103
|
+
const { connectString } = resolveDmConn(conn);
|
|
104
|
+
try {
|
|
105
|
+
// 注意:实参对象不要直接包进 `as unknown as` 断言——变异工具不为该形态生成
|
|
106
|
+
// ObjectLiteral 变异体,「删掉 poolAlias」这类关键退化会在变异报告中不可见(已验证)。
|
|
107
|
+
const pool = await db.createPool({
|
|
108
|
+
connectString,
|
|
109
|
+
poolAlias: nextPoolAlias(conn),
|
|
110
|
+
poolMin: 1,
|
|
111
|
+
poolMax: 10,
|
|
112
|
+
poolTimeout: 60,
|
|
113
|
+
queueTimeout: 60000,
|
|
114
|
+
});
|
|
115
|
+
return pool;
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
throw humanizeDmError(e);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
85
121
|
export async function createDmAdapter(conn, opts) {
|
|
86
122
|
const mode = opts?.mode ?? 'rw';
|
|
87
123
|
let pool;
|
|
@@ -89,21 +125,10 @@ export async function createDmAdapter(conn, opts) {
|
|
|
89
125
|
pool = opts.pool;
|
|
90
126
|
}
|
|
91
127
|
else {
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
pool = (await db.createPool({
|
|
95
|
-
connectString,
|
|
96
|
-
poolMin: 1,
|
|
97
|
-
poolMax: 10,
|
|
98
|
-
poolTimeout: 60,
|
|
99
|
-
queueTimeout: 60000,
|
|
100
|
-
}));
|
|
101
|
-
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
throw humanizeDmError(e);
|
|
104
|
-
}
|
|
128
|
+
pool = await createDmPool(conn);
|
|
105
129
|
}
|
|
106
|
-
/** 默认 execute 选项:显式 maxRows
|
|
130
|
+
/** 默认 execute 选项:显式 maxRows(dmdb 默认 0=无限制,必须覆盖)+ 显式 autoCommit:true
|
|
131
|
+
* (驱动连接属性默认已是 true,显式传递仅让语义不依赖驱动默认;SELECT 立即结束隐式事务,避免 ro 事务悬挂) */
|
|
107
132
|
const execOpts = { outFormat: db.OUT_FORMAT_OBJECT, maxRows: ROWS_MAX + 1, autoCommit: true };
|
|
108
133
|
/** 事务内 execute:显式关闭自动提交,由 tx 的 commit/rollback 收口 */
|
|
109
134
|
const execOptsTx = { ...execOpts, autoCommit: false };
|
|
@@ -312,8 +337,10 @@ export async function createDmAdapter(conn, opts) {
|
|
|
312
337
|
const n = Math.max(1, Math.min(Math.floor(limit) || 20, ROWS_MAX));
|
|
313
338
|
const off = Math.max(0, Math.floor(offset ?? 0) || 0);
|
|
314
339
|
try {
|
|
315
|
-
// 统一 ANSI 分页(不依赖 compatibleMode=oracle
|
|
316
|
-
|
|
340
|
+
// 统一 ANSI 分页(不依赖 compatibleMode=oracle)。ROW_LIMIT 子句语法图的
|
|
341
|
+
// <offset>/<大小> 均为字面 <整数>(DM8 SQL 手册),绑定占位符能否用于该
|
|
342
|
+
// 位置无官方佐证 → 内联经 clamp 的整数(off/n 源自内部 offset/limit,无注入面)
|
|
343
|
+
const r = await withConn((c) => c.execute(`SELECT * FROM "${escIdent(owner)}"."${escIdent(tab)}" OFFSET ${off} ROWS FETCH FIRST ${n} ROWS ONLY`, [], execOpts));
|
|
317
344
|
return await rowsToQueryResult(r);
|
|
318
345
|
}
|
|
319
346
|
catch (e) {
|
|
@@ -330,3 +357,7 @@ export async function createDmAdapter(conn, opts) {
|
|
|
330
357
|
return adapter;
|
|
331
358
|
}
|
|
332
359
|
export const factory = async (conn) => createDmAdapter(conn);
|
|
360
|
+
/** 数据传输读写端用的独立连接池(别名逐池唯一,可与主连接池并存) */
|
|
361
|
+
export async function openDmPool(conn) {
|
|
362
|
+
return await createDmPool(conn);
|
|
363
|
+
}
|
|
@@ -170,6 +170,11 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
170
170
|
? urlDbName(conn.url)
|
|
171
171
|
: defaultDbName(conn.fields);
|
|
172
172
|
const db = client.db(dbName);
|
|
173
|
+
/** 库定位:database 指定且非连接默认库时切库(client.db() 是轻量句柄,不建新连接);
|
|
174
|
+
* 表清单/预览按所选库真实返回,避免「清单来自固定库、定位发所选库」的错位 */
|
|
175
|
+
function dbOf(database) {
|
|
176
|
+
return database && database !== dbName ? client.db(database) : db;
|
|
177
|
+
}
|
|
173
178
|
function wrap(e) {
|
|
174
179
|
const M = vendorOrNull();
|
|
175
180
|
if (M && e instanceof M.MongoServerError) {
|
|
@@ -194,8 +199,17 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
194
199
|
connId: conn.meta.id,
|
|
195
200
|
async testConnect() {
|
|
196
201
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
// hello 响应没有 version 字段(官方手册字段表仅 isWritablePrimary/topologyVersion/
|
|
203
|
+
// maxBsonObjectSize/maxWireVersion 等),软件版本属 buildInfo 命令 → 取
|
|
204
|
+
// buildInfo.version;权限不足等失败回退 hello 仅判连通(版本信息尽力而为)
|
|
205
|
+
try {
|
|
206
|
+
const info = (await client.db('admin').command({ buildInfo: 1 }));
|
|
207
|
+
return { ok: true, serverInfo: `MongoDB ${String(info.version ?? '')}` };
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
await client.db('admin').command({ hello: 1 });
|
|
211
|
+
return { ok: true, serverInfo: 'MongoDB(版本信息不可读)' };
|
|
212
|
+
}
|
|
199
213
|
}
|
|
200
214
|
catch (e) {
|
|
201
215
|
return { ok: false, error: wrap(e).message };
|
|
@@ -338,8 +352,11 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
338
352
|
: isPlainObject(cmd.indexes) ? [cmd.indexes] : Array.isArray(cmd.indexes) ? cmd.indexes : null;
|
|
339
353
|
if (!specs)
|
|
340
354
|
throw new Error('MongoDB createIndex 需要 index 规格对象或数组:{"createIndex":"coll","index":{"key":{"a":1},"name":"a_1"}}');
|
|
341
|
-
|
|
342
|
-
|
|
355
|
+
// 官方返回索引名数组(mongodb.d.ts: createIndexes(...): Promise<string[]>),
|
|
356
|
+
// 无 createdNewIndexes 字段(曾按记忆臆造,恒 undefined);驱动不区分
|
|
357
|
+
// 新建/已存在,affectedRows = 规格数
|
|
358
|
+
const created = await db.collection(name).createIndexes(specs);
|
|
359
|
+
return { affectedRows: created.length, message: `已在集合 ${name} 创建索引(${created.length} 个)` };
|
|
343
360
|
}
|
|
344
361
|
if (op === 'dropIndex' || op === 'dropIndexes') {
|
|
345
362
|
const name = collOf(cmd, op);
|
|
@@ -417,9 +434,9 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
417
434
|
throw wrap(e);
|
|
418
435
|
}
|
|
419
436
|
},
|
|
420
|
-
async listTables() {
|
|
437
|
+
async listTables(database) {
|
|
421
438
|
try {
|
|
422
|
-
const infos = await
|
|
439
|
+
const infos = await dbOf(database).listCollections().toArray();
|
|
423
440
|
return infos.map((c) => ({ name: c.name, type: c.type === 'view' ? 'view' : 'collection' }));
|
|
424
441
|
}
|
|
425
442
|
catch (e) {
|
|
@@ -468,11 +485,11 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
468
485
|
throw wrap(e);
|
|
469
486
|
}
|
|
470
487
|
},
|
|
471
|
-
async previewRows(name, limit,
|
|
488
|
+
async previewRows(name, limit, database, offset) {
|
|
472
489
|
try {
|
|
473
490
|
const n = Math.max(1, Math.min(Math.floor(limit) || 20, FIND_MAX_LIMIT));
|
|
474
491
|
const skip = Math.max(0, Math.floor(offset ?? 0) || 0);
|
|
475
|
-
const docs = await
|
|
492
|
+
const docs = await dbOf(database).collection(name).find({}).sort({ _id: 1 }).skip(skip).limit(n).toArray();
|
|
476
493
|
return docsToResult(docs, false);
|
|
477
494
|
}
|
|
478
495
|
catch (e) {
|
|
@@ -587,3 +604,25 @@ function sanitizeUri(uri) {
|
|
|
587
604
|
return uri.replace(/:\/\/[^@/]*@/, '://***:***@');
|
|
588
605
|
}
|
|
589
606
|
export const factory = async (conn) => createMongoAdapter(conn);
|
|
607
|
+
export async function openMongoRaw(conn) {
|
|
608
|
+
const M = vendor();
|
|
609
|
+
const uri = conn.url ?? buildUri(conn.fields, conn.ssl);
|
|
610
|
+
const client = new M.MongoClient(uri, {
|
|
611
|
+
maxPoolSize: 10,
|
|
612
|
+
readPreference: 'primary',
|
|
613
|
+
w: 'majority',
|
|
614
|
+
family: 4,
|
|
615
|
+
});
|
|
616
|
+
try {
|
|
617
|
+
await client.connect();
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
throw new Error(`MongoDB 连接失败(${sanitizeUri(uri)}):${e instanceof Error ? e.message : String(e)}`);
|
|
621
|
+
}
|
|
622
|
+
const dbName = typeof conn.meta.database === 'string' && conn.meta.database !== ''
|
|
623
|
+
? conn.meta.database
|
|
624
|
+
: conn.url
|
|
625
|
+
? urlDbName(conn.url)
|
|
626
|
+
: defaultDbName(conn.fields);
|
|
627
|
+
return { client, db: client.db(dbName), M };
|
|
628
|
+
}
|
|
@@ -9,6 +9,23 @@ import mysql from 'mysql2/promise';
|
|
|
9
9
|
import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from '../sql-shared/common.js';
|
|
10
10
|
/** ro 模式下 query 允许的读语句首词白名单 */
|
|
11
11
|
const RO_READ_PREFIX = /^(select|show|desc|describe|explain|use|help|table)\b/i;
|
|
12
|
+
/**
|
|
13
|
+
* 吞掉 'connection' 回调里会话级 SET 的失败(ro / time_zone 仅是增强,失败不应拖垮连接与进程)。
|
|
14
|
+
* mysql2 promise 池的 'connection' 事件由核心池 emit、经 lib/promise/inherit_events.js
|
|
15
|
+
* 原样转发参数,回调收到的是 callback 版 PoolConnection:其 query() 返回 Query 命令
|
|
16
|
+
* 对象(EventEmitter,无 onResult 时错误走 emit('error'),lib/commands/command.js),
|
|
17
|
+
* 无监听会抛 uncaughtException 崩溃进程——.catch 防御对它恒不生效,必须挂 error 监听。
|
|
18
|
+
* 若驱动版本改为返回 Promise,则走 .catch 吞 rejection(双分支防御)。
|
|
19
|
+
*/
|
|
20
|
+
function swallowSetError(r) {
|
|
21
|
+
if (r && typeof r.catch === 'function') {
|
|
22
|
+
void r.catch(() => { });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (r && typeof r.on === 'function') {
|
|
26
|
+
r.on('error', () => { });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
12
29
|
function connOptions(conn) {
|
|
13
30
|
const base = {
|
|
14
31
|
waitForConnections: true,
|
|
@@ -65,12 +82,10 @@ export async function createMysqlAdapter(conn, opts) {
|
|
|
65
82
|
const readOnly = opts?.mode === 'ro';
|
|
66
83
|
if (readOnly) {
|
|
67
84
|
// 服务器级 ro 强制:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
|
|
68
|
-
//
|
|
85
|
+
// 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
|
|
86
|
+
// 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。
|
|
69
87
|
pool.on('connection', (c) => {
|
|
70
|
-
|
|
71
|
-
if (r && typeof r.catch === 'function') {
|
|
72
|
-
void r.catch(() => { });
|
|
73
|
-
}
|
|
88
|
+
swallowSetError(c.query('SET SESSION TRANSACTION READ ONLY'));
|
|
74
89
|
});
|
|
75
90
|
}
|
|
76
91
|
/** 事务激活期间路由到同一连接,保证 BEGIN/COMMIT 生效 */
|
|
@@ -220,3 +235,14 @@ export async function createMysqlAdapter(conn, opts) {
|
|
|
220
235
|
};
|
|
221
236
|
}
|
|
222
237
|
export const factory = (conn) => createMysqlAdapter(conn);
|
|
238
|
+
export async function openMysqlRaw(conn) {
|
|
239
|
+
const opts = connOptions(conn);
|
|
240
|
+
const pool = mysql.createPool({ ...opts, dateStrings: true });
|
|
241
|
+
// 回调式 query(带 err 回调吞错)。必须用回调式:非回调式返回的 Query 命令对象与
|
|
242
|
+
// promise 池连接状态机冲突 → 该连接所有后续 query 永挂(真机实证 mysql2 3.24.4)
|
|
243
|
+
pool.on('connection', (c) => {
|
|
244
|
+
// 运行时 c 是 callback 版连接(promise 池类型标注不反映),按回调式调用
|
|
245
|
+
c.query("SET time_zone = '+00:00'", () => { });
|
|
246
|
+
});
|
|
247
|
+
return { pool, database: typeof opts.database === 'string' ? opts.database : '' };
|
|
248
|
+
}
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Oracle 适配器(oracledb v7 thin 模式,DB 12.1+)。
|
|
3
3
|
*
|
|
4
4
|
* 设计要点(依据官方文档调研):
|
|
5
|
-
* - oracledb.createPool({ user, password, connectString,
|
|
5
|
+
* - oracledb.createPool({ user, password, connectString, poolAlias: <逐池唯一>,
|
|
6
|
+
* poolMin: 1, poolMax: 10, poolTimeout: 60 })。
|
|
6
7
|
* 禁止 SYSDBA 等特权连接(不透传 privilege)。
|
|
7
|
-
* -
|
|
8
|
+
* - poolAlias 显式传唯一值:不传则落到匿名池(不可经 getPool() 检索),见 createOraPool 注释。
|
|
9
|
+
* - CLOB 经全局 fetchAsString 转字符串;BLOB 经全局 fetchAsBuffer 转 Buffer。
|
|
10
|
+
* (fetchInfo 的键是【列名】而非类型名,按类型统一转换只能用全局 fetchXXX 属性——
|
|
11
|
+
* 官方 connection.rst「fetchInfo」节:「Each column is specified by name」)
|
|
8
12
|
* NUMBER 保持 number(超精度损失由用户承担,见文档标注)。
|
|
9
13
|
* - query() 仅允许 SELECT/WITH;execute() DML/DDL,autoCommit: true 单语句语义。
|
|
10
14
|
* - Oracle 无 BEGIN,事务用 SET TRANSACTION READ WRITE + commit/rollback(tx 成员)。
|
|
@@ -14,14 +18,17 @@
|
|
|
14
18
|
*/
|
|
15
19
|
import oracledb from 'oracledb';
|
|
16
20
|
import { sqlHead, SQL_READ_HEADS } from '../../guard/index.js';
|
|
17
|
-
// CLOB
|
|
21
|
+
// CLOB 统一按字符串返回;BLOB 统一按 Buffer 返回。均为官方全局按类型转换
|
|
22
|
+
// (fetchInfo 的键是列名而非类型名,`{ BLOB: … }` 只会匹配恰好名为 "BLOB" 的列,
|
|
23
|
+
// 按类型统一转换须用全局 fetchAsString/fetchAsBuffer——官方 connection.rst
|
|
24
|
+
// 「fetchInfo」节原文「Each column is specified by name, using Oracle's standard
|
|
25
|
+
// naming convention.」;fetchInfo 自 6.0 标记 deprecated,官方建议全局属性/fetchTypeHandler)
|
|
18
26
|
if (!oracledb.fetchAsString.includes(oracledb.CLOB)) {
|
|
19
27
|
oracledb.fetchAsString = [...oracledb.fetchAsString, oracledb.CLOB];
|
|
20
28
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
29
|
+
if (!oracledb.fetchAsBuffer.includes(oracledb.BLOB)) {
|
|
30
|
+
oracledb.fetchAsBuffer = [...oracledb.fetchAsBuffer, oracledb.BLOB];
|
|
31
|
+
}
|
|
25
32
|
const ROWS_MAX = 500;
|
|
26
33
|
const CELL_TRUNC = 1000;
|
|
27
34
|
const DDL_KEYWORDS = new Set([
|
|
@@ -75,8 +82,16 @@ async function normalizeCell(v) {
|
|
|
75
82
|
return trunc(v);
|
|
76
83
|
if (v instanceof Date)
|
|
77
84
|
return v.toISOString();
|
|
78
|
-
if (
|
|
79
|
-
|
|
85
|
+
if (Buffer.isBuffer(v))
|
|
86
|
+
return trunc(`0x${v.toString('hex')}`); // fetchAsBuffer 产出的 BLOB
|
|
87
|
+
if (v instanceof oracledb.Lob) {
|
|
88
|
+
// 官方 Lob 类成员仅 getData()/read()/close()/destroy() 等,无 toString()(官方 lob.rst;
|
|
89
|
+
// 实测 Lob.prototype.toString 即 Object.prototype.toString)——曾误用 toString()
|
|
90
|
+
// 致 LOB 列恒显示 '[object Object]'。getData() 按 CLOB/BFILE 返回 string、按
|
|
91
|
+
// 未转换 BLOB 返回 Buffer。判别用官方导出类 instanceof(constructor.name 非
|
|
92
|
+
// 官方承诺,esbuild 压缩改名场景会失效)。
|
|
93
|
+
const data = await v.getData();
|
|
94
|
+
const s = typeof data === 'string' ? data : `0x${Buffer.from(data).toString('hex')}`;
|
|
80
95
|
return trunc(s);
|
|
81
96
|
}
|
|
82
97
|
return trunc(String(v));
|
|
@@ -116,6 +131,37 @@ export function resolveOracleConn(conn) {
|
|
|
116
131
|
connectString: `${host}:${port}/${service}`,
|
|
117
132
|
};
|
|
118
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* 连接池别名:oracledb 的规则与 dmdb 不同——只有「显式传入的别名」或
|
|
136
|
+
* 「'default' 尚空闲时取到的缺省值」会登记进模块级 poolCache;两者都被占用时
|
|
137
|
+
* 驱动创建的是 poolAlias === undefined 的匿名池(不报错,也无法经 getPool() 检索),
|
|
138
|
+
* 只有显式重复别名才抛 NJS-046(lib/oracledb.js:640-649、lib/errors.js:352)。
|
|
139
|
+
* 本插件同进程内可并存多个池(连接的 ro/rw 各一份缓存、testConnection/testDraft 的
|
|
140
|
+
* 一次性池、控制台事务会话、数据传输独立池),故显式传唯一别名:每池身份可检索,
|
|
141
|
+
* 且不抢占 getPool() 的缺省键 'default'。回收靠 '_afterPoolClose' 事件(close 抛错则不触发)。
|
|
142
|
+
*/
|
|
143
|
+
let poolSeq = 0;
|
|
144
|
+
function nextPoolAlias(conn) {
|
|
145
|
+
return `dsh-ora-${conn.meta.id}-${++poolSeq}`;
|
|
146
|
+
}
|
|
147
|
+
/** 建池(连接串解析、池参数与别名单点维护,供适配器与数据传输入口共用) */
|
|
148
|
+
async function createOraPool(conn) {
|
|
149
|
+
const { user, password, connectString } = resolveOracleConn(conn);
|
|
150
|
+
try {
|
|
151
|
+
return await oracledb.createPool({
|
|
152
|
+
user,
|
|
153
|
+
password,
|
|
154
|
+
connectString,
|
|
155
|
+
poolAlias: nextPoolAlias(conn),
|
|
156
|
+
poolMin: 1,
|
|
157
|
+
poolMax: 10,
|
|
158
|
+
poolTimeout: 60,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
throw humanizeOraError(e);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
119
165
|
export async function createOracleAdapter(conn, opts) {
|
|
120
166
|
const mode = opts?.mode ?? 'rw';
|
|
121
167
|
let pool;
|
|
@@ -123,20 +169,7 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
123
169
|
pool = opts.pool;
|
|
124
170
|
}
|
|
125
171
|
else {
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
pool = await oracledb.createPool({
|
|
129
|
-
user,
|
|
130
|
-
password,
|
|
131
|
-
connectString,
|
|
132
|
-
poolMin: 1,
|
|
133
|
-
poolMax: 10,
|
|
134
|
-
poolTimeout: 60,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
catch (e) {
|
|
138
|
-
throw humanizeOraError(e);
|
|
139
|
-
}
|
|
172
|
+
pool = await createOraPool(conn);
|
|
140
173
|
}
|
|
141
174
|
function requireRw(action) {
|
|
142
175
|
if (mode === 'ro') {
|
|
@@ -166,7 +199,7 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
166
199
|
return { columns, rows, rowCount: rows.length, truncated: truncated || undefined };
|
|
167
200
|
}
|
|
168
201
|
// autoCommit 默认 true:query/元数据 SELECT 结束隐式事务避免 ro 事务悬挂;tx 专用连接显式 autoCommit:false 覆盖
|
|
169
|
-
const execOpts = { outFormat: oracledb.OBJECT, maxRows: ROWS_MAX + 1, autoCommit: true
|
|
202
|
+
const execOpts = { outFormat: oracledb.OBJECT, maxRows: ROWS_MAX + 1, autoCommit: true };
|
|
170
203
|
/**
|
|
171
204
|
* 从池借一条连接执行后归还。⚠️ oracledb 的 Pool 没有 execute,
|
|
172
205
|
* 一切语句必须在 Connection 上执行;close() 即归还连接。
|
|
@@ -374,3 +407,7 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
374
407
|
return adapter;
|
|
375
408
|
}
|
|
376
409
|
export const factory = async (conn) => createOracleAdapter(conn);
|
|
410
|
+
/** 数据传输读写端用的独立连接池(别名逐池唯一,可与主连接池并存) */
|
|
411
|
+
export async function openOraclePool(conn) {
|
|
412
|
+
return await createOraPool(conn);
|
|
413
|
+
}
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
9
|
import { createPgLikeAdapter } from '../sql-shared/pg-like.js';
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
|
+
/** 数据传输模块用的驱动加载入口(与适配器同一份 require 缓存) */
|
|
12
|
+
export function getPgDriver() {
|
|
13
|
+
return require('pg');
|
|
14
|
+
}
|
|
11
15
|
export async function createPostgresqlAdapter(conn, opts) {
|
|
12
16
|
const driver = require('pg');
|
|
13
17
|
return createPgLikeAdapter('postgresql', driver, conn, opts);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from './common.js';
|
|
2
|
-
|
|
2
|
+
/** 多主机 authority 逐台重组段(host[:port]) */
|
|
3
|
+
function joinHostSeg(h) {
|
|
4
|
+
return `${h.host}${h.port != null ? ':' + h.port : ''}`;
|
|
5
|
+
}
|
|
6
|
+
function poolConfig(conn, overrideUrl, max = 10) {
|
|
3
7
|
const base = {
|
|
4
|
-
max
|
|
8
|
+
max,
|
|
5
9
|
idleTimeoutMillis: 30000,
|
|
6
10
|
connectionTimeoutMillis: 5000,
|
|
7
11
|
};
|
|
@@ -104,18 +108,22 @@ export async function pickHost(driver, base, hosts, masterOnly) {
|
|
|
104
108
|
}
|
|
105
109
|
return undefined;
|
|
106
110
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* 建池前置解析(浏览适配器与传输读写端共用,保证两条路径连接语义一致):
|
|
113
|
+
* URL 规范化(剥 jdbc:、多主机 authority 重组)后,多主机一律逐台探测选可用节点——
|
|
114
|
+
* 纯 JS 驱动(pg 8.x 与 gaussdb-node)均无 libpq 式多主机 failover:带端口多台
|
|
115
|
+
* connectionString 在 pg-connection-string / gaussdb-connection-string parse 时直接抛
|
|
116
|
+
* Invalid URL(实测),不带端口则整串 host 'h1,h2' 直传 net.connect → DNS ENOTFOUND,
|
|
117
|
+
* 故不能透传,须拆台探测(targetservertype=master 两 kind 同义:只接受主节点)。
|
|
118
|
+
* 返回最终驱动池配置与最终连接 URL。
|
|
119
|
+
*/
|
|
120
|
+
export async function pgLikePoolCfg(kind, driver, conn, max) {
|
|
112
121
|
const norm = conn.url ? normalizeUrl(conn.url) : null;
|
|
113
|
-
const
|
|
114
|
-
const cleanUrl = norm ? norm.prefix + (norm.hosts ?? []).map(joinSeg).join(',') + norm.suffix : undefined;
|
|
122
|
+
const cleanUrl = norm ? norm.prefix + (norm.hosts ?? []).map(joinHostSeg).join(',') + norm.suffix : undefined;
|
|
115
123
|
let finalUrl = cleanUrl;
|
|
116
|
-
if (
|
|
124
|
+
if (norm?.hosts && norm.hosts.length > 1) {
|
|
117
125
|
const { prefix, suffix } = norm;
|
|
118
|
-
const joinHost = (h) => prefix +
|
|
126
|
+
const joinHost = (h) => prefix + joinHostSeg(h) + suffix;
|
|
119
127
|
const firstUrl = joinHost(norm.hosts[0]);
|
|
120
128
|
// targetServerType=master(键值大小写不敏感)→ 只接受主节点
|
|
121
129
|
const qi = suffix.indexOf('?');
|
|
@@ -124,7 +132,7 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
124
132
|
if (k.toLowerCase() === 'targetservertype' && v.toLowerCase() === 'master')
|
|
125
133
|
masterOnly = true;
|
|
126
134
|
}
|
|
127
|
-
const base = poolConfig(conn, firstUrl);
|
|
135
|
+
const base = poolConfig(conn, firstUrl, max);
|
|
128
136
|
// 探测凭据自 prefix userinfo 解出(按最后一个 @ / 最后一个 : 分界,decode 与
|
|
129
137
|
// percent-encode 注入互逆);仅密码无用户名时 user 不设
|
|
130
138
|
if (prefix.endsWith('@')) {
|
|
@@ -140,11 +148,39 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
140
148
|
base.urlUser = decodeURIComponent(body);
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
|
-
const picked = await pickHost(
|
|
151
|
+
const picked = await pickHost(driver, base, norm.hosts, masterOnly);
|
|
144
152
|
// 全败回退首台重组值:让 testConnect 报真实连接错误
|
|
145
153
|
finalUrl = picked ? joinHost(picked) : firstUrl;
|
|
146
154
|
}
|
|
147
|
-
|
|
155
|
+
return { cfg: poolConfig(conn, finalUrl, max), url: finalUrl };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 生成「同一份池配置仅覆盖库名」的 cfg:pg / gaussdb ConnectionParameters 按
|
|
159
|
+
* Object.assign({}, config, parse(config.connectionString)) 合并——connectionString
|
|
160
|
+
* 解析值覆盖全部显式字段(实测:{connectionString:'postgres://u:p@h1:5432/appdb',
|
|
161
|
+
* database:'otherdb',host:'x',user:'y'} → database 'appdb'/host 'h1'/user 'u';
|
|
162
|
+
* node-postgres packages/pg/lib/connection-parameters.js:53-57,gaussdb-node
|
|
163
|
+
* lib/connection-parameters.js:55-57 同款)。故 URL 形态叠加 database 字段会被 URL
|
|
164
|
+
* 里的原库名覆盖(跨库池静默连回原库),必须改写 URL path 段;字段形态直接叠加。
|
|
165
|
+
* 写回库名 encodeURIComponent 与驱动 parse 的 decodeURI 对空格/非 ASCII 往返无损
|
|
166
|
+
* (两驱动实测 parse('.../a%20b') → database 'a b';两驱动源码 pg-connection-string
|
|
167
|
+
* index.js:67 / gaussdb-connection-string index.js:62 均为 decodeURI(pathname))——
|
|
168
|
+
* 含 # / 等保留字符的库名 decodeURI 不还原(驱动能力边界,libpq 才有完整
|
|
169
|
+
* percent-decoding),此类库名无法经 URL 形态无损表达。
|
|
170
|
+
*/
|
|
171
|
+
export function pgCfgWithDatabase(cfg, db) {
|
|
172
|
+
const cs = cfg.connectionString;
|
|
173
|
+
// 无 authority 的非 URL 形态不产生垃圾改写,退回字段叠加
|
|
174
|
+
if (typeof cs !== 'string' || !cs.includes('://'))
|
|
175
|
+
return { ...cfg, database: db };
|
|
176
|
+
const norm = normalizeUrl(cs);
|
|
177
|
+
const qi = /[?#]/.exec(norm.suffix)?.index ?? -1;
|
|
178
|
+
const tail = qi >= 0 ? norm.suffix.slice(qi) : '';
|
|
179
|
+
const hosts = (norm.hosts ?? []).map(joinHostSeg).join(',');
|
|
180
|
+
return { ...cfg, connectionString: `${norm.prefix}${hosts}/${encodeURIComponent(db)}${tail}` };
|
|
181
|
+
}
|
|
182
|
+
export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
183
|
+
const { cfg, url: finalUrl } = await pgLikePoolCfg(kind, Driver, conn, 10);
|
|
148
184
|
const pool = new Driver.Pool(cfg);
|
|
149
185
|
const readOnly = opts?.mode === 'ro';
|
|
150
186
|
// 所有池(主池 + 跨库池)统一走此初始化。
|
|
@@ -166,6 +202,10 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
166
202
|
setupPool(pool);
|
|
167
203
|
// 事务激活期间所有 query/execute 路由到同一 client
|
|
168
204
|
let txClient = null;
|
|
205
|
+
/** close 幂等标志:pg-pool 的 end() 二次调用会抛 "Called end on pool more than once",
|
|
206
|
+
* 而 manager 的 console 会话在授权复核失败路径会重复关闭同一适配器(见 manager 的
|
|
207
|
+
* finish('rollback') 与外层 catch)——重复 close 须为 no-op。 */
|
|
208
|
+
let closed = false;
|
|
169
209
|
const run = (sql, params) => txClient ? txClient.query(sql, params) : pool.query(sql, params);
|
|
170
210
|
// 跨库浏览(Navicat 行为):树列出服务器上所有库,展开非连接库时用同一凭据开指向该库的池。
|
|
171
211
|
// 池按库缓存复用;ro 模式下新池同样套会话只读。连接自身的库(fields.database 或 url 库名)直接复用主池。
|
|
@@ -189,8 +229,10 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
189
229
|
const cached = dbPools.get(db);
|
|
190
230
|
if (cached)
|
|
191
231
|
return cached;
|
|
192
|
-
// 与主池同一份最终 cfg(多主机探测选定台后的 connectionString
|
|
193
|
-
|
|
232
|
+
// 与主池同一份最终 cfg(多主机探测选定台后的 connectionString),仅覆盖库名——
|
|
233
|
+
// URL 形态经 pgCfgWithDatabase 改写 path(叠加 database 字段会被驱动里 URL
|
|
234
|
+
// 库名覆盖,静默连回原库)
|
|
235
|
+
const created = new Driver.Pool(pgCfgWithDatabase(cfg, db));
|
|
194
236
|
setupPool(created); // 与主池一致:error 兜底 + ro 会话只读
|
|
195
237
|
dbPools.set(db, created);
|
|
196
238
|
return created;
|
|
@@ -349,6 +391,9 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
349
391
|
return toQueryResult(res);
|
|
350
392
|
}),
|
|
351
393
|
close: () => humanize(`${kind} 关闭连接`, async () => {
|
|
394
|
+
if (closed)
|
|
395
|
+
return; // 幂等:重复 close 为 no-op(见 closed 声明处注释)
|
|
396
|
+
closed = true;
|
|
352
397
|
await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
|
|
353
398
|
}),
|
|
354
399
|
};
|