@nsyan/db 1.1.0 → 1.2.0

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.
@@ -1,6 +1,6 @@
1
1
  // scan/walker.ts —— 目录漫步:默认忽略常见无关目录;resolveRoot 越界拒绝(Spec §8.5 红线)
2
2
  import { readdirSync, statSync, type Dirent } from "node:fs";
3
- import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
 
5
5
  const IGNORED_DIRS = new Set([
6
6
  "node_modules", ".git", "target", "dist", "venv", "logs", "docs",
@@ -10,6 +10,13 @@ const IGNORED_DIRS = new Set([
10
10
  const MAX_FILES = 2000;
11
11
  const MAX_DEPTH = 8;
12
12
 
13
+ /** 扫描目标配置文件:不受普通文件配额挤占(大型 Java 工程源码文件可轻易冲爆 MAX_FILES) */
14
+ const CONFIG_EXTS = new Set([".yml", ".yaml", ".properties"]);
15
+ function isConfigFile(name: string): boolean {
16
+ const base = name.toLowerCase();
17
+ return base === ".env" || base.startsWith(".env.") || CONFIG_EXTS.has(extname(base));
18
+ }
19
+
13
20
  /**
14
21
  * 把输入路径解析为绝对路径;越界(cwd 子树之外、`..` 上跳、cwd 本身除外)
15
22
  * 直接抛 "scan path out of scope"——与「密码不进上下文」同级别的红线。
@@ -24,11 +31,13 @@ export function resolveRoot(input: string): string {
24
31
  return abs;
25
32
  }
26
33
 
27
- /** 递归收集文本候选文件(忽略无关目录与隐藏目录,上限 MAX_FILES)。 */
34
+ /** 递归收集文本候选文件(忽略无关目录与隐藏目录,上限 MAX_FILES);配置类文件单独收集不被挤占 */
28
35
  export function walk(root: string): string[] {
29
36
  const out: string[] = [];
37
+ const configs: string[] = [];
30
38
  const visit = (dir: string, depth: number): void => {
31
- if (depth > MAX_DEPTH || out.length >= MAX_FILES) return;
39
+ // 不因 out 满而提前返回:必须走完整棵树,否则后遍历到的目录里的配置文件永远收不到
40
+ if (depth > MAX_DEPTH) return;
32
41
  let entries: Dirent[];
33
42
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
34
43
  for (const e of entries) {
@@ -37,11 +46,18 @@ export function walk(root: string): string[] {
37
46
  const p = join(dir, e.name);
38
47
  if (e.isDirectory()) visit(p, depth + 1);
39
48
  else if (e.isFile()) {
40
- try { if (statSync(p).isFile()) out.push(p); } catch { /* ignore */ }
41
- if (out.length >= MAX_FILES) return;
49
+ try {
50
+ if (statSync(p).isFile()) {
51
+ if (isConfigFile(e.name)) {
52
+ if (configs.length < MAX_FILES) configs.push(p);
53
+ } else if (out.length < MAX_FILES) {
54
+ out.push(p);
55
+ }
56
+ }
57
+ } catch { /* ignore */ }
42
58
  }
43
59
  }
44
60
  };
45
61
  visit(root, 0);
46
- return out;
62
+ return [...configs, ...out];
47
63
  }
package/src/core/types.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  // core/types.ts
2
2
  export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
3
- | "redis" | "elasticsearch" | "mongodb" | "hive" | "spark";
4
- export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata";
3
+ | "redis" | "elasticsearch" | "mongodb" | "hive" | "spark" | "neo4j";
4
+ export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata" | "graph";
5
5
 
6
6
  export interface ConnConfig {
7
7
  id: string; name: string; type: DbTypeId;
8
8
  description?: string; host?: string; port?: number;
9
9
  username?: string; password?: string;
10
10
  /** 家族语义:关系型=库名 / DM=schema / Redis=不用(用 dbIndex)/
11
- ES=默认 index / Hive-Spark=database 名 */
11
+ ES=默认 index / Hive-Spark=database 名 / Neo4j=图数据库名(缺省 neo4j) */
12
12
  database?: string;
13
13
  dbIndex?: number; // Redis 库号
14
14
  apiKey?: string; // ES 预留(二期)
@@ -8,18 +8,24 @@ import { register, filterTables, type Fingerprints } from "./dialect.js";
8
8
  // ── URL 解析(JDBC 主形态 + 原生 URI 双形态,Spec §7)───
9
9
 
10
10
  const DM_DEFAULT_PORT = 5236;
11
- const JDBC_RE = /^jdbc:dm:\/\/([^:/?#]+)(?::(\d+))?\/([^?#]+)$/;
12
- const NATIVE_RE = /^dm:\/\/([^:/?#@]+)(?::(\d+))?\/([^?#]+)$/;
11
+ const JDBC_RE = /^jdbc:dm:\/\/([^:/?#]+)(?::(\d+))?(?:\/([^?#]*))?$/;
12
+ const NATIVE_RE = /^dm:\/\/([^:/?#@]+)(?::(\d+))?(?:\/([^?#]*))?$/;
13
13
 
14
14
  function parseDmUrl(url: string): ParsedTarget | null {
15
- const clean = url.split("?")[0];
15
+ const qIdx = url.indexOf("?");
16
+ const clean = qIdx >= 0 ? url.slice(0, qIdx) : url;
16
17
  const m = clean.match(JDBC_RE) ?? clean.match(NATIVE_RE);
17
18
  if (!m) return null;
18
19
  const host = m[1];
19
20
  const port = m[2] ? parseInt(m[2], 10) : DM_DEFAULT_PORT;
20
- const database = m[3];
21
- if (!host || !database) return null;
22
- return { host, port, database };
21
+ // 库名 = URL 路径段;缺失时回退 query 的 schema= 参数(jdbc:dm://host:port?schema=x 常见形态)
22
+ let database = m[3] || undefined;
23
+ if (!database && qIdx >= 0) {
24
+ const schema = new URLSearchParams(url.slice(qIdx + 1)).get("schema");
25
+ if (schema) database = schema;
26
+ }
27
+ if (!host) return null;
28
+ return database !== undefined ? { host, port, database } : { host, port };
23
29
  }
24
30
 
25
31
  // ── DM(达梦)方言 ─────────────────────────────────
@@ -41,16 +47,29 @@ class DmDialect extends RelationalDialect {
41
47
  }
42
48
 
43
49
  displayUrl(config: ConnConfig): string {
44
- return `jdbc:dm://${config.host}:${config.port}/${config.database}`;
50
+ return `jdbc:dm://${config.host}:${config.port}${config.database ? "/" + config.database : ""}`;
45
51
  }
46
52
 
47
53
  protected async doConnect(config: ConnConfig, _timeoutMs: number): Promise<DbConnection> {
48
- const conn = await dmdb.getConnection({
49
- user: config.username,
50
- password: config.password,
51
- connectString: `${config.host}:${config.port}`,
52
- schema: config.database,
53
- });
54
+ let conn: dmdb.Connection;
55
+ try {
56
+ // loginEncrypt=false:跳过握手消息加密。dmdb 默认走 MD5/RSA 遗留算法,
57
+ // Node≥17(OpenSSL 3)报 digital envelope routines::unsupported;
58
+ // 若服务端强制加密,请以 NODE_OPTIONS=--openssl-legacy-provider 启动宿主
59
+ conn = await dmdb.getConnection({
60
+ user: config.username,
61
+ password: config.password,
62
+ connectString: `${config.host}:${config.port}`,
63
+ schema: config.database,
64
+ loginEncrypt: false,
65
+ });
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ if (/digital envelope routines|ERR_OSSL/i.test(msg)) {
69
+ throw new Error(`${msg}(DM 登录加密与 Node≥17 OpenSSL 3 不兼容;请以 NODE_OPTIONS=--openssl-legacy-provider 启动 pi)`);
70
+ }
71
+ throw err;
72
+ }
54
73
  return {
55
74
  type: "dm",
56
75
  client: conn,
@@ -84,7 +103,8 @@ class DmDialect extends RelationalDialect {
84
103
  const tables = await this.withConnection(config, async (conn) => {
85
104
  const dmConn = conn.client as dmdb.Connection;
86
105
  const res = await dmConn.execute(
87
- `SELECT TABLE_NAME, OWNER, COMMENTS FROM ALL_TABLES T
106
+ // 列名全部加表前缀:DM JOIN 中的裸列名报 -2112 有歧义
107
+ `SELECT T.TABLE_NAME, T.OWNER, C.COMMENTS FROM ALL_TABLES T
88
108
  LEFT JOIN ALL_TAB_COMMENTS C ON T.TABLE_NAME = C.TABLE_NAME AND T.OWNER = C.OWNER
89
109
  WHERE T.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
90
110
  ORDER BY T.OWNER, T.TABLE_NAME`,
@@ -110,18 +130,25 @@ class DmDialect extends RelationalDialect {
110
130
  try {
111
131
  const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
112
132
  const dmConn = conn.client as dmdb.Connection;
133
+ // schema 限定:"schema.table" 显式指定,否则用连接 schema(config.database,大小写保持原样,
134
+ // DM 建库时可能带引号存为小写);都无时不限定,保持旧兼容。避免同名表跨 schema 列重复
135
+ const dotIdx = table.indexOf(".");
136
+ const owner = (dotIdx > 0 ? table.slice(0, dotIdx) : config.database) || undefined;
137
+ const tableName = (dotIdx > 0 ? table.slice(dotIdx + 1) : table).toUpperCase();
138
+ const ownerFilter = owner ? " AND C.OWNER = :2" : "";
139
+ const binds = owner ? [tableName, owner] : [tableName];
113
140
  const res = await dmConn.execute(
114
141
  `SELECT
115
- COLUMN_NAME,
116
- DATA_TYPE || CASE WHEN DATA_PRECISION IS NOT NULL THEN '(' || DATA_PRECISION || ',' || DATA_SCALE || ')' WHEN DATA_LENGTH IS NOT NULL AND DATA_TYPE LIKE '%CHAR%' THEN '(' || DATA_LENGTH || ')' ELSE '' END,
117
- NULLABLE,
118
- DATA_DEFAULT,
119
- COMMENTS
142
+ C.COLUMN_NAME,
143
+ C.DATA_TYPE || CASE WHEN C.DATA_PRECISION IS NOT NULL THEN '(' || C.DATA_PRECISION || ',' || C.DATA_SCALE || ')' WHEN C.DATA_LENGTH IS NOT NULL AND C.DATA_TYPE LIKE '%CHAR%' THEN '(' || C.DATA_LENGTH || ')' ELSE '' END,
144
+ C.NULLABLE,
145
+ C.DATA_DEFAULT,
146
+ COM.COMMENTS
120
147
  FROM ALL_TAB_COLUMNS C
121
148
  LEFT JOIN ALL_COL_COMMENTS COM ON C.TABLE_NAME = COM.TABLE_NAME AND C.COLUMN_NAME = COM.COLUMN_NAME AND C.OWNER = COM.OWNER
122
- WHERE C.TABLE_NAME = :1 AND C.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
149
+ WHERE C.TABLE_NAME = :1${ownerFilter} AND C.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
123
150
  ORDER BY C.COLUMN_ID`,
124
- [table.toUpperCase()],
151
+ binds,
125
152
  );
126
153
  const cols: ColumnInfo[] = (res.rows ?? []).map((r: any) => ({
127
154
  name: r[0],
@@ -132,14 +159,16 @@ class DmDialect extends RelationalDialect {
132
159
  comment: r[4] || "",
133
160
  }));
134
161
 
135
- // 查主键
162
+ // 查主键(与列查询同 schema 限定)
136
163
  try {
164
+ const ownerFilterPk = owner ? " AND c.OWNER = :2" : "";
165
+ const bindsPk = owner ? [tableName, owner] : [tableName];
137
166
  const pkRes = await dmConn.execute(
138
167
  `SELECT cc.COLUMN_NAME
139
168
  FROM ALL_CONS_COLUMNS cc
140
169
  JOIN ALL_CONSTRAINTS c ON cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME AND cc.OWNER = c.OWNER
141
- WHERE c.CONSTRAINT_TYPE = 'P' AND c.TABLE_NAME = :1`,
142
- [table.toUpperCase()],
170
+ WHERE c.CONSTRAINT_TYPE = 'P' AND c.TABLE_NAME = :1${ownerFilterPk}`,
171
+ bindsPk,
143
172
  );
144
173
  const pkSet = new Set((pkRes.rows ?? []).map((r: any) => r[0]));
145
174
  for (const col of cols) {
@@ -40,6 +40,26 @@ function makeClient(config: ConnConfig, ClientClass: new (opts: Record<string, u
40
40
  return new ClientClass(opts);
41
41
  }
42
42
 
43
+ /** v7 客户端响应包 { body, statusCode, headers },v8+ 直接返回体——统一解包 */
44
+ function unwrap<T>(res: T | { body: T }): T {
45
+ const r = res as { body?: unknown } | null;
46
+ return r !== null && typeof r === "object" && "body" in r && (r as { body?: unknown }).body !== undefined
47
+ ? (r as { body: T }).body
48
+ : (res as T);
49
+ }
50
+
51
+ /** v7 客户端探测版本(v8/v9 产品校验拒收的低版本 ES 用) */
52
+ async function probeVersionViaV7(config: ConnConfig): Promise<string> {
53
+ const v7 = makeClient(config, ClientV7 as unknown as new (opts: Record<string, unknown>) => AnyClient);
54
+ try {
55
+ const info = await (v7 as ClientV7).info();
56
+ const body = unwrap(info) as { version?: { number?: string } };
57
+ return body.version?.number ?? "";
58
+ } finally {
59
+ try { await v7.close(); } catch { /* ignore */ }
60
+ }
61
+ }
62
+
43
63
  class ElasticsearchDialect extends SearchDialect {
44
64
  id = "elasticsearch" as const;
45
65
  label = "Elasticsearch";
@@ -60,33 +80,35 @@ class ElasticsearchDialect extends SearchDialect {
60
80
  }
61
81
 
62
82
  protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
63
- // 先用"最新客户端"做无版本探测 GET /(testConnection withConnection 共用);
64
- // GET / 返回 version.number 后按大版本分发(详见 testConnection)
83
+ // v8/v9 客户端强制产品校验(响应须带 X-elastic-product 头,ES 7.14+ 才有),
84
+ // 7.0~7.13 直接抛 "unknown product"。探测失败回退 v7 客户端再探——双探都失败才认定不可达。
65
85
  const probe = makeClient(config, ClientV8 as unknown as new (opts: Record<string, unknown>) => AnyClient);
86
+ let versionNumber = "";
87
+ let useV7 = false;
66
88
  try {
67
89
  const info = await (probe as ClientV8).info();
68
- const versionNumber = (info as unknown as { version?: { number?: string } }).version?.number ?? "";
69
- const major = pickMajor(versionNumber);
70
- if (major === 7) {
71
- const v7 = makeClient(config, ClientV7 as unknown as new (opts: Record<string, unknown>) => AnyClient);
72
- return { type: "elasticsearch", client: v7, async close() { await v7.close(); } };
73
- }
74
- // 8 及未知更高大版本:一律用最新客户端尝试(未知版本不硬拒,warning testConnection 给出)
75
- return { type: "elasticsearch", client: probe, async close() { await probe.close(); } };
76
- } catch (err: unknown) {
90
+ versionNumber = (info as unknown as { version?: { number?: string } }).version?.number ?? "";
91
+ } catch {
92
+ try { await probe.close(); } catch { /* ignore */ }
93
+ useV7 = true;
94
+ versionNumber = await probeVersionViaV7(config);
95
+ }
96
+ if (useV7 || pickMajor(versionNumber) === 7) {
77
97
  try { await probe.close(); } catch { /* ignore */ }
78
- throw err;
98
+ const v7 = makeClient(config, ClientV7 as unknown as new (opts: Record<string, unknown>) => AnyClient);
99
+ return { type: "elasticsearch", client: v7, async close() { await v7.close(); } };
79
100
  }
101
+ // 8 及未知更高大版本:一律用最新客户端尝试(未知版本不硬拒,warning 由 testConnection 给出)
102
+ return { type: "elasticsearch", client: probe, async close() { await probe.close(); } };
80
103
  }
81
104
 
82
105
  async versionQuery(conn: DbConnection): Promise<string> {
83
106
  // 连接建立时已做版本探测,这里复用一次 GET / 取完整版本号
84
107
  const client = conn.client as ClientV8;
85
- const info = await client.info();
86
- return (info as unknown as { version?: { number?: string } }).version?.number ?? "unknown";
108
+ const info = unwrap(await client.info()) as { version?: { number?: string } };
109
+ return info.version?.number ?? "unknown";
87
110
  }
88
111
 
89
- // testConnection 复写基类:同时返回服务端版本号 + 未知大版本的 warning(Spec §12)
90
112
  async testConnection(config: ConnConfig): Promise<TestConnectionResult> {
91
113
  const start = Date.now();
92
114
  try {
@@ -95,6 +117,9 @@ class ElasticsearchDialect extends SearchDialect {
95
117
  try {
96
118
  const info = await (probe as ClientV8).info();
97
119
  versionNumber = (info as unknown as { version?: { number?: string } }).version?.number ?? "";
120
+ } catch {
121
+ // v8/v9 产品校验拒收低版本 ES(<7.14 无产品头)→ v7 客户端探测
122
+ versionNumber = await probeVersionViaV7(config);
98
123
  } finally {
99
124
  try { await probe.close(); } catch { /* ignore */ }
100
125
  }
@@ -121,21 +146,21 @@ class ElasticsearchDialect extends SearchDialect {
121
146
  : undefined;
122
147
  if (kind.type === "query_string") {
123
148
  const res = await es.search({ index, size: opts.maxRows, q: kind.text });
124
- return hitsToRows(res);
149
+ return hitsToRows(unwrap(res));
125
150
  }
126
151
  if (kind.type === "read") {
127
152
  if (kind.endpoint === "_count") {
128
153
  const res = await es.count({ index });
129
- const count = (res as unknown as { count?: number }).count ?? 0;
154
+ const count = (unwrap(res) as unknown as { count?: number }).count ?? 0;
130
155
  return { columns: ["count"], rows: [[count]], rowCount: count };
131
156
  }
132
157
  if (kind.endpoint === "_mget") {
133
158
  const res = await es.mget({ index, body: { docs: [] } });
134
- return docsToRows(res);
159
+ return docsToRows(unwrap(res));
135
160
  }
136
161
  // _search:DSL 整体即 body
137
162
  const res = await es.search({ index, body, size: opts.maxRows });
138
- return hitsToRows(res);
163
+ return hitsToRows(unwrap(res));
139
164
  }
140
165
  throw new Error(`ES 写端点 ${kind.endpoint} 需走非只读确认流程执行,本方言 executeOn 仅执行读查询`);
141
166
  }
@@ -146,7 +171,7 @@ class ElasticsearchDialect extends SearchDialect {
146
171
  const tables: TableInfo[] = await this.withConnection(config, async (conn) => {
147
172
  const es = conn.client as ClientV8;
148
173
  const res = await es.cat.indices({ format: "json", h: "index,health,docs.count,store.size", s: "index" });
149
- const rows = (res as unknown as Array<Record<string, string>>).slice(0, 500);
174
+ const rows = (unwrap(res) as unknown as Array<Record<string, string>>).slice(0, 500);
150
175
  return filterTables(rows.map((r) => ({
151
176
  schema: "",
152
177
  name: r["index"] ?? "",
@@ -170,9 +195,9 @@ class ElasticsearchDialect extends SearchDialect {
170
195
  const es = conn.client as ClientV8;
171
196
  const mappingRes = await es.indices.getMapping({ index: target });
172
197
  const settingsRes = await es.indices.getSettings({ index: target });
173
- const mapping = mappingRes as unknown as Record<string, { mappings?: { properties?: Record<string, { type?: string; index?: boolean; analyzer?: string }> } }>;
198
+ const mapping = unwrap(mappingRes) as unknown as Record<string, { mappings?: { properties?: Record<string, { type?: string; index?: boolean; analyzer?: string }> } }>;
174
199
  const props = mapping[target]?.mappings?.properties ?? {};
175
- const settings = settingsRes as unknown as Record<string, { settings?: { index?: Record<string, string> } }>;
200
+ const settings = unwrap(settingsRes) as unknown as Record<string, { settings?: { index?: Record<string, string> } }>;
176
201
  const idxSettings = settings[target]?.settings?.index ?? {};
177
202
  const cols: ColumnInfo[] = Object.entries(props).map(([name, def]) => ({
178
203
  name,
@@ -0,0 +1,234 @@
1
+ // dialects/graph-dialect.ts —— 图家族基类(Cypher 语句切分、读写分类、结果拍平)
2
+ // 交互形态:query_database 的 sql 参数填 Cypher 原文(与关系型"逐条执行取最后一条结果"一致,
3
+ // 支持分号分隔多语句;注释与字符串字面量内的分号不切分)。
4
+ // 首个实现为 Neo4j(bolt 协议官方驱动)。与 Mongo JSON 信封语义不同,独立成基类。
5
+
6
+ import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
7
+ QueryResult } from "../core/types.js";
8
+ import type { Dialect, Verdict, Fingerprints } from "./dialect.js";
9
+ import { stripComments, splitStatements } from "../core/sql-text.js";
10
+
11
+ // ── Cypher 读写分类(本家族管控核心,Spec 共识:关键字白名单 + 保守兜底)──────
12
+ // 读 = MATCH/OPTIONAL MATCH/RETURN/WITH/UNWIND/SHOW(白名单类目)/CALL 只读过程白名单;
13
+ // 写 = CREATE/MERGE/DELETE/DETACH/SET/REMOVE/DROP/FOREACH/LOAD CSV/TERMINATE 任意出现;
14
+ // 恒拒 = CALL dbms.*(管理过程,与只读开关无关);未知语句保守按写。
15
+
16
+ /** 只读 CALL 过程白名单(小写精确前缀匹配;schema/元数据核心过程,不依赖 APOC) */
17
+ const READ_PROCEDURES: ReadonlySet<string> = new Set([
18
+ "db.labels", "db.relationshipTypes", "db.propertyKeys",
19
+ "db.indexes", "db.constraints",
20
+ "db.schema.visualization", "db.schema.nodeTypeProperties", "db.schema.relTypeProperties",
21
+ ]);
22
+
23
+ /** SHOW 允许的类目(其后第一个词);TRANSACTIONS 需进一步排除 TERMINATE */
24
+ const SHOW_CATEGORIES = /^(INDEX(?:ES)?|CONSTRAINT(?:S)?|PROCEDURES?|FUNCTIONS?|SETTINGS|DATABASES?|TRANSACTIONS?)\b/i;
25
+
26
+ /** 写关键字(任意深度出现即整条按写);字面量已剥离,不受字符串内容误伤 */
27
+ // 注:(?<![\w.$`]) 防止 n.create / `Remove` 这类属性名·反引号标识符误命中;
28
+ // CALL {} 子查询本身不算写——子查询内的写关键字会被扫到,纯读子查询放行 */
29
+ const WRITE_KEYWORD_RE =
30
+ /(?<![\w.$`])(?:CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|FOREACH|TERMINATE)(?![\w$`])|LOAD\s+CSV/i;
31
+
32
+ /** 剥离字符串字面量(' ")与反引号标识符内容,防止值内写词误判 */
33
+ function stripLiterals(text: string): string {
34
+ let out = "";
35
+ let q: string | null = null;
36
+ for (let i = 0; i < text.length; i++) {
37
+ const ch = text[i];
38
+ if (q) {
39
+ if (ch === q) q = null; // 字面量内容整体丢弃(Cypher '' 双写转义已随内容消失)
40
+ continue;
41
+ }
42
+ if (ch === "'" || ch === '"' || ch === "`") { q = ch; out += " "; continue; }
43
+ out += ch;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** 单条 Cypher 语句分类(isAllowed 与 executeOn 共用,保证裁决与执行一致) */
49
+ export function classifyStatement(stmt: string): Verdict {
50
+ const clean = stripComments(stmt);
51
+ const head = clean.trim().slice(0, 80);
52
+ const text = stripLiterals(clean);
53
+ const summary = head.replace(/\s+/g, " ");
54
+
55
+ // 1) CALL dbms.* 管理过程恒拒(版本查询走 versionQuery 直连,不经此裁决)
56
+ if (/\bCALL\s+dbms\./i.test(text)) {
57
+ return { ok: false, reason: `禁止执行管理过程:CALL dbms.*(硬限制)`, isWrite: true, summary: `${summary}(硬限制)` };
58
+ }
59
+ // 2) SHOW 类目白名单;TERMINATE 已被写关键字拦截(TRANSACTION ... TERMINATE)
60
+ const showM = /^\s*SHOW\s+(\w+)/i.exec(text);
61
+ if (showM) {
62
+ if (!SHOW_CATEGORIES.test(showM[1])) {
63
+ return { ok: false, reason: `未知 SHOW 类目:${showM[1]}(按拒绝处理)`, isWrite: true, summary };
64
+ }
65
+ return { ok: true, isWrite: WRITE_KEYWORD_RE.test(text), summary };
66
+ }
67
+ // 3) 任意深度写关键字 → 按写(含 MATCH ... DELETE、FOREACH 内写、CALL {} 子查询写)
68
+ const isWrite = WRITE_KEYWORD_RE.test(text);
69
+ if (isWrite) return { ok: true, isWrite: true, summary };
70
+ // 4) CALL 过程:扫描全部过程名,任一不在白名单 → 保守按写
71
+ // (apoc.* 无法安全区分读写,统一走写确认;dbms.* 已在步骤 1 恒拒)
72
+ const procs = [...text.matchAll(/\bCALL\s+([\w.]+)/gi)].map((m) => m[1].toLowerCase());
73
+ if (procs.length > 0) {
74
+ if (procs.every((p) => READ_PROCEDURES.has(p))) return { ok: true, isWrite: false, summary };
75
+ return { ok: true, isWrite: true, summary: `${summary}(未知过程,按写)` };
76
+ }
77
+ // 5) 已知读开头(MATCH/OPTIONAL/RETURN/WITH/UNWIND)→ 读;其余未知保守按写
78
+ if (/^\s*(MATCH|OPTIONAL|RETURN|WITH|UNWIND)\b/i.test(text)) {
79
+ return { ok: true, isWrite: false, summary };
80
+ }
81
+ return { ok: true, isWrite: true, summary: `${summary}(未知语句,按写)` };
82
+ }
83
+
84
+ /** 多语句分类:任一写 → 整体按写;任一恒拒 → 整体拒绝 */
85
+ export function classifyCypher(sql: string): Verdict {
86
+ const stmts = splitStatements(sql);
87
+ if (stmts.length === 0) {
88
+ return { ok: false, reason: "Cypher 语句不能为空,如 MATCH (n:Person) RETURN n LIMIT 10" };
89
+ }
90
+ let anyWrite = false;
91
+ const parts: string[] = [];
92
+ for (const s of stmts) {
93
+ const v = classifyStatement(s);
94
+ if (!v.ok) return v;
95
+ if (v.isWrite) anyWrite = true;
96
+ if (v.summary) parts.push(v.summary);
97
+ }
98
+ return { ok: true, isWrite: anyWrite, summary: stmts.length > 1 ? `${stmts.length} 条语句` : parts[0] ?? "" };
99
+ }
100
+
101
+ // ── 结果拍平(Record → 行;Node/Relationship/Path 等 graph 类型转展示原语)───
102
+
103
+ /** 驱动值 → 展示原语:Node/Relationship 摘要、Integer 取数值、时间类型 ISO、其余 JSON */
104
+ export function cellOf(v: unknown): unknown {
105
+ if (v === undefined || v === null) return null;
106
+ const t = v as { __isInteger__?: boolean; toString?: () => string;
107
+ labels?: string[]; properties?: Record<string, unknown>;
108
+ type?: string; startNodeElementId?: string; endNodeElementId?: string;
109
+ elementId?: string; segments?: unknown[];
110
+ toISOString?: () => string };
111
+ // neo4j Integer(驱动返回自定义类型,防超长精度丢失)
112
+ if (typeof v === "object" && t.__isInteger__ && typeof t.toString === "function") {
113
+ const n = Number(t.toString());
114
+ return Number.isSafeInteger(n) ? n : t.toString();
115
+ }
116
+ // Node:label(:a:b) + 属性 JSON
117
+ if (Array.isArray(t.labels)) {
118
+ return (t.labels.map((l) => `:${l}`).join("") || ":?")
119
+ + " " + JSON.stringify(t.properties ?? {});
120
+ }
121
+ // Relationship:-[TYPE]-> + 属性 JSON
122
+ if (typeof t.type === "string" && t.startNodeElementId !== undefined) {
123
+ return `-(${t.type})-> ${JSON.stringify(t.properties ?? {})}`;
124
+ }
125
+ // Path:段数摘要(节点/关系全展开过于冗长)
126
+ if (Array.isArray(t.segments)) return `<path:${t.segments.length}>`;
127
+ // 时间类型:统一 ISO 字符串
128
+ if (typeof t.toISOString === "function") return t.toISOString();
129
+ if (typeof v === "object") return JSON.stringify(v);
130
+ return v;
131
+ }
132
+
133
+ export function flattenRecords(
134
+ records: unknown[],
135
+ maxRows: number,
136
+ ): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
137
+ const shown = records.slice(0, maxRows);
138
+ const columns: string[] = [];
139
+ const rows: unknown[][] = [];
140
+ for (const r of shown) {
141
+ const keys = (r as { keys?: string[] }).keys ?? [];
142
+ const values = (r as { _fields?: unknown[] })._fields ?? [];
143
+ for (const k of keys) if (!columns.includes(k)) columns.push(k);
144
+ rows.push(keys.map((_, i) => cellOf(values[i])));
145
+ }
146
+ return { columns, rows, rowCount: rows.length, truncated: records.length >= maxRows };
147
+ }
148
+
149
+ // ── 基类 ──────────────────────────────────────────
150
+
151
+ export abstract class GraphDialect implements Dialect {
152
+ abstract id: Dialect["id"];
153
+ abstract label: string;
154
+ abstract family: Dialect["family"];
155
+ abstract defaultPort: number;
156
+ abstract fingerprints: Fingerprints;
157
+ abstract parseUrl(url: string): ParsedTarget | null;
158
+ abstract displayUrl(config: ConnConfig): string;
159
+ abstract versionQuery(conn: DbConnection): Promise<string>;
160
+ protected abstract doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection>;
161
+ /** 在图数据库上执行一条 Cypher 语句,返回驱动 Record 数组(database 缺省由方言决定) */
162
+ protected abstract runCypher(conn: DbConnection, config: ConnConfig,
163
+ cypher: string): Promise<{ records: unknown[]; summary: unknown }>;
164
+ abstract listTables(config: ConnConfig, pattern?: string): Promise<import("../core/types.js").ListTablesResult>;
165
+ abstract describeTable(config: ConnConfig, target: string): Promise<import("../core/types.js").DescribeTableResult>;
166
+
167
+ isAllowed(sql: string, readonly: boolean): Verdict {
168
+ const verdict = classifyCypher(sql);
169
+ if (!verdict.ok) return verdict;
170
+ if (verdict.isWrite && readonly) {
171
+ return { ...verdict, ok: false, reason: `只读模式下不允许执行写语句:${verdict.summary}` };
172
+ }
173
+ return verdict;
174
+ }
175
+
176
+ async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
177
+ const start = Date.now();
178
+ const verdict = classifyCypher(sql);
179
+ if (!verdict.ok) {
180
+ return { success: false, error: verdict.reason ?? "语句被拒绝", duration: `${Date.now() - start}ms` };
181
+ }
182
+ try {
183
+ const stmts = splitStatements(sql);
184
+ const result = await this.withConnection(config, async (conn) => {
185
+ let last: { records: unknown[]; summary: unknown } = { records: [], summary: null };
186
+ for (const s of stmts) last = await this.runCypher(conn, config, s);
187
+ return last;
188
+ }, opts.timeoutSec * 1000);
189
+ const { columns, rows, rowCount, truncated } = flattenRecords(result.records, opts.maxRows);
190
+ const counters = summarizeCounters(result.summary);
191
+ // 写语句无返回记录时,用单列结果行回显变更统计(否则 rowCount=0 无反馈)
192
+ const outColumns = counters && columns.length === 0 ? ["result"] : columns;
193
+ const outRows = counters && columns.length === 0 ? [[counters]] : rows;
194
+ return {
195
+ success: true, columns: outColumns, rows: outRows, rowCount: outRows.length,
196
+ truncated, duration: `${Date.now() - start}ms`,
197
+ };
198
+ } catch (err: unknown) {
199
+ return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
200
+ }
201
+ }
202
+
203
+ async withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs = 10_000): Promise<T> {
204
+ const conn = await this.doConnect(config, timeoutMs);
205
+ try { return await fn(conn); }
206
+ finally { await conn.close(); }
207
+ }
208
+
209
+ async testConnection(config: ConnConfig): Promise<import("../core/types.js").TestConnectionResult> {
210
+ const start = Date.now();
211
+ try {
212
+ const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
213
+ return { success: true, version, latency: `${Date.now() - start}ms` };
214
+ } catch (err: unknown) {
215
+ return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
216
+ }
217
+ }
218
+ }
219
+
220
+ /** 写语句统计摘要(driver summary.counters → "created 2 nodes, set 3 props") */
221
+ function summarizeCounters(summary: unknown): string | undefined {
222
+ const c = (summary as { counters?: { _stats?: Record<string, number> } })?.counters?._stats;
223
+ if (!c) return undefined;
224
+ const LABELS: Record<string, string> = {
225
+ nodesCreated: "创建节点", nodesDeleted: "删除节点", relationshipsCreated: "创建关系",
226
+ relationshipsDeleted: "删除关系", propertiesSet: "设置属性", labelsAdded: "添加标签",
227
+ labelsRemoved: "移除标签", indexesAdded: "创建索引", indexesRemoved: "删除索引",
228
+ constraintsAdded: "创建约束", constraintsRemoved: "删除约束",
229
+ };
230
+ const parts = Object.entries(c)
231
+ .filter(([k, v]) => v > 0 && LABELS[k])
232
+ .map(([k, v]) => `${LABELS[k]} ${v}`);
233
+ return parts.length > 0 ? parts.join(",") : undefined;
234
+ }
@@ -9,5 +9,6 @@ export { dmDialect } from "./dm.js";
9
9
  export { redisDialect } from "./redis.js";
10
10
  export { esDialect } from "./elasticsearch.js";
11
11
  export { mongoDialect } from "./mongodb.js";
12
+ export { neo4jDialect } from "./neo4j.js";
12
13
  export { hiveDialect } from "./hive.js";
13
14
  export { sparkDialect } from "./spark.js";
@@ -37,6 +37,7 @@ const READ_CMDS = [
37
37
  "LRANGE", "LLEN", "LINDEX", "SMEMBERS", "SCARD", "SISMEMBER",
38
38
  "ZRANGE", "ZSCORE", "ZCARD", "SCAN", "TYPE", "TTL", "PTTL", "EXISTS",
39
39
  "STRLEN", "GETRANGE", "INFO", "DBSIZE", "RANDOMKEY", "OBJECT", "MEMORY",
40
+ "PING", "TIME", "ECHO", "LOLWUT", "LASTSAVE",
40
41
  ];
41
42
 
42
43
  // ── 恒拒命令(与只读开关无关;CONFIG 一刀切含 CONFIG GET,有意从紧)───
@@ -109,7 +110,19 @@ export abstract class KvDialect implements Dialect {
109
110
  }
110
111
 
111
112
  // ── 命令结果转行(展示层统一转字符串,保持原类型)───
113
+ // ioredis sendCommand 对字符串响应可能返回 Buffer,先递归解码为 UTF-8
114
+ // (否则 INFO/SCAN/GET 等会按字节逐行渲染成乱码数字)
115
+ function decodeValue(v: unknown): unknown {
116
+ if (Buffer.isBuffer(v)) return v.toString("utf8");
117
+ if (Array.isArray(v)) return v.map(decodeValue);
118
+ if (v !== null && typeof v === "object") {
119
+ return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, decodeValue(x)]));
120
+ }
121
+ return v;
122
+ }
123
+
112
124
  function toRows(cmd: string, raw: unknown): unknown[][] {
125
+ raw = decodeValue(raw);
113
126
  if (raw === null || raw === undefined) return [];
114
127
  if (Array.isArray(raw)) {
115
128
  // 偶数长度数组(HGETALL 等)按 k/v 配对展示
@@ -224,6 +224,13 @@ class MongoDialect extends DocumentDialect {
224
224
  const r = raw as RawCommandResult;
225
225
  if (r?.cursor?.firstBatch !== undefined) return r.cursor.firstBatch;
226
226
  if (Array.isArray(r?.values)) return r.values.map((v) => ({ value: v })); // distinct
227
+ // 单文档响应(count/insert 等):剥掉协议噪声字段 ok:1,避免多一列无信息量输出
228
+ // (仅剩 ok 一键时保留,避免空列)
229
+ if (r !== null && typeof r === "object" && "ok" in r && Object.keys(r).length > 1) {
230
+ const clone = { ...(r as Record<string, unknown>) };
231
+ delete clone.ok;
232
+ return [clone];
233
+ }
227
234
  return [raw]; // count/collStats/写结果等单文档响应
228
235
  }
229
236
 
@@ -78,7 +78,18 @@ class MysqlDialect extends RelationalDialect {
78
78
  try {
79
79
  await mysqlConn.execute(`SET max_execution_time = ${timeoutMs}`);
80
80
  } catch { /* ignore: server too old for max_execution_time */ }
81
- const [rows, fields] = await mysqlConn.execute(stmt);
81
+ // 事务控制等语句(START TRANSACTION/BEGIN/COMMIT/ROLLBACK…)不支持 prepared 协议,
82
+ // mysql2 抛 ER_UNSUPPORTED_PS;此类语句降级 query()(插件从不绑定参数,语义等价)
83
+ let result: [unknown, unknown];
84
+ try {
85
+ result = await mysqlConn.execute(stmt);
86
+ } catch (err) {
87
+ const code = (err as { code?: string } | null)?.code ?? "";
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ if (code !== "ER_UNSUPPORTED_PS" && !/prepared statement protocol/i.test(msg)) throw err;
90
+ result = await mysqlConn.query(stmt);
91
+ }
92
+ const [rows, fields] = result as [{ affectedRows?: number; length?: number }, Array<{ name: string }>];
82
93
  if (Array.isArray(fields) && fields.length > 0) {
83
94
  const columns = fields.map((f: any) => f.name);
84
95
  const data = (rows as any[]).slice(0, maxRows).map((r: any) => columns.map((col: string) => r[col]));