@nsyan/db 1.0.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 +128 -0
- package/index.ts +929 -0
- package/package.json +57 -0
- package/src/config.ts +220 -0
- package/src/core/export.ts +14 -0
- package/src/core/index.ts +7 -0
- package/src/core/policy.ts +11 -0
- package/src/core/scan/candidates.ts +286 -0
- package/src/core/scan/parsers.ts +149 -0
- package/src/core/scan/placeholders.ts +24 -0
- package/src/core/scan/scoring.ts +25 -0
- package/src/core/scan/spring.ts +73 -0
- package/src/core/scan/walker.ts +47 -0
- package/src/core/sql-text.ts +72 -0
- package/src/core/types.ts +88 -0
- package/src/core/whitelist.ts +4 -0
- package/src/dialects/bigdata-dialect.ts +197 -0
- package/src/dialects/dialect.ts +38 -0
- package/src/dialects/dm.ts +159 -0
- package/src/dialects/elasticsearch.ts +219 -0
- package/src/dialects/hive.ts +124 -0
- package/src/dialects/index.ts +12 -0
- package/src/dialects/kv-dialect.ts +127 -0
- package/src/dialects/mysql.ts +158 -0
- package/src/dialects/oracle.ts +177 -0
- package/src/dialects/postgresql.ts +168 -0
- package/src/dialects/redis.ts +190 -0
- package/src/dialects/relational-dialect.ts +76 -0
- package/src/dialects/search-dialect.ts +128 -0
- package/src/dialects/spark.ts +120 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/dialects/bigdata-dialect.ts —— 大数据 SQL 引擎家族基类(Hive / Spark Thrift Server 共享 HS2 协议栈)
|
|
2
|
+
// 注:Hive 与 Spark Thrift Server 同协议(HiveServer2 Thrift),连接建立、会话变量、
|
|
3
|
+
// 会话关闭、batch 拉取、取消 operation 全部在基类实现,方言只提供差异点
|
|
4
|
+
// (写关键字集、会话变量前缀、版本查询、DESCRIBE 解析)。
|
|
5
|
+
import type { ConnConfig, DbConnection, ExecOpts, QueryResult,
|
|
6
|
+
ListTablesResult, DescribeTableResult, TestConnectionResult, TableInfo } from "../core/types.js";
|
|
7
|
+
import { splitStatements, isWriteStatement, isDropStatement } from "../core/sql-text.js";
|
|
8
|
+
import type { Dialect, Verdict, Fingerprints } from "./dialect.js";
|
|
9
|
+
import { likeMatch } from "./dialect.js";
|
|
10
|
+
|
|
11
|
+
// ── HS2 会话/操作最小结构(按 hive-driver 实际 API: HiveSession/HiveOperation)───
|
|
12
|
+
// 用 unknown + 收窄避免对 hive-driver 强类型依赖;仅单元测试可构造 FakeHs2Session。
|
|
13
|
+
export interface Hs2Operation {
|
|
14
|
+
setMaxRows(n: number): void;
|
|
15
|
+
fetch(): Promise<Hs2Status>;
|
|
16
|
+
hasMoreRows(): boolean;
|
|
17
|
+
getSchema(): { columns: Array<{ columnName: string; comment?: string }> } | null;
|
|
18
|
+
getData(): Array<{ rows?: Array<{ colVals: Array<{ value?: unknown }> }> }>;
|
|
19
|
+
flush(): void;
|
|
20
|
+
cancel(): Promise<unknown>;
|
|
21
|
+
close(): Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
export interface Hs2Session {
|
|
24
|
+
executeStatement(stmt: string): Promise<Hs2Operation>;
|
|
25
|
+
close(): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
interface Hs2Status { statusCode?: number; errorMessage?: string; }
|
|
28
|
+
|
|
29
|
+
function statusFailed(s: Hs2Status): string | null {
|
|
30
|
+
const code = s?.statusCode;
|
|
31
|
+
if (code === undefined || code === 0) return null;
|
|
32
|
+
return s.errorMessage || `operation failed (statusCode=${code})`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
36
|
+
return new Promise<T>((resolve, reject) => {
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
reject(new Error(`${label} 超时(超过 ${timeoutMs / 1000} 秒)`));
|
|
39
|
+
}, timeoutMs);
|
|
40
|
+
promise.then(
|
|
41
|
+
(val) => { clearTimeout(timer); resolve(val); },
|
|
42
|
+
(err) => { clearTimeout(timer); reject(err); }
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export abstract class BigDataDialect implements Dialect {
|
|
48
|
+
abstract id: Dialect["id"];
|
|
49
|
+
abstract label: string;
|
|
50
|
+
abstract family: Dialect["family"];
|
|
51
|
+
abstract defaultPort: number;
|
|
52
|
+
abstract fingerprints: Fingerprints;
|
|
53
|
+
abstract parseUrl(url: string): ReturnType<Dialect["parseUrl"]>;
|
|
54
|
+
abstract displayUrl(config: ConnConfig): string;
|
|
55
|
+
abstract versionQuery(conn: DbConnection): Promise<string>;
|
|
56
|
+
/** 方言的写关键字集(大小写不敏感正则片段,逐语句匹配) */
|
|
57
|
+
protected abstract writeKeywords: RegExp;
|
|
58
|
+
/** 会话变量前缀:hive. / spark.(SET <prefix>key=value 在 withConnection 内下发) */
|
|
59
|
+
protected abstract sessionPrefix: string;
|
|
60
|
+
|
|
61
|
+
// ── HS2 会话建立:openSession 由方言 doConnect 完成,client 即 Hs2Session ──
|
|
62
|
+
protected abstract doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection>;
|
|
63
|
+
|
|
64
|
+
async withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs = 30_000): Promise<T> {
|
|
65
|
+
// 建连(TCP 握手 + openSession)同样纳入超时,避免无界挂起;
|
|
66
|
+
// 默认 30s(HS2 握手慢于关系型),各方言不再各自加超时
|
|
67
|
+
const conn = await withTimeout(this.doConnect(config, timeoutMs), timeoutMs, "连接数据库");
|
|
68
|
+
try { return await fn(conn); }
|
|
69
|
+
finally { await conn.close(); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 只读检查:复用 sql-text 逐语句机制 + 家族写关键字(与 Task 1 WRITE_RE 一致方向)───
|
|
73
|
+
isAllowed(sql: string, readonly: boolean): Verdict {
|
|
74
|
+
const stmts = splitStatements(sql);
|
|
75
|
+
if (stmts.length === 0) return { ok: false, reason: "SQL 语句为空" };
|
|
76
|
+
for (const s of stmts) {
|
|
77
|
+
if (isDropStatement(s)) {
|
|
78
|
+
return { ok: false, reason: `禁止执行 DROP 操作:${s.slice(0, 80)}`, summary: `DROP(硬限制): ${s.slice(0, 60)}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const first = stmts[0];
|
|
82
|
+
const kind = /^\s*(\w+)/.exec(first)?.[1]?.toUpperCase() ?? "SQL";
|
|
83
|
+
const targets = [...first.matchAll(/\b(?:FROM|INTO|UPDATE|TABLE)\s+([A-Za-z0-9_."]+)/gi)].map((m) => m[1]).slice(0, 3).join(", ");
|
|
84
|
+
const summary = `${kind}${targets ? " " + targets : ""}(共 ${stmts.length} 条语句)`;
|
|
85
|
+
const flagged = (s: string): boolean => isWriteStatement(s) || this.writeKeywords.test(s);
|
|
86
|
+
if (readonly) {
|
|
87
|
+
for (const s of stmts) {
|
|
88
|
+
if (flagged(s)) {
|
|
89
|
+
return { ok: false, reason: `只读模式下不允许执行非查询语句:${s.slice(0, 80)}`, isWrite: true, summary };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const isWrite = stmts.some(flagged);
|
|
94
|
+
return { ok: true, isWrite, summary };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── 执行:executeStatement → 循环 fetch batch → 达 maxRows 即停 + truncated 标注 ──
|
|
98
|
+
// 超时用客户端 withTimeout 兜底,超时后尝试 cancel 释放服务端资源
|
|
99
|
+
protected async runStatement(session: Hs2Session, stmt: string, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean }> {
|
|
100
|
+
const op = await session.executeStatement(stmt);
|
|
101
|
+
const execMs = opts.timeoutSec * 1000;
|
|
102
|
+
try {
|
|
103
|
+
const maxRows = Math.max(1, opts.maxRows);
|
|
104
|
+
const status = await withTimeout(op.fetch(), execMs, "执行查询");
|
|
105
|
+
const failed = statusFailed(status);
|
|
106
|
+
if (failed) throw new Error(failed);
|
|
107
|
+
op.setMaxRows(Math.min(maxRows, 1000));
|
|
108
|
+
const schema = op.getSchema();
|
|
109
|
+
const columns = (schema?.columns ?? []).map((c) => c.columnName);
|
|
110
|
+
const rows: unknown[][] = [];
|
|
111
|
+
let truncated = false;
|
|
112
|
+
// 首批已在 fetch() 内拉取;逐批 hasMoreRows 拉取,达 maxRows 即停
|
|
113
|
+
for (;;) {
|
|
114
|
+
for (const rs of op.getData()) {
|
|
115
|
+
for (const row of rs.rows ?? []) {
|
|
116
|
+
if (rows.length >= maxRows) { truncated = true; break; }
|
|
117
|
+
rows.push((row.colVals ?? []).map((cv) => cv?.value ?? null));
|
|
118
|
+
}
|
|
119
|
+
if (truncated) break;
|
|
120
|
+
}
|
|
121
|
+
op.flush();
|
|
122
|
+
if (truncated || !op.hasMoreRows()) break;
|
|
123
|
+
const st = await withTimeout(op.fetch(), execMs, "拉取结果");
|
|
124
|
+
const f = statusFailed(st);
|
|
125
|
+
if (f) throw new Error(f);
|
|
126
|
+
}
|
|
127
|
+
return { columns, rows, rowCount: rows.length, truncated };
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
try { await op.cancel(); } catch { /* 释放资源尽力而为 */ }
|
|
130
|
+
throw err;
|
|
131
|
+
} finally {
|
|
132
|
+
try { await op.close(); } catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 版本查询统一走 runStatement 的受保护拉取路径(超时 + cancel + close),失败兜底 "unknown"
|
|
137
|
+
protected async fetchVersion(session: Hs2Session, stmt = "SELECT version()", timeoutSec = 30): Promise<string> {
|
|
138
|
+
try {
|
|
139
|
+
const r = await this.runStatement(session, stmt, { readonly: true, maxRows: 1, timeoutSec });
|
|
140
|
+
const val = r.rows[0]?.[0];
|
|
141
|
+
return val !== undefined && val !== null ? String(val) : "unknown";
|
|
142
|
+
} catch {
|
|
143
|
+
return "unknown";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
|
|
147
|
+
const start = Date.now();
|
|
148
|
+
const stmts = splitStatements(sql);
|
|
149
|
+
try {
|
|
150
|
+
let last = { columns: [] as string[], rows: [] as unknown[][], rowCount: 0 };
|
|
151
|
+
let truncated = false;
|
|
152
|
+
await this.withConnection(config, async (conn) => {
|
|
153
|
+
const session = conn.client as Hs2Session;
|
|
154
|
+
for (const s of stmts) {
|
|
155
|
+
const r = await this.runStatement(session, s, opts);
|
|
156
|
+
last = { columns: r.columns, rows: r.rows, rowCount: r.rowCount };
|
|
157
|
+
if (r.truncated) truncated = true;
|
|
158
|
+
}
|
|
159
|
+
}, opts.timeoutSec * 1000);
|
|
160
|
+
const duration = `${Date.now() - start}ms`;
|
|
161
|
+
return { success: true, ...last, duration, truncated: truncated || undefined };
|
|
162
|
+
} catch (err: unknown) {
|
|
163
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async testConnection(config: ConnConfig): Promise<TestConnectionResult> {
|
|
168
|
+
const start = Date.now();
|
|
169
|
+
try {
|
|
170
|
+
const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
|
|
171
|
+
return { success: true, version, latency: `${Date.now() - start}ms` };
|
|
172
|
+
} catch (err: unknown) {
|
|
173
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// SHOW TABLES + 可选 pattern 过滤(HS2 无 LIKE 下推,内存过滤)
|
|
178
|
+
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
179
|
+
try {
|
|
180
|
+
const tables = await this.withConnection(config, async (conn) => {
|
|
181
|
+
const session = conn.client as Hs2Session;
|
|
182
|
+
const r = await this.runStatement(session, "SHOW TABLES", { readonly: true, maxRows: 5000, timeoutSec: 30 });
|
|
183
|
+
const nameIdx = r.columns.findIndex((c) => /table/i.test(c));
|
|
184
|
+
const rows: TableInfo[] = r.rows
|
|
185
|
+
.map((row) => String(row[nameIdx >= 0 ? nameIdx : 0] ?? ""))
|
|
186
|
+
.filter((name) => name && (!pattern || likeMatch(name, pattern)))
|
|
187
|
+
.map((name) => ({ schema: config.database ?? "", name, type: "TABLE", description: "" }));
|
|
188
|
+
return rows;
|
|
189
|
+
});
|
|
190
|
+
return { success: true, tables, count: tables.length };
|
|
191
|
+
} catch (err: unknown) {
|
|
192
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
abstract describeTable(config: ConnConfig, target: string): Promise<DescribeTableResult>;
|
|
197
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// dialects/dialect.ts
|
|
2
|
+
import type { ConnConfig, ParsedTarget, DbConnection, ExecOpts, DbTypeId, DbFamily,
|
|
3
|
+
QueryResult, ListTablesResult, DescribeTableResult, TestConnectionResult } from "../core/types.js";
|
|
4
|
+
|
|
5
|
+
export interface Fingerprints { urlPatterns: RegExp[]; configKeys: string[]; }
|
|
6
|
+
export interface Verdict { ok: boolean; reason?: string; isWrite?: boolean; summary?: string; }
|
|
7
|
+
|
|
8
|
+
export interface Dialect {
|
|
9
|
+
id: DbTypeId;
|
|
10
|
+
label: string;
|
|
11
|
+
family: DbFamily;
|
|
12
|
+
defaultPort: number;
|
|
13
|
+
fingerprints: Fingerprints;
|
|
14
|
+
parseUrl(url: string): ParsedTarget | null;
|
|
15
|
+
withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs?: number): Promise<T>;
|
|
16
|
+
testConnection(config: ConnConfig): Promise<TestConnectionResult>;
|
|
17
|
+
isAllowed(sql: string, readonly: boolean): Verdict;
|
|
18
|
+
executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult>;
|
|
19
|
+
listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult>;
|
|
20
|
+
describeTable(config: ConnConfig, target: string): Promise<DescribeTableResult>;
|
|
21
|
+
displayUrl(config: ConnConfig): string;
|
|
22
|
+
versionQuery(conn: DbConnection): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const registry = new Map<DbTypeId, Dialect>();
|
|
26
|
+
export function register(d: Dialect): void { registry.set(d.id, d); }
|
|
27
|
+
|
|
28
|
+
// SQL LIKE(%/_)转正则,listTables(pattern) 内存过滤共用(Spec §11.1 P0)
|
|
29
|
+
export function likeMatch(name: string, pattern: string): boolean {
|
|
30
|
+
const re = new RegExp("^" + pattern.split("").map((ch) =>
|
|
31
|
+
ch === "%" ? ".*" : ch === "_" ? "." : ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("") + "$", "i");
|
|
32
|
+
return re.test(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function filterTables<T extends { name: string; schema?: string }>(tables: T[], pattern?: string): T[] {
|
|
36
|
+
if (!pattern) return tables;
|
|
37
|
+
return tables.filter((t) => likeMatch(t.name, pattern) || (t.schema ? likeMatch(t.schema, pattern) : false));
|
|
38
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/dialects/dm.ts
|
|
2
|
+
import dmdb from "dmdb";
|
|
3
|
+
import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
|
|
4
|
+
ListTablesResult, DescribeTableResult, ColumnInfo } from "../core/types.js";
|
|
5
|
+
import { RelationalDialect } from "./relational-dialect.js";
|
|
6
|
+
import { register, filterTables, type Fingerprints } from "./dialect.js";
|
|
7
|
+
|
|
8
|
+
// ── URL 解析(JDBC 主形态 + 原生 URI 双形态,Spec §7)───
|
|
9
|
+
|
|
10
|
+
const DM_DEFAULT_PORT = 5236;
|
|
11
|
+
const JDBC_RE = /^jdbc:dm:\/\/([^:/?#]+)(?::(\d+))?\/([^?#]+)$/;
|
|
12
|
+
const NATIVE_RE = /^dm:\/\/([^:/?#@]+)(?::(\d+))?\/([^?#]+)$/;
|
|
13
|
+
|
|
14
|
+
function parseDmUrl(url: string): ParsedTarget | null {
|
|
15
|
+
const clean = url.split("?")[0];
|
|
16
|
+
const m = clean.match(JDBC_RE) ?? clean.match(NATIVE_RE);
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
const host = m[1];
|
|
19
|
+
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 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── DM(达梦)方言 ─────────────────────────────────
|
|
26
|
+
// 注:dmdb 为官方 JS 驱动(API 仿 oracledb),macOS ARM64 已验证可安装加载
|
|
27
|
+
// (见 Task 9 spike)。DM9 兼容性未验证(Spec §12:仅声明测过 DM8)。
|
|
28
|
+
|
|
29
|
+
class DmDialect extends RelationalDialect {
|
|
30
|
+
id = "dm" as const;
|
|
31
|
+
label = "DM";
|
|
32
|
+
family = "relational" as const;
|
|
33
|
+
defaultPort = DM_DEFAULT_PORT;
|
|
34
|
+
fingerprints: Fingerprints = {
|
|
35
|
+
urlPatterns: [/^jdbc:dm:\/\//, /^dm:\/\//],
|
|
36
|
+
configKeys: ["spring.datasource.url"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
parseUrl(url: string): ParsedTarget | null {
|
|
40
|
+
return parseDmUrl(url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
displayUrl(config: ConnConfig): string {
|
|
44
|
+
return `jdbc:dm://${config.host}:${config.port}/${config.database}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
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
|
+
return {
|
|
55
|
+
type: "dm",
|
|
56
|
+
client: conn,
|
|
57
|
+
async close() { await conn.close(); },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
protected async doExecute(client: unknown, stmt: string, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
62
|
+
const dmConn = client as dmdb.Connection;
|
|
63
|
+
const maxRows = opts.maxRows;
|
|
64
|
+
const res = await dmConn.execute(stmt, [], {
|
|
65
|
+
maxRows,
|
|
66
|
+
fetchArraySize: maxRows,
|
|
67
|
+
});
|
|
68
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
69
|
+
const columns = res.metaData.map((m: any) => m.name);
|
|
70
|
+
const rows = (res.rows ?? []).slice(0, maxRows).map((r: any) => [...r]);
|
|
71
|
+
return { columns, rows, rowCount: res.rows?.length ?? 0 };
|
|
72
|
+
}
|
|
73
|
+
return { columns: [], rows: [], rowCount: res.rowsAffected ?? 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async versionQuery(conn: DbConnection): Promise<string> {
|
|
77
|
+
const dmConn = conn.client as dmdb.Connection;
|
|
78
|
+
const res = await dmConn.execute("SELECT * FROM V$VERSION");
|
|
79
|
+
return (res.rows ?? [])[0]?.[0] as string ?? "unknown";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
83
|
+
try {
|
|
84
|
+
const tables = await this.withConnection(config, async (conn) => {
|
|
85
|
+
const dmConn = conn.client as dmdb.Connection;
|
|
86
|
+
const res = await dmConn.execute(
|
|
87
|
+
`SELECT TABLE_NAME, OWNER, COMMENTS FROM ALL_TABLES T
|
|
88
|
+
LEFT JOIN ALL_TAB_COMMENTS C ON T.TABLE_NAME = C.TABLE_NAME AND T.OWNER = C.OWNER
|
|
89
|
+
WHERE T.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
|
|
90
|
+
ORDER BY T.OWNER, T.TABLE_NAME`,
|
|
91
|
+
);
|
|
92
|
+
const all = (res.rows ?? []).map((r: any) => ({
|
|
93
|
+
schema: r[1],
|
|
94
|
+
name: r[0],
|
|
95
|
+
type: "TABLE",
|
|
96
|
+
description: r[2] || "",
|
|
97
|
+
}));
|
|
98
|
+
return filterTables(all, pattern);
|
|
99
|
+
});
|
|
100
|
+
return { success: true, tables, count: tables.length };
|
|
101
|
+
} catch (err: unknown) {
|
|
102
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async describeTable(config: ConnConfig, table: string): Promise<DescribeTableResult> {
|
|
107
|
+
if (!table.trim()) {
|
|
108
|
+
return { success: false, error: "表名不能为空" };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
|
|
112
|
+
const dmConn = conn.client as dmdb.Connection;
|
|
113
|
+
const res = await dmConn.execute(
|
|
114
|
+
`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
|
|
120
|
+
FROM ALL_TAB_COLUMNS C
|
|
121
|
+
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')
|
|
123
|
+
ORDER BY C.COLUMN_ID`,
|
|
124
|
+
[table.toUpperCase()],
|
|
125
|
+
);
|
|
126
|
+
const cols: ColumnInfo[] = (res.rows ?? []).map((r: any) => ({
|
|
127
|
+
name: r[0],
|
|
128
|
+
type: r[1],
|
|
129
|
+
nullable: r[2] === "Y",
|
|
130
|
+
default: r[3] || null,
|
|
131
|
+
primaryKey: false,
|
|
132
|
+
comment: r[4] || "",
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
// 查主键
|
|
136
|
+
try {
|
|
137
|
+
const pkRes = await dmConn.execute(
|
|
138
|
+
`SELECT cc.COLUMN_NAME
|
|
139
|
+
FROM ALL_CONS_COLUMNS cc
|
|
140
|
+
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()],
|
|
143
|
+
);
|
|
144
|
+
const pkSet = new Set((pkRes.rows ?? []).map((r: any) => r[0]));
|
|
145
|
+
for (const col of cols) {
|
|
146
|
+
if (pkSet.has(col.name)) col.primaryKey = true;
|
|
147
|
+
}
|
|
148
|
+
} catch { /* ignore */ }
|
|
149
|
+
return cols;
|
|
150
|
+
});
|
|
151
|
+
return { success: true, columns, count: columns.length };
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const dmDialect = new DmDialect();
|
|
159
|
+
register(dmDialect);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// dialects/elasticsearch.ts —— Elasticsearch 方言(SearchDialect + v7/v8 双客户端分发)
|
|
2
|
+
import { Client as ClientV8 } from "@elastic/elasticsearch";
|
|
3
|
+
import { Client as ClientV7 } from "es7";
|
|
4
|
+
import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
|
|
5
|
+
TestConnectionResult, ListTablesResult, DescribeTableResult,
|
|
6
|
+
TableInfo, ColumnInfo } from "../core/types.js";
|
|
7
|
+
import { SearchDialect, type DslKind } from "./search-dialect.js";
|
|
8
|
+
import { register, filterTables, type Fingerprints } from "./dialect.js";
|
|
9
|
+
|
|
10
|
+
// ── URL 解析:http(s)://host:port(认证走账号/密码字段,API Key 二期)───
|
|
11
|
+
const ES_RE = /^(https?):\/\/([^:/?#@]+)(?::(\d+))?(\/.*)?$/;
|
|
12
|
+
|
|
13
|
+
function parseEsUrl(url: string): ParsedTarget | null {
|
|
14
|
+
const clean = url.split("?")[0].replace(/\/+$/, "");
|
|
15
|
+
const m = clean.match(ES_RE);
|
|
16
|
+
if (!m) return null;
|
|
17
|
+
const host = m[2];
|
|
18
|
+
const defaultPort = m[1] === "https" ? 443 : 9200;
|
|
19
|
+
const port = m[3] ? parseInt(m[3], 10) : defaultPort;
|
|
20
|
+
const ssl = m[1] === "https";
|
|
21
|
+
if (!host) return null;
|
|
22
|
+
return { host, port, ssl };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 从 `version.number`(如 "7.17.0")取大版本号 */
|
|
26
|
+
export function pickMajor(versionNumber: string): number {
|
|
27
|
+
const major = parseInt(versionNumber.split(".")[0], 10);
|
|
28
|
+
return Number.isNaN(major) ? 0 : major;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type AnyClient = ClientV8 | ClientV7;
|
|
32
|
+
|
|
33
|
+
function makeClient(config: ConnConfig, ClientClass: new (opts: Record<string, unknown>) => AnyClient): AnyClient {
|
|
34
|
+
const scheme = (config.port === 443 || config.options?.scheme === "https") ? "https" : "http";
|
|
35
|
+
const node = `${scheme}://${config.host ?? "localhost"}:${config.port ?? 9200}`;
|
|
36
|
+
const opts: Record<string, unknown> = { node, requestTimeout: 30_000 };
|
|
37
|
+
if (config.username) {
|
|
38
|
+
opts.auth = { username: config.username, password: config.password ?? "" };
|
|
39
|
+
}
|
|
40
|
+
return new ClientClass(opts);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class ElasticsearchDialect extends SearchDialect {
|
|
44
|
+
id = "elasticsearch" as const;
|
|
45
|
+
label = "Elasticsearch";
|
|
46
|
+
family = "search" as const;
|
|
47
|
+
defaultPort = 9200;
|
|
48
|
+
fingerprints: Fingerprints = {
|
|
49
|
+
urlPatterns: [/^https?:\/\/[^/]*:9200/],
|
|
50
|
+
configKeys: ["spring.elasticsearch.uris", "spring.data.elasticsearch.client.reactive.endpoints", "spring.data.elasticsearch.uris"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
parseUrl(url: string): ParsedTarget | null {
|
|
54
|
+
return parseEsUrl(url);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
displayUrl(config: ConnConfig): string {
|
|
58
|
+
const scheme = (config.port === 443 || config.options?.scheme === "https") ? "https" : "http";
|
|
59
|
+
return `${scheme}://${config.host}:${config.port}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
|
|
63
|
+
// 先用"最新客户端"做无版本探测 GET /(testConnection 与 withConnection 共用);
|
|
64
|
+
// GET / 返回 version.number 后按大版本分发(详见 testConnection)
|
|
65
|
+
const probe = makeClient(config, ClientV8 as unknown as new (opts: Record<string, unknown>) => AnyClient);
|
|
66
|
+
try {
|
|
67
|
+
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) {
|
|
77
|
+
try { await probe.close(); } catch { /* ignore */ }
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async versionQuery(conn: DbConnection): Promise<string> {
|
|
83
|
+
// 连接建立时已做版本探测,这里复用一次 GET / 取完整版本号
|
|
84
|
+
const client = conn.client as ClientV8;
|
|
85
|
+
const info = await client.info();
|
|
86
|
+
return (info as unknown as { version?: { number?: string } }).version?.number ?? "unknown";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// testConnection 复写基类:同时返回服务端版本号 + 未知大版本的 warning(Spec §12)
|
|
90
|
+
async testConnection(config: ConnConfig): Promise<TestConnectionResult> {
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const probe = makeClient(config, ClientV8 as unknown as new (opts: Record<string, unknown>) => AnyClient);
|
|
94
|
+
let versionNumber = "";
|
|
95
|
+
try {
|
|
96
|
+
const info = await (probe as ClientV8).info();
|
|
97
|
+
versionNumber = (info as unknown as { version?: { number?: string } }).version?.number ?? "";
|
|
98
|
+
} finally {
|
|
99
|
+
try { await probe.close(); } catch { /* ignore */ }
|
|
100
|
+
}
|
|
101
|
+
const major = pickMajor(versionNumber);
|
|
102
|
+
if (major !== 7 && major !== 8) {
|
|
103
|
+
// 未知/更高大版本:用最新客户端建连验证 + 版本警告(不硬拒)
|
|
104
|
+
const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
|
|
105
|
+
return { success: true, version, latency: `${Date.now() - start}ms`, warning: `未识别的 ES 大版本(${versionNumber || "unknown"}),已用最新客户端尝试,结果可能不准确` };
|
|
106
|
+
}
|
|
107
|
+
const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
|
|
108
|
+
return { success: true, version, latency: `${Date.now() - start}ms` };
|
|
109
|
+
} catch (err: unknown) {
|
|
110
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
115
|
+
const es = client as ClientV8;
|
|
116
|
+
const index = config.database;
|
|
117
|
+
if (!index) throw new Error("ES 连接缺少默认 index(database 字段)");
|
|
118
|
+
// 注:DSL 需整体透传为 body——kind 只做分类,原文从 sql 取(JSON.parse 失败即 query_string,已在上游分支处理)
|
|
119
|
+
const body = kind.type === "read" && kind.endpoint === "_search"
|
|
120
|
+
? (JSON.parse(sql) as Record<string, unknown>)
|
|
121
|
+
: undefined;
|
|
122
|
+
if (kind.type === "query_string") {
|
|
123
|
+
const res = await es.search({ index, size: opts.maxRows, q: kind.text });
|
|
124
|
+
return hitsToRows(res);
|
|
125
|
+
}
|
|
126
|
+
if (kind.type === "read") {
|
|
127
|
+
if (kind.endpoint === "_count") {
|
|
128
|
+
const res = await es.count({ index });
|
|
129
|
+
const count = (res as unknown as { count?: number }).count ?? 0;
|
|
130
|
+
return { columns: ["count"], rows: [[count]], rowCount: count };
|
|
131
|
+
}
|
|
132
|
+
if (kind.endpoint === "_mget") {
|
|
133
|
+
const res = await es.mget({ index, body: { docs: [] } });
|
|
134
|
+
return docsToRows(res);
|
|
135
|
+
}
|
|
136
|
+
// _search:DSL 整体即 body
|
|
137
|
+
const res = await es.search({ index, body, size: opts.maxRows });
|
|
138
|
+
return hitsToRows(res);
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`ES 写端点 ${kind.endpoint} 需走非只读确认流程执行,本方言 executeOn 仅执行读查询`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// listTables → cat.indices(名称/健康/文档数/大小)
|
|
144
|
+
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
145
|
+
try {
|
|
146
|
+
const tables: TableInfo[] = await this.withConnection(config, async (conn) => {
|
|
147
|
+
const es = conn.client as ClientV8;
|
|
148
|
+
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);
|
|
150
|
+
return filterTables(rows.map((r) => ({
|
|
151
|
+
schema: "",
|
|
152
|
+
name: r["index"] ?? "",
|
|
153
|
+
type: "INDEX",
|
|
154
|
+
description: `健康:${r["health"] ?? "?"} 文档数:${r["docs.count"] ?? "?"} 大小:${r["store.size"] ?? "?"}`,
|
|
155
|
+
})), pattern);
|
|
156
|
+
});
|
|
157
|
+
return { success: true, tables, count: tables.length };
|
|
158
|
+
} catch (err: unknown) {
|
|
159
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// describeTable → getMapping + getSettings(字段/类型/可搜索性 + 分片副本)
|
|
164
|
+
async describeTable(config: ConnConfig, target: string): Promise<DescribeTableResult> {
|
|
165
|
+
if (!target.trim()) {
|
|
166
|
+
return { success: false, error: "index 名不能为空" };
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
|
|
170
|
+
const es = conn.client as ClientV8;
|
|
171
|
+
const mappingRes = await es.indices.getMapping({ index: target });
|
|
172
|
+
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 }> } }>;
|
|
174
|
+
const props = mapping[target]?.mappings?.properties ?? {};
|
|
175
|
+
const settings = settingsRes as unknown as Record<string, { settings?: { index?: Record<string, string> } }>;
|
|
176
|
+
const idxSettings = settings[target]?.settings?.index ?? {};
|
|
177
|
+
const cols: ColumnInfo[] = Object.entries(props).map(([name, def]) => ({
|
|
178
|
+
name,
|
|
179
|
+
type: def.type ?? "object",
|
|
180
|
+
nullable: true,
|
|
181
|
+
default: null,
|
|
182
|
+
primaryKey: false,
|
|
183
|
+
comment: def.index === false ? "不可搜索" : (def.analyzer ? `analyzer:${def.analyzer}` : ""),
|
|
184
|
+
}));
|
|
185
|
+
cols.push({
|
|
186
|
+
name: "_settings",
|
|
187
|
+
type: "meta",
|
|
188
|
+
nullable: true,
|
|
189
|
+
default: null,
|
|
190
|
+
primaryKey: false,
|
|
191
|
+
comment: `分片:${idxSettings["number_of_shards"] ?? "?"} 副本:${idxSettings["number_of_replicas"] ?? "?"}`,
|
|
192
|
+
});
|
|
193
|
+
return cols;
|
|
194
|
+
});
|
|
195
|
+
return { success: true, columns, count: columns.length };
|
|
196
|
+
} catch (err: unknown) {
|
|
197
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 结果转换 ──────────────────────────────
|
|
203
|
+
function hitsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number } {
|
|
204
|
+
const body = res as { hits?: { total?: number | { value?: number }; hits?: Array<{ _id?: string; _source?: unknown }> } };
|
|
205
|
+
const hits = body.hits?.hits ?? [];
|
|
206
|
+
const total = typeof body.hits?.total === "number" ? body.hits.total : (body.hits?.total?.value ?? hits.length);
|
|
207
|
+
const rows = hits.map((h) => [h._id, typeof h._source === "object" ? JSON.stringify(h._source) : h._source]);
|
|
208
|
+
return { columns: ["_id", "_source"], rows, rowCount: typeof total === "number" ? total : hits.length };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function docsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number } {
|
|
212
|
+
const body = res as { docs?: Array<{ _id?: string; _source?: unknown; found?: boolean }> };
|
|
213
|
+
const docs = body.docs ?? [];
|
|
214
|
+
const rows = docs.map((d) => [d._id, d.found ? JSON.stringify(d._source) : "not found"]);
|
|
215
|
+
return { columns: ["_id", "_source"], rows, rowCount: rows.length };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const esDialect = new ElasticsearchDialect();
|
|
219
|
+
register(esDialect);
|