dsh-db-tool 0.1.3 → 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 CHANGED
@@ -66,6 +66,7 @@ window.__ModuleLoader__.load({
66
66
  modeRw: "读写",
67
67
  // 浏览
68
68
  noDatabases: "无可用数据库",
69
+ noSchemas: "无模式",
69
70
  noTables: "无表",
70
71
  loadFailed: "加载失败",
71
72
  structure: "结构",
@@ -150,6 +151,7 @@ window.__ModuleLoader__.load({
150
151
  modeRo: "read-only ro",
151
152
  modeRw: "read-write rw",
152
153
  noDatabases: "No databases available",
154
+ noSchemas: "No schemas",
153
155
  noTables: "No tables",
154
156
  loadFailed: "Failed to load",
155
157
  structure: "Structure",
@@ -708,7 +710,8 @@ window.__ModuleLoader__.load({
708
710
  const [loading, setLoading] = React.useState({});
709
711
  const [error, setError] = React.useState({}); // 树节点加载失败信息(就地显示,可点重试)
710
712
  const [dbs, setDbs] = React.useState({}); // connId -> string[]
711
- const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db>" -> TableInfo[]
713
+ const [schemasMap, setSchemasMap] = React.useState({}); // "<connId>/<db>" -> string[](PG/GaussDB 库内 schema 层)
714
+ const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db|schema>" -> TableInfo[]
712
715
  const [sel, setSel] = React.useState(null); // {connId, db, table: TableInfo}
713
716
  const [schema, setSchema] = React.useState([]);
714
717
  const [preview, setPreview] = React.useState(null); // QueryResult
@@ -718,7 +721,7 @@ window.__ModuleLoader__.load({
718
721
 
719
722
  // 会话项目切换 / 连接列表变化时清空树缓存,避免陈旧授权下的旧数据
720
723
  React.useEffect(() => {
721
- setOpen({}); setLoading({}); setError({}); setDbs({}); setTablesMap({}); setSel(null); setSchema([]); setPreview(null);
724
+ setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({}); setSel(null); setSchema([]); setPreview(null);
722
725
  }, [projectPath]);
723
726
 
724
727
  function toggle(key, load) {
@@ -744,22 +747,37 @@ window.__ModuleLoader__.load({
744
747
  setDbs((m) => Object.assign({}, m, { [c.id]: list || [] }));
745
748
  }));
746
749
  }
750
+ // PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
751
+ const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
747
752
  function toggleDb(c, d) {
748
753
  if (!projectPath) return;
754
+ const useSchemas = !!HAS_SCHEMAS[c.kind];
755
+ const what = useSchemas ? "schemas" : "tables";
749
756
  toggle("d:" + c.id + "/" + d, () =>
750
- api("tables" + qs({ project: projectPath, connId: c.id, database: d })).then((list) => {
751
- setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d]: list || [] }));
757
+ api(what + qs({ project: projectPath, connId: c.id, database: d })).then((list) => {
758
+ const setter = useSchemas ? setSchemasMap : setTablesMap;
759
+ setter((m) => Object.assign({}, m, { [c.id + "/" + d]: list || [] }));
760
+ }));
761
+ }
762
+ function toggleSchema(c, d, s) {
763
+ if (!projectPath) return;
764
+ // PG 系跨库浏览:tables 的 database 传 "库名.schema"(Navicat 官方行为,服务端按库开连接)
765
+ toggle("s:" + c.id + "/" + d + "/" + s, () =>
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 || [] }));
752
768
  }));
753
769
  }
754
770
  // 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
755
771
  const openSeq = React.useRef(0);
756
772
  const openTable = React.useCallback((s, pg) => {
757
773
  if (!s || !projectPath) return;
774
+ // PG 系跨库:database 传 "库名.schema";其它库传库名
775
+ const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
758
776
  const seq = ++openSeq.current;
759
777
  setBusy("open");
760
778
  Promise.all([
761
- api("schema" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name })),
762
- api("preview" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
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 })),
763
781
  ])
