@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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// dialects/neo4j.ts —— Neo4j 方言(GraphDialect + neo4j-driver 官方 Bolt 驱动)
|
|
2
|
+
// 交互形态:query_database 的 sql 参数填 Cypher 原文,如 MATCH (n:Person) RETURN n LIMIT 10
|
|
3
|
+
// 版本声明(对齐官方兼容矩阵):驱动 neo4j-driver@^5.28,server 4.4 ~ 2025.x 兼容;
|
|
4
|
+
// 已验证 4.4.29 community(10.2.15.249 真连冒烟)。驱动纯 JS 零原生编译。
|
|
5
|
+
// 图结构语义:label/关系类型 当"表"(关系类型带 rel: 前缀);describeTable 采样 ≤100 推断
|
|
6
|
+
// 属性键(对齐 Mongo Q5 共识),只依赖核心过程与 SHOW,不依赖 APOC。
|
|
7
|
+
|
|
8
|
+
import neo4j, { Driver } from "neo4j-driver";
|
|
9
|
+
import type { ConnConfig, DbConnection, ParsedTarget,
|
|
10
|
+
ListTablesResult, DescribeTableResult, TableInfo, ColumnInfo } from "../core/types.js";
|
|
11
|
+
import { GraphDialect, cellOf } from "./graph-dialect.js";
|
|
12
|
+
import { register, filterTables, type Fingerprints } from "./dialect.js";
|
|
13
|
+
|
|
14
|
+
// ── URL 解析:bolt(s|+s|+ssc):// 与 neo4j(s|+s|+ssc):// ────
|
|
15
|
+
// neo4j:// 是集群路由 scheme(多主机 seed list 逗号分隔);路径段 = database 名。
|
|
16
|
+
// IPv6 字面量主机([::1]:7687)不拆端口(与 mongodb.ts 同款处理)。
|
|
17
|
+
|
|
18
|
+
const NEO4J_RE = /^(bolt|neo4j)(\+s|\+ssc)?:\/\/(?:([^:/?#@]+)(?::([^@]*))?@)?([^/?#]+)(?:\/([^?]*))?(?:\?(.*))?$/;
|
|
19
|
+
|
|
20
|
+
export function parseNeo4jUrl(url: string): ParsedTarget | null {
|
|
21
|
+
const m = url.trim().match(NEO4J_RE);
|
|
22
|
+
if (!m) return null;
|
|
23
|
+
const ssl = m[2] === "+s" || m[2] === "+ssc";
|
|
24
|
+
let username: string | undefined;
|
|
25
|
+
let password: string | undefined;
|
|
26
|
+
if (m[3] !== undefined) {
|
|
27
|
+
try {
|
|
28
|
+
username = decodeURIComponent(m[3]);
|
|
29
|
+
password = m[4] !== undefined ? decodeURIComponent(m[4]) : undefined;
|
|
30
|
+
} catch { /* 编码异常按原文 */ username = m[3]; password = m[4]; }
|
|
31
|
+
}
|
|
32
|
+
let port = 7687;
|
|
33
|
+
const hosts = m[5].split(",").map((s) => s.trim()).filter(Boolean);
|
|
34
|
+
if (hosts.length === 0) return null;
|
|
35
|
+
let hostStr = hosts.join(",");
|
|
36
|
+
if (hosts.length === 1 && !hosts[0].startsWith("[")) {
|
|
37
|
+
const colon = hosts[0].lastIndexOf(":");
|
|
38
|
+
if (colon > 0) {
|
|
39
|
+
const p = parseInt(hosts[0].slice(colon + 1), 10);
|
|
40
|
+
if (Number.isFinite(p)) { hostStr = hosts[0].slice(0, colon); port = p; }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let database = m[6] !== undefined && m[6] !== "" ? decodeURIComponent(m[6]) : undefined;
|
|
44
|
+
const options: Record<string, string> = {};
|
|
45
|
+
if (m[7]) {
|
|
46
|
+
for (const [k, v] of new URLSearchParams(m[7])) options[k] = v;
|
|
47
|
+
}
|
|
48
|
+
if (ssl) options.ssl = "true";
|
|
49
|
+
const out: ParsedTarget = { host: hostStr, port };
|
|
50
|
+
if (username !== undefined) out.username = username;
|
|
51
|
+
if (password !== undefined) out.password = password;
|
|
52
|
+
if (database !== undefined) out.database = database;
|
|
53
|
+
out.ssl = ssl;
|
|
54
|
+
if (Object.keys(options).length > 0) out.options = options;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** ConnConfig → 标准 Bolt URI(直连 bolt:// 起底——单机社区版无路由服务,
|
|
59
|
+
* neo4j:// 集群路由 scheme 会导致 "No routing servers available";
|
|
60
|
+
* options.ssl=true 还原 +s scheme。集群用户可改用 options 存路由地址,后续按需扩展) */
|
|
61
|
+
function buildUri(config: ConnConfig): string {
|
|
62
|
+
const ssl = config.options?.ssl === "true";
|
|
63
|
+
const scheme = ssl ? "bolt+s" : "bolt";
|
|
64
|
+
const host = config.host ?? "localhost";
|
|
65
|
+
const hostPart = host.includes(",") || host.includes("]")
|
|
66
|
+
? host
|
|
67
|
+
: `${host}:${config.port ?? 7687}`;
|
|
68
|
+
const auth = config.username
|
|
69
|
+
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? "")}@`
|
|
70
|
+
: "";
|
|
71
|
+
return `${scheme}://${auth}${hostPart}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── 工具函数 ──────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function errMsg(err: unknown): string {
|
|
77
|
+
return err instanceof Error ? err.message : String(err);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface RawRecord { keys?: string[]; _fields?: unknown[] }
|
|
81
|
+
interface RawSummary { counters?: { _stats?: Record<string, number> } }
|
|
82
|
+
|
|
83
|
+
interface SampledProp { name: string; count: number; types: string[] }
|
|
84
|
+
|
|
85
|
+
const SAMPLE_NODES = 100;
|
|
86
|
+
const MAX_DESCRIBE_PROPS = 100;
|
|
87
|
+
|
|
88
|
+
/** 驱动值的类型名(describeTable 采样推断用) */
|
|
89
|
+
function typeOf(v: unknown): string {
|
|
90
|
+
if (v === null) return "null";
|
|
91
|
+
const t = v as { __isInteger__?: boolean; labels?: string[]; type?: string;
|
|
92
|
+
segments?: unknown[]; constructor?: { name?: string } };
|
|
93
|
+
if (typeof v === "number" || t.__isInteger__) return Number.isInteger(Number(v)) ? "integer" : "float";
|
|
94
|
+
if (typeof v === "boolean") return "boolean";
|
|
95
|
+
if (typeof v === "string") return "string";
|
|
96
|
+
if (v instanceof Date) return "datetime";
|
|
97
|
+
if (Array.isArray(t.labels)) return "node";
|
|
98
|
+
if (typeof t.type === "string") return "relationship";
|
|
99
|
+
if (Array.isArray(t.segments)) return "path";
|
|
100
|
+
if (Array.isArray(v)) return "list";
|
|
101
|
+
if (typeof v === "object" && t.constructor?.name?.startsWith("Duration")) return "duration";
|
|
102
|
+
if (typeof v === "object" && t.constructor?.name) return t.constructor.name.toLowerCase();
|
|
103
|
+
return typeof v;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 采样 ≤100 个实体,推断属性键清单(出现次数 + 类型分布),返回实际采样总数 */
|
|
107
|
+
function sampleProps(records: RawRecord[]): { fields: SampledProp[]; total: number } {
|
|
108
|
+
const counts = new Map<string, number>();
|
|
109
|
+
const types = new Map<string, Set<string>>();
|
|
110
|
+
let total = 0;
|
|
111
|
+
for (const r of records) {
|
|
112
|
+
const fields = r._fields ?? [];
|
|
113
|
+
const entity = fields[0];
|
|
114
|
+
if (entity === null || entity === undefined || typeof entity !== "object") continue;
|
|
115
|
+
const props = (entity as { properties?: Record<string, unknown> }).properties;
|
|
116
|
+
if (!props || typeof props !== "object") continue;
|
|
117
|
+
total++;
|
|
118
|
+
for (const [k, v] of Object.entries(props)) {
|
|
119
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
120
|
+
const set = types.get(k) ?? new Set<string>();
|
|
121
|
+
set.add(typeOf(v));
|
|
122
|
+
types.set(k, set);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const fields = [...counts.entries()]
|
|
126
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
127
|
+
.slice(0, MAX_DESCRIBE_PROPS)
|
|
128
|
+
.map(([name, count]) => ({ name, count, types: [...(types.get(name) ?? [])] }));
|
|
129
|
+
return { fields, total };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 反引号引用 Cypher 标识符(label 可能是 `0` 这类数字/特殊字符名,实测环境已出现) */
|
|
133
|
+
function quoteId(name: string): string {
|
|
134
|
+
return "`" + name.replace(/`/g, "``") + "`";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 方言实现 ──────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
class Neo4jDialect extends GraphDialect {
|
|
140
|
+
id = "neo4j" as const;
|
|
141
|
+
label = "Neo4j";
|
|
142
|
+
family = "graph" as const;
|
|
143
|
+
defaultPort = 7687;
|
|
144
|
+
fingerprints: Fingerprints = {
|
|
145
|
+
urlPatterns: [/^(bolt|neo4j)(\+s|\+ssc)?:\/\//],
|
|
146
|
+
configKeys: ["spring.neo4j.uri", "spring.data.neo4j.uri"],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
parseUrl(url: string): ParsedTarget | null {
|
|
150
|
+
return parseNeo4jUrl(url);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
displayUrl(config: ConnConfig): string {
|
|
154
|
+
const ssl = config.options?.ssl === "true";
|
|
155
|
+
const scheme = ssl ? "bolt+s" : "bolt";
|
|
156
|
+
const host = config.host ?? "localhost";
|
|
157
|
+
const hostPart = host.includes(",") || host.includes("]") ? host : `${host}:${config.port ?? 7687}`;
|
|
158
|
+
return `${scheme}://${hostPart}${config.database ? `/${config.database}` : ""}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
|
|
162
|
+
const driver: Driver = neo4j.driver(buildUri(config), neo4j.auth.basic(config.username ?? "", config.password ?? ""), {
|
|
163
|
+
connectionTimeout: timeoutMs,
|
|
164
|
+
maxConnectionPoolSize: 10,
|
|
165
|
+
});
|
|
166
|
+
// 首次使用即验证可达性,失败立即释放(testConnection/查询统一走此路径)
|
|
167
|
+
try {
|
|
168
|
+
await driver.verifyConnectivity();
|
|
169
|
+
} catch (err: unknown) {
|
|
170
|
+
try { await driver.close(); } catch { /* ignore */ }
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
type: "neo4j",
|
|
175
|
+
client: driver,
|
|
176
|
+
async close() { try { await driver.close(); } catch { /* ignore */ } },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 执行一条 Cypher(每连接单 session;缺省库 neo4j) */
|
|
181
|
+
protected async runCypher(conn: DbConnection, config: ConnConfig,
|
|
182
|
+
cypher: string): Promise<{ records: RawRecord[]; summary: RawSummary }> {
|
|
183
|
+
const driver = conn.client as Driver;
|
|
184
|
+
const session = driver.session({ database: config.database || "neo4j" });
|
|
185
|
+
try {
|
|
186
|
+
const result = await session.run(cypher);
|
|
187
|
+
return {
|
|
188
|
+
records: result.records as unknown as RawRecord[],
|
|
189
|
+
summary: result.summary as unknown as RawSummary,
|
|
190
|
+
};
|
|
191
|
+
} finally {
|
|
192
|
+
try { await session.close(); } catch { /* ignore */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async versionQuery(conn: DbConnection): Promise<string> {
|
|
197
|
+
const driver = conn.client as Driver;
|
|
198
|
+
const session = driver.session({ database: "system", defaultAccessMode: neo4j.session.READ });
|
|
199
|
+
try {
|
|
200
|
+
const r = await session.run("CALL dbms.components() YIELD versions, edition RETURN versions[0] AS version, edition");
|
|
201
|
+
const rec = r.records[0];
|
|
202
|
+
const v = rec?.get("version") ?? "unknown";
|
|
203
|
+
const e = rec?.get("edition") ?? "";
|
|
204
|
+
return `${v}${e ? ` (${e})` : ""}`;
|
|
205
|
+
} catch {
|
|
206
|
+
// system 库不可读时回退默认库
|
|
207
|
+
const s2 = driver.session({ database: "neo4j", defaultAccessMode: neo4j.session.READ });
|
|
208
|
+
try {
|
|
209
|
+
const r2 = await s2.run("CALL dbms.components() YIELD versions RETURN versions[0] AS version");
|
|
210
|
+
return r2.records[0]?.get("version") ?? "unknown";
|
|
211
|
+
} finally { try { await s2.close(); } catch { /* ignore */ } }
|
|
212
|
+
} finally {
|
|
213
|
+
try { await session.close(); } catch { /* ignore */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// listTables → db.labels()(NODE)+ db.relationshipTypes()(RELATIONSHIP,rel: 前缀)
|
|
218
|
+
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
219
|
+
try {
|
|
220
|
+
const tables: TableInfo[] = await this.withConnection(config, async (conn) => {
|
|
221
|
+
const db = config.database || "neo4j";
|
|
222
|
+
const out: TableInfo[] = [];
|
|
223
|
+
const labels = await this.runCypher(conn, config, "CALL db.labels() YIELD label RETURN label");
|
|
224
|
+
for (const r of labels.records) {
|
|
225
|
+
const name = String((r._fields ?? [])[0] ?? "");
|
|
226
|
+
if (name) out.push({ schema: db, name, type: "NODE LABEL", description: "" });
|
|
227
|
+
}
|
|
228
|
+
const rels = await this.runCypher(conn, config, "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType");
|
|
229
|
+
for (const r of rels.records) {
|
|
230
|
+
const name = String((r._fields ?? [])[0] ?? "");
|
|
231
|
+
if (name) out.push({ schema: db, name: `rel:${name}`, type: "RELATIONSHIP", description: "" });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
});
|
|
235
|
+
const filtered = filterTables(tables, pattern);
|
|
236
|
+
return { success: true, tables: filtered, count: filtered.length };
|
|
237
|
+
} catch (err: unknown) {
|
|
238
|
+
return { success: false, error: errMsg(err) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// describeTable → 节点/关系计数 + SHOW INDEXES/CONSTRAINTS + 采样 ≤100 推断属性(不依赖 APOC)
|
|
243
|
+
// target:label 名;关系类型用 `rel:TYPE` 形式(与 listTables 输出一致)
|
|
244
|
+
async describeTable(config: ConnConfig, target: string): Promise<DescribeTableResult> {
|
|
245
|
+
const name = target.trim();
|
|
246
|
+
if (!name) return { success: false, error: "目标不能为空(label 名或 rel:关系类型)" };
|
|
247
|
+
const isRel = name.toLowerCase().startsWith("rel:");
|
|
248
|
+
const graphName = isRel ? name.slice(4) : name;
|
|
249
|
+
if (!graphName) return { success: false, error: "目标名不能为空" };
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const columns = await this.withConnection(config, async (conn) => {
|
|
253
|
+
const matchPart = isRel
|
|
254
|
+
? `MATCH ()-[e:${quoteId(graphName)}]->()`
|
|
255
|
+
: `MATCH (e:${quoteId(graphName)})`;
|
|
256
|
+
// 实体计数
|
|
257
|
+
let count = "?";
|
|
258
|
+
try {
|
|
259
|
+
const c = await this.runCypher(conn, config, `${matchPart} RETURN count(e) AS n`);
|
|
260
|
+
count = String(cellOf((c.records[0]?._fields ?? [])[0]));
|
|
261
|
+
} catch { /* 计数失败不阻断元数据 */ }
|
|
262
|
+
|
|
263
|
+
// 索引/约束(SHOW 全量取回,内存过滤目标 label/类型)
|
|
264
|
+
const indexLines: string[] = [];
|
|
265
|
+
const uniqueProps = new Set<string>();
|
|
266
|
+
try {
|
|
267
|
+
const idx = await this.runCypher(conn, config,
|
|
268
|
+
"SHOW INDEXES YIELD name, type, entityType, labelsOrTypes, properties, state RETURN *");
|
|
269
|
+
for (const r of idx.records) {
|
|
270
|
+
const f = r._fields ?? [];
|
|
271
|
+
const types = f[3] as string[] | null;
|
|
272
|
+
if (!Array.isArray(types) || !types.includes(graphName)) continue;
|
|
273
|
+
indexLines.push(`${f[0]}(${f[1]}, ${f[5] ?? "online"}) ON ${isRel ? "rel" : "node"}(${types.join(":")}).(${(f[4] ?? []).join(",")})`);
|
|
274
|
+
}
|
|
275
|
+
} catch { /* SHOW 失败降级 */ }
|
|
276
|
+
try {
|
|
277
|
+
const cons = await this.runCypher(conn, config,
|
|
278
|
+
"SHOW CONSTRAINTS YIELD name, type, entityType, labelsOrTypes, properties RETURN *");
|
|
279
|
+
for (const r of cons.records) {
|
|
280
|
+
const f = r._fields ?? [];
|
|
281
|
+
const types = f[3] as string[] | null;
|
|
282
|
+
if (!Array.isArray(types) || !types.includes(graphName)) continue;
|
|
283
|
+
for (const p of (f[4] ?? []) as string[]) if (String(f[1]).toUpperCase().includes("UNIQUE")) uniqueProps.add(p);
|
|
284
|
+
indexLines.push(`${f[0]}(${f[1]}) ON ${types.join(":")}.(${(f[4] ?? []).join(",")})`);
|
|
285
|
+
}
|
|
286
|
+
} catch { /* 降级 */ }
|
|
287
|
+
|
|
288
|
+
const cols: ColumnInfo[] = [
|
|
289
|
+
{
|
|
290
|
+
name: isRel ? "relationships" : "nodes", type: "meta", nullable: true, default: null, primaryKey: false,
|
|
291
|
+
comment: `${isRel ? "关系" : "节点"}数 ${count}${indexLines.length > 0 ? `;${indexLines.slice(0, 10).join(";")}` : ""}`,
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// 采样推断属性键(返回实体本体,驱动侧聚合)
|
|
296
|
+
const sampled = await this.runCypher(conn, config, `${matchPart} RETURN e LIMIT ${SAMPLE_NODES}`);
|
|
297
|
+
const { fields, total } = sampleProps(sampled.records);
|
|
298
|
+
if (fields.length === 0) {
|
|
299
|
+
cols.push({
|
|
300
|
+
name: "(properties)", type: "meta", nullable: true, default: null, primaryKey: false,
|
|
301
|
+
comment: `无样本或实体为空(采样 ≤${SAMPLE_NODES} 推断,非权威 schema)`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
for (const f of fields) {
|
|
305
|
+
cols.push({
|
|
306
|
+
name: f.name, type: f.types.slice(0, 3).join("/"), nullable: f.count < total,
|
|
307
|
+
default: null, primaryKey: uniqueProps.has(f.name),
|
|
308
|
+
comment: `出现 ${f.count}/${total}(采样 ≤${SAMPLE_NODES} 推断)${uniqueProps.has(f.name) ? ";UNIQUE" : ""}`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return cols;
|
|
312
|
+
});
|
|
313
|
+
return { success: true, columns, count: columns.length };
|
|
314
|
+
} catch (err: unknown) {
|
|
315
|
+
return { success: false, error: errMsg(err) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const neo4jDialect = new Neo4jDialect();
|
|
321
|
+
register(neo4jDialect);
|
|
@@ -22,6 +22,14 @@ const READ_KEYS: Record<string, "_search" | "_count" | "_mget"> = {
|
|
|
22
22
|
|
|
23
23
|
const WRITE_KEYS = ["bulk", "doc_write", "delete", "update", "mapping", "settings"];
|
|
24
24
|
|
|
25
|
+
/** 无 query 键的纯检索体安全键(aggs-only / size/sort 等 _search body),按读分类 */
|
|
26
|
+
const SEARCH_BODY_KEYS: ReadonlySet<string> = new Set([
|
|
27
|
+
"aggs", "aggregations", "size", "from", "sort", "_source", "fields",
|
|
28
|
+
"docvalue_fields", "stored_fields", "highlight", "suggest", "collapse",
|
|
29
|
+
"track_total_hits", "min_score", "post_filter", "indices_boost",
|
|
30
|
+
"terminate_after", "timeout", "version", "seq_no_primary_term", "explain", "knn",
|
|
31
|
+
]);
|
|
32
|
+
|
|
25
33
|
// `DELETE <index>` 纯字符串形式(删索引)恒拒——大小写不敏感、前导空白容忍
|
|
26
34
|
const DELETE_INDEX_RE = /^\s*DELETE\s+\S+/i;
|
|
27
35
|
|
|
@@ -42,6 +50,10 @@ export function parseDsl(input: string): DslKind {
|
|
|
42
50
|
return { type: "write", endpoint: k, detail: describeDetail(body) };
|
|
43
51
|
}
|
|
44
52
|
}
|
|
53
|
+
// 纯检索体(aggs/sort/size 等无 query 键的 _search body)按读
|
|
54
|
+
if (keys.some((k) => SEARCH_BODY_KEYS.has(k.toLowerCase()))) {
|
|
55
|
+
return { type: "read", endpoint: "_search", detail: describeDetail(body) };
|
|
56
|
+
}
|
|
45
57
|
// JSON 但无已知信封 key——保守按写处理(未知操作的写意图不可排除)
|
|
46
58
|
return { type: "write", endpoint: keys[0] ?? "unknown", detail: describeDetail(body) };
|
|
47
59
|
} catch {
|
|
@@ -89,12 +101,15 @@ export abstract class SearchDialect implements Dialect {
|
|
|
89
101
|
const summary = `SEARCH(${kind.endpoint}/${kind.detail || "match"})`;
|
|
90
102
|
return { ok: true, isWrite: false, summary };
|
|
91
103
|
}
|
|
92
|
-
//
|
|
104
|
+
// 写端点:本方言 executeOn 仅实现读,早期明确拒绝
|
|
105
|
+
// (原先只读模式才拒、可写模式放行到确认后才报“仅执行读查询”,体验差)
|
|
93
106
|
const summary = `SEARCH(${kind.endpoint}/${kind.detail || "write"})`;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: `ES 方言仅支持读查询(_search/_count/_mget),写端点不支持:${kind.endpoint}`,
|
|
110
|
+
isWrite: true,
|
|
111
|
+
summary,
|
|
112
|
+
};
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
|