@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.
- package/README.md +67 -138
- package/docs/USAGE.md +138 -0
- package/index.ts +12 -4
- package/package.json +8 -2
- package/src/config.ts +2 -2
- package/src/core/scan/candidates.ts +5 -3
- package/src/core/scan/parsers.ts +1 -1
- package/src/core/scan/spring.ts +25 -2
- package/src/core/scan/walker.ts +22 -6
- package/src/core/types.ts +3 -3
- package/src/dialects/dm.ts +53 -24
- package/src/dialects/elasticsearch.ts +47 -22
- package/src/dialects/graph-dialect.ts +234 -0
- package/src/dialects/index.ts +1 -0
- package/src/dialects/kv-dialect.ts +13 -0
- package/src/dialects/mongodb.ts +7 -0
- package/src/dialects/mysql.ts +12 -1
- package/src/dialects/neo4j.ts +321 -0
- package/src/dialects/search-dialect.ts +20 -5
package/src/core/scan/walker.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
41
|
-
|
|
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 预留(二期)
|
package/src/dialects/dm.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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}
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
64
|
-
//
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/dialects/index.ts
CHANGED
|
@@ -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 配对展示
|
package/src/dialects/mongodb.ts
CHANGED
|
@@ -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
|
|
package/src/dialects/mysql.ts
CHANGED
|
@@ -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
|
-
|
|
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]));
|