764
782
  .then(([sch, prev]) => {
765
783
  if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
@@ -795,19 +813,43 @@ window.__ModuleLoader__.load({
795
813
  if (!list) continue;
796
814
  if (list.length === 0) { treeRows.push(treerow(ck + ":e", 1, false, true, t("noDatabases"))); continue; }
797
815
  for (const d of list) {
816
+ const useSchemas = !!HAS_SCHEMAS[c.kind];
798
817
  const dk = "d:" + c.id + "/" + d;
799
818
  const dOpen = !!open[dk];
800
819
  treeRows.push(treerow(dk, 1, dOpen, false, d, () => toggleDb(c, d)));
801
820
  if (!dOpen) continue;
802
- const tkey = c.id + "/" + d;
803
- const tlist = tablesMap[tkey];
804
821
  if (loading[dk]) { treeRows.push(treerow(dk + ":l", 2, false, true, "…")); continue; }
805
822
  if (error[dk]) { treeRows.push(treerow(dk + ":x", 2, false, true, t("loadFailed") + ":" + error[dk], () => retry(dk, () => toggleDb(c, d)))); continue; }
823
+ // PG/GaussDB:库 → 模式 → 表(三层,Navicat 官方层级);schema 节点复用 open/loading/error 状态
824
+ if (useSchemas) {
825
+ const slist = schemasMap[c.id + "/" + d];
826
+ if (!slist) continue;
827
+ if (slist.length === 0) { treeRows.push(treerow(dk + ":e", 2, false, true, t("noSchemas"))); continue; }
828
+ for (const s of slist) {
829
+ const sk = "s:" + c.id + "/" + d + "/" + s;
830
+ const sOpen = !!open[sk];
831
+ treeRows.push(treerow(sk, 2, sOpen, false, s, () => toggleSchema(c, d, s)));
832
+ if (!sOpen) continue;
833
+ const tlist = tablesMap[c.id + "/" + d + "/" + s];
834
+ if (loading[sk]) { treeRows.push(treerow(sk + ":l", 3, false, true, "…")); continue; }
835
+ if (error[sk]) { treeRows.push(treerow(sk + ":x", 3, false, true, t("loadFailed") + ":" + error[sk], () => retry(sk, () => toggleSchema(c, d, s)))); continue; }
836
+ if (!tlist) continue;
837
+ if (tlist.length === 0) { treeRows.push(treerow(sk + ":e", 3, false, true, t("noTables"))); continue; }
838
+ for (const tb of tlist) {
839
+ const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
840
+ treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
841
+ tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
842
+ () => setSel({ connId: c.id, db: d, schemaName: s, table: tb }), active));
843
+ }
844
+ }
845
+ continue;
846
+ }
847
+ const tlist = tablesMap[c.id + "/" + d];
806
848
  if (!tlist) continue;
807
849
  if (tlist.length === 0) { treeRows.push(treerow(dk + ":e", 2, false, true, t("noTables"))); continue; }
808
850
  for (const tb of tlist) {
809
851
  const active = !!sel && sel.connId === c.id && sel.db === d && sel.table.name === tb.name;
810
- treeRows.push(treerow("t:" + tkey + "/" + tb.name, 2, false, true,
852
+ treeRows.push(treerow("t:" + c.id + "/" + d + "/" + tb.name, 2, false, true,
811
853
  tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
812
854
  () => setSel({ connId: c.id, db: d, table: tb }), active));
813
855
  }
@@ -832,7 +874,7 @@ window.__ModuleLoader__.load({
832
874
  ["structure", "preview"].map((v) =>
833
875
  React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
834
876
  ),
835
- 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),
836
878
  view === "structure"
837
879
  ? resultTable(
838
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
- 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) {
@@ -116,30 +155,41 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
116
155
  const res = await pool.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true ORDER BY datname');
117
156
  return res.rows.map((r) => String(r.datname));
118
157
  }),
158
+ // 库内 schema 清单(Navicat 官方层级:数据库 → 模式 → 表)。
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
163
+ WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
164
+ ORDER BY nspname`);
165
+ return res.rows.map((r) => String(r.nspname));
166
+ }),
167
+ // database 参数 = "库名.schema"(跨库浏览)或 "schema"(当前库)
119
168
  listTables: (database) => humanize(`${kind} 列出表`, async () => {
120
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
121
- const res = await pool.query(`SELECT table_name, table_type FROM information_schema.tables
169
+ const { db, schema } = parseBrowseTarget(database, 'schema');
170
+ const res = await poolFor(db).query(`SELECT table_name, table_type FROM information_schema.tables
122
171
  WHERE table_schema = $1 ORDER BY table_name`, [schema]);
123
172
  return res.rows.map((r) => {
124
173
  const rawType = String(r.table_type);
125
174
  return {
126
175
  name: String(r.table_name),
127
176
  type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
128
- database: schema,
177
+ database: db ? `${db}.${schema}` : schema,
129
178
  };
130
179
  });
131
180
  }),
132
181
  describeTable: (table, database) => humanize(`${kind} 查看表结构`, async () => {
133
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
182
+ const { db, schema } = parseBrowseTarget(database, 'schema');
134
183
  const tbl = assertIdent(table, '表名');
135
- const res = await pool.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
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,
136
186
  col_description((quote_ident($1) || '.' || quote_ident($2))::regclass, c.ordinal_position) AS col_comment
137
187
  FROM information_schema.columns c
138
188
  WHERE c.table_schema = $1 AND c.table_name = $2
139
189
  ORDER BY c.ordinal_position`, [schema, tbl]);
140
190
  if (res.rows.length === 0)
141
- throw new Error(`表不存在: ${schema}.${tbl}`);
142
- const pks = await pool.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
191
+ throw new Error(`表不存在: ${db ? db + '.' : ''}${schema}.${tbl}`);
192
+ const pks = await p.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
143
193
  JOIN information_schema.key_column_usage kcu
144
194
  ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
145
195
  WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`, [schema, tbl]);
@@ -158,13 +208,17 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
158
208
  });
159
209
  }),
