@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,124 @@
|
|
|
1
|
+
// src/dialects/hive.ts —— Hive 方言(BigDataDialect + hive-driver HS2)
|
|
2
|
+
// Spike 结论(Task 8):本机无可用 Hive/Spark Thrift 端点,SPIKE-SKIPPED;
|
|
3
|
+
// 写法依据 hive-driver@1.0.1 实际 API(HiveClient.connect → openSession →
|
|
4
|
+
// session.executeStatement → operation.fetch/hasMoreRows/getSchema/getData/flush/cancel/close),
|
|
5
|
+
// 与 brief 示例的裸 client.fetchResults 写法不同,按实际调整。live 端到端待真实环境验证。
|
|
6
|
+
// 注:hive-driver 的 dist/index.js 无 default export(经 macOS tsx 实测确认:
|
|
7
|
+
// `import hive from` 得 undefined,必须用 namespace import),故用 `import * as`。
|
|
8
|
+
import * as hive from "hive-driver";
|
|
9
|
+
import type { ConnConfig, DbConnection, ParsedTarget,
|
|
10
|
+
DescribeTableResult, ColumnInfo } from "../core/types.js";
|
|
11
|
+
import { BigDataDialect, type Hs2Session } from "./bigdata-dialect.js";
|
|
12
|
+
import { register, type Fingerprints } from "./dialect.js";
|
|
13
|
+
|
|
14
|
+
// ── URL 解析:jdbc:hive2://host:port/database(默认端口 10000)───
|
|
15
|
+
const HIVE2_RE = /^jdbc:hive2:\/\/([^:/?#]+)(?::(\d+))?\/([^?#]*)$/;
|
|
16
|
+
const DEFAULT_PORT = 10000;
|
|
17
|
+
|
|
18
|
+
function parseHiveUrl(url: string): ParsedTarget | null {
|
|
19
|
+
const clean = url.split("?")[0];
|
|
20
|
+
const m = clean.match(HIVE2_RE);
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
const host = m[1];
|
|
23
|
+
const port = m[2] ? parseInt(m[2], 10) : DEFAULT_PORT;
|
|
24
|
+
const database = m[3] || undefined;
|
|
25
|
+
if (!host) return null;
|
|
26
|
+
return { host, port, database };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { TCLIService, TCLIService_types } = hive.thrift as unknown as {
|
|
30
|
+
TCLIService: object; TCLIService_types: { TProtocolVersion: Record<string, number> };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function buildSessionConfig(config: ConnConfig): Record<string, string> {
|
|
34
|
+
const vars: Record<string, string> = {};
|
|
35
|
+
for (const [k, v] of Object.entries(config.options ?? {})) {
|
|
36
|
+
const key = k.startsWith("hive.") ? k : `hive.${k}`;
|
|
37
|
+
vars[key] = v;
|
|
38
|
+
}
|
|
39
|
+
return vars;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class HiveDialect extends BigDataDialect {
|
|
43
|
+
id = "hive" as const;
|
|
44
|
+
label = "Hive";
|
|
45
|
+
family = "bigdata" as const;
|
|
46
|
+
defaultPort = DEFAULT_PORT;
|
|
47
|
+
fingerprints: Fingerprints = {
|
|
48
|
+
urlPatterns: [/^jdbc:hive2:\/\//],
|
|
49
|
+
configKeys: ["spring.datasource.url"],
|
|
50
|
+
};
|
|
51
|
+
// Task 1 WRITE_RE 已含 MSCK|CACHE|REFRESH;家族集补 HiveQL 特有写形态
|
|
52
|
+
protected writeKeywords = /\b(INSERT\s+(INTO|OVERWRITE)|CREATE\s+TABLE(\s+AS)?|LOAD\s+DATA|MSCK|ALTER|DROP)\b/i;
|
|
53
|
+
protected sessionPrefix = "hive.";
|
|
54
|
+
|
|
55
|
+
parseUrl(url: string): ParsedTarget | null {
|
|
56
|
+
return parseHiveUrl(url);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
displayUrl(config: ConnConfig): string {
|
|
60
|
+
return `jdbc:hive2://${config.host}:${config.port ?? DEFAULT_PORT}/${config.database ?? "default"}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
|
|
64
|
+
const client = new hive.HiveClient(TCLIService, TCLIService_types);
|
|
65
|
+
await client.connect(
|
|
66
|
+
{ host: config.host ?? "localhost", port: config.port ?? DEFAULT_PORT },
|
|
67
|
+
new hive.connections.TcpConnection(),
|
|
68
|
+
config.password
|
|
69
|
+
? new hive.auth.PlainTcpAuthentication({ username: config.username ?? "", password: config.password })
|
|
70
|
+
: new hive.auth.NoSaslAuthentication(),
|
|
71
|
+
);
|
|
72
|
+
const configuration = buildSessionConfig(config);
|
|
73
|
+
if (config.database) configuration["hive.cli.currentDb"] = config.database;
|
|
74
|
+
const session = await client.openSession({
|
|
75
|
+
client_protocol: TCLIService_types.TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V10,
|
|
76
|
+
username: config.username || undefined,
|
|
77
|
+
password: config.password || undefined,
|
|
78
|
+
configuration,
|
|
79
|
+
});
|
|
80
|
+
void timeoutMs;
|
|
81
|
+
const hs2session = session as unknown as Hs2Session;
|
|
82
|
+
return {
|
|
83
|
+
type: "hive",
|
|
84
|
+
client: hs2session,
|
|
85
|
+
async close() {
|
|
86
|
+
try { await session.close(); } catch { /* ignore */ }
|
|
87
|
+
client.close();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async versionQuery(conn: DbConnection): Promise<string> {
|
|
93
|
+
const session = conn.client as Hs2Session;
|
|
94
|
+
// 走基类受保护拉取路径(超时 + cancel + close),Hive 无统一版本函数时兜底 "unknown"
|
|
95
|
+
return this.fetchVersion(session);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// DESCRIBE FORMATTED → 列名/类型/注释(遇 # 分区信息段即停)
|
|
99
|
+
async describeTable(config: ConnConfig, table: string): Promise<DescribeTableResult> {
|
|
100
|
+
if (!table.trim()) {
|
|
101
|
+
return { success: false, error: "表名不能为空" };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
|
|
105
|
+
const session = conn.client as Hs2Session;
|
|
106
|
+
const r = await this.runStatement(session, `DESCRIBE FORMATTED ${table}`,
|
|
107
|
+
{ readonly: true, maxRows: 500, timeoutSec: 30 });
|
|
108
|
+
const cols: ColumnInfo[] = [];
|
|
109
|
+
for (const row of r.rows) {
|
|
110
|
+
const [name, type, comment] = row.map((v) => String(v ?? "").trim());
|
|
111
|
+
if (!name || name.startsWith("#")) break;
|
|
112
|
+
cols.push({ name, type, nullable: true, default: null, primaryKey: false, comment: comment ?? "" });
|
|
113
|
+
}
|
|
114
|
+
return cols;
|
|
115
|
+
});
|
|
116
|
+
return { success: true, columns, count: columns.length };
|
|
117
|
+
} catch (err: unknown) {
|
|
118
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const hiveDialect = new HiveDialect();
|
|
124
|
+
register(hiveDialect);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// src/dialects/index.ts —— 聚合各方言(新增方言时加一行 re-export;
|
|
2
|
+
// 方言文件自带 register() 副作用,遗漏聚合行时 registry 规模断言失败)
|
|
3
|
+
export { registry, register } from "./dialect.js";
|
|
4
|
+
export type { Dialect, Verdict, Fingerprints } from "./dialect.js";
|
|
5
|
+
export { postgresqlDialect } from "./postgresql.js";
|
|
6
|
+
export { mysqlDialect } from "./mysql.js";
|
|
7
|
+
export { oracleDialect } from "./oracle.js";
|
|
8
|
+
export { dmDialect } from "./dm.js";
|
|
9
|
+
export { redisDialect } from "./redis.js";
|
|
10
|
+
export { esDialect } from "./elasticsearch.js";
|
|
11
|
+
export { hiveDialect } from "./hive.js";
|
|
12
|
+
export { sparkDialect } from "./spark.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// dialects/kv-dialect.ts —— KV 家族基类(命令切分、白名单 verdict、sendCommand 执行)
|
|
2
|
+
import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
|
|
3
|
+
QueryResult } from "../core/types.js";
|
|
4
|
+
import { matchCommand } from "../core/whitelist.js";
|
|
5
|
+
import type { Dialect, Verdict, Fingerprints } from "./dialect.js";
|
|
6
|
+
|
|
7
|
+
// ── 命令切分(空白 + 双引号)───
|
|
8
|
+
// 注:Redis 无分号语句概念,sql 整体视为一条命令
|
|
9
|
+
export function splitCommand(input: string): string[] {
|
|
10
|
+
const parts: string[] = [];
|
|
11
|
+
let cur = "";
|
|
12
|
+
let inQuote = false;
|
|
13
|
+
for (let i = 0; i < input.length; i++) {
|
|
14
|
+
const ch = input[i];
|
|
15
|
+
if (inQuote) {
|
|
16
|
+
if (ch === '"') {
|
|
17
|
+
inQuote = false;
|
|
18
|
+
} else {
|
|
19
|
+
cur += ch;
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === '"') { inQuote = true; continue; }
|
|
24
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
25
|
+
if (cur) { parts.push(cur); cur = ""; }
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
cur += ch;
|
|
29
|
+
}
|
|
30
|
+
if (cur) parts.push(cur);
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── 只读命令白名单(Spec §4.2;KEYS 已移除,生产库一律 SCAN)───
|
|
35
|
+
const READ_CMDS = [
|
|
36
|
+
"GET", "MGET", "HGETALL", "HGET", "HMGET", "HKEYS", "HVALS", "HLEN",
|
|
37
|
+
"LRANGE", "LLEN", "LINDEX", "SMEMBERS", "SCARD", "SISMEMBER",
|
|
38
|
+
"ZRANGE", "ZSCORE", "ZCARD", "SCAN", "TYPE", "TTL", "PTTL", "EXISTS",
|
|
39
|
+
"STRLEN", "GETRANGE", "INFO", "DBSIZE", "RANDOMKEY", "OBJECT", "MEMORY",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ── 恒拒命令(与只读开关无关;CONFIG 一刀切含 CONFIG GET,有意从紧)───
|
|
43
|
+
const DENY_ALWAYS = ["FLUSHALL", "FLUSHDB", "CONFIG", "SHUTDOWN", "SLAVEOF", "REPLICAOF", "DEBUG"];
|
|
44
|
+
|
|
45
|
+
export abstract class KvDialect implements Dialect {
|
|
46
|
+
abstract id: Dialect["id"];
|
|
47
|
+
abstract label: string;
|
|
48
|
+
abstract family: Dialect["family"];
|
|
49
|
+
abstract defaultPort: number;
|
|
50
|
+
abstract fingerprints: Fingerprints;
|
|
51
|
+
abstract parseUrl(url: string): ParsedTarget | null;
|
|
52
|
+
abstract displayUrl(config: ConnConfig): string;
|
|
53
|
+
abstract versionQuery(conn: DbConnection): Promise<string>;
|
|
54
|
+
protected abstract doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection>;
|
|
55
|
+
// 注:doSendCommand 直接收驱动 client(unknown),各方言内部收窄为私有连接类型
|
|
56
|
+
protected abstract doSendCommand(client: unknown, args: string[]): Promise<unknown>;
|
|
57
|
+
|
|
58
|
+
isAllowed(sql: string, readonly: boolean): Verdict {
|
|
59
|
+
const args = splitCommand(sql);
|
|
60
|
+
if (args.length === 0) return { ok: false, reason: "命令为空" };
|
|
61
|
+
const cmd = args[0].toUpperCase();
|
|
62
|
+
const rest = args.slice(1).join(" ");
|
|
63
|
+
const summary = `${cmd}${rest ? " " + rest.slice(0, 40) : ""}`;
|
|
64
|
+
if (matchCommand(cmd, DENY_ALWAYS)) {
|
|
65
|
+
return { ok: false, reason: `禁止执行危险命令:${cmd}`, isWrite: true, summary: `${cmd}(硬限制)` };
|
|
66
|
+
}
|
|
67
|
+
if (matchCommand(cmd, READ_CMDS)) {
|
|
68
|
+
return { ok: true, isWrite: false, summary };
|
|
69
|
+
}
|
|
70
|
+
if (readonly) {
|
|
71
|
+
return { ok: false, reason: `只读模式下不允许执行写命令:${cmd}`, isWrite: true, summary };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, isWrite: true, summary };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
|
|
77
|
+
const start = Date.now();
|
|
78
|
+
const args = splitCommand(sql);
|
|
79
|
+
if (args.length === 0) {
|
|
80
|
+
return { success: false, error: "命令为空" };
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const raw = await this.withConnection(config, (conn) => this.doSendCommand(conn.client, args), opts.timeoutSec * 1000);
|
|
84
|
+
const rows = toRows(args[0], raw).slice(0, opts.maxRows);
|
|
85
|
+
const columns = ["result"];
|
|
86
|
+
return { success: true, columns, rows, rowCount: rows.length, duration: `${Date.now() - start}ms` };
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs = 10_000): Promise<T> {
|
|
93
|
+
const conn = await this.doConnect(config, timeoutMs);
|
|
94
|
+
try { return await fn(conn); }
|
|
95
|
+
finally { await conn.close(); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async testConnection(config: ConnConfig): Promise<import("../core/types.js").TestConnectionResult> {
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
try {
|
|
101
|
+
const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
|
|
102
|
+
return { success: true, version, latency: `${Date.now() - start}ms` };
|
|
103
|
+
} catch (err: unknown) {
|
|
104
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
abstract listTables(config: ConnConfig, pattern?: string): Promise<import("../core/types.js").ListTablesResult>;
|
|
108
|
+
abstract describeTable(config: ConnConfig, target: string): Promise<import("../core/types.js").DescribeTableResult>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 命令结果转行(展示层统一转字符串,保持原类型)───
|
|
112
|
+
function toRows(cmd: string, raw: unknown): unknown[][] {
|
|
113
|
+
if (raw === null || raw === undefined) return [];
|
|
114
|
+
if (Array.isArray(raw)) {
|
|
115
|
+
// 偶数长度数组(HGETALL 等)按 k/v 配对展示
|
|
116
|
+
if (cmd.toUpperCase() === "HGETALL" && raw.length % 2 === 0) {
|
|
117
|
+
const out: unknown[][] = [];
|
|
118
|
+
for (let i = 0; i < raw.length; i += 2) out.push([raw[i], raw[i + 1]]);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
return raw.map((v) => [Array.isArray(v) ? JSON.stringify(v) : v]);
|
|
122
|
+
}
|
|
123
|
+
if (typeof raw === "object") {
|
|
124
|
+
return Object.entries(raw as Record<string, unknown>).map(([k, v]) => [k, v]);
|
|
125
|
+
}
|
|
126
|
+
return [[raw]];
|
|
127
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/dialects/mysql.ts
|
|
2
|
+
import mysql from "mysql2/promise";
|
|
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 JDBC_RE = /^jdbc:mysql:\/\/([^:/?#]+)(?::(\d+))?\/([^?#]+).*$/;
|
|
11
|
+
const NATIVE_RE = /^mysql:\/\/(?:([^:/?#@]+)(?::([^/?#@]*))?@)?([^:/?#]+)(?::(\d+))?\/([^?#]+).*$/;
|
|
12
|
+
|
|
13
|
+
function parseMysqlUrl(url: string): ParsedTarget | null {
|
|
14
|
+
const clean = url.split("?")[0];
|
|
15
|
+
const m = clean.match(JDBC_RE);
|
|
16
|
+
if (m) {
|
|
17
|
+
const host = m[1];
|
|
18
|
+
const port = m[2] ? parseInt(m[2], 10) : 3306;
|
|
19
|
+
const database = m[3];
|
|
20
|
+
if (!host || !database) return null;
|
|
21
|
+
return { host, port, database };
|
|
22
|
+
}
|
|
23
|
+
const n = clean.match(NATIVE_RE);
|
|
24
|
+
if (n) {
|
|
25
|
+
const host = n[3];
|
|
26
|
+
const port = n[4] ? parseInt(n[4], 10) : 3306;
|
|
27
|
+
const database = n[5];
|
|
28
|
+
if (!host || !database) return null;
|
|
29
|
+
const out: ParsedTarget = { host, port, database };
|
|
30
|
+
if (n[1] !== undefined) out.username = decodeURIComponent(n[1]);
|
|
31
|
+
if (n[2] !== undefined) out.password = decodeURIComponent(n[2]);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── MySQL 方言(逻辑从 src/db.ts 原样搬入)───
|
|
38
|
+
|
|
39
|
+
class MysqlDialect extends RelationalDialect {
|
|
40
|
+
id = "mysql" as const;
|
|
41
|
+
label = "MySQL";
|
|
42
|
+
family = "relational" as const;
|
|
43
|
+
defaultPort = 3306;
|
|
44
|
+
fingerprints: Fingerprints = {
|
|
45
|
+
urlPatterns: [/^jdbc:mysql:\/\//, /^mysql:\/\//],
|
|
46
|
+
configKeys: ["spring.datasource.url"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
parseUrl(url: string): ParsedTarget | null {
|
|
50
|
+
return parseMysqlUrl(url);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
displayUrl(config: ConnConfig): string {
|
|
54
|
+
return `jdbc:mysql://${config.host}:${config.port}/${config.database}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected async doConnect(config: ConnConfig, _timeoutMs: number): Promise<DbConnection> {
|
|
58
|
+
const conn = await mysql.createConnection({
|
|
59
|
+
host: config.host,
|
|
60
|
+
port: config.port,
|
|
61
|
+
user: config.username,
|
|
62
|
+
password: config.password,
|
|
63
|
+
database: config.database,
|
|
64
|
+
charset: "utf8mb4",
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
type: "mysql",
|
|
68
|
+
client: conn,
|
|
69
|
+
async close() { await conn.end(); },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
protected async doExecute(client: unknown, stmt: string, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
74
|
+
const mysqlConn = client as mysql.Connection;
|
|
75
|
+
const timeoutMs = opts.timeoutSec * 1000;
|
|
76
|
+
const maxRows = opts.maxRows;
|
|
77
|
+
// 设置 max_execution_time;老版本服务端(<5.7.8)SET 失败时忽略,客户端超时仍生效
|
|
78
|
+
try {
|
|
79
|
+
await mysqlConn.execute(`SET max_execution_time = ${timeoutMs}`);
|
|
80
|
+
} catch { /* ignore: server too old for max_execution_time */ }
|
|
81
|
+
const [rows, fields] = await mysqlConn.execute(stmt);
|
|
82
|
+
if (Array.isArray(fields) && fields.length > 0) {
|
|
83
|
+
const columns = fields.map((f: any) => f.name);
|
|
84
|
+
const data = (rows as any[]).slice(0, maxRows).map((r: any) => columns.map((col: string) => r[col]));
|
|
85
|
+
return { columns, rows: data, rowCount: (rows as any[]).length };
|
|
86
|
+
}
|
|
87
|
+
const affected = (rows as any)?.affectedRows ?? 0;
|
|
88
|
+
return { columns: [], rows: [], rowCount: affected };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async versionQuery(conn: DbConnection): Promise<string> {
|
|
92
|
+
const mysqlConn = conn.client as mysql.Connection;
|
|
93
|
+
const [rows] = await mysqlConn.execute("SELECT version() AS v");
|
|
94
|
+
return (rows as any[])[0].v;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
98
|
+
try {
|
|
99
|
+
const tables = await this.withConnection(config, async (conn) => {
|
|
100
|
+
const mysqlConn = conn.client as mysql.Connection;
|
|
101
|
+
const [rows] = await mysqlConn.execute(
|
|
102
|
+
"SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
|
|
103
|
+
[config.database],
|
|
104
|
+
);
|
|
105
|
+
const all = (rows as any[]).map((r: any) => ({
|
|
106
|
+
schema: "",
|
|
107
|
+
name: r.TABLE_NAME,
|
|
108
|
+
type: r.TABLE_TYPE === "BASE TABLE" ? "TABLE" : r.TABLE_TYPE,
|
|
109
|
+
description: r.TABLE_COMMENT || "",
|
|
110
|
+
}));
|
|
111
|
+
return filterTables(all, pattern);
|
|
112
|
+
});
|
|
113
|
+
return { success: true, tables, count: tables.length };
|
|
114
|
+
} catch (err: unknown) {
|
|
115
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async describeTable(config: ConnConfig, table: string): Promise<DescribeTableResult> {
|
|
120
|
+
if (!table.trim()) {
|
|
121
|
+
return { success: false, error: "表名不能为空" };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
|
|
125
|
+
const mysqlConn = conn.client as mysql.Connection;
|
|
126
|
+
const safeTable = table.replace(/`/g, "``");
|
|
127
|
+
const [rows] = await mysqlConn.execute(`DESCRIBE \`${safeTable}\``);
|
|
128
|
+
const cols: ColumnInfo[] = (rows as any[]).map((r: any) => ({
|
|
129
|
+
name: r.Field,
|
|
130
|
+
type: r.Type,
|
|
131
|
+
nullable: r.Null === "YES",
|
|
132
|
+
default: r.Default,
|
|
133
|
+
primaryKey: r.Key === "PRI",
|
|
134
|
+
comment: "",
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// 获取注释
|
|
138
|
+
try {
|
|
139
|
+
const [commentRows] = await mysqlConn.execute(
|
|
140
|
+
"SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
|
|
141
|
+
[config.database, table],
|
|
142
|
+
);
|
|
143
|
+
const commentMap = new Map((commentRows as any[]).map((r: any) => [r.COLUMN_NAME, r.COLUMN_COMMENT]));
|
|
144
|
+
for (const col of cols) {
|
|
145
|
+
col.comment = commentMap.get(col.name) || "";
|
|
146
|
+
}
|
|
147
|
+
} catch { /* ignore */ }
|
|
148
|
+
return cols;
|
|
149
|
+
});
|
|
150
|
+
return { success: true, columns, count: columns.length };
|
|
151
|
+
} catch (err: unknown) {
|
|
152
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const mysqlDialect = new MysqlDialect();
|
|
158
|
+
register(mysqlDialect);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/dialects/oracle.ts
|
|
2
|
+
import oracledb from "oracledb";
|
|
3
|
+
import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
|
|
4
|
+
ListTablesResult, DescribeTableResult, ColumnInfo, TestConnectionResult } from "../core/types.js";
|
|
5
|
+
import { RelationalDialect } from "./relational-dialect.js";
|
|
6
|
+
import { register, filterTables, type Fingerprints } from "./dialect.js";
|
|
7
|
+
|
|
8
|
+
// ── URL 解析(从 index.ts parseJdbcUrl 原样搬入:service 名 + SID 双分支)───
|
|
9
|
+
|
|
10
|
+
const SVC_RE = /^jdbc:oracle:thin:@\/\/([^:/?#]+)(?::(\d+))?\/([^?#]+)$/;
|
|
11
|
+
const SID_RE = /^jdbc:oracle:thin:@([^:/?#]+)(?::(\d+))?:([^?#]+)$/;
|
|
12
|
+
|
|
13
|
+
function parseOracleUrl(url: string): ParsedTarget | null {
|
|
14
|
+
const clean = url.split("?")[0];
|
|
15
|
+
const svc = clean.match(SVC_RE);
|
|
16
|
+
if (svc) {
|
|
17
|
+
const host = svc[1];
|
|
18
|
+
const port = svc[2] ? parseInt(svc[2], 10) : 1521;
|
|
19
|
+
const database = svc[3];
|
|
20
|
+
if (!host || !database) return null;
|
|
21
|
+
return { host, port, database };
|
|
22
|
+
}
|
|
23
|
+
const sid = clean.match(SID_RE);
|
|
24
|
+
if (sid) {
|
|
25
|
+
const host = sid[1];
|
|
26
|
+
const port = sid[2] ? parseInt(sid[2], 10) : 1521;
|
|
27
|
+
const database = sid[3];
|
|
28
|
+
if (!host || !database) return null;
|
|
29
|
+
return { host, port, database };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Oracle 方言(逻辑从 src/db.ts 原样搬入)───
|
|
35
|
+
|
|
36
|
+
class OracleDialect extends RelationalDialect {
|
|
37
|
+
id = "oracle" as const;
|
|
38
|
+
label = "Oracle";
|
|
39
|
+
family = "relational" as const;
|
|
40
|
+
defaultPort = 1521;
|
|
41
|
+
fingerprints: Fingerprints = {
|
|
42
|
+
urlPatterns: [/^jdbc:oracle:thin:@/],
|
|
43
|
+
configKeys: ["spring.datasource.url"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
parseUrl(url: string): ParsedTarget | null {
|
|
47
|
+
return parseOracleUrl(url);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
displayUrl(config: ConnConfig): string {
|
|
51
|
+
return `jdbc:oracle:thin:@//${config.host}:${config.port}/${config.database}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Spec §12:Thin 模式硬性要求 DB ≥12.1;连接失败且疑似老版本时给出版本原因指引
|
|
55
|
+
async testConnection(config: ConnConfig): Promise<TestConnectionResult> {
|
|
56
|
+
const result = await super.testConnection(config);
|
|
57
|
+
if (!result.success
|
|
58
|
+
&& /ORA-28040|ORA-03134|no matching authentication protocol/i.test(result.error ?? "")) {
|
|
59
|
+
result.error += "\n疑似 Oracle 服务端版本过低(<12.1):Thin 模式要求服务端 ≥12.1,11g 及以下需 Thick 模式(Instant Client),暂未支持。请联系 DBA 升级或使用其他客户端。";
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected async doConnect(config: ConnConfig, _timeoutMs: number): Promise<DbConnection> {
|
|
65
|
+
const conn = await oracledb.getConnection({
|
|
66
|
+
user: config.username,
|
|
67
|
+
password: config.password,
|
|
68
|
+
connectString: `${config.host}:${config.port}/${config.database}`,
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
type: "oracle",
|
|
72
|
+
client: conn,
|
|
73
|
+
async close() { await conn.close(); },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected async doExecute(client: unknown, stmt: string, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
78
|
+
const oracleConn = client as oracledb.Connection;
|
|
79
|
+
const timeoutMs = opts.timeoutSec * 1000;
|
|
80
|
+
const maxRows = opts.maxRows;
|
|
81
|
+
const res = await oracleConn.execute(stmt, [], {
|
|
82
|
+
maxRows,
|
|
83
|
+
fetchArraySize: maxRows,
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
});
|
|
86
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
87
|
+
const columns = res.metaData.map((m: any) => m.name);
|
|
88
|
+
const rows = (res.rows ?? []).slice(0, maxRows).map((r: any) => [...r]);
|
|
89
|
+
return { columns, rows, rowCount: res.rows?.length ?? 0 };
|
|
90
|
+
}
|
|
91
|
+
return { columns: [], rows: [], rowCount: res.rowsAffected ?? 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async versionQuery(conn: DbConnection): Promise<string> {
|
|
95
|
+
const oracleConn = conn.client as oracledb.Connection;
|
|
96
|
+
const res = await oracleConn.execute("SELECT version FROM v$instance");
|
|
97
|
+
return (res.rows ?? [])[0]?.[0] as string ?? "unknown";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
101
|
+
try {
|
|
102
|
+
const tables = await this.withConnection(config, async (conn) => {
|
|
103
|
+
const oracleConn = conn.client as oracledb.Connection;
|
|
104
|
+
const res = await oracleConn.execute(
|
|
105
|
+
`SELECT TABLE_NAME, OWNER, COMMENTS FROM ALL_TABLES T
|
|
106
|
+
LEFT JOIN ALL_TAB_COMMENTS C ON T.TABLE_NAME = C.TABLE_NAME AND T.OWNER = C.OWNER
|
|
107
|
+
WHERE T.OWNER NOT IN ('SYS', 'SYSTEM', 'DBSNMP', 'XDB')
|
|
108
|
+
ORDER BY T.OWNER, T.TABLE_NAME`,
|
|
109
|
+
);
|
|
110
|
+
const all = (res.rows ?? []).map((r: any) => ({
|
|
111
|
+
schema: r[1],
|
|
112
|
+
name: r[0],
|
|
113
|
+
type: "TABLE",
|
|
114
|
+
description: r[2] || "",
|
|
115
|
+
}));
|
|
116
|
+
return filterTables(all, pattern);
|
|
117
|
+
});
|
|
118
|
+
return { success: true, tables, count: tables.length };
|
|
119
|
+
} catch (err: unknown) {
|
|
120
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async describeTable(config: ConnConfig, table: string): Promise<DescribeTableResult> {
|
|
125
|
+
if (!table.trim()) {
|
|
126
|
+
return { success: false, error: "表名不能为空" };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
|
|
130
|
+
const oracleConn = conn.client as oracledb.Connection;
|
|
131
|
+
const res = await oracleConn.execute(
|
|
132
|
+
`SELECT
|
|
133
|
+
COLUMN_NAME,
|
|
134
|
+
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,
|
|
135
|
+
NULLABLE,
|
|
136
|
+
DATA_DEFAULT,
|
|
137
|
+
COMMENTS
|
|
138
|
+
FROM ALL_TAB_COLUMNS C
|
|
139
|
+
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
|
|
140
|
+
WHERE C.TABLE_NAME = :1 AND C.OWNER NOT IN ('SYS', 'SYSTEM', 'DBSNMP', 'XDB')
|
|
141
|
+
ORDER BY C.COLUMN_ID`,
|
|
142
|
+
[table.toUpperCase()],
|
|
143
|
+
);
|
|
144
|
+
const cols: ColumnInfo[] = (res.rows ?? []).map((r: any) => ({
|
|
145
|
+
name: r[0],
|
|
146
|
+
type: r[1],
|
|
147
|
+
nullable: r[2] === "Y",
|
|
148
|
+
default: r[3] || null,
|
|
149
|
+
primaryKey: false,
|
|
150
|
+
comment: r[4] || "",
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
// 查主键
|
|
154
|
+
try {
|
|
155
|
+
const pkRes = await oracleConn.execute(
|
|
156
|
+
`SELECT cc.COLUMN_NAME
|
|
157
|
+
FROM ALL_CONS_COLUMNS cc
|
|
158
|
+
JOIN ALL_CONSTRAINTS c ON cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME AND cc.OWNER = c.OWNER
|
|
159
|
+
WHERE c.CONSTRAINT_TYPE = 'P' AND c.TABLE_NAME = :1`,
|
|
160
|
+
[table.toUpperCase()],
|
|
161
|
+
);
|
|
162
|
+
const pkSet = new Set((pkRes.rows ?? []).map((r: any) => r[0]));
|
|
163
|
+
for (const col of cols) {
|
|
164
|
+
if (pkSet.has(col.name)) col.primaryKey = true;
|
|
165
|
+
}
|
|
166
|
+
} catch { /* ignore */ }
|
|
167
|
+
return cols;
|
|
168
|
+
});
|
|
169
|
+
return { success: true, columns, count: columns.length };
|
|
170
|
+
} catch (err: unknown) {
|
|
171
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const oracleDialect = new OracleDialect();
|
|
177
|
+
register(oracleDialect);
|