dsh-db-tool 0.1.4 → 0.1.6

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 CHANGED
@@ -68,6 +68,7 @@ window.__ModuleLoader__.load({
68
68
  noDatabases: "无可用数据库",
69
69
  noSchemas: "无模式",
70
70
  noTables: "无表",
71
+ defaultDb: "默认库",
71
72
  loadFailed: "加载失败",
72
73
  structure: "结构",
73
74
  preview: "数据预览",
@@ -153,6 +154,7 @@ window.__ModuleLoader__.load({
153
154
  noDatabases: "No databases available",
154
155
  noSchemas: "No schemas",
155
156
  noTables: "No tables",
157
+ defaultDb: "Default DB",
156
158
  loadFailed: "Failed to load",
157
159
  structure: "Structure",
158
160
  preview: "Preview",
@@ -445,6 +447,20 @@ window.__ModuleLoader__.load({
445
447
  /** 新建表单初始态:分字段模式 + 当前 kind 的官方默认值 */
446
448
  const freshForm = () => withKindDefaults({ ...EMPTY_FORM, mode: "fields" });
447
449
 
450
+ /** 编辑回填:从 meta 还原已存配置(密码不出库,留空=保留原密码)。
451
+ * url 方式 → 回填脱敏 url;分字段方式 → 回填 host/port/user/database。 */
452
+ function buildEditForm(c) {
453
+ const mode = c.mode || (c.safeUrl ? "url" : "fields");
454
+ return {
455
+ ...EMPTY_FORM,
456
+ id: c.id, kind: c.kind, name: c.name || "", mode,
457
+ url: c.safeUrl || "",
458
+ host: c.host || "", port: c.port != null ? String(c.port) : "",
459
+ user: c.user || "", database: c.database || "",
460
+ ssl: !!c.ssl,
461
+ };
462
+ }
463
+
448
464
  function ConnForm(props) {
449
465
  // 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
450
466
  const [form, setForm] = React.useState(() => props.initial || freshForm());
@@ -459,18 +475,29 @@ window.__ModuleLoader__.load({
459
475
  setDirty(new Set());
460
476
  setDraftTest(null);
461
477
  }
462
- /** 从 URL 模式切回分字段:按当前 kind 重新补全官方默认值(未手改字段) */
478
+ /** 从 URL 模式切回分字段:保留表单里已有值(编辑回填场景),只对空字段补官方默认值 */
463
479
  function reenterFields() {
464
- setForm((prev) => withKindDefaults(Object.assign({}, prev, { mode: "fields" }), dirty));
480
+ setForm((prev) => {
481
+ const keep = new Set(dirty);
482
+ for (const k of ["host", "port", "user", "database"]) if (prev[k]) keep.add(k);
483
+ return withKindDefaults(Object.assign({}, prev, { mode: "fields" }), keep);
484
+ });
465
485
  setDraftTest(null);
466
486
  }
467
487
  function draftBody() {
468
488
  const body = { kind: form.kind, ssl: !!form.ssl };
469
- if (form.mode === "url") body.url = form.url;
470
- else {
489
+ // 编辑保存语义:url 模式未改动(仍等于回填的脱敏 url)→ 不发 url,保留 secrets 原值
490
+ const origUrl = props.initial && props.initial.mode === "url" ? props.initial.url : undefined;
491
+ if (form.mode === "url") {
492
+ if (form.url && form.url !== origUrl) body.url = form.url;
493
+ // 新建 url 连接必须发
494
+ if (!props.initial && form.url) body.url = form.url;
495
+ } else {
471
496
  body.fields = { host: form.host, user: form.user, database: form.database || undefined };
472
497
  if (form.port) body.fields.port = Number(form.port);
473
498
  if (form.password) body.fields.password = form.password;
499
+ // 从 url 方式切到分字段保存 → 服务端清除已存 url(否则连接仍走旧 url)
500
+ if (props.initial && props.initial.mode === "url") body.clearUrl = true;
474
501
  }
475
502
  return body;
476
503
  }
@@ -583,7 +610,7 @@ window.__ModuleLoader__.load({
583
610
 
584
611
  if (editing) {
585
612
  return React.createElement(ConnForm, {
586
- initial: editing === "new" ? null : Object.assign({}, EMPTY_FORM, editing),
613
+ initial: editing === "new" ? null : buildEditForm(editing),
587
614
  busy: busy === "save",
588
615
  onSubmit: saveConn,
589
616
  onCancel: () => setEditing(null),
@@ -761,20 +788,23 @@ window.__ModuleLoader__.load({
761
788
  }
762
789
  function toggleSchema(c, d, s) {
763
790
  if (!projectPath) return;
791
+ // PG 系跨库浏览:tables 的 database 传 "库名.schema"(Navicat 官方行为,服务端按库开连接)
764
792
  toggle("s:" + c.id + "/" + d + "/" + s, () =>
765
- api("tables" + qs({ project: projectPath, connId: c.id, database: s })).then((list) => {
766
- setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + s]: list || [] }));
793
+ api("tables" + qs({ project: projectPath, connId: c.id, database: d + "." + s })).then((list) => {
794
+ setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
767
795
  }));
768
796
  }
769
797
  // 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
770
798
  const openSeq = React.useRef(0);
771
799
  const openTable = React.useCallback((s, pg) => {
772
800
  if (!s || !projectPath) return;
801
+ // PG 系跨库:database 传 "库名.schema";其它库传库名
802
+ const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
773
803
  const seq = ++openSeq.current;
774
804
  setBusy("open");
775
805
  Promise.all([
776
- api("schema" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name })),
777
- api("preview" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
806
+ api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
807
+ api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
778
808
  ])
779
809
  .then(([sch, prev]) => {
780
810
  if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
@@ -827,16 +857,16 @@ window.__ModuleLoader__.load({
827
857
  const sOpen = !!open[sk];
828
858
  treeRows.push(treerow(sk, 2, sOpen, false, s, () => toggleSchema(c, d, s)));
829
859
  if (!sOpen) continue;
830
- const tlist = tablesMap[c.id + "/" + s];
860
+ const tlist = tablesMap[c.id + "/" + d + "/" + s];
831
861
  if (loading[sk]) { treeRows.push(treerow(sk + ":l", 3, false, true, "…")); continue; }
832
862
  if (error[sk]) { treeRows.push(treerow(sk + ":x", 3, false, true, t("loadFailed") + ":" + error[sk], () => retry(sk, () => toggleSchema(c, d, s)))); continue; }
833
863
  if (!tlist) continue;
834
864
  if (tlist.length === 0) { treeRows.push(treerow(sk + ":e", 3, false, true, t("noTables"))); continue; }
835
865
  for (const tb of tlist) {
836
- const active = !!sel && sel.connId === c.id && sel.db === s && sel.table.name === tb.name;
866
+ const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
837
867
  treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
838
868
  tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
839
- () => setSel({ connId: c.id, db: s, table: tb }), active));
869
+ () => setSel({ connId: c.id, db: d, schemaName: s, table: tb }), active));
840
870
  }
841
871
  }
842
872
  continue;
@@ -871,7 +901,7 @@ window.__ModuleLoader__.load({
871
901
  ["structure", "preview"].map((v) =>
872
902
  React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
873
903
  ),
874
- React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + " / " + sel.table.name),
904
+ React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + (sel.schemaName ? "." + sel.schemaName : "") + " / " + sel.table.name),
875
905
  view === "structure"
876
906
  ? resultTable(
877
907
  [t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
@@ -906,6 +936,14 @@ window.__ModuleLoader__.load({
906
936
  const [params, setParams] = React.useState("");
907
937
  const [result, setResult] = React.useState(null); // {kind:'query',...}|{kind:'exec',...}
908
938
  const [busy, setBusy] = React.useState(false);
939
+ // Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
940
+ const [dbList, setDbList] = React.useState([]);
941
+ const [db, setDb] = React.useState("");
942
+ React.useEffect(() => {
943
+ setDbList([]); setDb("");
944
+ if (!connId || !projectPath) return;
945
+ api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
946
+ }, [connId, projectPath]);
909
947
 
910
948
  async function run() {
911
949
  setBusy(true);
@@ -921,7 +959,7 @@ window.__ModuleLoader__.load({
921
959
  let parsedParams;
922
960
  if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
923
961
  const data = await runGuarded(
924
- (challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, challengeId } }),
962
+ (challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, database: db || undefined, challengeId } }),
925
963
  askConfirm,
926
964
  );
927
965
  setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
@@ -929,7 +967,7 @@ window.__ModuleLoader__.load({
929
967
  let parsedParams;
930
968
  if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
931
969
  const data = await runGuarded(
932
- (challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, challengeId } }),
970
+ (challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, database: db || undefined, challengeId } }),
933
971
  askConfirm,
934
972
  );
935
973
  setResult({ kind: "query", data });
@@ -968,6 +1006,10 @@ window.__ModuleLoader__.load({
968
1006
  React.createElement("select", { value: connId, onChange: (e) => setConnId(e.target.value) },
969
1007
  React.createElement("option", { value: "" }, t("viewManage") + "…"),
970
1008
  conns.map((c) => React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + ")"))),
1009
+ // 当前库(Navicat 式跨库:选中非默认库后 SQL 在该库执行,pg/gaussdb 按库路由)
1010
+ dbList.length > 1 ? React.createElement("select", { value: db, onChange: (e) => setDb(e.target.value) },
1011
+ React.createElement("option", { value: "" }, t("defaultDb")),
1012
+ dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
971
1013
  ),
972
1014
  React.createElement(
973
1015
  "div",
@@ -28,25 +28,64 @@ function poolConfig(conn) {
28
28
  export async function createPgLikeAdapter(kind, Driver, conn, opts) {
29
29
  const pool = new Driver.Pool(poolConfig(conn));
30
30
  const readOnly = opts?.mode === 'ro';
31
- if (readOnly) {
32
- // 服务器级 ro 强制(官方手段):每个新连接会话设为只读事务。
33
- // fail-closed:SET 失败时 release(err) 让驱动销毁该连接、等待的 acquire 收到错误——
34
- // 会话级只读是 ro 的最后防线,不允许静默降级成可写连接。
35
- pool.on('connect', (c) => {
31
+ // 服务器级 ro 强制(官方手段):每个新连接会话设为只读事务。
32
+ // fail-closed:SET 失败时 release(err) 让驱动销毁该连接、等待的 acquire 收到错误——
33
+ // 会话级只读是 ro 的最后防线,不允许静默降级成可写连接。
34
+ const setupReadOnly = (p) => {
35
+ p.on('connect', (c) => {
36
36
  void c.query('SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY').catch((err) => {
37
37
  c.release(err instanceof Error ? err : new Error(String(err)));
38
38
  });
39
39
  });
40
40
  // pg 约定:release(err) 若无等待者会 emit pool 'error',不监听会崩进程
41
- pool.on('error', () => { });
42
- }
41
+ p.on('error', () => { });
42
+ };
43
+ if (readOnly)
44
+ setupReadOnly(pool);
43
45
  // 事务激活期间所有 query/execute 路由到同一 client
44
46
  let txClient = null;
45
47
  const run = (sql, params) => txClient ? txClient.query(sql, params) : pool.query(sql, params);
48
+ // 跨库浏览(Navicat 行为):树列出服务器上所有库,展开非连接库时用同一凭据开指向该库的池。
49
+ // 池按库缓存复用;ro 模式下新池同样套会话只读。连接自身的库(fields.database 或 url 库名)直接复用主池。
50
+ const dbPools = new Map();
51
+ const mainDb = () => {
52
+ if (conn.fields)
53
+ return conn.fields.database ? String(conn.fields.database) : null;
54
+ if (!conn.url)
55
+ return null;
56
+ try {
57
+ return decodeURIComponent(new URL(conn.url).pathname.replace(/^\//, '')) || null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ };
63
+ const poolFor = (db) => {
64
+ if (!db || db === mainDb())
65
+ return pool;
66
+ const cached = dbPools.get(db);
67
+ if (cached)
68
+ return cached;
69
+ const created = new Driver.Pool({ ...poolConfig(conn), database: db });
70
+ if (readOnly)
71
+ setupReadOnly(created);
72
+ dbPools.set(db, created);
73
+ return created;
74
+ };
46
75
  const defaultSchema = () => {
47
76
  const f = conn.fields;
48
77
  return f && typeof f.schema === 'string' && f.schema ? f.schema : 'public';
49
78
  };
79
+ /** 浏览目标解析:"db.schema"(跨库)| "schema"(当前库)| 空(当前库默认 schema)。
80
+ * PG 标识符不允许裸点,按第一个点切分安全;多余点由 assertIdent 拒绝。 */
81
+ const parseBrowseTarget = (ref, what) => {
82
+ if (!ref)
83
+ return { db: null, schema: defaultSchema() };
84
+ const i = ref.indexOf('.');
85
+ if (i < 0)
86
+ return { db: null, schema: assertIdent(ref, what) };
87
+ return { db: assertIdent(ref.slice(0, i), '数据库'), schema: assertIdent(ref.slice(i + 1), what) };
88
+ };
50
89
  function toQueryResult(res) {
51
90
  let columns = (res.fields ?? []).map((f) => f.name);
52
91
  if (columns.length === 0 && res.rows.length > 0) {
@@ -103,9 +142,17 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
103
142
  const v = res.rows[0]?.v;
104
143
  return { ok: true, serverInfo: v == null ? kind : String(v) };
105
144
  }),
106
- query: (sql, params) => humanize(`${kind} 查询失败`, async () => toQueryResult(await run(sql, params ?? []))),
107
- execute: (statement, params) => humanize(`${kind} 执行失败`, async () => {
108
- const res = await run(statement, params ?? []);
145
+ query: (sql, params, database) => humanize(`${kind} 查询失败`, async () => {
146
+ // 跨库操控(Navicat 式):指定目标库 → 路由到该库的池(主池事务不跨库,忽略事务路由)
147
+ const res = database
148
+ ? await poolFor(assertIdent(database, '数据库')).query(sql, params ?? [])
149
+ : await run(sql, params ?? []);
150
+ return toQueryResult(res);
151
+ }),
152
+ execute: (statement, params, database) => humanize(`${kind} 执行失败`, async () => {
153
+ const res = database
154
+ ? await poolFor(assertIdent(database, '数据库')).query(statement, params ?? [])
155
+ : await run(statement, params ?? []);
109
156
  const n = res.rowCount;
110
157
  return {
111
158
  affectedRows: typeof n === 'number' ? n : undefined,
@@ -117,37 +164,40 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
117
164
  return res.rows.map((r) => String(r.datname));
118
165
  }),
119
166
  // 库内 schema 清单(Navicat 官方层级:数据库 → 模式 → 表)。
120
- // PG 连接固定单库,database 参数无法跨库,忽略;pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
121
- listSchemas: () => humanize(`${kind} 列出模式`, async () => {
122
- const res = await pool.query(`SELECT nspname FROM pg_catalog.pg_namespace
167
+ // database 参数 = 库名(UI 树第一层);pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
168
+ listSchemas: (database) => humanize(`${kind} 列出模式`, async () => {
169
+ const p = poolFor(database ? assertIdent(database, '数据库') : null);
170
+ const res = await p.query(`SELECT nspname FROM pg_catalog.pg_namespace
123
171
  WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
124
172
  ORDER BY nspname`);
125
173
  return res.rows.map((r) => String(r.nspname));
126
174
  }),
175
+ // database 参数 = "库名.schema"(跨库浏览)或 "schema"(当前库)
127
176
  listTables: (database) => humanize(`${kind} 列出表`, async () => {
128
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
129
- const res = await pool.query(`SELECT table_name, table_type FROM information_schema.tables
177
+ const { db, schema } = parseBrowseTarget(database, 'schema');
178
+ const res = await poolFor(db).query(`SELECT table_name, table_type FROM information_schema.tables
130
179
  WHERE table_schema = $1 ORDER BY table_name`, [schema]);
131
180
  return res.rows.map((r) => {
132
181
  const rawType = String(r.table_type);
133
182
  return {
134
183
  name: String(r.table_name),
135
184
  type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
136
- database: schema,
185
+ database: db ? `${db}.${schema}` : schema,
137
186
  };
138
187
  });
139
188
  }),
140
189
  describeTable: (table, database) => humanize(`${kind} 查看表结构`, async () => {
141
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
190
+ const { db, schema } = parseBrowseTarget(database, 'schema');
142
191
  const tbl = assertIdent(table, '表名');
143
- const res = await pool.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
192
+ const p = poolFor(db);
193
+ const res = await p.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
144
194
  col_description((quote_ident($1) || '.' || quote_ident($2))::regclass, c.ordinal_position) AS col_comment
145
195
  FROM information_schema.columns c
146
196
  WHERE c.table_schema = $1 AND c.table_name = $2
147
197
  ORDER BY c.ordinal_position`, [schema, tbl]);
148
198
  if (res.rows.length === 0)
149
- throw new Error(`表不存在: ${schema}.${tbl}`);
150
- const pks = await pool.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
199
+ throw new Error(`表不存在: ${db ? db + '.' : ''}${schema}.${tbl}`);
200
+ const pks = await p.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
151
201
  JOIN information_schema.key_column_usage kcu
152
202
  ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
153
203
  WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`, [schema, tbl]);
@@ -166,13 +216,17 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
166
216
  });
167
217
  }),
168
218
  previewRows: (table, limit, database, offset) => humanize(`${kind} 预览行`, async () => {
169
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
219
+ const { db, schema } = parseBrowseTarget(database, 'schema');
170
220
  const tbl = assertIdent(table, '表名');
171
221
  const lim = clampLimit(limit);
172
222
  const off = clampOffset(offset);
173
- const res = await run(`SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`, [lim, off]);
223
+ const sql = `SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`;
224
+ // 当前库保持事务路由(run);跨库必须用该库自己的池
225
+ const res = db ? await poolFor(db).query(sql, [lim, off]) : await run(sql, [lim, off]);
174
226
  return toQueryResult(res);
175
227
  }),
176
- close: () => humanize(`${kind} 关闭连接`, () => pool.end()),
228
+ close: () => humanize(`${kind} 关闭连接`, async () => {
229
+ await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
230
+ }),
177
231
  };
178
232
  }
@@ -177,6 +177,7 @@ async function route(req, res, service, path, q, opts) {
177
177
  ...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
178
178
  ...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
179
179
  ...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
180
+ ...(b['clearUrl'] === true ? { clearUrl: true } : {}),
180
181
  ...(ssl !== undefined ? { ssl } : {}),
181
182
  }));
182
183
  }
@@ -222,11 +223,11 @@ async function route(req, res, service, path, q, opts) {
222
223
  }
223
224
  if (path === '/api/query' && req.method === 'POST') {
224
225
  const b = await readBody(req);
225
- return await sendMaybeConfirm(res, service.query(projectOfBody(b), str(b['connId']), str(b['sql']), b['params'], optStr(b['challengeId'])));
226
+ return await sendMaybeConfirm(res, service.query(projectOfBody(b), str(b['connId']), str(b['sql']), b['params'], optStr(b['challengeId']), optStr(b['database'])));
226
227
  }
227
228
  if (path === '/api/execute' && req.method === 'POST') {
228
229
  const b = await readBody(req);
229
- return await sendMaybeConfirm(res, service.execute(projectOfBody(b), str(b['connId']), str(b['statement']), b['params'], optStr(b['challengeId'])));
230
+ return await sendMaybeConfirm(res, service.execute(projectOfBody(b), str(b['connId']), str(b['statement']), b['params'], optStr(b['challengeId']), optStr(b['database'])));
230
231
  }
231
232
  if (path === '/api/script' && req.method === 'POST') {
232
233
  const b = await readBody(req);
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ const TOOL_DESCRIPTION = `数据库管理工具(dsh-db-tool):在已配置
25
25
  action 说明:
26
26
  - list_connections:列出连接(脱敏,含各连接的 kind 与授权信息需另经 HTTP 面板查看)。
27
27
  - query:只读 SQL/命令(conn_id, sql, params?)。
28
- - execute:写操作/DDL(conn_id, statement, params?)。
28
+ - execute:写操作/DDL(conn_id, statement, params?, database? 跨库目标)。
29
29
  - schema:结构浏览(conn_id;给 table 查列、给 database 查表、都不给列库)。
30
30
  - preview:预览行,limit≤50(conn_id, table, database?, limit?)。
31
31
  - run_script:子进程沙箱脚本(conn_id, code),60s 超时强杀,permission model 禁文件
@@ -84,7 +84,7 @@ export function apply(ctx) {
84
84
  sql: { type: 'string', description: 'query 的只读 SQL/命令' },
85
85
  statement: { type: 'string', description: 'execute 的语句' },
86
86
  params: { type: 'array', items: {}, description: '占位符参数' },
87
- database: { type: 'string', description: '库/schema(可选)' },
87
+ database: { type: 'string', description: '库/schema(可选)。query/execute:指定目标库即跨库执行(Navicat 式,pg/gaussdb 支持按库路由);schema/preview:浏览目标(pg/gaussdb 传 "库名.schema")' },
88
88
  table: { type: 'string', description: '表/集合/键(schema/preview 用)' },
89
89
  limit: { type: 'number', description: 'preview 行数上限(≤50)' },
90
90
  offset: { type: 'number', description: 'preview 行偏移(翻页用,默认 0)' },
package/dist/manager.js CHANGED
@@ -190,7 +190,7 @@ export class DbToolService {
190
190
  }
191
191
  }
192
192
  /* -- SQL 控制台(guard + challenge) -- */
193
- async query(projectPath, connId, sql, params, challengeId) {
193
+ async query(projectPath, connId, sql, params, challengeId, database) {
194
194
  const statement = assertNonEmpty(sql, 'sql');
195
195
  return this.runGuarded({
196
196
  projectPath,
@@ -198,11 +198,11 @@ export class DbToolService {
198
198
  op: 'query',
199
199
  statement,
200
200
  challengeId,
201
- run: (adapter) => adapter.query(statement, params),
201
+ run: (adapter) => adapter.query(statement, params, database),
202
202
  rowsAffected: (r) => r.rowCount,
203
203
  });
204
204
  }
205
- async execute(projectPath, connId, statement, params, challengeId) {
205
+ async execute(projectPath, connId, statement, params, challengeId, database) {
206
206
  const stmt = assertNonEmpty(statement, 'statement');
207
207
  return this.runGuarded({
208
208
  projectPath,
@@ -210,7 +210,7 @@ export class DbToolService {
210
210
  op: 'execute',
211
211
  statement: stmt,
212
212
  challengeId,
213
- run: (adapter) => adapter.execute(stmt, params),
213
+ run: (adapter) => adapter.execute(stmt, params, database),
214
214
  rowsAffected: (r) => r.affectedRows,
215
215
  });
216
216
  }
@@ -429,11 +429,11 @@ export async function handleToolAction(service, args, projectPath) {
429
429
  case 'list_connections':
430
430
  return ok(j(service.listConnections()));
431
431
  case 'query': {
432
- const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId);
432
+ const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId, args.database);
433
433
  return ok(needConfirmText(r) ?? j(r));
434
434
  }
435
435
  case 'execute': {
436
- const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId);
436
+ const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId, args.database);
437
437
  return ok(needConfirmText(r) ?? j(r));
438
438
  }
439
439
  case 'schema': {
@@ -40,14 +40,21 @@ function toMeta(rec) {
40
40
  const meta = { id: rec.id, kind: rec.kind };
41
41
  if (rec.name !== undefined)
42
42
  meta.name = rec.name;
43
- if (rec.urlSafe !== undefined)
43
+ if (rec.urlSafe !== undefined) {
44
44
  meta.safeUrl = rec.urlSafe;
45
+ meta.mode = 'url';
46
+ }
47
+ else {
48
+ meta.mode = 'fields';
49
+ }
45
50
  if (rec.fields) {
46
- const { host, port, database } = rec.fields;
51
+ const { host, port, user, database } = rec.fields;
47
52
  if (typeof host === 'string')
48
53
  meta.host = host;
49
54
  if (typeof port === 'number')
50
55
  meta.port = port;
56
+ if (typeof user === 'string')
57
+ meta.user = user;
51
58
  if (typeof database === 'string')
52
59
  meta.database = database;
53
60
  }
@@ -125,6 +132,11 @@ export class ConnectionStore {
125
132
  rec.urlSafe = redactUrl(patch.url);
126
133
  this.secrets.set(id, { url: patch.url });
127
134
  }
135
+ if (patch.clearUrl && rec.urlSafe !== undefined) {
136
+ delete rec.urlSafe;
137
+ // set 为合并写;显式置 undefined 经 JSON 序列化后等效删除该键
138
+ this.secrets.set(id, { url: undefined });
139
+ }
128
140
  this.save(data);
129
141
  return toMeta(rec);
130
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@ DatabaseManager({ action: "query", conn_id: "c1", sql: "SELECT id, name FROM use
33
33
  ```
34
34
 
35
35
  - `sql` 必填;`params` 为参数数组(占位符见方言速查);可选 `challenge_id`(危险读如 Redis `KEYS *` 触发确认后重试用)。
36
+ - 可选 `database`:跨库执行目标(Navicat 式)。pg/gaussdb 传库名(如 `gycwd`)即在该库上执行(同一凭据开独立连接,ro 授权下同样强制只读);其余库种忽略此参数(MySQL 可直接用 `库名.表` 语法)。
36
37
  - 返回 `{columns, rows, rowCount, truncated?}`,所有单元格已规范化为 string/number/null。
37
38
  - ro 与 rw 授权下都可用,但只读接口收到非查询语句会被拒。
38
39
 
@@ -43,6 +44,7 @@ DatabaseManager({ action: "execute", conn_id: "c1", statement: "UPDATE users SET
43
44
  ```
44
45
 
45
46
  - `statement`(或 `sql`)必填,单条语句;DDL/DML/维护命令都会触发危险确认。
47
+ - 可选 `database`:跨库执行目标,语义同 query(pg/gaussdb 支持按库路由,目标库权限由数据库侧用户权限控制)。
46
48
  - 返回 `{affectedRows?, message}`,`message` 为中文结果描述。
47
49
  - ro 授权下直接返回 `READ_ONLY` 错误。
48
50
 
@@ -57,7 +59,7 @@ DatabaseManager({ action: "schema", conn_id: "c1", database: "shop", table: "use
57
59
  ```
58
60
 
59
61
  - 表列表返回 `TableInfo[]`:`{name, type?, comment?, database?}`(Redis 的 type 为 hash|list|set|zset|string|stream)。
60
- - PostgreSQL/GaussDB 为「数据库 → 模式(schema) → 表」三层语义:`database` 参数此时应传 **schema 名**(如 `public`);不确定时先执行 `SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'` 列出可用 schema。
62
+ - PostgreSQL/GaussDB 为「数据库 → 模式(schema) → 表」三层语义,支持跨库浏览(Navicat 行为):`database` 参数传 `"库名.schema"` 即可浏览连接库之外的库(如 `gycwd.public`,服务端自动用同一凭据开指向该库的连接);只传 schema 名(如 `public`)则查连接自身库。`listSchemas(database)` 的参数是**纯库名**。不确定 schema 时先执行 `SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'`。
61
63
  - 列结构返回 `ColumnInfo[]`:`{name, dataType, nullable, key?, default?, comment?}`。
62
64
 
63
65
  ### 5. preview — 预览表数据