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 +57 -15
- package/dist/adapters/sql-shared/pg-like.js +77 -23
- package/dist/http/index.js +3 -2
- package/dist/index.js +2 -2
- package/dist/manager.js +6 -6
- package/dist/store/connections.js +14 -2
- package/package.json +1 -1
- package/skills/db-admin/SKILL.md +3 -1
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
|
|
478
|
+
/** 从 URL 模式切回分字段:保留表单里已有值(编辑回填场景),只对空字段补官方默认值 */
|
|
463
479
|
function reenterFields() {
|
|
464
|
-
setForm((prev) =>
|
|
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
|
-
|
|
470
|
-
|
|
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 :
|
|
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:
|
|
777
|
-
api("preview" + qs({ project: projectPath, connId: s.connId, database:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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 () =>
|
|
107
|
-
|
|
108
|
-
const res =
|
|
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
|
-
//
|
|
121
|
-
listSchemas: () => humanize(`${kind} 列出模式`, async () => {
|
|
122
|
-
const
|
|
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 =
|
|
129
|
-
const res = await
|
|
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 =
|
|
190
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
142
191
|
const tbl = assertIdent(table, '表名');
|
|
143
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
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} 关闭连接`, () =>
|
|
228
|
+
close: () => humanize(`${kind} 关闭连接`, async () => {
|
|
229
|
+
await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
|
|
230
|
+
}),
|
|
177
231
|
};
|
|
178
232
|
}
|
package/dist/http/index.js
CHANGED
|
@@ -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.
|
|
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",
|
package/skills/db-admin/SKILL.md
CHANGED
|
@@ -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) →
|
|
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 — 预览表数据
|