160
210
  previewRows: (table, limit, database, offset) => humanize(`${kind} 预览行`, async () => {
161
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
211
+ const { db, schema } = parseBrowseTarget(database, 'schema');
162
212
  const tbl = assertIdent(table, '表名');
163
213
  const lim = clampLimit(limit);
164
214
  const off = clampOffset(offset);
165
- const res = await run(`SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`, [lim, off]);
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]);
166
218
  return toQueryResult(res);
167
219
  }),
168
- close: () => humanize(`${kind} 关闭连接`, () => pool.end()),
220
+ close: () => humanize(`${kind} 关闭连接`, async () => {
221
+ await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
222
+ }),
169
223
  };
170
224
  }
@@ -210,6 +210,9 @@ async function route(req, res, service, path, q, opts) {
210
210
  if (path === '/api/tables' && req.method === 'GET') {
211
211
  return sendOk(res, await service.tables(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
212
212
  }
213
+ if (path === '/api/schemas' && req.method === 'GET') {
214
+ return sendOk(res, await service.schemas(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
215
+ }
213
216
  if (path === '/api/schema' && req.method === 'GET') {
214
217
  return sendOk(res, await service.schema(projectOf(q), required(q, 'connId'), required(q, 'table'), q.get('database') ?? undefined));
215
218
  }
package/dist/manager.js CHANGED
@@ -146,6 +146,21 @@ export class DbToolService {
146
146
  throw this.toDriverError(e);
147
147
  }
148
148
  }
149
+ /** 库内 schema 清单(仅 PG/GaussDB 等三层语义适配器实现;ro 即可)。 */
150
+ async schemas(projectPath, connId, database) {
151
+ const { key, adapter } = await this.authorize(projectPath, connId, false);
152
+ if (!adapter.listSchemas)
153
+ return [];
154
+ try {
155
+ const result = await adapter.listSchemas(database);
156
+ this.audit(key, connId, 'schemas', database ?? 'default', 'none', false, true);
157
+ return result;
158
+ }
159
+ catch (e) {
160
+ this.audit(key, connId, 'schemas', database ?? 'default', 'none', false, false, this.errText(e));
161
+ throw this.toDriverError(e);
162
+ }
163
+ }
149
164
  async schema(projectPath, connId, table, database) {
150
165
  const t = assertNonEmpty(table, 'table');
151
166
  const { key, adapter } = await this.authorize(projectPath, connId, false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "0.1.3",
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",
@@ -57,6 +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) → 表」三层语义,支持跨库浏览(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'`。
60
61
  - 列结构返回 `ColumnInfo[]`:`{name, dataType, nullable, key?, default?, comment?}`。
61
62
 
62
63
  ### 5. preview — 预览表数据