@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1

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/drivers.js ADDED
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Database drivers behind one interface.
3
+ *
4
+ * test() → { version, user, database, tableCount, readOnlyGrant, grants, warnings }
5
+ * introspect() → { version, tables, columns, foreignKeys, indexes } (raw, driver-neutral)
6
+ * query(sql, { timeoutMs, maxRows }) → { columns: [{ name, kind, table?, column? }], rows: any[][] }
7
+ * explain(sql, { timeoutMs }) → { plan: object[], scans: [{ table, rows }] }
8
+ * sampleColumn(table, column) → [{ value, count }]
9
+ * preview(table, limit) → same shape as query
10
+ * ident(name) → quoted identifier for the dialect
11
+ * close()
12
+ *
13
+ * Every driver executes statements read-only: MySQL and PostgreSQL inside a
14
+ * READ ONLY transaction with a statement timeout, Elasticsearch through the
15
+ * SQL API, which cannot write.
16
+ */
17
+ import { createServer } from 'node:net'
18
+ import mysql from 'mysql2/promise'
19
+ import pg from 'pg'
20
+ import ssh2 from 'ssh2'
21
+ import { stripSql } from './sql-guard.js'
22
+
23
+ export class DriverError extends Error {
24
+ constructor(code, message) {
25
+ super(message)
26
+ this.code = code
27
+ }
28
+ }
29
+
30
+ export const DIALECTS = {
31
+ mysql: { label: 'MySQL', defaultPort: 3306, sqlName: 'MySQL' },
32
+ postgres: { label: 'PostgreSQL', defaultPort: 5432, sqlName: 'PostgreSQL' },
33
+ elasticsearch: { label: 'Elasticsearch', defaultPort: 9200, sqlName: 'Elasticsearch SQL' },
34
+ sqlite: { label: '本地文件(CSV / Excel)', defaultPort: 0, sqlName: 'SQLite' },
35
+ }
36
+
37
+ export function parseVersion(v) {
38
+ const m = String(v ?? '').match(/(\d+)\.(\d+)(?:\.(\d+))?/)
39
+ return m ? Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3] ?? 0) : 0
40
+ }
41
+
42
+ function toCell(value) {
43
+ if (value === null || value === undefined) return null
44
+ if (typeof value === 'bigint') return value.toString()
45
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString()
46
+ if (Buffer.isBuffer(value)) {
47
+ if (value.length === 1 && (value[0] === 0 || value[0] === 1)) return value[0]
48
+ return `0x${value.subarray(0, 64).toString('hex')}${value.length > 64 ? '…' : ''}`
49
+ }
50
+ if (typeof value === 'object') return JSON.stringify(value)
51
+ return value
52
+ }
53
+
54
+ // ---------------------------------------------------------------- SSH tunnel
55
+
56
+ /**
57
+ * Open a local TCP listener that forwards every connection through one SSH
58
+ * session to targetHost:targetPort. Returns { host, port, close }.
59
+ */
60
+ export async function openTunnel(ssh, targetHost, targetPort) {
61
+ const client = new ssh2.Client()
62
+ await new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => reject(new DriverError('ssh/timeout', 'SSH 连接超时')), 15000)
64
+ client.once('ready', () => { clearTimeout(timer); resolve() })
65
+ client.once('error', (e) => { clearTimeout(timer); reject(new DriverError('ssh/error', `SSH 连接失败:${e.message}`)) })
66
+ client.connect({
67
+ host: ssh.host,
68
+ port: ssh.port || 22,
69
+ username: ssh.user,
70
+ ...(ssh.privateKey ? { privateKey: ssh.privateKey, passphrase: ssh.passphrase || undefined } : { password: ssh.password }),
71
+ readyTimeout: 15000,
72
+ keepaliveInterval: 20000,
73
+ })
74
+ })
75
+ let broken = null
76
+ client.on('error', (e) => { broken = e })
77
+ client.on('close', () => { broken ??= new Error('SSH connection closed') })
78
+ const sockets = new Set()
79
+ const server = createServer((socket) => {
80
+ sockets.add(socket)
81
+ socket.on('close', () => sockets.delete(socket))
82
+ if (broken) { socket.destroy(); return }
83
+ client.forwardOut('127.0.0.1', 0, targetHost, targetPort, (err, stream) => {
84
+ if (err) { socket.destroy(err); return }
85
+ socket.pipe(stream).pipe(socket)
86
+ stream.on('error', () => socket.destroy())
87
+ socket.on('error', () => stream.destroy())
88
+ })
89
+ })
90
+ await new Promise((resolve, reject) => server.listen(0, '127.0.0.1', resolve).once('error', reject))
91
+ const { port } = server.address()
92
+ return {
93
+ host: '127.0.0.1',
94
+ port,
95
+ get broken() { return broken },
96
+ async close() {
97
+ for (const s of sockets) s.destroy()
98
+ await new Promise(r => server.close(() => r()))
99
+ client.end()
100
+ },
101
+ }
102
+ }
103
+
104
+ // --------------------------------------------------------------------- MySQL
105
+
106
+ const MYSQL_NUM = new Set([0, 1, 2, 3, 4, 5, 8, 9, 246, 13])
107
+ const MYSQL_TIME = new Set([7, 10, 11, 12, 14])
108
+
109
+ class MysqlDriver {
110
+ constructor(conf) {
111
+ this.conf = conf
112
+ this.dialect = 'mysql'
113
+ this.pool = mysql.createPool({
114
+ host: conf.host, port: conf.port, user: conf.user, password: conf.password, database: conf.database,
115
+ charset: conf.charset || 'utf8mb4', timezone: 'local',
116
+ connectionLimit: 3, maxIdle: 1, idleTimeout: 60000, connectTimeout: 10000,
117
+ multipleStatements: false, dateStrings: true, supportBigNumbers: true, bigNumberStrings: true,
118
+ ...(conf.ssl ? { ssl: { rejectUnauthorized: false } } : {}),
119
+ })
120
+ this.version = conf.serverVersion ?? ''
121
+ }
122
+
123
+ ident(name) {
124
+ return `\`${String(name).replace(/`/g, '``')}\``
125
+ }
126
+
127
+ async run(sql, params, timeoutMs) {
128
+ const conn = await this.pool.getConnection()
129
+ try {
130
+ await conn.query('START TRANSACTION READ ONLY')
131
+ try {
132
+ const [rows, fields] = await conn.query({ sql, values: params, timeout: timeoutMs, rowsAsArray: true })
133
+ return { rows, fields: fields ?? [] }
134
+ } finally {
135
+ await conn.query('ROLLBACK').catch(() => {})
136
+ }
137
+ } catch (error) {
138
+ if (error.code === 'PROTOCOL_SEQUENCE_TIMEOUT') {
139
+ conn.destroy()
140
+ throw new DriverError('query/timeout', `查询超时(>${timeoutMs}ms)`)
141
+ }
142
+ throw error
143
+ } finally {
144
+ try { conn.release() } catch {}
145
+ }
146
+ }
147
+
148
+ async objects(sql, params, timeoutMs) {
149
+ const { rows, fields } = await this.run(sql, params, timeoutMs)
150
+ return rows.map(r => Object.fromEntries(fields.map((f, i) => [f.name, r[i]])))
151
+ }
152
+
153
+ async test() {
154
+ const [ver] = await this.objects('SELECT VERSION() AS version, CURRENT_USER() AS user, DATABASE() AS db', [], 10000)
155
+ const grants = (await this.objects('SHOW GRANTS FOR CURRENT_USER()', [], 10000)).map(g => String(Object.values(g)[0]))
156
+ const writes = grants.some(g => /\b(ALL PRIVILEGES|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|GRANT OPTION)\b/i.test(g))
157
+ const [{ n }] = await this.objects('SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()', [], 10000)
158
+ const version = String(ver.version)
159
+ this.version = version
160
+ const old = parseVersion(version) < 50700 && !/mariadb/i.test(version)
161
+ return {
162
+ version, user: ver.user, database: ver.db, tableCount: Number(n), readOnlyGrant: !writes, grants: grants.slice(0, 12),
163
+ warnings: [...(old ? ['服务器版本低于 5.7,部分功能可能不可用'] : []), ...(writes ? ['当前账号拥有写权限。查询始终在只读事务中执行,但仍建议使用只读账号。'] : [])],
164
+ }
165
+ }
166
+
167
+ async introspect(timeoutMs) {
168
+ const db = this.conf.database
169
+ const q = (sql) => this.objects(sql, [db], timeoutMs)
170
+ const [tables, columns, fks, indexes, [ver]] = await Promise.all([
171
+ q(`SELECT TABLE_NAME AS name, TABLE_TYPE AS type, TABLE_ROWS AS \`rows\`, TABLE_COMMENT AS comment FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`),
172
+ q(`SELECT TABLE_NAME AS tableName, COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS columnType, IS_NULLABLE AS nullable, COLUMN_KEY AS \`key\`, EXTRA AS extra, COLUMN_COMMENT AS comment FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME, ORDINAL_POSITION`),
173
+ q(`SELECT TABLE_NAME AS tableName, COLUMN_NAME AS columnName, REFERENCED_TABLE_NAME AS refTable, REFERENCED_COLUMN_NAME AS refColumn FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL`),
174
+ q(`SELECT TABLE_NAME AS tableName, INDEX_NAME AS indexName, NON_UNIQUE AS nonUnique, COLUMN_NAME AS columnName FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`),
175
+ this.objects('SELECT VERSION() AS version', [], timeoutMs),
176
+ ])
177
+ this.version = String(ver.version)
178
+ return {
179
+ version: this.version,
180
+ tables: tables.map(t => ({ name: t.name, type: t.type === 'VIEW' ? 'VIEW' : 'TABLE', rows: t.rows === null ? null : Number(t.rows), comment: t.comment ?? '' })),
181
+ columns: columns.map(c => ({ table: c.tableName, name: c.name, dataType: String(c.dataType).toLowerCase(), columnType: String(c.columnType).toLowerCase(), nullable: c.nullable === 'YES', key: c.key || '', comment: c.comment ?? '', ...(c.extra ? { extra: c.extra } : {}) })),
182
+ foreignKeys: fks.map(r => ({ from: { table: r.tableName, column: r.columnName }, to: { table: r.refTable, column: r.refColumn } })),
183
+ indexes: indexes.map(i => ({ table: i.tableName, name: i.indexName, unique: Number(i.nonUnique) === 0, column: i.columnName })),
184
+ }
185
+ }
186
+
187
+ get useHint() {
188
+ return parseVersion(this.version) >= 50708 && !/mariadb/i.test(this.version)
189
+ }
190
+
191
+ /**
192
+ * Run one statement streaming its rows: at most maxRows + 1 rows are kept;
193
+ * once that many arrived the pooled connection is destroyed (never returned
194
+ * to the pool mid-result), which also aborts the statement on the server.
195
+ */
196
+ async stream(sql, { timeoutMs, maxRows }) {
197
+ const limit = Number.isFinite(maxRows) && maxRows >= 0 ? maxRows + 1 : Infinity
198
+ const conn = await this.pool.getConnection()
199
+ const core = conn.connection
200
+ let destroyed = false
201
+ const destroy = () => { if (!destroyed) { destroyed = true; try { conn.destroy() } catch {} } }
202
+ // Late protocol errors after an intentional destroy must not crash the process.
203
+ const swallow = () => {}
204
+ core.on('error', swallow)
205
+ // MAX_EXECUTION_TIME as a hint only fits a leading SELECT; other heads (WITH …) get the session limit.
206
+ const sessionLimit = this.useHint && !/MAX_EXECUTION_TIME/i.test(sql)
207
+ try {
208
+ if (sessionLimit) await conn.query(`SET SESSION max_execution_time = ${Math.max(1, Math.floor(timeoutMs))}`)
209
+ await conn.query('START TRANSACTION READ ONLY')
210
+ const out = await new Promise((resolve, reject) => {
211
+ const rows = []
212
+ let fields = []
213
+ let settled = false
214
+ const q = core.query({ sql, timeout: timeoutMs + 2000, rowsAsArray: true })
215
+ q.on('fields', (f) => { fields = f ?? [] })
216
+ q.on('result', (row) => {
217
+ if (settled) return
218
+ rows.push(row)
219
+ if (rows.length >= limit) {
220
+ settled = true
221
+ destroy()
222
+ resolve({ rows, fields, aborted: true })
223
+ }
224
+ })
225
+ q.on('error', (error) => {
226
+ if (settled) return
227
+ settled = true
228
+ reject(error)
229
+ })
230
+ q.on('end', () => {
231
+ if (settled) return
232
+ settled = true
233
+ resolve({ rows, fields, aborted: false })
234
+ })
235
+ })
236
+ if (!out.aborted) {
237
+ await conn.query('ROLLBACK').catch(() => {})
238
+ if (sessionLimit) await conn.query('SET SESSION max_execution_time = DEFAULT').catch(() => destroy())
239
+ }
240
+ return out
241
+ } catch (error) {
242
+ if (error.code === 'PROTOCOL_SEQUENCE_TIMEOUT') {
243
+ destroy()
244
+ throw new DriverError('query/timeout', `查询超时(>${timeoutMs}ms)`)
245
+ }
246
+ if (!destroyed) {
247
+ await conn.query('ROLLBACK').catch(() => destroy())
248
+ if (sessionLimit && !destroyed) await conn.query('SET SESSION max_execution_time = DEFAULT').catch(() => destroy())
249
+ }
250
+ throw error
251
+ } finally {
252
+ // A destroyed connection keeps the no-op listener: its socket may still report errors.
253
+ if (!destroyed) { core.removeListener('error', swallow); try { conn.release() } catch {} }
254
+ }
255
+ }
256
+
257
+ async query(sql, { timeoutMs, maxRows }) {
258
+ const { rows, fields } = await this.stream(sql, { timeoutMs, maxRows })
259
+ return {
260
+ columns: fields.map((f, i) => ({
261
+ name: f.name,
262
+ kind: MYSQL_TIME.has(f.type) ? 'time' : MYSQL_NUM.has(f.type) ? (/(^id$|_id$)/i.test(f.name) ? 'text' : 'number') : 'auto',
263
+ table: f.orgTable, column: f.orgName, index: i,
264
+ })),
265
+ rows: rows.map(r => r.map(toCell)),
266
+ }
267
+ }
268
+
269
+ async explain(sql, { timeoutMs }) {
270
+ const plan = await this.objects(`EXPLAIN ${sql}`, [], timeoutMs)
271
+ return {
272
+ plan: plan.map(r => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, toCell(v)]))),
273
+ scans: plan.filter(r => String(r.type ?? '').toUpperCase() === 'ALL' && r.table && !String(r.table).startsWith('<')).map(r => ({ table: r.table, rows: Number(r.rows ?? 0) })),
274
+ }
275
+ }
276
+
277
+ async sampleColumn(table, column) {
278
+ const rows = await this.objects(`SELECT ${this.useHint ? '/*+ MAX_EXECUTION_TIME(3000) */ ' : ''}${this.ident(column)} AS v, COUNT(*) AS n FROM ${this.ident(table)} GROUP BY ${this.ident(column)} ORDER BY n DESC LIMIT 13`, [], 5000)
279
+ return rows.map(r => ({ value: toCell(r.v), count: Number(r.n) }))
280
+ }
281
+
282
+ previewSql(table, limit) {
283
+ return `SELECT * FROM ${this.ident(table)} LIMIT ${limit}`
284
+ }
285
+
286
+ async close() {
287
+ await this.pool.end().catch(() => {})
288
+ }
289
+ }
290
+
291
+ // ---------------------------------------------------------------- PostgreSQL
292
+
293
+ /** Leading keyword when a PostgreSQL statement can be declared as a cursor, else ''. */
294
+ export function cursorHead(sql) {
295
+ let code
296
+ try { code = stripSql(String(sql), { dialect: 'postgres' }).code } catch { return '' }
297
+ const head = (code.trim().match(/^\(*\s*([A-Za-z]+)/)?.[1] ?? '').toUpperCase()
298
+ return ['SELECT', 'WITH', 'VALUES', 'TABLE'].includes(head) ? head : ''
299
+ }
300
+
301
+ // pg type OIDs
302
+ const PG_NUM = new Set([20, 21, 23, 26, 700, 701, 1700, 790])
303
+ const PG_TIME = new Set([1082, 1083, 1114, 1184, 1266])
304
+
305
+ class PostgresDriver {
306
+ constructor(conf) {
307
+ this.conf = conf
308
+ this.dialect = 'postgres'
309
+ this.version = conf.serverVersion ?? ''
310
+ this.pool = new pg.Pool({
311
+ host: conf.host, port: conf.port, user: conf.user, password: conf.password, database: conf.database,
312
+ max: 3, idleTimeoutMillis: 60000, connectionTimeoutMillis: 10000,
313
+ ...(conf.ssl ? { ssl: { rejectUnauthorized: false } } : {}),
314
+ })
315
+ this.pool.on('error', () => {})
316
+ // Return text for dates and big numbers so values stay lossless and JSON friendly.
317
+ this.types = {
318
+ getTypeParser: (oid, format) => {
319
+ if (PG_TIME.has(oid) || oid === 20 || oid === 1700) return (v) => v
320
+ return pg.types.getTypeParser(oid, format)
321
+ },
322
+ }
323
+ }
324
+
325
+ ident(name) {
326
+ return String(name).split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.')
327
+ }
328
+
329
+ async run(sql, params, timeoutMs, rowMode) {
330
+ const client = await this.pool.connect()
331
+ try {
332
+ await client.query('BEGIN READ ONLY')
333
+ await client.query(`SET LOCAL statement_timeout = ${Math.max(1, Math.floor(timeoutMs))}`)
334
+ try {
335
+ // Extended protocol (parse/bind/execute) refuses multi-statement strings, so a
336
+ // guard mistake cannot smuggle `; COMMIT; …` past the READ ONLY transaction.
337
+ return await client.query({ text: sql, values: params ?? [], types: this.types, queryMode: 'extended', ...(rowMode ? { rowMode } : {}) })
338
+ } finally {
339
+ await client.query('ROLLBACK').catch(() => {})
340
+ }
341
+ } catch (error) {
342
+ if (error.code === '57014') throw new DriverError('query/timeout', `查询超时(>${timeoutMs}ms)`)
343
+ throw error
344
+ } finally {
345
+ client.release()
346
+ }
347
+ }
348
+
349
+ async objects(sql, params, timeoutMs) {
350
+ return (await this.run(sql, params, timeoutMs, undefined)).rows
351
+ }
352
+
353
+ get schemas() {
354
+ const list = String(this.conf.schemas ?? '').split(/[,\s]+/).filter(Boolean)
355
+ return list.length ? list : null
356
+ }
357
+
358
+ schemaFilter(col) {
359
+ return this.schemas
360
+ ? { sql: `${col} = ANY($1)`, params: [this.schemas] }
361
+ : { sql: `${col} NOT IN ('pg_catalog','information_schema') AND ${col} NOT LIKE 'pg_toast%' AND ${col} NOT LIKE 'pg_temp%'`, params: [] }
362
+ }
363
+
364
+ async test() {
365
+ const [info] = await this.objects('SELECT version() AS version, current_user AS "user", current_database() AS db, current_setting(\'server_version\') AS sv', [], 10000)
366
+ const f = this.schemaFilter('table_schema')
367
+ const [{ n }] = await this.objects(`SELECT COUNT(*)::int AS n FROM information_schema.tables WHERE ${f.sql}`, f.params, 10000)
368
+ const [priv] = await this.objects(`SELECT
369
+ (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS superuser,
370
+ EXISTS (SELECT 1 FROM information_schema.role_table_grants WHERE grantee = current_user AND privilege_type IN ('INSERT','UPDATE','DELETE','TRUNCATE')) AS writes,
371
+ (SELECT COUNT(*) FROM pg_namespace n WHERE has_schema_privilege(n.oid, 'CREATE') AND n.nspname NOT LIKE 'pg_%' AND n.nspname <> 'information_schema')::int AS create_schemas`, [], 10000)
372
+ const writes = Boolean(priv.superuser || priv.writes)
373
+ this.version = String(info.sv)
374
+ return {
375
+ version: `PostgreSQL ${info.sv}`, user: info.user, database: info.db, tableCount: Number(n), readOnlyGrant: !writes,
376
+ grants: [`superuser: ${priv.superuser}`, `table write grants: ${priv.writes}`, `schemas with CREATE: ${priv.create_schemas}`],
377
+ warnings: writes ? ['当前账号拥有写权限(或为超级用户)。查询始终在只读事务中执行,但仍建议使用只读账号。'] : [],
378
+ }
379
+ }
380
+
381
+ async introspect(timeoutMs) {
382
+ const f = this.schemaFilter('n.nspname')
383
+ const tables = await this.objects(`
384
+ SELECT n.nspname AS schema, c.relname AS name, c.relkind AS kind,
385
+ GREATEST(c.reltuples, 0)::bigint AS rows, obj_description(c.oid, 'pg_class') AS comment
386
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
387
+ WHERE c.relkind IN ('r','v','m','p','f') AND NOT c.relispartition AND ${f.sql}
388
+ ORDER BY n.nspname, c.relname`, f.params, timeoutMs)
389
+ const columns = await this.objects(`
390
+ SELECT n.nspname AS schema, c.relname AS table, a.attname AS name,
391
+ format_type(a.atttypid, a.atttypmod) AS column_type, t.typname AS data_type,
392
+ NOT a.attnotnull AS nullable, col_description(c.oid, a.attnum) AS comment,
393
+ EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary AND a.attnum = ANY(i.indkey)) AS pk
394
+ FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_type t ON t.oid = a.atttypid
395
+ WHERE a.attnum > 0 AND NOT a.attisdropped AND c.relkind IN ('r','v','m','p','f') AND NOT c.relispartition AND ${f.sql}
396
+ ORDER BY n.nspname, c.relname, a.attnum`, f.params, timeoutMs)
397
+ const fks = await this.objects(`
398
+ SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, nf.nspname AS ref_schema, cf.relname AS ref_table, af.attname AS ref_column
399
+ FROM pg_constraint k
400
+ JOIN pg_class c ON c.oid = k.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace
401
+ JOIN pg_class cf ON cf.oid = k.confrelid JOIN pg_namespace nf ON nf.oid = cf.relnamespace
402
+ JOIN LATERAL unnest(k.conkey, k.confkey) AS u(col, refcol) ON true
403
+ JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.col
404
+ JOIN pg_attribute af ON af.attrelid = k.confrelid AND af.attnum = u.refcol
405
+ WHERE k.contype = 'f' AND ${f.sql}`, f.params, timeoutMs)
406
+ const indexes = await this.objects(`
407
+ SELECT n.nspname AS schema, c.relname AS table, ic.relname AS index_name, i.indisunique AS unique, a.attname AS column
408
+ FROM pg_index i JOIN pg_class c ON c.oid = i.indrelid JOIN pg_class ic ON ic.oid = i.indexrelid
409
+ JOIN pg_namespace n ON n.oid = c.relnamespace
410
+ JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
411
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
412
+ WHERE ${f.sql} ORDER BY n.nspname, c.relname, ic.relname, k.ord`, f.params, timeoutMs)
413
+ const [{ sv }] = await this.objects(`SELECT current_setting('server_version') AS sv`, [], timeoutMs)
414
+ this.version = sv
415
+ const single = new Set(tables.map(t => t.schema)).size === 1 && (tables[0]?.schema === 'public')
416
+ const qn = (schema, name) => (single ? name : `${schema}.${name}`)
417
+ const pgType = (t) => {
418
+ if (/^(int2|int4|int8|serial)/.test(t)) return t === 'int2' ? 'smallint' : t === 'int8' ? 'bigint' : 'int'
419
+ if (/^(float4|float8|numeric)$/.test(t)) return t === 'numeric' ? 'decimal' : 'double'
420
+ if (t === 'bool') return 'tinyint'
421
+ if (/^(timestamp|timestamptz)$/.test(t)) return 'datetime'
422
+ if (t === 'date') return 'date'
423
+ if (/^(time|timetz)$/.test(t)) return 'time'
424
+ if (/^(varchar|bpchar|text|name|citext)$/.test(t)) return 'varchar'
425
+ if (/^(json|jsonb)$/.test(t)) return 'json'
426
+ return t
427
+ }
428
+ return {
429
+ version: sv,
430
+ qualified: !single,
431
+ tables: tables.map(t => ({ name: qn(t.schema, t.name), type: t.kind === 'v' || t.kind === 'm' ? 'VIEW' : 'TABLE', rows: t.rows === null ? null : Number(t.rows), comment: t.comment ?? '' })),
432
+ columns: columns.map(c => ({ table: qn(c.schema, c.table), name: c.name, dataType: pgType(c.data_type), columnType: c.column_type, nullable: c.nullable, key: c.pk ? 'PRI' : '', comment: c.comment ?? '', ...(c.data_type === 'bool' ? { columnType: 'tinyint(1)' } : {}) })),
433
+ foreignKeys: fks.map(r => ({ from: { table: qn(r.schema, r.table), column: r.column }, to: { table: qn(r.ref_schema, r.ref_table), column: r.ref_column } })),
434
+ indexes: indexes.map(i => ({ table: qn(i.schema, i.table), name: i.index_name, unique: i.unique, column: i.column })),
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Run a statement through a server-side cursor so at most maxRows + 1 rows
440
+ * are pulled. Statements that cannot be declared as a cursor (SHOW, EXPLAIN)
441
+ * run as a plain query.
442
+ */
443
+ async cursorRun(sql, { timeoutMs, maxRows }) {
444
+ const head = cursorHead(sql)
445
+ if (!head || !Number.isFinite(maxRows)) return this.run(sql, [], timeoutMs, 'array')
446
+ const client = await this.pool.connect()
447
+ try {
448
+ await client.query('BEGIN READ ONLY')
449
+ await client.query(`SET LOCAL statement_timeout = ${Math.max(1, Math.floor(timeoutMs))}`)
450
+ try {
451
+ // Extended protocol refuses multi-statement strings: the DECLARE cannot carry a second statement.
452
+ await client.query({ text: `DECLARE nl2sql_cur NO SCROLL CURSOR FOR ${sql}`, queryMode: 'extended' })
453
+ return await client.query({ text: `FETCH FORWARD ${Math.max(1, Math.floor(maxRows) + 1)} FROM nl2sql_cur`, types: this.types, queryMode: 'extended', rowMode: 'array' })
454
+ } finally {
455
+ await client.query('ROLLBACK').catch(() => {})
456
+ }
457
+ } catch (error) {
458
+ if (error.code === '57014') throw new DriverError('query/timeout', `查询超时(>${timeoutMs}ms)`)
459
+ throw error
460
+ } finally {
461
+ client.release()
462
+ }
463
+ }
464
+
465
+ async query(sql, { timeoutMs, maxRows }) {
466
+ const res = await this.cursorRun(sql, { timeoutMs, maxRows })
467
+ return {
468
+ columns: (res.fields ?? []).map((f, i) => ({
469
+ name: f.name,
470
+ kind: PG_TIME.has(f.dataTypeID) ? 'time' : PG_NUM.has(f.dataTypeID) ? (/(^id$|_id$)/i.test(f.name) ? 'text' : 'number') : 'auto',
471
+ index: i,
472
+ })),
473
+ rows: (res.rows ?? []).map(r => r.map(toCell)),
474
+ }
475
+ }
476
+
477
+ async explain(sql, { timeoutMs }) {
478
+ const res = await this.objects(`EXPLAIN (FORMAT JSON) ${sql}`, [], timeoutMs)
479
+ const root = res[0]?.['QUERY PLAN']?.[0]?.Plan
480
+ const flat = []
481
+ const scans = []
482
+ const walk = (node, depth) => {
483
+ if (!node) return
484
+ flat.push({ depth, node: node['Node Type'], relation: node['Relation Name'] ?? null, index: node['Index Name'] ?? null, rows: node['Plan Rows'], cost: node['Total Cost'], filter: node.Filter ?? node['Index Cond'] ?? null })
485
+ if (node['Node Type'] === 'Seq Scan' && node['Relation Name']) scans.push({ table: node['Relation Name'], rows: Number(node['Plan Rows'] ?? 0) * 1 })
486
+ for (const child of node.Plans ?? []) walk(child, depth + 1)
487
+ }
488
+ walk(root, 0)
489
+ // Plan Rows of a filtered Seq Scan is the estimated output; use the table size for the guard.
490
+ for (const s of scans) {
491
+ try {
492
+ const [{ n }] = await this.objects('SELECT GREATEST(reltuples, 0)::bigint AS n FROM pg_class WHERE relname = $1 ORDER BY reltuples DESC LIMIT 1', [s.table], 3000)
493
+ s.rows = Math.max(s.rows, Number(n))
494
+ } catch {}
495
+ }
496
+ return { plan: flat, scans }
497
+ }
498
+
499
+ async sampleColumn(table, column) {
500
+ const rows = await this.objects(`SELECT ${this.ident(column)}::text AS v, COUNT(*)::bigint AS n FROM ${this.ident(table)} GROUP BY 1 ORDER BY 2 DESC LIMIT 13`, [], 5000)
501
+ return rows.map(r => ({ value: r.v, count: Number(r.n) }))
502
+ }
503
+
504
+ previewSql(table, limit) {
505
+ return `SELECT * FROM ${this.ident(table)} LIMIT ${limit}`
506
+ }
507
+
508
+ async close() {
509
+ await this.pool.end().catch(() => {})
510
+ }
511
+ }
512
+
513
+ // ------------------------------------------------------------ Elasticsearch
514
+
515
+ const ES_NUM = new Set(['long', 'integer', 'short', 'byte', 'double', 'float', 'half_float', 'scaled_float', 'unsigned_long'])
516
+ const ES_TIME = new Set(['datetime', 'date', 'time', 'date_nanos'])
517
+
518
+ class ElasticsearchDriver {
519
+ constructor(conf) {
520
+ this.conf = conf
521
+ this.dialect = 'elasticsearch'
522
+ this.version = conf.serverVersion ?? ''
523
+ const scheme = conf.ssl ? 'https' : 'http'
524
+ this.base = `${scheme}://${conf.host}:${conf.port}`
525
+ this.headers = { 'content-type': 'application/json', accept: 'application/json' }
526
+ if (conf.apiKey) this.headers.authorization = `ApiKey ${conf.apiKey}`
527
+ else if (conf.user) this.headers.authorization = `Basic ${Buffer.from(`${conf.user}:${conf.password ?? ''}`).toString('base64')}`
528
+ this.indexPattern = String(conf.database || '*').trim() || '*'
529
+ }
530
+
531
+ ident(name) {
532
+ return /^[A-Za-z_][A-Za-z0-9_.]*$/.test(name) ? name : `"${String(name).replace(/"/g, '""')}"`
533
+ }
534
+
535
+ async request(method, path, body, timeoutMs = 15000) {
536
+ const controller = new AbortController()
537
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
538
+ let res
539
+ try {
540
+ res = await fetch(`${this.base}${path}`, {
541
+ method, headers: this.headers, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal,
542
+ // Self-signed clusters are common internally; Node fetch honours NODE_TLS_REJECT_UNAUTHORIZED only.
543
+ })
544
+ } catch (error) {
545
+ if (error.name === 'AbortError') throw new DriverError('query/timeout', `请求超时(>${timeoutMs}ms)`)
546
+ throw new DriverError('es/connect', `无法连接 Elasticsearch:${error.cause?.code ?? error.message}`)
547
+ } finally {
548
+ clearTimeout(timer)
549
+ }
550
+ const text = await res.text()
551
+ let json
552
+ try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } }
553
+ if (!res.ok) {
554
+ const reason = json?.error?.root_cause?.[0]?.reason ?? json?.error?.reason ?? json?.error ?? text.slice(0, 300)
555
+ throw new DriverError(res.status === 401 || res.status === 403 ? 'es/auth' : 'es/error', `Elasticsearch ${res.status}: ${typeof reason === 'string' ? reason : JSON.stringify(reason)}`)
556
+ }
557
+ return json
558
+ }
559
+
560
+ async test() {
561
+ const info = await this.request('GET', '/')
562
+ const version = String(info.version?.number ?? '')
563
+ this.version = version
564
+ let indices = []
565
+ try { indices = await this.request('GET', `/_cat/indices/${encodeURIComponent(this.indexPattern)}?format=json&h=index`) } catch {}
566
+ let readOnly = null
567
+ const grants = []
568
+ try {
569
+ const priv = await this.request('POST', '/_security/user/_has_privileges', { index: [{ names: [this.indexPattern], privileges: ['write', 'delete', 'manage'] }] })
570
+ const p = Object.values(priv.index ?? {})[0] ?? {}
571
+ readOnly = !(p.write || p.delete || p.manage)
572
+ grants.push(`write=${p.write} delete=${p.delete} manage=${p.manage}`)
573
+ } catch {
574
+ grants.push('security disabled or privileges API unavailable')
575
+ }
576
+ const major = parseVersion(version)
577
+ return {
578
+ version: `Elasticsearch ${version}`, user: this.conf.user || (this.conf.apiKey ? 'api-key' : 'anonymous'), database: this.indexPattern,
579
+ tableCount: indices.filter(i => !String(i.index).startsWith('.')).length, readOnlyGrant: readOnly ?? undefined, grants,
580
+ warnings: [
581
+ ...(major < 60300 ? ['Elasticsearch 6.3 以下不支持 SQL 接口'] : []),
582
+ ...(readOnly === false ? ['该账号对索引有写权限。插件只通过只读的 SQL 接口查询,但仍建议使用只读账号。'] : []),
583
+ ...(readOnly === null ? ['集群未开启安全认证,无法检查账号权限'] : []),
584
+ ],
585
+ }
586
+ }
587
+
588
+ async introspect(timeoutMs) {
589
+ const info = await this.request('GET', '/', undefined, timeoutMs)
590
+ this.version = String(info.version?.number ?? '')
591
+ const cat = await this.request('GET', `/_cat/indices/${encodeURIComponent(this.indexPattern)}?format=json&h=index,docs.count,status`, undefined, timeoutMs)
592
+ const visible = cat.filter(i => !String(i.index).startsWith('.')).sort((a, b) => String(a.index).localeCompare(String(b.index))).slice(0, 300)
593
+ const tables = []
594
+ const columns = []
595
+ const indexes = []
596
+ if (visible.length) {
597
+ const mappings = await this.request('GET', `/${visible.map(i => encodeURIComponent(i.index)).join(',')}/_mapping`, undefined, timeoutMs)
598
+ for (const idx of visible) {
599
+ const props = mappings[idx.index]?.mappings?.properties ?? {}
600
+ tables.push({ name: idx.index, type: 'TABLE', rows: idx['docs.count'] === null ? null : Number(idx['docs.count']), comment: mappings[idx.index]?.mappings?._meta?.description ?? '' })
601
+ const walk = (properties, prefix) => {
602
+ for (const [field, spec] of Object.entries(properties ?? {})) {
603
+ const name = prefix ? `${prefix}.${field}` : field
604
+ if (spec.properties) { walk(spec.properties, name); continue }
605
+ const t = spec.type ?? 'object'
606
+ const dataType = ES_NUM.has(t) ? (t === 'long' || t === 'unsigned_long' ? 'bigint' : t === 'integer' || t === 'short' || t === 'byte' ? 'int' : 'double')
607
+ : t === 'date' || t === 'date_nanos' ? 'datetime' : t === 'boolean' ? 'tinyint' : t === 'keyword' || t === 'constant_keyword' || t === 'wildcard' ? 'varchar' : t === 'text' || t === 'match_only_text' ? 'text' : t
608
+ columns.push({ table: idx.index, name, dataType, columnType: t + (spec.fields?.keyword ? ' (+keyword)' : ''), nullable: true, key: name === '_id' ? 'PRI' : '', comment: spec.meta?.description ?? '', ...(t === 'boolean' ? { columnType: 'tinyint(1)' } : {}) })
609
+ if (t !== 'text') indexes.push({ table: idx.index, name: `${name}`, unique: false, column: name })
610
+ }
611
+ }
612
+ walk(props, '')
613
+ }
614
+ }
615
+ return { version: this.version, tables, columns, foreignKeys: [], indexes: [] }
616
+ }
617
+
618
+ async query(sql, { timeoutMs, maxRows }) {
619
+ const fetchSize = Math.min(Math.max(maxRows + 1, 1), 10000)
620
+ const body = { query: sql, fetch_size: fetchSize, request_timeout: `${Math.ceil(timeoutMs / 1000)}s`, page_timeout: '30s', time_zone: this.conf.timezone && this.conf.timezone !== 'local' ? this.conf.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone }
621
+ const res = await this.request('POST', '/_sql?format=json', body, timeoutMs + 3000)
622
+ const rows = [...(res.rows ?? [])]
623
+ let cursor = res.cursor
624
+ while (cursor && rows.length <= maxRows) {
625
+ const next = await this.request('POST', '/_sql?format=json', { cursor }, timeoutMs + 3000)
626
+ rows.push(...(next.rows ?? []))
627
+ cursor = next.cursor
628
+ }
629
+ if (cursor) await this.request('POST', '/_sql/close', { cursor }).catch(() => {})
630
+ return {
631
+ columns: (res.columns ?? []).map((c, i) => ({ name: c.name, kind: ES_TIME.has(c.type) ? 'time' : ES_NUM.has(c.type) ? 'number' : 'auto', index: i })),
632
+ rows: rows.map(r => r.map(toCell)),
633
+ }
634
+ }
635
+
636
+ async explain(sql, { timeoutMs }) {
637
+ const res = await this.request('POST', '/_sql/translate', { query: sql }, timeoutMs)
638
+ return { plan: [{ translated_query_dsl: JSON.stringify(res) }], scans: [] }
639
+ }
640
+
641
+ async sampleColumn(table, column) {
642
+ const res = await this.request('POST', `/${encodeURIComponent(table)}/_search`, { size: 0, aggs: { v: { terms: { field: column, size: 13 } } } }, 5000)
643
+ return (res.aggregations?.v?.buckets ?? []).map(b => ({ value: b.key_as_string ?? b.key, count: b.doc_count }))
644
+ }
645
+
646
+ previewSql(table, limit) {
647
+ return `SELECT * FROM ${this.ident(table)} LIMIT ${limit}`
648
+ }
649
+
650
+ async close() {}
651
+ }
652
+
653
+ // ------------------------------------------------------------------- factory
654
+
655
+ /**
656
+ * Create a driver for a data source; opens the SSH tunnel first when
657
+ * configured. The returned driver's close() also closes the tunnel.
658
+ * @param {object} conf - data source record plus resolved secrets.
659
+ */
660
+ export async function createDriver(conf) {
661
+ if (conf.type === 'sqlite') {
662
+ const { SqliteDriver, useErrorClass } = await import('./sqlite-driver.js')
663
+ useErrorClass(DriverError)
664
+ return new SqliteDriver({ file: conf.file })
665
+ }
666
+ let tunnel
667
+ let effective = conf
668
+ if (conf.ssh?.enabled) {
669
+ tunnel = await openTunnel(conf.ssh, conf.host, conf.port)
670
+ effective = { ...conf, host: tunnel.host, port: tunnel.port }
671
+ }
672
+ const Driver = conf.type === 'postgres' ? PostgresDriver : conf.type === 'elasticsearch' ? ElasticsearchDriver : MysqlDriver
673
+ const driver = new Driver(effective)
674
+ if (tunnel) {
675
+ const close = driver.close.bind(driver)
676
+ driver.close = async () => { await close(); await tunnel.close() }
677
+ driver.tunnel = tunnel
678
+ }
679
+ return driver
680
+ }
681
+
682
+ /** Map a driver/database error to a user-readable message. */
683
+ export function describeError(error) {
684
+ if (error instanceof DriverError) return { code: error.code, message: error.message }
685
+ const code = error?.code ?? 'ERR'
686
+ const hints = {
687
+ ER_ACCESS_DENIED_ERROR: '用户名或密码错误', '28P01': '用户名或密码错误', '28000': '认证失败',
688
+ ECONNREFUSED: '无法连接到数据库服务器(连接被拒绝)', ETIMEDOUT: '连接超时', ENOTFOUND: '主机名无法解析',
689
+ ER_BAD_DB_ERROR: '数据库不存在', '3D000': '数据库不存在', ER_QUERY_TIMEOUT: '查询超过最大执行时间', '57014': '查询超时被取消',
690
+ '25006': '只读事务中不能执行写操作', ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION: '只读事务中不能执行写操作',
691
+ }
692
+ const hint = hints[code] ? `${hints[code]}:` : ''
693
+ return { code: `db/${code}`, message: `${hint}${error?.sqlMessage ?? error?.message ?? String(error)}` }
694
+ }