@nsyan/db 1.0.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.
@@ -0,0 +1,273 @@
1
+ // dialects/document-dialect.ts —— 文档家族基类(JSON 命令信封解析、读写分类、limit 注入、结果拍平)
2
+ // 注:交互语义是"发 JSON 命令文档、拿文档结果"(db.runCommand 形态),与关系型/KV/搜索的
3
+ // 语句·命令·DSL 语义均不同,独立成基类。首个实现为 MongoDB(Spec 共识:Q1 选 JSON 信封)。
4
+
5
+ import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
6
+ QueryResult } from "../core/types.js";
7
+ import type { Dialect, Verdict, Fingerprints } from "./dialect.js";
8
+
9
+ // ── 信封分类(读写管控核心,Spec 共识 Q3)──────────────
10
+ // 读白名单 + 写需确认 + 管理/DDL/服务端 JS 恒拒 + 未知命令保守按写(ES parseDsl 同款兜底)
11
+
12
+ /** 读命令白名单(小写;find/count/distinct/aggregate 分类前会先过 JS/写回深扫) */
13
+ const READ_COMMANDS: ReadonlySet<string> = new Set([
14
+ "find", "count", "distinct", "aggregate",
15
+ "collstats", "dbstats", "listcollections", "listindexes", "dataSize",
16
+ "ping", "buildinfo", "hello", "ismaster", "isdbgrid", "serverstatus",
17
+ ]);
18
+
19
+ /** 恒拒命令前缀(小写匹配;与只读开关无关):DDL/复制集/分片/运维一刀切 */
20
+ const DENY_PREFIXES: readonly string[] = [
21
+ "drop", // drop / dropDatabase / dropIndexes / dropUsers ...
22
+ "create", // create / createIndexes / createUser / createSearchIndexes ...
23
+ "replset", // replSetInitiate / replSetReconfig / replSetStepDown ...
24
+ "addshard", "removeshard", "balancer",
25
+ "eval", // eval / $eval 不走前缀也命中 DENY_COMMANDS,此处兜底变体
26
+ "shutdown", "kill", "fsync", "repair", "compact", "configurefailpoint",
27
+ ];
28
+
29
+ /** 恒拒命令(精确小写匹配) */
30
+ const DENY_COMMANDS: ReadonlySet<string> = new Set([
31
+ "collmod", "renamecollection", "converttocapped", "clonecollectionascapped",
32
+ "reindex", "setparameter", "setfeaturecompatibilityversion", "logrotate",
33
+ "enablesharding", "shardcollection", "movechunk", "moveprimary", "split",
34
+ "applyops", "currentop", "clone", "copydb", "clonecollection", "$eval",
35
+ ]);
36
+
37
+ /** 服务端 JS 执行(任意深度出现即恒拒,Q3 共识) */
38
+ const JS_KEYS: ReadonlySet<string> = new Set(["$where", "$function", "$accumulator"]);
39
+ /** aggregate 写回管道阶段(任意深度出现 → 整条按写分类) */
40
+ const AGG_WRITE_KEYS: ReadonlySet<string> = new Set(["$out", "$merge"]);
41
+
42
+ /** 深度扫描:任意层的对象 key(小写)命中 targets 即 true */
43
+ export function hasAnyKey(node: unknown, targets: ReadonlySet<string>): boolean {
44
+ if (Array.isArray(node)) return node.some((n) => hasAnyKey(n, targets));
45
+ if (node !== null && typeof node === "object") {
46
+ return Object.entries(node as Record<string, unknown>).some(([k, v]) =>
47
+ targets.has(k.toLowerCase()) || hasAnyKey(v, targets));
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export interface EnvelopeClassified {
53
+ envelope?: Record<string, unknown>;
54
+ verdict: Verdict;
55
+ }
56
+
57
+ /** 解析 + 分类一条命令信封(isAllowed 与 executeOn 共用,保证裁决与执行一致) */
58
+ export function classifyEnvelope(sql: string): EnvelopeClassified {
59
+ let body: unknown;
60
+ try {
61
+ body = JSON.parse(sql.trim());
62
+ } catch {
63
+ return {
64
+ verdict: {
65
+ ok: false,
66
+ reason: "MongoDB 命令信封必须是合法 JSON 对象,如 {\"find\":\"users\",\"filter\":{}}(单命令一次执行,不支持多语句)",
67
+ },
68
+ };
69
+ }
70
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
71
+ return {
72
+ verdict: { ok: false, reason: "命令信封必须是 JSON 对象(顶层 key 为命令名),如 {\"find\":\"users\"}" },
73
+ };
74
+ }
75
+ const envelope = body as Record<string, unknown>;
76
+ const cmd = Object.keys(envelope)[0] ?? "";
77
+ const target = envelope[cmd];
78
+ const summary = `${cmd}${typeof target === "string" ? " " + target.slice(0, 40) : ""}`;
79
+
80
+ // 1) 服务端 JS 恒拒(比命令分类优先:$match 里夹带 $where 也拦)
81
+ if (hasAnyKey(envelope, JS_KEYS)) {
82
+ return {
83
+ envelope,
84
+ verdict: {
85
+ ok: false,
86
+ reason: "禁止服务端 JS 执行($where / $function / $accumulator),与只读开关无关",
87
+ isWrite: true,
88
+ summary: `${summary}(硬限制)`,
89
+ },
90
+ };
91
+ }
92
+ // 2) 管理/DDL 恒拒(前缀 + 精确名单)
93
+ const cmdLower = cmd.toLowerCase();
94
+ if (DENY_PREFIXES.some((p) => cmdLower.startsWith(p)) || DENY_COMMANDS.has(cmdLower)) {
95
+ return {
96
+ envelope,
97
+ verdict: {
98
+ ok: false,
99
+ reason: `禁止执行管理/DDL 命令:${cmd}(硬限制)`,
100
+ isWrite: true,
101
+ summary: `${summary}(硬限制)`,
102
+ },
103
+ };
104
+ }
105
+ // 3) aggregate 含 $out/$merge → 整条按写(readonly 由 isAllowed 统一裁决)
106
+ if (cmdLower === "aggregate" && hasAnyKey(envelope, AGG_WRITE_KEYS)) {
107
+ return { envelope, verdict: { ok: true, isWrite: true, summary: `${summary}($out/$merge 写回)` } };
108
+ }
109
+ // 4) 读白名单
110
+ if (READ_COMMANDS.has(cmdLower)) {
111
+ return { envelope, verdict: { ok: true, isWrite: false, summary } };
112
+ }
113
+ // 5) 已知写命令与其余未知命令一律按写(未知写意图不可排除)
114
+ return { envelope, verdict: { ok: true, isWrite: true, summary } };
115
+ }
116
+
117
+ // ── limit 注入(Spec 共识 Q8:无界查询自动封顶,bulk 上限 1000)───
118
+
119
+ /** 单条写命令文档数组上限(防手滑不防恶意;writable 模式另有确认框兜底) */
120
+ export const MAX_BULK_DOCS = 1000;
121
+
122
+ export function injectLimits(envelope: Record<string, unknown>, maxRows: number): void {
123
+ const cmd = (Object.keys(envelope)[0] ?? "").toLowerCase();
124
+ if (cmd === "find") {
125
+ // Mongo 语义:limit ≤ 0 等价于“不限”,与未提供同等对待,一并收敛到 maxRows
126
+ const userLimit = typeof envelope.limit === "number" && Number.isFinite(envelope.limit) && envelope.limit > 0
127
+ ? envelope.limit : undefined;
128
+ const limit = Math.min(Math.max(1, Math.trunc(userLimit ?? maxRows)), maxRows);
129
+ envelope.limit = limit;
130
+ envelope.batchSize = limit; // db.command 走 firstBatch,batchSize 决定单批返回量
131
+ return;
132
+ }
133
+ if (cmd === "aggregate" && Array.isArray(envelope.pipeline)) {
134
+ // 空 pipeline 同样需要封顶(全集合扫描)
135
+ const last = envelope.pipeline.length > 0
136
+ ? envelope.pipeline[envelope.pipeline.length - 1] as Record<string, unknown> | null
137
+ : null;
138
+ const hasTailLimit = last !== null && typeof last === "object"
139
+ && ("$limit" in last || "$count" in last);
140
+ if (!hasTailLimit) {
141
+ envelope.pipeline = [...(envelope.pipeline as unknown[]), { $limit: maxRows }];
142
+ }
143
+ const cursor = (envelope.cursor !== null && typeof envelope.cursor === "object"
144
+ ? { ...(envelope.cursor as Record<string, unknown>) }
145
+ : {}) as Record<string, unknown>;
146
+ cursor.batchSize = maxRows;
147
+ envelope.cursor = cursor;
148
+ return;
149
+ }
150
+ // 写命令文档数组封顶(insert/update/delete 的 documents 字段)
151
+ for (const field of ["documents", "updates", "deletes"]) {
152
+ const arr = envelope[field];
153
+ if (Array.isArray(arr) && arr.length > MAX_BULK_DOCS) {
154
+ envelope[field] = arr.slice(0, MAX_BULK_DOCS);
155
+ }
156
+ }
157
+ }
158
+
159
+ // ── 结果拍平(Spec 共识 Q7:顶层字段并集,上限 50 列)───
160
+
161
+ export const MAX_QUERY_COLUMNS = 50;
162
+
163
+ /** BSON 值 → 展示原语:ObjectId 取 hex、其余 BSON 优先 toJSON(Binary/UUID → base64,避免 [object Object])、Date ISO、对象/数组 JSON */
164
+ function cellOf(v: unknown): unknown {
165
+ if (v === undefined) return null;
166
+ if (v === null || typeof v !== "object") return v;
167
+ const b = v as { toHexString?: () => string; _bsontype?: string; toJSON?: () => unknown };
168
+ if (typeof b.toHexString === "function") return b.toHexString();
169
+ if (b._bsontype) {
170
+ const j = typeof b.toJSON === "function" ? b.toJSON() : undefined;
171
+ return j !== undefined && j !== null && typeof j !== "object" ? j : JSON.stringify(v);
172
+ }
173
+ if (v instanceof Date) return v.toISOString();
174
+ return JSON.stringify(v);
175
+ }
176
+
177
+ export function flattenDocs(
178
+ docs: unknown[],
179
+ maxRows: number,
180
+ ): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
181
+ const shown = docs.slice(0, maxRows);
182
+ // 列 = 顶层字段并集,首现顺序,封顶 MAX_QUERY_COLUMNS
183
+ // truncated 语义对齐 bigdata 方言:结果数达 maxRows 上限即标截断(无法区分“恰好等于”)
184
+ const columns: string[] = [];
185
+ const seen = new Set<string>();
186
+ for (const d of shown) {
187
+ if (columns.length >= MAX_QUERY_COLUMNS) break;
188
+ if (d !== null && typeof d === "object" && !Array.isArray(d)) {
189
+ for (const k of Object.keys(d as Record<string, unknown>)) {
190
+ if (!seen.has(k)) {
191
+ seen.add(k);
192
+ columns.push(k);
193
+ if (columns.length >= MAX_QUERY_COLUMNS) break;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ const rows = shown.map((d) => {
199
+ if (d === null || typeof d !== "object" || Array.isArray(d)) return [cellOf(d)];
200
+ const obj = d as Record<string, unknown>;
201
+ return columns.map((c) => cellOf(obj[c]));
202
+ });
203
+ return { columns, rows, rowCount: rows.length, truncated: docs.length >= maxRows };
204
+ }
205
+
206
+ // ── 基类 ──────────────────────────────────────────
207
+
208
+ export abstract class DocumentDialect implements Dialect {
209
+ abstract id: Dialect["id"];
210
+ abstract label: string;
211
+ abstract family: Dialect["family"];
212
+ abstract defaultPort: number;
213
+ abstract fingerprints: Fingerprints;
214
+ abstract parseUrl(url: string): ParsedTarget | null;
215
+ abstract displayUrl(config: ConnConfig): string;
216
+ abstract versionQuery(conn: DbConnection): Promise<string>;
217
+ protected abstract doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection>;
218
+ /** 执行一条已过白名单的命令信封,返回驱动原始响应 */
219
+ protected abstract doCommand(client: unknown, config: ConnConfig,
220
+ envelope: Record<string, unknown>): Promise<unknown>;
221
+ /** 原始响应 → 文档数组(MongoDB 取 cursor.firstBatch / distinct.values,默认整响应单行) */
222
+ protected extractDocs(raw: unknown): unknown[] {
223
+ return [raw];
224
+ }
225
+ abstract listTables(config: ConnConfig, pattern?: string): Promise<import("../core/types.js").ListTablesResult>;
226
+ abstract describeTable(config: ConnConfig, target: string): Promise<import("../core/types.js").DescribeTableResult>;
227
+
228
+ isAllowed(sql: string, readonly: boolean): Verdict {
229
+ const { verdict } = classifyEnvelope(sql);
230
+ if (!verdict.ok) return verdict;
231
+ if (verdict.isWrite && readonly) {
232
+ return { ...verdict, ok: false, reason: `只读模式下不允许执行写命令:${verdict.summary}` };
233
+ }
234
+ return verdict;
235
+ }
236
+
237
+ async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
238
+ const start = Date.now();
239
+ const { envelope, verdict } = classifyEnvelope(sql);
240
+ if (!envelope || !verdict.ok) {
241
+ return { success: false, error: verdict.reason ?? "命令被拒绝", duration: `${Date.now() - start}ms` };
242
+ }
243
+ injectLimits(envelope, opts.maxRows);
244
+ try {
245
+ const raw = await this.withConnection(
246
+ config,
247
+ (conn) => this.doCommand(conn.client, config, envelope),
248
+ opts.timeoutSec * 1000,
249
+ );
250
+ const docs = this.extractDocs(raw);
251
+ const { columns, rows, rowCount, truncated } = flattenDocs(docs, opts.maxRows);
252
+ return { success: true, columns, rows, rowCount, truncated, duration: `${Date.now() - start}ms` };
253
+ } catch (err: unknown) {
254
+ return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
255
+ }
256
+ }
257
+
258
+ async withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs = 10_000): Promise<T> {
259
+ const conn = await this.doConnect(config, timeoutMs);
260
+ try { return await fn(conn); }
261
+ finally { await conn.close(); }
262
+ }
263
+
264
+ async testConnection(config: ConnConfig): Promise<import("../core/types.js").TestConnectionResult> {
265
+ const start = Date.now();
266
+ try {
267
+ const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
268
+ return { success: true, version, latency: `${Date.now() - start}ms` };
269
+ } catch (err: unknown) {
270
+ return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
271
+ }
272
+ }
273
+ }
@@ -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
+ }
@@ -8,5 +8,7 @@ export { oracleDialect } from "./oracle.js";
8
8
  export { dmDialect } from "./dm.js";
9
9
  export { redisDialect } from "./redis.js";
10
10
  export { esDialect } from "./elasticsearch.js";
11
+ export { mongoDialect } from "./mongodb.js";
12
+ export { neo4jDialect } from "./neo4j.js";
11
13
  export { hiveDialect } from "./hive.js";
12
14
  export { sparkDialect } from "./spark.js";