dsh-db-tool 0.1.4 → 0.1.5
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 +11 -8
- package/dist/adapters/sql-shared/pg-like.js +66 -20
- package/package.json +1 -1
- package/skills/db-admin/SKILL.md +1 -1
package/client/client.js
CHANGED
|
@@ -761,20 +761,23 @@ window.__ModuleLoader__.load({
|
|
|
761
761
|
}
|
|
762
762
|
function toggleSchema(c, d, s) {
|
|
763
763
|
if (!projectPath) return;
|
|
764
|
+
// PG 系跨库浏览:tables 的 database 传 "库名.schema"(Navicat 官方行为,服务端按库开连接)
|
|
764
765
|
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 || [] }));
|
|
766
|
+
api("tables" + qs({ project: projectPath, connId: c.id, database: d + "." + s })).then((list) => {
|
|
767
|
+
setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
|
|
767
768
|
}));
|
|
768
769
|
}
|
|
769
770
|
// 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
|
|
770
771
|
const openSeq = React.useRef(0);
|
|
771
772
|
const openTable = React.useCallback((s, pg) => {
|
|
772
773
|
if (!s || !projectPath) return;
|
|
774
|
+
// PG 系跨库:database 传 "库名.schema";其它库传库名
|
|
775
|
+
const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
|
|
773
776
|
const seq = ++openSeq.current;
|
|
774
777
|
setBusy("open");
|
|
775
778
|
Promise.all([
|
|
776
|
-
api("schema" + qs({ project: projectPath, connId: s.connId, database:
|
|
777
|
-
api("preview" + qs({ project: projectPath, connId: s.connId, database:
|
|
779
|
+
api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
|
|
780
|
+
api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
|
|
778
781
|
])
|
|
779
782
|
.then(([sch, prev]) => {
|
|
780
783
|
if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
|
|
@@ -827,16 +830,16 @@ window.__ModuleLoader__.load({
|
|
|
827
830
|
const sOpen = !!open[sk];
|
|
828
831
|
treeRows.push(treerow(sk, 2, sOpen, false, s, () => toggleSchema(c, d, s)));
|
|
829
832
|
if (!sOpen) continue;
|
|
830
|
-
const tlist = tablesMap[c.id + "/" + s];
|
|
833
|
+
const tlist = tablesMap[c.id + "/" + d + "/" + s];
|
|
831
834
|
if (loading[sk]) { treeRows.push(treerow(sk + ":l", 3, false, true, "…")); continue; }
|
|
832
835
|
if (error[sk]) { treeRows.push(treerow(sk + ":x", 3, false, true, t("loadFailed") + ":" + error[sk], () => retry(sk, () => toggleSchema(c, d, s)))); continue; }
|
|
833
836
|
if (!tlist) continue;
|
|
834
837
|
if (tlist.length === 0) { treeRows.push(treerow(sk + ":e", 3, false, true, t("noTables"))); continue; }
|
|
835
838
|
for (const tb of tlist) {
|
|
836
|
-
const active = !!sel && sel.connId === c.id && sel.db === s && sel.table.name === tb.name;
|
|
839
|
+
const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
|
|
837
840
|
treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
|
|
838
841
|
tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
|
|
839
|
-
() => setSel({ connId: c.id, db: s, table: tb }), active));
|
|
842
|
+
() => setSel({ connId: c.id, db: d, schemaName: s, table: tb }), active));
|
|
840
843
|
}
|
|
841
844
|
}
|
|
842
845
|
continue;
|
|
@@ -871,7 +874,7 @@ window.__ModuleLoader__.load({
|
|
|
871
874
|
["structure", "preview"].map((v) =>
|
|
872
875
|
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
|
|
873
876
|
),
|
|
874
|
-
React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + " / " + sel.table.name),
|
|
877
|
+
React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + (sel.schemaName ? "." + sel.schemaName : "") + " / " + sel.table.name),
|
|
875
878
|
view === "structure"
|
|
876
879
|
? resultTable(
|
|
877
880
|
[t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
|
|
@@ -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) {
|
|
@@ -117,37 +156,40 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
117
156
|
return res.rows.map((r) => String(r.datname));
|
|
118
157
|
}),
|
|
119
158
|
// 库内 schema 清单(Navicat 官方层级:数据库 → 模式 → 表)。
|
|
120
|
-
//
|
|
121
|
-
listSchemas: () => humanize(`${kind} 列出模式`, async () => {
|
|
122
|
-
const
|
|
159
|
+
// database 参数 = 库名(UI 树第一层);pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
|
|
160
|
+
listSchemas: (database) => humanize(`${kind} 列出模式`, async () => {
|
|
161
|
+
const p = poolFor(database ? assertIdent(database, '数据库') : null);
|
|
162
|
+
const res = await p.query(`SELECT nspname FROM pg_catalog.pg_namespace
|
|
123
163
|
WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
|
|
124
164
|
ORDER BY nspname`);
|
|
125
165
|
return res.rows.map((r) => String(r.nspname));
|
|
126
166
|
}),
|
|
167
|
+
// database 参数 = "库名.schema"(跨库浏览)或 "schema"(当前库)
|
|
127
168
|
listTables: (database) => humanize(`${kind} 列出表`, async () => {
|
|
128
|
-
const schema =
|
|
129
|
-
const res = await
|
|
169
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
170
|
+
const res = await poolFor(db).query(`SELECT table_name, table_type FROM information_schema.tables
|
|
130
171
|
WHERE table_schema = $1 ORDER BY table_name`, [schema]);
|
|
131
172
|
return res.rows.map((r) => {
|
|
132
173
|
const rawType = String(r.table_type);
|
|
133
174
|
return {
|
|
134
175
|
name: String(r.table_name),
|
|
135
176
|
type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
|
|
136
|
-
database: schema,
|
|
177
|
+
database: db ? `${db}.${schema}` : schema,
|
|
137
178
|
};
|
|
138
179
|
});
|
|
139
180
|
}),
|
|
140
181
|
describeTable: (table, database) => humanize(`${kind} 查看表结构`, async () => {
|
|
141
|
-
const schema =
|
|
182
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
142
183
|
const tbl = assertIdent(table, '表名');
|
|
143
|
-
const
|
|
184
|
+
const p = poolFor(db);
|
|
185
|
+
const res = await p.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
|
|
144
186
|
col_description((quote_ident($1) || '.' || quote_ident($2))::regclass, c.ordinal_position) AS col_comment
|
|
145
187
|
FROM information_schema.columns c
|
|
146
188
|
WHERE c.table_schema = $1 AND c.table_name = $2
|
|
147
189
|
ORDER BY c.ordinal_position`, [schema, tbl]);
|
|
148
190
|
if (res.rows.length === 0)
|
|
149
|
-
throw new Error(`表不存在: ${schema}.${tbl}`);
|
|
150
|
-
const pks = await
|
|
191
|
+
throw new Error(`表不存在: ${db ? db + '.' : ''}${schema}.${tbl}`);
|
|
192
|
+
const pks = await p.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
|
|
151
193
|
JOIN information_schema.key_column_usage kcu
|
|
152
194
|
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
153
195
|
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`, [schema, tbl]);
|
|
@@ -166,13 +208,17 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
166
208
|
});
|
|
167
209
|
}),
|
|
168
210
|
previewRows: (table, limit, database, offset) => humanize(`${kind} 预览行`, async () => {
|
|
169
|
-
const schema =
|
|
211
|
+
const { db, schema } = parseBrowseTarget(database, 'schema');
|
|
170
212
|
const tbl = assertIdent(table, '表名');
|
|
171
213
|
const lim = clampLimit(limit);
|
|
172
214
|
const off = clampOffset(offset);
|
|
173
|
-
const
|
|
215
|
+
const sql = `SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`;
|
|
216
|
+
// 当前库保持事务路由(run);跨库必须用该库自己的池
|
|
217
|
+
const res = db ? await poolFor(db).query(sql, [lim, off]) : await run(sql, [lim, off]);
|
|
174
218
|
return toQueryResult(res);
|
|
175
219
|
}),
|
|
176
|
-
close: () => humanize(`${kind} 关闭连接`, () =>
|
|
220
|
+
close: () => humanize(`${kind} 关闭连接`, async () => {
|
|
221
|
+
await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
|
|
222
|
+
}),
|
|
177
223
|
};
|
|
178
224
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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
|
@@ -57,7 +57,7 @@ DatabaseManager({ action: "schema", conn_id: "c1", database: "shop", table: "use
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
- 表列表返回 `TableInfo[]`:`{name, type?, comment?, database?}`(Redis 的 type 为 hash|list|set|zset|string|stream)。
|
|
60
|
-
- PostgreSQL/GaussDB 为「数据库 → 模式(schema) →
|
|
60
|
+
- 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
61
|
- 列结构返回 `ColumnInfo[]`:`{name, dataType, nullable, key?, default?, comment?}`。
|
|
62
62
|
|
|
63
63
|
### 5. preview — 预览表数据
|