@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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Nl2SqlService methods: uploads.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ *
5
+ * Files can be sent once (base64 in parseUpload / importUpload), or staged on
6
+ * the server: stageUpload (one request) or stageUploadChunk (several requests)
7
+ * keep the bytes under <dataDir>/uploads/<uploadId> (0600) for one hour, and
8
+ * importUpload({ uploadId }) imports them without the browser re-sending.
9
+ */
10
+ import { appendFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import { dropTable, importTable, listTables, sanitizeTableName } from '../sqlite-driver.js'
13
+ import { coerceValue, inferColumns, parseCsv, readXlsx } from '../xlsx.js'
14
+ import { UserError, newId } from './shared.js'
15
+
16
+ export const UPLOAD_MAX_BYTES = 200 * 1024 * 1024
17
+ export const UPLOAD_TTL_MS = 60 * 60 * 1000
18
+ const UPLOAD_ID = /^up-[0-9a-f]{10}$/
19
+
20
+ const isText = (fileName) => /\.(csv|tsv|txt)$/i.test(String(fileName ?? ''))
21
+
22
+ /** Raw sheets of a CSV / Excel file: [{ name, rows, encoding?, delimiter?, truncated }]. */
23
+ function readSheets(fileName, buf) {
24
+ try {
25
+ if (isText(fileName)) {
26
+ const p = parseCsv(buf)
27
+ return [{ name: String(fileName).replace(/\.[^.]+$/, ''), rows: p.rows, encoding: p.encoding, delimiter: p.delimiter, truncated: p.truncated }]
28
+ }
29
+ return readXlsx(buf)
30
+ } catch (error) {
31
+ throw new UserError('nl2sql/invalid', error.message)
32
+ }
33
+ }
34
+
35
+ /** Preview shape returned by parseUpload / stageUpload. */
36
+ function previewSheets(raw, fileName, headerRow) {
37
+ return raw.filter(sh => sh.rows.some(r => r.length)).map(sh => {
38
+ const rows = sh.rows.filter(r => r.some(v => v !== null && v !== undefined && v !== ''))
39
+ const header = headerRow ? rows[0] ?? [] : (rows[0] ?? []).map((_, i) => `column_${i + 1}`)
40
+ const body = headerRow ? rows.slice(1) : rows
41
+ const width = Math.max(header.length, ...body.slice(0, 1000).map(r => r.length))
42
+ const cols = inferColumns(Array.from({ length: width }, (_, i) => header[i] ?? ''), body.map(r => r.map(v => v === null || v === undefined ? '' : String(v))))
43
+ return { sheet: sh.name, table: sanitizeTableName(sh.name || fileName || 'data'), columns: cols, rowCount: body.length, preview: body.slice(0, 20), encoding: sh.encoding, delimiter: sh.delimiter, truncated: Boolean(sh.truncated) }
44
+ })
45
+ }
46
+
47
+ function decode(base64) {
48
+ return Buffer.from(String(base64 ?? ''), 'base64')
49
+ }
50
+
51
+ function checkSize(n) {
52
+ if (n > UPLOAD_MAX_BYTES) throw new UserError('nl2sql/invalid', '文件超过 200 MB')
53
+ }
54
+
55
+ export const methods = {
56
+ // ----------------------------------------------------------- file uploads
57
+
58
+ /**
59
+ * Parse an uploaded CSV / Excel file (base64) into a preview: sheets with
60
+ * inferred column types and the first rows, without importing.
61
+ */
62
+ parseUpload({ fileName, base64, headerRow = true }) {
63
+ const buf = decode(base64)
64
+ if (!buf.length) throw new UserError('nl2sql/invalid', '文件为空')
65
+ checkSize(buf.length)
66
+ return previewSheets(readSheets(fileName, buf), fileName, headerRow)
67
+ },
68
+
69
+ uploadDir() {
70
+ return join(this.dir, 'uploads')
71
+ },
72
+
73
+ uploadPath(id) {
74
+ if (!UPLOAD_ID.test(String(id ?? ''))) throw new UserError('nl2sql/invalid', '上传编号无效')
75
+ return join(this.uploadDir(), id)
76
+ },
77
+
78
+ /** Delete staged files (complete or partial) older than the TTL. */
79
+ async cleanupUploads(now = Date.now()) {
80
+ let names = []
81
+ try { names = await readdir(this.uploadDir()) } catch { return 0 }
82
+ let removed = 0
83
+ for (const name of names) {
84
+ const id = name.replace(/\.json$/, '')
85
+ const file = join(this.uploadDir(), name)
86
+ try {
87
+ const st = await stat(file)
88
+ if (now - st.mtimeMs <= UPLOAD_TTL_MS) continue
89
+ await rm(file, { force: true })
90
+ this.uploadSessions?.delete(id)
91
+ if (this.stagedParse?.id === id) this.stagedParse = null
92
+ removed++
93
+ } catch {}
94
+ }
95
+ return removed
96
+ },
97
+
98
+ async writeUploadMeta(id, meta) {
99
+ await writeFile(`${this.uploadPath(id)}.json`, JSON.stringify(meta), { mode: 0o600 })
100
+ },
101
+
102
+ async readUploadMeta(id) {
103
+ try { return JSON.parse(await readFile(`${this.uploadPath(id)}.json`, 'utf8')) } catch { return undefined }
104
+ },
105
+
106
+ /** Parse a fully staged file once, remember the parse for the following import, return the preview. */
107
+ async finishStage(id, fileName, buf, headerRow) {
108
+ const raw = readSheets(fileName, buf)
109
+ // Keep only the latest parse in memory: importUpload right after staging reuses it.
110
+ this.stagedParse = { id, fileName, raw }
111
+ await this.writeUploadMeta(id, { fileName, bytes: buf.length, complete: true, createdAt: Date.now() })
112
+ return { uploadId: id, fileName, bytes: buf.length, sheets: previewSheets(raw, fileName, headerRow) }
113
+ },
114
+
115
+ /** Store one whole file (base64) on the server, parse it once, return the preview. */
116
+ async stageUpload({ fileName, base64, headerRow = true }) {
117
+ await this.cleanupUploads()
118
+ const name = String(fileName ?? '').trim().slice(0, 255)
119
+ if (!name) throw new UserError('nl2sql/invalid', '缺少文件名')
120
+ const buf = decode(base64)
121
+ if (!buf.length) throw new UserError('nl2sql/invalid', '文件为空')
122
+ checkSize(buf.length)
123
+ await mkdir(this.uploadDir(), { recursive: true, mode: 0o700 })
124
+ const id = newId('up')
125
+ await writeFile(this.uploadPath(id), buf, { mode: 0o600 })
126
+ try {
127
+ return await this.finishStage(id, name, buf, headerRow)
128
+ } catch (error) {
129
+ await this.discardUpload(id)
130
+ throw error
131
+ }
132
+ },
133
+
134
+ /**
135
+ * Chunked staging: chunks arrive in order (index 0..total-1). The first
136
+ * chunk opens an upload (uploadId omitted); later ones must name it. The
137
+ * last chunk parses the file and returns the stageUpload result; earlier
138
+ * chunks return { uploadId, received }.
139
+ */
140
+ async stageUploadChunk({ uploadId, fileName, index, total, base64, headerRow = true }) {
141
+ this.uploadSessions ??= new Map()
142
+ const idx = Number(index)
143
+ const tot = Number(total)
144
+ if (!Number.isInteger(tot) || tot < 1 || tot > 100000) throw new UserError('nl2sql/invalid', 'total 无效')
145
+ if (!Number.isInteger(idx) || idx < 0 || idx >= tot) throw new UserError('nl2sql/invalid', 'index 无效')
146
+ const chunk = decode(base64)
147
+ let up
148
+ if (idx === 0 && !uploadId) {
149
+ await this.cleanupUploads()
150
+ const name = String(fileName ?? '').trim().slice(0, 255)
151
+ if (!name) throw new UserError('nl2sql/invalid', '缺少文件名')
152
+ await mkdir(this.uploadDir(), { recursive: true, mode: 0o700 })
153
+ up = { id: newId('up'), fileName: name, total: tot, next: 0, bytes: 0, startedAt: Date.now() }
154
+ await writeFile(this.uploadPath(up.id), Buffer.alloc(0), { mode: 0o600 })
155
+ this.uploadSessions.set(up.id, up)
156
+ } else {
157
+ up = uploadId ? this.uploadSessions.get(String(uploadId)) : undefined
158
+ if (!up) throw new UserError('nl2sql/invalid', '上传不存在或已过期,请重新上传')
159
+ if (tot !== up.total) throw new UserError('nl2sql/invalid', 'total 与首个分片不一致')
160
+ if (idx !== up.next) throw new UserError('nl2sql/invalid', `分片顺序错误:期望第 ${up.next} 片,收到第 ${idx} 片`)
161
+ }
162
+ if (up.bytes + chunk.length > UPLOAD_MAX_BYTES) {
163
+ await this.discardUpload(up.id)
164
+ throw new UserError('nl2sql/invalid', '文件超过 200 MB')
165
+ }
166
+ await appendFile(this.uploadPath(up.id), chunk)
167
+ up.bytes += chunk.length
168
+ up.next = idx + 1
169
+ if (up.next < up.total) return { uploadId: up.id, received: up.next }
170
+ this.uploadSessions.delete(up.id)
171
+ if (!up.bytes) { await this.discardUpload(up.id); throw new UserError('nl2sql/invalid', '文件为空') }
172
+ try {
173
+ return await this.finishStage(up.id, up.fileName, await readFile(this.uploadPath(up.id)), headerRow)
174
+ } catch (error) {
175
+ await this.discardUpload(up.id)
176
+ throw error
177
+ }
178
+ },
179
+
180
+ async discardUpload(id) {
181
+ this.uploadSessions?.delete(id)
182
+ if (this.stagedParse?.id === id) this.stagedParse = null
183
+ try { await rm(this.uploadPath(id), { force: true }); await rm(`${this.uploadPath(id)}.json`, { force: true }) } catch {}
184
+ },
185
+
186
+ /** Raw sheets of a staged upload: the cached parse, or one read + one parse. */
187
+ async stagedSheets(uploadId) {
188
+ const file = this.uploadPath(uploadId)
189
+ const meta = await this.readUploadMeta(uploadId)
190
+ if (!meta?.complete) throw new UserError('nl2sql/invalid', '上传不存在或已过期(暂存文件保留 1 小时),请重新上传')
191
+ if (this.stagedParse?.id === uploadId) return { fileName: meta.fileName, raw: this.stagedParse.raw }
192
+ let buf
193
+ try { buf = await readFile(file) } catch { throw new UserError('nl2sql/invalid', '上传不存在或已过期(暂存文件保留 1 小时),请重新上传') }
194
+ return { fileName: meta.fileName, raw: readSheets(meta.fileName, buf) }
195
+ },
196
+
197
+ /** Import one or more sheets of an uploaded file into a local file data source. */
198
+ async importUpload({ datasourceId, newName, uploadId, fileName, base64, headerRow = true, sheets: picks, mode = 'create' }) {
199
+ let raw
200
+ if (uploadId) {
201
+ const staged = await this.stagedSheets(uploadId)
202
+ fileName = fileName || staged.fileName
203
+ raw = staged.raw
204
+ }
205
+ let ds = datasourceId ? this.requireDatasource(datasourceId) : undefined
206
+ if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || String(fileName ?? '本地文件').replace(/\.[^.]+$/, '').slice(0, 60), description: `上传的文件:${fileName}` })
207
+ if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '只能导入到「本地文件」类型的数据源')
208
+ const ds2 = this.requireDatasource(ds.id)
209
+ if (!raw) {
210
+ const buf = decode(base64)
211
+ checkSize(buf.length)
212
+ raw = isText(fileName) ? [{ name: String(fileName).replace(/\.[^.]+$/, ''), rows: parseCsv(buf).rows }] : readXlsx(buf)
213
+ }
214
+ const results = []
215
+ await this.closeDriver(ds2.id)
216
+ for (const sh of raw) {
217
+ const pick = picks?.find(p => p.sheet === sh.name)
218
+ if (picks?.length && !pick) continue
219
+ const rows = sh.rows.filter(r => r.some(v => v !== null && v !== undefined && v !== ''))
220
+ if (!rows.length) continue
221
+ const header = headerRow ? rows[0] : rows[0].map((_, i) => `column_${i + 1}`)
222
+ const body = headerRow ? rows.slice(1) : rows
223
+ const width = Math.max(header.length, ...body.slice(0, 1000).map(r => r.length))
224
+ const strBody = body.map(r => Array.from({ length: width }, (_, i) => r[i] === null || r[i] === undefined ? '' : String(r[i])))
225
+ let cols = inferColumns(Array.from({ length: width }, (_, i) => header[i] ?? ''), strBody)
226
+ if (pick?.columns?.length === cols.length) cols = cols.map((c, i) => ({ ...c, name: pick.columns[i].name || c.name, type: pick.columns[i].type || c.type }))
227
+ const values = strBody.map(r => r.map((v, i) => coerceValue(v, cols[i].type)))
228
+ try {
229
+ const res = importTable({ file: ds2.file, table: pick?.table || sh.name || 'data', columns: cols.map(c => ({ name: c.name, type: c.type })), rows: values, mode: pick?.mode ?? mode, source: fileName })
230
+ results.push({ sheet: sh.name, ...res, ok: true })
231
+ } catch (error) {
232
+ results.push({ sheet: sh.name, ok: false, error: error.message })
233
+ }
234
+ }
235
+ if (results.some(r => r.ok)) {
236
+ await this.analyzeSchema(ds2.id, { refresh: true })
237
+ if (uploadId) await this.discardUpload(uploadId)
238
+ }
239
+ this.auditAdmin('upload', `${fileName} → ${ds2.name}`)
240
+ await this.save()
241
+ return { datasourceId: ds2.id, datasourceName: ds2.name, results }
242
+ },
243
+
244
+ listFileTables(datasourceId) {
245
+ const ds = this.requireDatasource(datasourceId)
246
+ if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '不是本地文件数据源')
247
+ try { return listTables({ file: ds.file }) } catch { return [] }
248
+ },
249
+
250
+ async dropFileTable({ datasourceId, table }) {
251
+ const ds = this.requireDatasource(datasourceId)
252
+ if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '不是本地文件数据源')
253
+ await this.closeDriver(ds.id)
254
+ dropTable({ file: ds.file, table })
255
+ await this.analyzeSchema(ds.id, { refresh: true })
256
+ return this.listFileTables(ds.id)
257
+ },
258
+ }
package/sql-guard.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Read-only SQL admission for MySQL 5.7+, PostgreSQL, Elasticsearch SQL and SQLite.
3
+ *
4
+ * The guard is a syntactic first line of defence; execution additionally runs
5
+ * inside a READ ONLY transaction with a server-side time limit, so a statement
6
+ * the guard misjudges still cannot write.
7
+ */
8
+
9
+ const ALLOWED_HEADS = {
10
+ mysql: new Set(['SELECT', 'SHOW', 'DESC', 'DESCRIBE', 'EXPLAIN', 'WITH']),
11
+ postgres: new Set(['SELECT', 'SHOW', 'EXPLAIN', 'WITH', 'VALUES', 'TABLE']),
12
+ elasticsearch: new Set(['SELECT', 'SHOW', 'DESC', 'DESCRIBE']),
13
+ sqlite: new Set(['SELECT', 'WITH', 'VALUES', 'EXPLAIN']),
14
+ }
15
+
16
+ // PostgreSQL functions that write, signal, sleep, or reach the file system.
17
+ const PG_FORBIDDEN = /\b(pg_sleep\w*|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_read_file|pg_read_binary_file|pg_ls_dir|pg_stat_file|lo_\w+|dblink\w*|nextval|setval|set_config|pg_advisory\w*|pg_try_advisory\w*|pg_notify|txid_current|pg_switch_wal|pg_create_\w+|pg_drop_\w+|pg_logical_\w+|copy|query_to_xml\w*|query_to_json\w*|table_to_xml\w*|cursor_to_xml\w*|schema_to_xml\w*|database_to_xml\w*|pg_get_\w*def|pg_get_expr|xmlparse|xmltable|pg_read_\w+|pg_file_\w+|pg_execute\w*|current_setting|pg_settings)\s*\(/i
18
+
19
+ // SQLite: extension loading, file I/O (CLI/fileio shell functions), tokenizer pointer tricks,
20
+ // and statements that touch other database files or the schema/connection state.
21
+ const SQLITE_FORBIDDEN_FN = /\b(load_extension|readfile|writefile|edit|fts3_tokenizer|zipfile|sqlar_\w+|lsmode|randomblob|zeroblob)\s*\(/i
22
+ const SQLITE_FORBIDDEN_STMT = /\b(ATTACH|DETACH|PRAGMA|VACUUM|REINDEX|RETURNING)\b/i
23
+
24
+ const FORBIDDEN = [
25
+ [/\bINTO\s+(OUTFILE|DUMPFILE)\b/i, 'INTO OUTFILE/DUMPFILE is not allowed'],
26
+ [/\bINTO\s+@/i, 'SELECT ... INTO @variable is not allowed'],
27
+ [/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i, 'SELECT ... FOR UPDATE/SHARE takes locks and is not allowed'],
28
+ [/\bLOCK\s+IN\s+SHARE\s+MODE\b/i, 'LOCK IN SHARE MODE is not allowed'],
29
+ [/\b(SLEEP|BENCHMARK|LOAD_FILE|GET_LOCK|RELEASE_LOCK|RELEASE_ALL_LOCKS)\s*\(/i, 'time-wasting or file/lock functions are not allowed'],
30
+ [/\b(INSERT|UPDATE|DELETE|REPLACE|MERGE|CREATE|ALTER|DROP|TRUNCATE|RENAME|GRANT|REVOKE|CALL|HANDLER|LOAD|SET|USE|KILL|FLUSH|RESET|SHUTDOWN|INSTALL|UNINSTALL|PREPARE|EXECUTE|DEALLOCATE|LOCK|UNLOCK|START|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|XA|DO|ANALYZE|OPTIMIZE|REPAIR|CHECKSUM)\b/i, 'only read-only statements are allowed'],
31
+ ]
32
+
33
+ /**
34
+ * Per-dialect lexical rules. Getting these exactly right matters: if the guard
35
+ * thinks a quote is escaped when the server does not (or vice versa), text the
36
+ * server executes would be hidden inside a "string".
37
+ * mysql: `-- ` needs whitespace after it, `#` comments, backslash escapes in '…' and "…", `…` identifiers
38
+ * postgres: any `--` comments, no `#`, backslash escapes only inside E'…', $tag$…$tag$ dollar quoting, "…" identifiers
39
+ * sqlite: any `--` comments, no `#`, no backslash escapes, "…" / `…` / […] identifiers
40
+ * elasticsearch: any `--` comments, no `#`, no backslash escapes, "…" identifiers
41
+ */
42
+ export function dialectRules(dialect = 'mysql') {
43
+ const mysql = dialect === 'mysql'
44
+ return {
45
+ dashDashNeedsSpace: mysql,
46
+ hashComment: mysql,
47
+ backslashEscapes: mysql, // postgres: only E'…' strings (handled separately)
48
+ dollarQuotes: dialect === 'postgres',
49
+ escapeStrings: dialect === 'postgres',
50
+ bracketIdents: dialect === 'sqlite',
51
+ backtickIdents: mysql || dialect === 'sqlite',
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Tokenize SQL into code / string / identifier / comment segments using the
57
+ * dialect's lexical rules. Unterminated strings, identifiers and comments are
58
+ * reported (the server would reject them, and they can hide code).
59
+ * @returns {{ segments: { kind: 'code'|'string'|'ident'|'comment', text: string, start: number, end: number }[], unterminated: boolean, executableComment: boolean }}
60
+ */
61
+ export function lexSql(sql, { dialect = 'mysql' } = {}) {
62
+ const r = dialectRules(dialect)
63
+ const segments = []
64
+ let unterminated = false
65
+ let executableComment = false
66
+ let code = ''
67
+ let codeStart = 0
68
+ const flush = (end) => { if (code) segments.push({ kind: 'code', text: code, start: codeStart, end }); code = '' }
69
+ const push = (kind, start, end) => { flush(start); segments.push({ kind, text: sql.slice(start, end), start, end }); codeStart = end }
70
+ let i = 0
71
+ while (i < sql.length) {
72
+ const c = sql[i]
73
+ const n = sql[i + 1]
74
+ // -- comments
75
+ if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || sql[i + 2] === undefined || /\s/.test(sql[i + 2]))) {
76
+ const start = i
77
+ while (i < sql.length && sql[i] !== '\n') i++
78
+ push('comment', start, i)
79
+ continue
80
+ }
81
+ if (c === '#' && r.hashComment) {
82
+ const start = i
83
+ while (i < sql.length && sql[i] !== '\n') i++
84
+ push('comment', start, i)
85
+ continue
86
+ }
87
+ if (c === '/' && n === '*') {
88
+ const start = i
89
+ if (sql[i + 2] === '!' && dialect === 'mysql') executableComment = true
90
+ // PostgreSQL block comments nest.
91
+ let depth = 1
92
+ i += 2
93
+ while (i < sql.length && depth > 0) {
94
+ if (dialect === 'postgres' && sql[i] === '/' && sql[i + 1] === '*') { depth++; i += 2; continue }
95
+ if (sql[i] === '*' && sql[i + 1] === '/') { depth--; i += 2; continue }
96
+ i++
97
+ }
98
+ if (depth > 0) unterminated = true
99
+ push('comment', start, i)
100
+ continue
101
+ }
102
+ // PostgreSQL dollar quoting: $$…$$ or $tag$…$tag$ (tag cannot start with a digit: $1 is a parameter).
103
+ if (c === '$' && r.dollarQuotes) {
104
+ const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i))
105
+ if (m && !/[A-Za-z0-9_]/.test(sql[i - 1] ?? '')) {
106
+ const tag = m[0]
107
+ const start = i
108
+ const close = sql.indexOf(tag, i + tag.length)
109
+ if (close < 0) { unterminated = true; i = sql.length } else i = close + tag.length
110
+ push('string', start, i)
111
+ continue
112
+ }
113
+ }
114
+ const isEString = r.escapeStrings && (c === 'E' || c === 'e') && n === '\'' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '')
115
+ if (c === '\'' || isEString || c === '"' || (c === '`' && r.backtickIdents) || (c === '[' && r.bracketIdents)) {
116
+ const start = i
117
+ if (isEString) i++
118
+ const q = sql[i]
119
+ const closeCh = q === '[' ? ']' : q
120
+ const backslash = q === '\'' || q === '"' ? (r.backslashEscapes || isEString) : false
121
+ i++
122
+ let closed = false
123
+ while (i < sql.length) {
124
+ if (backslash && sql[i] === '\\') { i += 2; continue }
125
+ if (sql[i] === closeCh) {
126
+ if (closeCh !== ']' && sql[i + 1] === closeCh) { i += 2; continue }
127
+ closed = true
128
+ break
129
+ }
130
+ i++
131
+ }
132
+ if (!closed) unterminated = true
133
+ i = Math.min(sql.length, i + 1)
134
+ const isString = q === '\'' || (q === '"' && dialect === 'mysql')
135
+ push(isString ? 'string' : 'ident', start, i)
136
+ continue
137
+ }
138
+ if (!code) codeStart = i
139
+ code += c
140
+ i++
141
+ }
142
+ flush(sql.length)
143
+ return { segments, unterminated, executableComment }
144
+ }
145
+
146
+ /**
147
+ * Replace string literals, quoted identifiers and comments with placeholders so
148
+ * the keyword checks only see SQL structure.
149
+ * @returns {{ code: string, executableComment: boolean, unterminated: boolean }}
150
+ */
151
+ export function stripSql(sql, { dialect = 'mysql' } = {}) {
152
+ const { segments, unterminated, executableComment } = lexSql(sql, { dialect })
153
+ const code = segments.map(s => s.kind === 'code' ? s.text : s.kind === 'string' ? ' _str_ ' : s.kind === 'ident' ? ' _ident_ ' : ' ').join('')
154
+ return { code, executableComment, unterminated }
155
+ }
156
+
157
+ /**
158
+ * Validate one statement and return its normalized form.
159
+ * @param {string} input - user or model SQL.
160
+ * @returns {{ sql: string, head: string, hasLimit: boolean }}
161
+ * @throws Error with a model-readable reason when the statement is refused.
162
+ */
163
+ export function admitSql(input, { dialect = 'mysql' } = {}) {
164
+ if (typeof input !== 'string' || input.trim() === '') throw new Error('SQL is empty')
165
+ let sql = input.trim()
166
+ while (sql.endsWith(';')) sql = sql.slice(0, -1).trimEnd()
167
+ const { code, executableComment, unterminated } = stripSql(sql, { dialect })
168
+ if (executableComment) throw new Error('MySQL executable comments (/*! ... */) are not allowed')
169
+ if (unterminated) throw new Error('unterminated string, identifier or comment')
170
+ if (code.includes(';')) throw new Error('only one statement may be executed at a time')
171
+ const head = (code.trim().match(/^\(*\s*([A-Za-z]+)/)?.[1] ?? '').toUpperCase()
172
+ const allowed = ALLOWED_HEADS[dialect] ?? ALLOWED_HEADS.mysql
173
+ if (!allowed.has(head)) throw new Error(`only read-only statements (${[...allowed].join(' / ')}) are allowed (got ${head || 'unknown'})`)
174
+ if (dialect === 'postgres') {
175
+ if (PG_FORBIDDEN.test(code)) throw new Error('this PostgreSQL function is not allowed in read-only queries')
176
+ if (/\bINTO\b/i.test(code) && head !== 'EXPLAIN') throw new Error('SELECT ... INTO is not allowed')
177
+ if (/\bEXPLAIN\b[^)]*\bANALY[SZ]E\b/i.test(code)) throw new Error('EXPLAIN ANALYZE executes the statement and is not allowed')
178
+ }
179
+ if (dialect === 'sqlite') {
180
+ if (head === 'EXPLAIN' && !/^\(*\s*EXPLAIN\s+QUERY\s+PLAN\b/i.test(code.trim())) throw new Error('only EXPLAIN QUERY PLAN is allowed')
181
+ if (SQLITE_FORBIDDEN_FN.test(code)) throw new Error('this SQLite function is not allowed in read-only queries')
182
+ const m = code.match(SQLITE_FORBIDDEN_STMT)
183
+ if (m) throw new Error(`${m[1].toUpperCase()} is not allowed; only read-only SELECT queries are allowed`)
184
+ if (/\bINTO\b/i.test(code)) throw new Error('INTO is not allowed in read-only queries')
185
+ }
186
+ for (const [pattern, reason] of FORBIDDEN) {
187
+ for (const m of code.matchAll(new RegExp(pattern.source, 'gi'))) {
188
+ // REPLACE()/INSERT() are string functions and CHARACTER SET is a clause;
189
+ // these spellings stay allowed inside a SELECT.
190
+ if (isFunctionCall(code, m.index, m[0])) continue
191
+ if (/^SET$/i.test(m[1] ?? '') && /CHARACTER\s+$/i.test(code.slice(Math.max(0, m.index - 12), m.index))) continue
192
+ if (/^ANALYZE$/i.test(m[1] ?? '') && head === 'EXPLAIN' && dialect === 'mysql') continue
193
+ if (/^LOCK$/i.test(m[1] ?? '') && /FOR\s+(UPDATE|SHARE|NO\s+KEY|KEY)\s*$/i.test(code.slice(Math.max(0, m.index - 20), m.index))) continue
194
+ throw new Error(reason)
195
+ }
196
+ }
197
+ const hasLimit = /\bLIMIT\s+\d+(\s*(,|OFFSET)\s*\d+)?\s*$/i.test(code.trim())
198
+ return { sql, head, hasLimit }
199
+ }
200
+
201
+ function isFunctionCall(code, index, word) {
202
+ const name = word.trim()
203
+ if (!/^(REPLACE|INSERT|LOCK|SET|LOAD)$/i.test(name)) return false
204
+ return /^\s*\(/.test(code.slice(index + word.length))
205
+ }
206
+
207
+ /**
208
+ * Prepare a SELECT for execution: cap rows and attach the server time limit.
209
+ * @param {{ sql: string, head: string, hasLimit: boolean }} admitted
210
+ * @param {{ maxRows: number, timeoutMs: number, useHint?: boolean, dialect?: string }} limits
211
+ * @returns {string}
212
+ */
213
+ export function prepareSql(admitted, limits) {
214
+ let sql = admitted.sql
215
+ const dialect = limits.dialect ?? 'mysql'
216
+ if (admitted.head === 'SELECT' || (admitted.head === 'WITH' && (dialect === 'postgres' || dialect === 'sqlite'))) {
217
+ if (dialect === 'mysql' && limits.useHint !== false && !/MAX_EXECUTION_TIME/i.test(sql)) sql = sql.replace(/^(\(*\s*)SELECT\b/i, `$1SELECT /*+ MAX_EXECUTION_TIME(${Math.max(1, Math.floor(limits.timeoutMs))}) */`)
218
+ // Elasticsearch pages with fetch_size instead; PostgreSQL, SQLite and MySQL get a LIMIT one row past the cap.
219
+ if (!admitted.hasLimit && dialect !== 'elasticsearch') sql = `${sql}\nLIMIT ${limits.maxRows + 1}`
220
+ }
221
+ return sql
222
+ }