@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.
@@ -37,6 +37,7 @@ const READ_CMDS = [
37
37
  "LRANGE", "LLEN", "LINDEX", "SMEMBERS", "SCARD", "SISMEMBER",
38
38
  "ZRANGE", "ZSCORE", "ZCARD", "SCAN", "TYPE", "TTL", "PTTL", "EXISTS",
39
39
  "STRLEN", "GETRANGE", "INFO", "DBSIZE", "RANDOMKEY", "OBJECT", "MEMORY",
40
+ "PING", "TIME", "ECHO", "LOLWUT", "LASTSAVE",
40
41
  ];
41
42
 
42
43
  // ── 恒拒命令(与只读开关无关;CONFIG 一刀切含 CONFIG GET,有意从紧)───
@@ -109,7 +110,19 @@ export abstract class KvDialect implements Dialect {
109
110
  }
110
111
 
111
112
  // ── 命令结果转行(展示层统一转字符串,保持原类型)───
113
+ // ioredis sendCommand 对字符串响应可能返回 Buffer,先递归解码为 UTF-8
114
+ // (否则 INFO/SCAN/GET 等会按字节逐行渲染成乱码数字)
115
+ function decodeValue(v: unknown): unknown {
116
+ if (Buffer.isBuffer(v)) return v.toString("utf8");
117
+ if (Array.isArray(v)) return v.map(decodeValue);
118
+ if (v !== null && typeof v === "object") {
119
+ return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, decodeValue(x)]));
120
+ }
121
+ return v;
122
+ }
123
+
112
124
  function toRows(cmd: string, raw: unknown): unknown[][] {
125
+ raw = decodeValue(raw);
113
126
  if (raw === null || raw === undefined) return [];
114
127
  if (Array.isArray(raw)) {
115
128
  // 偶数长度数组(HGETALL 等)按 k/v 配对展示
@@ -0,0 +1,341 @@
1
+ // dialects/mongodb.ts —— MongoDB 方言(DocumentDialect + mongodb 官方驱动)
2
+ // 交互形态:query_database 的 sql 参数填 JSON 命令信封(db.runCommand 文档),
3
+ // 如 {"find":"users","filter":{"age":{"$gt":18}},"limit":20}
4
+ // 版本声明(Spec 共识 Q2):驱动 mongodb@^6,server 4.2~8.x 可用;已验证主流区 6.0/7.0/8.0。
5
+ // 驱动纯 JS 零原生编译;4.2/4.4 可用未验证(5.0 已 EOL 不承诺)。
6
+
7
+ import { MongoClient } from "mongodb";
8
+ import type { ConnConfig, DbConnection, ParsedTarget,
9
+ ListTablesResult, DescribeTableResult, TableInfo, ColumnInfo } from "../core/types.js";
10
+ import { DocumentDialect } from "./document-dialect.js";
11
+ import { register, filterTables, type Fingerprints } from "./dialect.js";
12
+
13
+ // ── URL 解析:mongodb(srv)://[user:pass@]host[:port][,host2...][/db][?opts] ───
14
+ // 兼收 Atlas SRV;IPv6 主机不在 v1 支持范围(split(":") 会误切,见 parseHosts 注释)
15
+
16
+ const MONGO_RE = /^mongodb(?:\+srv)?:\/\/(?:([^:/?#@]+)(?::([^@]*))?@)?([^/?#]+)(?:\/([^?]*))?(?:\?(.*))?$/;
17
+
18
+ function parseMongoUrl(url: string): ParsedTarget | null {
19
+ const m = url.trim().match(MONGO_RE);
20
+ if (!m) return null;
21
+ const srv = url.trim().startsWith("mongodb+srv://");
22
+ let username: string | undefined;
23
+ let password: string | undefined;
24
+ if (m[1] !== undefined) {
25
+ try {
26
+ username = decodeURIComponent(m[1]);
27
+ password = m[2] !== undefined ? decodeURIComponent(m[2]) : undefined;
28
+ } catch { /* 编码异常按原文 */ username = m[1]; password = m[2]; }
29
+ }
30
+ // 多主机 seed list 原样保留(驱动接受逗号分隔);单主机拆出端口
31
+ let port = 27017;
32
+ const hosts = m[3].split(",").map((s) => s.trim()).filter(Boolean);
33
+ if (hosts.length === 0) return null;
34
+ let hostStr = hosts.join(",");
35
+ if (hosts.length === 1 && !hosts[0].startsWith("[")) { // [ 开头 = IPv6 字面量,不拆端口
36
+ const colon = hosts[0].lastIndexOf(":");
37
+ if (colon > 0) {
38
+ const p = parseInt(hosts[0].slice(colon + 1), 10);
39
+ if (Number.isFinite(p)) { hostStr = hosts[0].slice(0, colon); port = p; }
40
+ }
41
+ }
42
+ let database = m[4] !== undefined && m[4] !== "" ? decodeURIComponent(m[4]) : undefined;
43
+ const options: Record<string, string> = {};
44
+ if (m[5]) {
45
+ for (const [k, v] of new URLSearchParams(m[5])) options[k] = v;
46
+ }
47
+ if (srv) {
48
+ options.srv = "true"; // 建连侧据此还原 +srv scheme
49
+ if (options.tls === undefined && options.ssl === undefined) options.tls = "true"; // SRV 默认 TLS
50
+ }
51
+ const out: ParsedTarget = { host: hostStr, port };
52
+ if (username !== undefined) out.username = username;
53
+ if (password !== undefined) out.password = password;
54
+ if (database !== undefined) out.database = database;
55
+ out.ssl = srv || options.tls === "true" || options.ssl === "true";
56
+ out.options = options;
57
+ return out;
58
+ }
59
+
60
+ /** ConnConfig → 标准连接 URI(authSource 缺省 admin,与官方 URI 语义一致;Q6 共识) */
61
+ function buildUri(config: ConnConfig): string {
62
+ const opts = config.options ?? {};
63
+ const srv = opts.srv === "true";
64
+ const scheme = srv ? "mongodb+srv" : "mongodb";
65
+ const host = config.host ?? "localhost";
66
+ // 多主机/ srv / IPv6 字面量(含 ])不追加端口(seed 自带、SRV 解析或字面量内含端口)
67
+ const hostPart = srv || host.includes(",") || host.includes("]")
68
+ ? host
69
+ : `${host}:${config.port ?? 27017}`;
70
+ const auth = config.username
71
+ ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? "")}@`
72
+ : "";
73
+ const params = new URLSearchParams();
74
+ for (const [k, v] of Object.entries(opts)) {
75
+ if (k === "srv") continue; // 内部标记不透传
76
+ params.set(k, v);
77
+ }
78
+ if (config.username && !params.has("authSource") && !params.has("authMechanism")) {
79
+ params.set("authSource", "admin");
80
+ }
81
+ // 注:不用 URLSearchParams#size(Node <19.8 无此属性)
82
+ const qs = [...params.keys()].length > 0 ? `?${params.toString()}` : "";
83
+ return `${scheme}://${auth}${hostPart}${config.database ? `/${config.database}` : ""}${qs}`;
84
+ }
85
+
86
+ // ── 工具函数 ──────────────────────────────────────
87
+
88
+ function errMsg(err: unknown): string {
89
+ return err instanceof Error ? err.message : String(err);
90
+ }
91
+
92
+ function fmtBytes(n: unknown): string {
93
+ const v = typeof n === "number" && Number.isFinite(n) ? n : undefined;
94
+ if (v === undefined) return "?";
95
+ if (v < 1024) return `${v}B`;
96
+ if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}KB`;
97
+ if (v < 1024 * 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)}MB`;
98
+ return `${(v / 1024 / 1024 / 1024).toFixed(2)}GB`;
99
+ }
100
+
101
+ interface RawCommandResult {
102
+ cursor?: { firstBatch?: unknown[] };
103
+ values?: unknown[];
104
+ version?: string;
105
+ [k: string]: unknown;
106
+ }
107
+
108
+ interface CollectionInfo {
109
+ name: string;
110
+ type?: string;
111
+ options?: { validator?: { $jsonSchema?: Record<string, unknown> } };
112
+ }
113
+
114
+ interface IndexInfo { name?: string; key?: Record<string, unknown> }
115
+
116
+ interface JsonSchemaNode {
117
+ bsonType?: string | string[];
118
+ type?: string | string[];
119
+ properties?: Record<string, JsonSchemaNode>;
120
+ required?: string[];
121
+ }
122
+
123
+ interface SampledField { name: string; count: number; types: string[] }
124
+
125
+ // describeTable 采样推断参数(Spec 共识 Q5:validator 权威 > 采样 ≤100 推断)
126
+ const SAMPLE_DOCS = 100;
127
+ const MAX_DESCRIBE_FIELDS = 100;
128
+
129
+ function bsonTypeOf(v: unknown): string {
130
+ if (v === null) return "null";
131
+ if (Array.isArray(v)) return "array";
132
+ if (v instanceof Date) return "date";
133
+ if (typeof v === "object") {
134
+ const b = v as { _bsontype?: string };
135
+ if (b._bsontype) return b._bsontype.toLowerCase();
136
+ return "object";
137
+ }
138
+ if (typeof v === "number") return Number.isInteger(v) ? "int" : "double";
139
+ return typeof v as string;
140
+ }
141
+
142
+ /** $jsonSchema validator → 字段清单(权威 schema,顶层 properties) */
143
+ function fieldsFromJsonSchema(schema: JsonSchemaNode): Array<{ name: string; type: string; required: boolean; authoritative: true }> {
144
+ const props = schema.properties ?? {};
145
+ const required = new Set(schema.required ?? []);
146
+ return Object.entries(props).map(([name, node]) => {
147
+ const bt = node.bsonType ?? node.type;
148
+ const type = Array.isArray(bt) ? bt.join("/") : (bt ?? "any");
149
+ return { name, type, required: required.has(name), authoritative: true as const };
150
+ });
151
+ }
152
+
153
+ /** 采样 ≤100 文档推断顶层字段(variety 思路:出现次数 + 类型分布),返回实际采样总数 */
154
+ async function sampleFields(db: { command: (cmd: Record<string, unknown>) => Promise<RawCommandResult> }, collection: string): Promise<{ fields: SampledField[]; total: number }> {
155
+ const res = await db.command({ find: collection, filter: {}, limit: SAMPLE_DOCS, batchSize: SAMPLE_DOCS });
156
+ const docs = (res.cursor?.firstBatch ?? []) as unknown[];
157
+ const counts = new Map<string, number>();
158
+ const types = new Map<string, Set<string>>();
159
+ for (const d of docs) {
160
+ if (d === null || typeof d !== "object" || Array.isArray(d)) continue;
161
+ for (const [k, v] of Object.entries(d as Record<string, unknown>)) {
162
+ counts.set(k, (counts.get(k) ?? 0) + 1);
163
+ const set = types.get(k) ?? new Set<string>();
164
+ set.add(bsonTypeOf(v));
165
+ types.set(k, set);
166
+ }
167
+ }
168
+ const fields = [...counts.entries()]
169
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
170
+ .slice(0, MAX_DESCRIBE_FIELDS)
171
+ .map(([name, count]) => ({ name, count, types: [...(types.get(name) ?? [])] }));
172
+ return { fields, total: docs.length };
173
+ }
174
+
175
+ // ── 方言实现 ──────────────────────────────────────
176
+
177
+ class MongoDialect extends DocumentDialect {
178
+ id = "mongodb" as const;
179
+ label = "MongoDB";
180
+ family = "document" as const;
181
+ defaultPort = 27017;
182
+ fingerprints: Fingerprints = {
183
+ urlPatterns: [/^mongodb(\+srv)?:\/\//],
184
+ configKeys: ["spring.data.mongodb.uri", "spring.mongodb.uri"],
185
+ };
186
+
187
+ parseUrl(url: string): ParsedTarget | null {
188
+ return parseMongoUrl(url);
189
+ }
190
+
191
+ displayUrl(config: ConnConfig): string {
192
+ const srv = config.options?.srv === "true";
193
+ const scheme = srv ? "mongodb+srv" : "mongodb";
194
+ const host = config.host ?? "localhost";
195
+ const hostPart = srv || host.includes(",") || host.includes("]") ? host : `${host}:${config.port ?? 27017}`;
196
+ return `${scheme}://${hostPart}${config.database ? `/${config.database}` : ""}`;
197
+ }
198
+
199
+ protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
200
+ const client = new MongoClient(buildUri(config), {
201
+ serverSelectionTimeoutMS: timeoutMs,
202
+ connectTimeoutMS: timeoutMs,
203
+ socketTimeoutMS: Math.max(timeoutMs, 1_000),
204
+ });
205
+ try {
206
+ await client.connect();
207
+ } catch (err: unknown) {
208
+ try { await client.close(); } catch { /* ignore */ }
209
+ throw err;
210
+ }
211
+ return {
212
+ type: "mongodb",
213
+ client,
214
+ async close() { try { await client.close(); } catch { /* ignore */ } },
215
+ };
216
+ }
217
+
218
+ protected async doCommand(client: unknown, config: ConnConfig, envelope: Record<string, unknown>): Promise<unknown> {
219
+ const db = (client as MongoClient).db(config.database || "test");
220
+ return db.command(envelope);
221
+ }
222
+
223
+ protected extractDocs(raw: unknown): unknown[] {
224
+ const r = raw as RawCommandResult;
225
+ if (r?.cursor?.firstBatch !== undefined) return r.cursor.firstBatch;
226
+ if (Array.isArray(r?.values)) return r.values.map((v) => ({ value: v })); // distinct
227
+ // 单文档响应(count/insert 等):剥掉协议噪声字段 ok:1,避免多一列无信息量输出
228
+ // (仅剩 ok 一键时保留,避免空列)
229
+ if (r !== null && typeof r === "object" && "ok" in r && Object.keys(r).length > 1) {
230
+ const clone = { ...(r as Record<string, unknown>) };
231
+ delete clone.ok;
232
+ return [clone];
233
+ }
234
+ return [raw]; // count/collStats/写结果等单文档响应
235
+ }
236
+
237
+ async versionQuery(conn: DbConnection): Promise<string> {
238
+ const r = await (conn.client as MongoClient).db("admin").command({ buildInfo: 1 }) as RawCommandResult;
239
+ return r.version ?? "unknown";
240
+ }
241
+
242
+ // listTables → listCollections(Q5 共识;nameOnly 快路径,LIKE 过滤走共用 filterTables)
243
+ async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
244
+ try {
245
+ const tables: TableInfo[] = await this.withConnection(config, async (conn) => {
246
+ const db = (conn.client as MongoClient).db(config.database || "test");
247
+ const res = await db.command({ listCollections: 1, nameOnly: true }) as RawCommandResult;
248
+ const batch = (res.cursor?.firstBatch ?? []) as Array<{ name: string; type?: string }>;
249
+ return batch.map((i) => ({
250
+ schema: config.database ?? "",
251
+ name: i.name,
252
+ type: (i.type ?? "collection").toUpperCase(),
253
+ description: "",
254
+ }));
255
+ });
256
+ const filtered = filterTables(tables, pattern);
257
+ return { success: true, tables: filtered, count: filtered.length };
258
+ } catch (err: unknown) {
259
+ return { success: false, error: errMsg(err) };
260
+ }
261
+ }
262
+
263
+ // describeTable → collStats + listIndexes + validator $jsonSchema / 采样 ≤100 推断(Q5 共识)
264
+ async describeTable(config: ConnConfig, target: string): Promise<DescribeTableResult> {
265
+ const name = target.trim();
266
+ if (!name) return { success: false, error: "集合名不能为空" };
267
+ try {
268
+ const columns = await this.withConnection(config, async (conn) => {
269
+ const client = conn.client as MongoClient;
270
+ const db = client.db(config.database || "test");
271
+ // 集合元数据(含 validator;nameOnly=false 才带 options)
272
+ const lc = await db.command({ listCollections: 1, filter: { name }, nameOnly: false }) as RawCommandResult;
273
+ const infos = (lc.cursor?.firstBatch ?? []) as CollectionInfo[];
274
+ const info = infos[0];
275
+ if (!info) throw new Error(`集合不存在: ${name}(库: ${config.database || "test"})`);
276
+
277
+ // collStats / 索引失败降级(权限不足时元数据照常返回)
278
+ let stats: Record<string, unknown> = {};
279
+ try { stats = await db.command({ collStats: name }) as RawCommandResult; } catch { /* ignore */ }
280
+ let indexes: IndexInfo[] = [];
281
+ try {
282
+ const ir = await db.command({ listIndexes: name }) as RawCommandResult;
283
+ indexes = (ir.cursor?.firstBatch ?? []) as IndexInfo[];
284
+ } catch { /* ignore */ }
285
+
286
+ const cols: ColumnInfo[] = [
287
+ {
288
+ name: "documents", type: "meta", nullable: true, default: null, primaryKey: false,
289
+ comment: `文档数 ${typeof stats.count === "number" ? stats.count : "?"};数据量 ${fmtBytes(stats.size)};`
290
+ + `平均文档 ${fmtBytes(stats.avgObjSize)};存储 ${fmtBytes(stats.storageSize)}${stats.capped ? ";capped" : ""}`,
291
+ },
292
+ {
293
+ name: "indexes", type: "meta", nullable: true, default: null, primaryKey: false,
294
+ comment: indexes.length === 0 ? "(无索引信息)"
295
+ : indexes.slice(0, 10).map((i) => i.name).join(", ")
296
+ + (indexes.length > 10 ? ` 等 ${indexes.length} 个` : ""),
297
+ },
298
+ ];
299
+ const validator = info.options?.validator?.$jsonSchema;
300
+ if (validator) {
301
+ cols.push({
302
+ name: "validator", type: "meta", nullable: true, default: null, primaryKey: false,
303
+ comment: `$jsonSchema(权威 schema):${JSON.stringify(validator).slice(0, 300)}`,
304
+ });
305
+ }
306
+
307
+ // 字段清单:validator 权威优先,缺失时采样推断
308
+ if (validator) {
309
+ for (const f of fieldsFromJsonSchema(validator as JsonSchemaNode)) {
310
+ cols.push({
311
+ name: f.name, type: f.type, nullable: !f.required, default: null,
312
+ primaryKey: f.name === "_id", comment: `${f.authoritative ? "来自 $jsonSchema" : ""}${f.required ? ";required" : ""}`,
313
+ });
314
+ }
315
+ } else {
316
+ const { fields, total } = await sampleFields({ command: (c) => db.command(c) }, name);
317
+ for (const f of fields) {
318
+ cols.push({
319
+ name: f.name, type: f.types.slice(0, 3).join("/"), nullable: f.count < total,
320
+ default: null, primaryKey: f.name === "_id",
321
+ comment: `出现 ${f.count}/${total}(采样 ≤${SAMPLE_DOCS} 推断,非权威 schema)`,
322
+ });
323
+ }
324
+ if (fields.length === 0) {
325
+ cols.push({
326
+ name: "(fields)", type: "meta", nullable: true, default: null, primaryKey: false,
327
+ comment: "集合为空或采样失败,无法推断字段",
328
+ });
329
+ }
330
+ }
331
+ return cols;
332
+ });
333
+ return { success: true, columns, count: columns.length };
334
+ } catch (err: unknown) {
335
+ return { success: false, error: errMsg(err) };
336
+ }
337
+ }
338
+ }
339
+
340
+ export const mongoDialect = new MongoDialect();
341
+ register(mongoDialect);
@@ -78,7 +78,18 @@ class MysqlDialect extends RelationalDialect {
78
78
  try {
79
79
  await mysqlConn.execute(`SET max_execution_time = ${timeoutMs}`);
80
80
  } catch { /* ignore: server too old for max_execution_time */ }
81
- const [rows, fields] = await mysqlConn.execute(stmt);
81
+ // 事务控制等语句(START TRANSACTION/BEGIN/COMMIT/ROLLBACK…)不支持 prepared 协议,
82
+ // mysql2 抛 ER_UNSUPPORTED_PS;此类语句降级 query()(插件从不绑定参数,语义等价)
83
+ let result: [unknown, unknown];
84
+ try {
85
+ result = await mysqlConn.execute(stmt);
86
+ } catch (err) {
87
+ const code = (err as { code?: string } | null)?.code ?? "";
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ if (code !== "ER_UNSUPPORTED_PS" && !/prepared statement protocol/i.test(msg)) throw err;
90
+ result = await mysqlConn.query(stmt);
91
+ }
92
+ const [rows, fields] = result as [{ affectedRows?: number; length?: number }, Array<{ name: string }>];
82
93
  if (Array.isArray(fields) && fields.length > 0) {
83
94
  const columns = fields.map((f: any) => f.name);
84
95
  const data = (rows as any[]).slice(0, maxRows).map((r: any) => columns.map((col: string) => r[col]));