@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
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
|
+
}
|