@nifrajs/mcp-db 3.5.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/engine.d.ts +71 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +494 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +8 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -610
- package/dist/index.js.map +1 -1
- package/dist/internal/pg-exec.d.ts +113 -0
- package/dist/internal/pg-exec.d.ts.map +1 -0
- package/dist/internal/pg-exec.js +286 -0
- package/dist/internal/pg-exec.js.map +1 -0
- package/dist/internal/pg-lexer.d.ts +52 -0
- package/dist/internal/pg-lexer.d.ts.map +1 -0
- package/dist/internal/pg-lexer.js +273 -0
- package/dist/internal/pg-lexer.js.map +1 -0
- package/dist/internal/shared.d.ts +161 -0
- package/dist/internal/shared.d.ts.map +1 -0
- package/dist/internal/shared.js +538 -0
- package/dist/internal/shared.js.map +1 -0
- package/dist/internal/sqlite-session.d.ts +21 -0
- package/dist/internal/sqlite-session.d.ts.map +1 -0
- package/dist/internal/sqlite-session.js +254 -0
- package/dist/internal/sqlite-session.js.map +1 -0
- package/dist/postgres.d.ts +148 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +580 -0
- package/dist/postgres.js.map +1 -0
- package/package.json +15 -2
- package/src/engine.ts +664 -0
- package/src/index.ts +43 -640
- package/src/internal/pg-exec.ts +386 -0
- package/src/internal/pg-lexer.ts +279 -0
- package/src/internal/shared.ts +705 -0
- package/src/internal/sqlite-session.ts +273 -0
- package/src/postgres.ts +777 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server-enforced layers of `@nifrajs/mcp-db/postgres`: the read-only transaction with its
|
|
3
|
+
* timeouts, the extended-protocol statement, the cursor, and the plan-level table scope. Nothing here
|
|
4
|
+
* parses the caller's SQL - the refusals in this file come from the server, and the only text lexed
|
|
5
|
+
* is the expressions of the server's own verbose plan.
|
|
6
|
+
*
|
|
7
|
+
* Bun 1.4.2 sends `sql.unsafe(text)` and `sql.unsafe(text, [])` over the SIMPLE protocol, which runs
|
|
8
|
+
* several `;`-separated statements. Only a tagged-template call (or `unsafe` with a non-empty value
|
|
9
|
+
* list) uses the EXTENDED protocol, where the server refuses a second statement with "cannot insert
|
|
10
|
+
* multiple commands into a prepared statement". So every statement carrying caller text goes through
|
|
11
|
+
* {@link extended}; `unsafe` is used only for fixed text this file writes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { lexPostgres } from "./pg-lexer.ts"
|
|
15
|
+
import { type DbRefusal, dbRefusal } from "./shared.ts"
|
|
16
|
+
|
|
17
|
+
/** A pending query: awaitable for object rows, or `.values()` for array rows (checked by readers). */
|
|
18
|
+
export interface PgQuery extends PromiseLike<readonly unknown[]> {
|
|
19
|
+
values(): PromiseLike<readonly unknown[]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rows as records: a driver row that is not an object is dropped, never cast. */
|
|
23
|
+
export function records(rows: readonly unknown[]): Record<string, unknown>[] {
|
|
24
|
+
return rows.filter(
|
|
25
|
+
(row): row is Record<string, unknown> =>
|
|
26
|
+
typeof row === "object" && row !== null && !Array.isArray(row),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One connection reserved from the pool (`Bun.SQL`'s `ReservedSQL`, structurally). */
|
|
31
|
+
export interface PgConnection {
|
|
32
|
+
(strings: TemplateStringsArray, ...values: unknown[]): PgQuery
|
|
33
|
+
unsafe(text: string): PgQuery
|
|
34
|
+
release(): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A Postgres client (`Bun.SQL`, structurally): the engine reserves one connection per call. */
|
|
38
|
+
export interface PostgresClient {
|
|
39
|
+
reserve(): Promise<PgConnection>
|
|
40
|
+
close(options?: { readonly timeout?: number }): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run caller text as ONE extended-protocol statement with no parameters. */
|
|
44
|
+
export function extended(connection: PgConnection, text: string): PgQuery {
|
|
45
|
+
const strings: TemplateStringsArray = Object.assign([text], { raw: [text] })
|
|
46
|
+
return connection(strings)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const WRITE_KEYWORD =
|
|
50
|
+
/at or near "(insert|update|delete|merge|call|do|copy|create|drop|alter|truncate|grant|revoke|lock|vacuum|analyze|cluster|reindex|refresh|comment|security|import|listen|notify|load|discard|reset|set|show|begin|commit|rollback|savepoint|release|prepare|execute|deallocate|declare|fetch|move|close|checkpoint|explain)"/i
|
|
51
|
+
|
|
52
|
+
/** Map a driver error to a refusal by its SQLSTATE; only a genuine query error is a failure. */
|
|
53
|
+
export function pgFailure(error: unknown, context = "query failed"): DbRefusal {
|
|
54
|
+
// Bun.SQL puts the SQLSTATE on `errno`.
|
|
55
|
+
const state =
|
|
56
|
+
typeof error === "object" && error !== null && "errno" in error ? error.errno : undefined
|
|
57
|
+
const message = `${context}: ${error instanceof Error ? error.message : String(error)}`
|
|
58
|
+
if (typeof state !== "string" || !/^[0-9A-Z]{5}$/.test(state)) {
|
|
59
|
+
return dbRefusal("NIFRA_DB_DRIVER", message)
|
|
60
|
+
}
|
|
61
|
+
const text = error instanceof Error ? error.message : ""
|
|
62
|
+
if (state === "25006" || state === "25001") return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
|
|
63
|
+
if (state === "0A000" && /data-modifying/i.test(text)) {
|
|
64
|
+
return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
state === "42601" &&
|
|
68
|
+
(/multiple commands/i.test(text) ||
|
|
69
|
+
/INTO is not allowed/i.test(text) ||
|
|
70
|
+
WRITE_KEYWORD.test(text))
|
|
71
|
+
) {
|
|
72
|
+
return dbRefusal("NIFRA_DB_WRITE_REFUSED", message)
|
|
73
|
+
}
|
|
74
|
+
if (state === "57014" || state === "55P03" || state === "25P03") {
|
|
75
|
+
return dbRefusal("NIFRA_DB_TIMEOUT", message)
|
|
76
|
+
}
|
|
77
|
+
if (state === "42501") {
|
|
78
|
+
return dbRefusal(
|
|
79
|
+
/for (table|view|relation|schema|sequence|materialized view|foreign table)\b/i.test(text)
|
|
80
|
+
? "NIFRA_DB_TABLE_EXCLUDED"
|
|
81
|
+
: "NIFRA_DB_FUNCTION_REFUSED",
|
|
82
|
+
message,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
if (/^(08|28|3D|53|57P|XX)/.test(state)) return dbRefusal("NIFRA_DB_DRIVER", message)
|
|
86
|
+
return dbRefusal("NIFRA_DB_QUERY_FAILED", message)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Validated milliseconds for a `SET LOCAL` (an integer, so it can be written into the text). */
|
|
90
|
+
function millis(timeoutMs: number): number {
|
|
91
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
92
|
+
throw new RangeError("timeoutMs must be a positive integer")
|
|
93
|
+
}
|
|
94
|
+
return timeoutMs
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run `work` inside `BEGIN READ ONLY` on one reserved connection, with the statement, lock and
|
|
99
|
+
* idle-in-transaction timeouts set for this transaction only. Always rolls back, so nothing a call
|
|
100
|
+
* does (a `set_config`, a session setting) outlives it.
|
|
101
|
+
*/
|
|
102
|
+
export async function inReadOnlyTransaction<T>(
|
|
103
|
+
client: PostgresClient,
|
|
104
|
+
timeoutMs: number,
|
|
105
|
+
work: (connection: PgConnection) => Promise<T | DbRefusal>,
|
|
106
|
+
): Promise<T | DbRefusal> {
|
|
107
|
+
const ms = millis(timeoutMs)
|
|
108
|
+
let connection: PgConnection
|
|
109
|
+
try {
|
|
110
|
+
connection = await client.reserve()
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return pgFailure(error, "could not connect")
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
// nifra-expect sql-dynamic: fixed statements; the one interpolation is a validated integer of milliseconds
|
|
116
|
+
await connection.unsafe(
|
|
117
|
+
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${ms}; SET LOCAL lock_timeout = ${ms}; SET LOCAL idle_in_transaction_session_timeout = ${ms}`,
|
|
118
|
+
)
|
|
119
|
+
return await work(connection)
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return pgFailure(error)
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
await connection.unsafe("ROLLBACK")
|
|
125
|
+
} catch {
|
|
126
|
+
// A connection the server already closed has nothing to roll back.
|
|
127
|
+
}
|
|
128
|
+
connection.release()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A relation (or a function scanned as one) the plan reads, as the plan names it. */
|
|
133
|
+
export interface PlanRelation {
|
|
134
|
+
readonly schema: string
|
|
135
|
+
readonly name: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A column of a scanned relation that a plan expression names; `*` is a whole-row reference. */
|
|
139
|
+
export interface PlanColumn {
|
|
140
|
+
readonly relation: PlanRelation
|
|
141
|
+
readonly column: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What an `EXPLAIN (VERBOSE, FORMAT JSON)` plan reads, InitPlans and SubPlans included. */
|
|
145
|
+
export interface PlanReads {
|
|
146
|
+
readonly relations: readonly PlanRelation[]
|
|
147
|
+
/** Functions in FROM (Function Scan nodes): a system view such as `pg_stat_activity` plans as one. */
|
|
148
|
+
readonly functions: readonly PlanRelation[]
|
|
149
|
+
/**
|
|
150
|
+
* Every relation column an expression of the plan uses: output, filter, join, sort or index
|
|
151
|
+
* condition. A verbose plan qualifies each one with its scan's unique alias, so an alias or an
|
|
152
|
+
* expression in the query still names the column it reads.
|
|
153
|
+
*/
|
|
154
|
+
readonly columns: readonly PlanColumn[]
|
|
155
|
+
/**
|
|
156
|
+
* Outputs left out of `columns` because they may be a physical target list: a node under an
|
|
157
|
+
* aggregate, join or result emitting plain columns of one relation. The planner emits every column
|
|
158
|
+
* there whether or not the parent uses it, and the parent's own expressions name what it does use.
|
|
159
|
+
* When the list is not the relation's full column list, it is exactly what was asked for and counts.
|
|
160
|
+
*/
|
|
161
|
+
readonly outputs: readonly PlanOutput[]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Plain columns of one relation that a node outputs, as listed. */
|
|
165
|
+
export interface PlanOutput {
|
|
166
|
+
readonly relation: PlanRelation
|
|
167
|
+
readonly columns: readonly string[]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Nodes that pass their child's rows up unchanged: whether a child's output is used is decided above.
|
|
171
|
+
const PASS_THROUGH_NODES = new Set([
|
|
172
|
+
"Limit",
|
|
173
|
+
"Sort",
|
|
174
|
+
"Incremental Sort",
|
|
175
|
+
"Material",
|
|
176
|
+
"Memoize",
|
|
177
|
+
"Hash",
|
|
178
|
+
"Gather",
|
|
179
|
+
"Gather Merge",
|
|
180
|
+
"Unique",
|
|
181
|
+
"LockRows",
|
|
182
|
+
])
|
|
183
|
+
|
|
184
|
+
// Nodes whose own expressions a verbose plan prints in terms of the base columns they use.
|
|
185
|
+
const PROJECTING_NODES = new Set([
|
|
186
|
+
"Aggregate",
|
|
187
|
+
"Group",
|
|
188
|
+
"WindowAgg",
|
|
189
|
+
"Nested Loop",
|
|
190
|
+
"Hash Join",
|
|
191
|
+
"Merge Join",
|
|
192
|
+
"Result",
|
|
193
|
+
"ProjectSet",
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
// Fields that name a node or a relation rather than hold an expression.
|
|
197
|
+
const PLAN_NAME_FIELDS = new Set([
|
|
198
|
+
"Node Type",
|
|
199
|
+
"Relation Name",
|
|
200
|
+
"Schema",
|
|
201
|
+
"Alias",
|
|
202
|
+
"Index Name",
|
|
203
|
+
"CTE Name",
|
|
204
|
+
"Function Name",
|
|
205
|
+
"Subplan Name",
|
|
206
|
+
"Parent Relationship",
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
/** Every relation and every FROM-clause function a verbose JSON plan scans, and the columns it uses. */
|
|
210
|
+
export function planReads(plan: unknown): PlanReads {
|
|
211
|
+
const relations = new Map<string, PlanRelation>()
|
|
212
|
+
const functions = new Map<string, PlanRelation>()
|
|
213
|
+
const aliases = new Map<string, PlanRelation>()
|
|
214
|
+
const expressions: string[] = []
|
|
215
|
+
// Outputs under a projecting parent, resolved once every alias is known.
|
|
216
|
+
const underProjection: (readonly string[])[] = []
|
|
217
|
+
const visit = (node: unknown, depth: number, projected: boolean): void => {
|
|
218
|
+
if (depth > 256 || typeof node !== "object" || node === null) return
|
|
219
|
+
if (Array.isArray(node)) {
|
|
220
|
+
for (const item of node) visit(item, depth + 1, projected)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const field = (key: string): unknown => Reflect.get(node, key)
|
|
224
|
+
const declared = field("Schema")
|
|
225
|
+
const schema = typeof declared === "string" ? declared : ""
|
|
226
|
+
for (const [key, found] of [
|
|
227
|
+
["Relation Name", relations],
|
|
228
|
+
["Function Name", functions],
|
|
229
|
+
] as const) {
|
|
230
|
+
const name = field(key)
|
|
231
|
+
if (typeof name === "string") found.set(`${schema}.${name}`, { schema, name })
|
|
232
|
+
}
|
|
233
|
+
const relation = field("Relation Name")
|
|
234
|
+
const alias = field("Alias")
|
|
235
|
+
if (typeof relation === "string") {
|
|
236
|
+
aliases.set(typeof alias === "string" ? alias : relation, { schema, name: relation })
|
|
237
|
+
}
|
|
238
|
+
// A SubPlan's or InitPlan's result reaches its parent as `(SubPlan n)`, not as base columns.
|
|
239
|
+
const role = field("Parent Relationship")
|
|
240
|
+
const own = projected && role !== "SubPlan" && role !== "InitPlan"
|
|
241
|
+
for (const [key, value] of Object.entries(node)) {
|
|
242
|
+
if (PLAN_NAME_FIELDS.has(key)) continue
|
|
243
|
+
if (typeof value === "string") expressions.push(value)
|
|
244
|
+
else if (Array.isArray(value)) {
|
|
245
|
+
const texts = value.filter((item): item is string => typeof item === "string")
|
|
246
|
+
if (key === "Output" && own) underProjection.push(texts)
|
|
247
|
+
else expressions.push(...texts)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const type = String(field("Node Type"))
|
|
251
|
+
visit(field("Plan"), depth + 1, false)
|
|
252
|
+
visit(
|
|
253
|
+
field("Plans"),
|
|
254
|
+
depth + 1,
|
|
255
|
+
PASS_THROUGH_NODES.has(type) ? own : PROJECTING_NODES.has(type),
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
visit(plan, 0, false)
|
|
259
|
+
|
|
260
|
+
const outputs: PlanOutput[] = []
|
|
261
|
+
for (const list of underProjection) {
|
|
262
|
+
// Plain `alias.column` items of one relation (a dropped column shows as a NULL constant).
|
|
263
|
+
let relation: PlanRelation | undefined
|
|
264
|
+
const listed: string[] = []
|
|
265
|
+
for (const text of list) {
|
|
266
|
+
if (text.startsWith("NULL::")) continue
|
|
267
|
+
const tokens = lexPostgres(text).tokens
|
|
268
|
+
const [qualifier, dot, column] = tokens
|
|
269
|
+
const named = qualifier?.kind === "word" ? aliases.get(qualifier.value) : undefined
|
|
270
|
+
if (
|
|
271
|
+
tokens.length !== 3 ||
|
|
272
|
+
named === undefined ||
|
|
273
|
+
dot?.value !== "." ||
|
|
274
|
+
column?.kind !== "word" ||
|
|
275
|
+
(relation !== undefined && relation !== named)
|
|
276
|
+
) {
|
|
277
|
+
relation = undefined
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
relation = named
|
|
281
|
+
listed.push(column.value)
|
|
282
|
+
}
|
|
283
|
+
if (relation !== undefined) outputs.push({ relation, columns: listed })
|
|
284
|
+
else expressions.push(...list)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const columns = new Map<string, PlanColumn>()
|
|
288
|
+
for (const text of expressions) {
|
|
289
|
+
const { tokens } = lexPostgres(text)
|
|
290
|
+
for (let i = 0; i + 2 < tokens.length; i++) {
|
|
291
|
+
const [qualifier, dot, column] = [tokens[i], tokens[i + 1], tokens[i + 2]]
|
|
292
|
+
if (qualifier?.kind !== "word" || dot?.kind !== "punct" || dot.value !== ".") continue
|
|
293
|
+
const relation = aliases.get(qualifier.value)
|
|
294
|
+
if (relation === undefined || column === undefined) continue
|
|
295
|
+
if (column.kind === "word" || (column.kind === "punct" && column.value === "*")) {
|
|
296
|
+
columns.set(`${relation.schema}.${relation.name}.${column.value}`, {
|
|
297
|
+
relation,
|
|
298
|
+
column: column.value,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
relations: [...relations.values()],
|
|
305
|
+
functions: [...functions.values()],
|
|
306
|
+
columns: [...columns.values()],
|
|
307
|
+
outputs,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The JSON document an `EXPLAIN (FORMAT JSON)` row carries, whatever the driver parsed it into. */
|
|
312
|
+
export function planDocument(rows: readonly unknown[]): unknown {
|
|
313
|
+
const value = records(rows)[0]?.["QUERY PLAN"]
|
|
314
|
+
if (typeof value !== "string") return value
|
|
315
|
+
const parsed: unknown = JSON.parse(value)
|
|
316
|
+
return parsed
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Refuse when anything the plan reads (or a table it inherits from) is out of scope. */
|
|
320
|
+
export type ScopeCheck = (
|
|
321
|
+
connection: PgConnection,
|
|
322
|
+
reads: PlanReads,
|
|
323
|
+
) => Promise<DbRefusal | undefined>
|
|
324
|
+
|
|
325
|
+
/** Options for {@link fetchThroughCursor}. */
|
|
326
|
+
export interface CursorOptions {
|
|
327
|
+
readonly maxRows: number
|
|
328
|
+
readonly scope: ScopeCheck
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Declare the caller's query as a NO SCROLL cursor (the server accepts only SELECT/VALUES there and
|
|
333
|
+
* refuses a data-modifying WITH), check the plan's relations, then FETCH at most `maxRows + 1` rows.
|
|
334
|
+
*
|
|
335
|
+
* The query is wrapped as a subquery so each row also carries the result's column names: Bun returns
|
|
336
|
+
* array rows without names, and object rows lose a duplicated name. The wrapper's first column is a
|
|
337
|
+
* JSON array of the names, read from the first row.
|
|
338
|
+
*/
|
|
339
|
+
export async function fetchThroughCursor(
|
|
340
|
+
connection: PgConnection,
|
|
341
|
+
sql: string,
|
|
342
|
+
options: CursorOptions,
|
|
343
|
+
): Promise<{ readonly columns: string[]; readonly rows: unknown[][] } | DbRefusal> {
|
|
344
|
+
const wrapped = `SELECT (SELECT json_agg(nifra_k)::text FROM json_object_keys(row_to_json(nifra_q)) AS nifra_k) AS nifra_columns, nifra_q.* FROM (\n${sql}\n) AS nifra_q`
|
|
345
|
+
await extended(connection, `DECLARE nifra_c NO SCROLL CURSOR FOR ${wrapped}`)
|
|
346
|
+
const plan = planDocument(await extended(connection, `EXPLAIN (VERBOSE, FORMAT JSON) ${wrapped}`))
|
|
347
|
+
const refusal = await options.scope(connection, planReads(plan))
|
|
348
|
+
if (refusal !== undefined) return refusal
|
|
349
|
+
const rowCap = options.maxRows + 1
|
|
350
|
+
const fetched =
|
|
351
|
+
// nifra-expect sql-dynamic: a fixed FETCH whose one interpolation is the integer row cap
|
|
352
|
+
(await connection.unsafe(`FETCH FORWARD ${rowCap} FROM nifra_c`).values()).filter(
|
|
353
|
+
(row): row is unknown[] => Array.isArray(row),
|
|
354
|
+
)
|
|
355
|
+
const first = fetched[0]?.[0]
|
|
356
|
+
const names: unknown = typeof first === "string" ? JSON.parse(first) : []
|
|
357
|
+
const columns = Array.isArray(names)
|
|
358
|
+
? names.filter((name): name is string => typeof name === "string")
|
|
359
|
+
: []
|
|
360
|
+
return { columns, rows: fetched.map((row) => row.slice(1)) }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Options for {@link explainThroughServer}. */
|
|
364
|
+
export interface ExplainOptions {
|
|
365
|
+
readonly analyze: boolean
|
|
366
|
+
readonly scope: ScopeCheck
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Plan the caller's query. The cursor declaration runs first purely as the statement gate (then is
|
|
371
|
+
* closed), the VERBOSE plan feeds the scope check, and the returned plan is plain `FORMAT JSON` -
|
|
372
|
+
* with `analyze`, executed inside the same read-only transaction and timeout.
|
|
373
|
+
*/
|
|
374
|
+
export async function explainThroughServer(
|
|
375
|
+
connection: PgConnection,
|
|
376
|
+
sql: string,
|
|
377
|
+
options: ExplainOptions,
|
|
378
|
+
): Promise<{ readonly plan: unknown } | DbRefusal> {
|
|
379
|
+
await extended(connection, `DECLARE nifra_c NO SCROLL CURSOR FOR ${sql}`)
|
|
380
|
+
await connection.unsafe("CLOSE nifra_c")
|
|
381
|
+
const verbose = planDocument(await extended(connection, `EXPLAIN (VERBOSE, FORMAT JSON) ${sql}`))
|
|
382
|
+
const refusal = await options.scope(connection, planReads(verbose))
|
|
383
|
+
if (refusal !== undefined) return refusal
|
|
384
|
+
const mode = options.analyze ? "ANALYZE, FORMAT JSON" : "FORMAT JSON"
|
|
385
|
+
return { plan: planDocument(await extended(connection, `EXPLAIN (${mode}) ${sql}`)) }
|
|
386
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Postgres tokenizer for the parser layer of `@nifrajs/mcp-db/postgres`. It is the SECOND layer: the
|
|
3
|
+
* server already refuses several statements (extended protocol), anything but a query (the cursor),
|
|
4
|
+
* and writes (the read-only transaction). This layer adds what the server allows but a dev agent must
|
|
5
|
+
* not do: call a function that reaches outside the query (server files, locks, other sessions,
|
|
6
|
+
* settings, notifications, another database), or one that runs SQL from a string where the plan check
|
|
7
|
+
* cannot see it (`query_to_xml`).
|
|
8
|
+
*
|
|
9
|
+
* The lexer follows Postgres with `standard_conforming_strings = on`, which the engine sets on every
|
|
10
|
+
* connection so the server reads string boundaries exactly as this lexer does.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One lexical unit. Strings, comments and dollar-quoted bodies produce nothing. */
|
|
14
|
+
export interface PgToken {
|
|
15
|
+
readonly kind: "word" | "punct" | "number" | "param"
|
|
16
|
+
/** Identifier text (quotes removed, `""` collapsed) or the punctuation character. */
|
|
17
|
+
readonly value: string
|
|
18
|
+
/** An unquoted word - the only kind that can be a keyword. */
|
|
19
|
+
readonly bare: boolean
|
|
20
|
+
/** Where the token starts in the source. */
|
|
21
|
+
readonly offset: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PgLexResult {
|
|
25
|
+
readonly tokens: readonly PgToken[]
|
|
26
|
+
/** A string, quoted identifier, comment or dollar-quoted body never closed. */
|
|
27
|
+
readonly unterminated: boolean
|
|
28
|
+
/** A `U&"..."` identifier: its escapes can spell any name, so it is refused outright. */
|
|
29
|
+
readonly unicodeIdentifier: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WORD_START = /[A-Za-z_\u0080-]/
|
|
33
|
+
const WORD_PART = /[A-Za-z0-9_$\u0080-]/
|
|
34
|
+
const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-][A-Za-z0-9_\u0080-]*)?\$/
|
|
35
|
+
|
|
36
|
+
/** Tokenize Postgres SQL far enough to see statements, parentheses and function calls. */
|
|
37
|
+
export function lexPostgres(sql: string): PgLexResult {
|
|
38
|
+
const tokens: PgToken[] = []
|
|
39
|
+
let unterminated = false
|
|
40
|
+
let unicodeIdentifier = false
|
|
41
|
+
let i = 0
|
|
42
|
+
const skipString = (escapes: boolean): void => {
|
|
43
|
+
i++ // past the opening quote
|
|
44
|
+
while (i < sql.length) {
|
|
45
|
+
const char = sql[i]
|
|
46
|
+
if (escapes && char === "\\") {
|
|
47
|
+
i += 2
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (char === "'") {
|
|
51
|
+
if (sql[i + 1] === "'") {
|
|
52
|
+
i += 2
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
i++
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
i++
|
|
59
|
+
}
|
|
60
|
+
unterminated = true
|
|
61
|
+
}
|
|
62
|
+
while (i < sql.length) {
|
|
63
|
+
const char = sql.charAt(i)
|
|
64
|
+
const next = sql[i + 1]
|
|
65
|
+
if (/\s/.test(char)) {
|
|
66
|
+
i++
|
|
67
|
+
} else if (char === "-" && next === "-") {
|
|
68
|
+
const end = sql.indexOf("\n", i)
|
|
69
|
+
i = end === -1 ? sql.length : end + 1
|
|
70
|
+
} else if (char === "/" && next === "*") {
|
|
71
|
+
// Postgres block comments nest.
|
|
72
|
+
let depth = 1
|
|
73
|
+
i += 2
|
|
74
|
+
while (i < sql.length && depth > 0) {
|
|
75
|
+
if (sql[i] === "/" && sql[i + 1] === "*") {
|
|
76
|
+
depth++
|
|
77
|
+
i += 2
|
|
78
|
+
} else if (sql[i] === "*" && sql[i + 1] === "/") {
|
|
79
|
+
depth--
|
|
80
|
+
i += 2
|
|
81
|
+
} else i++
|
|
82
|
+
}
|
|
83
|
+
if (depth > 0) unterminated = true
|
|
84
|
+
} else if (char === "'") {
|
|
85
|
+
skipString(false)
|
|
86
|
+
} else if ((char === "E" || char === "e") && next === "'") {
|
|
87
|
+
i++
|
|
88
|
+
skipString(true)
|
|
89
|
+
} else if ((char === "U" || char === "u") && next === "&" && sql[i + 2] === "'") {
|
|
90
|
+
i += 2
|
|
91
|
+
skipString(false)
|
|
92
|
+
} else if ((char === "U" || char === "u") && next === "&" && sql[i + 2] === '"') {
|
|
93
|
+
unicodeIdentifier = true
|
|
94
|
+
i += 2
|
|
95
|
+
} else if (
|
|
96
|
+
(char === "B" ||
|
|
97
|
+
char === "b" ||
|
|
98
|
+
char === "X" ||
|
|
99
|
+
char === "x" ||
|
|
100
|
+
char === "N" ||
|
|
101
|
+
char === "n") &&
|
|
102
|
+
next === "'"
|
|
103
|
+
) {
|
|
104
|
+
i++
|
|
105
|
+
skipString(false)
|
|
106
|
+
} else if (char === '"') {
|
|
107
|
+
const offset = i
|
|
108
|
+
let value = ""
|
|
109
|
+
let closed = false
|
|
110
|
+
i++
|
|
111
|
+
while (i < sql.length) {
|
|
112
|
+
if (sql[i] === '"') {
|
|
113
|
+
if (sql[i + 1] === '"') {
|
|
114
|
+
value += '"'
|
|
115
|
+
i += 2
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
i++
|
|
119
|
+
closed = true
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
value += sql[i]
|
|
123
|
+
i++
|
|
124
|
+
}
|
|
125
|
+
if (!closed) unterminated = true
|
|
126
|
+
tokens.push({ kind: "word", value, bare: false, offset })
|
|
127
|
+
} else if (char === "$") {
|
|
128
|
+
const tag = DOLLAR_TAG.exec(sql.slice(i, i + 64))?.[0]
|
|
129
|
+
if (tag !== undefined) {
|
|
130
|
+
const end = sql.indexOf(tag, i + tag.length)
|
|
131
|
+
if (end === -1) {
|
|
132
|
+
unterminated = true
|
|
133
|
+
i = sql.length
|
|
134
|
+
} else i = end + tag.length
|
|
135
|
+
} else if (next !== undefined && next >= "0" && next <= "9") {
|
|
136
|
+
const start = i
|
|
137
|
+
i++
|
|
138
|
+
while (i < sql.length && /[0-9]/.test(sql.charAt(i))) i++
|
|
139
|
+
tokens.push({ kind: "param", value: sql.slice(start, i), bare: false, offset: start })
|
|
140
|
+
} else {
|
|
141
|
+
tokens.push({ kind: "punct", value: char, bare: false, offset: i })
|
|
142
|
+
i++
|
|
143
|
+
}
|
|
144
|
+
} else if (WORD_START.test(char)) {
|
|
145
|
+
const start = i
|
|
146
|
+
while (i < sql.length && WORD_PART.test(sql.charAt(i))) i++
|
|
147
|
+
tokens.push({ kind: "word", value: sql.slice(start, i), bare: true, offset: start })
|
|
148
|
+
} else if (
|
|
149
|
+
(char >= "0" && char <= "9") ||
|
|
150
|
+
(char === "." && next !== undefined && /[0-9]/.test(next))
|
|
151
|
+
) {
|
|
152
|
+
// Digits only: a letter right after a number starts a new word, so `1pg_sleep(` still shows
|
|
153
|
+
// the call (older servers lex it the same way).
|
|
154
|
+
const start = i
|
|
155
|
+
while (i < sql.length && /[0-9_.]/.test(sql.charAt(i))) i++
|
|
156
|
+
if ((sql[i] === "e" || sql[i] === "E") && /[0-9+-]/.test(sql[i + 1] ?? "")) {
|
|
157
|
+
i += 2
|
|
158
|
+
while (i < sql.length && /[0-9]/.test(sql.charAt(i))) i++
|
|
159
|
+
}
|
|
160
|
+
tokens.push({ kind: "number", value: sql.slice(start, i), bare: false, offset: start })
|
|
161
|
+
} else {
|
|
162
|
+
tokens.push({ kind: "punct", value: char, bare: false, offset: i })
|
|
163
|
+
i++
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { tokens, unterminated, unicodeIdentifier }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Function-name prefixes the parser layer refuses. Each reaches outside a read-only query or runs SQL
|
|
171
|
+
* from a string the plan check cannot see; the server lets a read-only transaction call most of them.
|
|
172
|
+
*/
|
|
173
|
+
export const DENIED_FUNCTION_PREFIXES: readonly string[] = [
|
|
174
|
+
"dblink",
|
|
175
|
+
"lo_",
|
|
176
|
+
"pg_read_",
|
|
177
|
+
"pg_ls_",
|
|
178
|
+
"pg_stat_file",
|
|
179
|
+
"pg_terminate_backend",
|
|
180
|
+
"pg_cancel_backend",
|
|
181
|
+
"pg_reload_conf",
|
|
182
|
+
"set_config",
|
|
183
|
+
"pg_advisory",
|
|
184
|
+
"pg_try_advisory",
|
|
185
|
+
"pg_notify",
|
|
186
|
+
"query_to_xml",
|
|
187
|
+
"table_to_xml",
|
|
188
|
+
"cursor_to_xml",
|
|
189
|
+
"schema_to_xml",
|
|
190
|
+
"database_to_xml",
|
|
191
|
+
"pg_file_",
|
|
192
|
+
"pg_logical_emit_message",
|
|
193
|
+
"pg_stat_reset",
|
|
194
|
+
"pg_stat_get_",
|
|
195
|
+
"pg_switch_wal",
|
|
196
|
+
"pg_create_restore_point",
|
|
197
|
+
"pg_promote",
|
|
198
|
+
"pg_rotate_logfile",
|
|
199
|
+
"nextval",
|
|
200
|
+
"setval",
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
const READ_HEADS = new Set(["select", "with", "values", "table"])
|
|
204
|
+
const LOCKING_STRENGTHS = new Set(["update", "share", "no", "key"])
|
|
205
|
+
|
|
206
|
+
/** Why the parser layer refused a statement. */
|
|
207
|
+
export type PgLintRefusal =
|
|
208
|
+
| { readonly kind: "statement"; readonly message: string }
|
|
209
|
+
| { readonly kind: "function"; readonly message: string; readonly name: string }
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Lint one statement: a single read-only query with balanced parentheses, no unicode-escaped
|
|
213
|
+
* identifier and no denied function call. `undefined` when it passes.
|
|
214
|
+
*/
|
|
215
|
+
export function lintPostgres(sql: string): PgLintRefusal | undefined {
|
|
216
|
+
const { tokens, unterminated, unicodeIdentifier } = lexPostgres(sql)
|
|
217
|
+
if (unterminated) {
|
|
218
|
+
return { kind: "statement", message: "the statement has an unterminated string or comment" }
|
|
219
|
+
}
|
|
220
|
+
if (unicodeIdentifier) {
|
|
221
|
+
return { kind: "statement", message: 'unicode-escaped identifiers (U&"...") are not accepted' }
|
|
222
|
+
}
|
|
223
|
+
const terminator = tokens.findIndex((token) => token.kind === "punct" && token.value === ";")
|
|
224
|
+
if (terminator !== -1 && tokens.slice(terminator + 1).length > 0) {
|
|
225
|
+
return { kind: "statement", message: "only a single statement is allowed" }
|
|
226
|
+
}
|
|
227
|
+
const body = terminator === -1 ? tokens : tokens.slice(0, terminator)
|
|
228
|
+
const head = body.find((token) => !(token.kind === "punct" && token.value === "("))
|
|
229
|
+
if (head === undefined) return { kind: "statement", message: "empty query" }
|
|
230
|
+
if (!head.bare || !READ_HEADS.has(head.value.toLowerCase())) {
|
|
231
|
+
return {
|
|
232
|
+
kind: "statement",
|
|
233
|
+
message: `only a read-only query (SELECT, WITH, VALUES or TABLE) is allowed, not ${head.value.toUpperCase()}`,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
let depth = 0
|
|
237
|
+
for (const [index, token] of body.entries()) {
|
|
238
|
+
if (token.kind !== "punct" && token.kind !== "word") continue
|
|
239
|
+
const next = body[index + 1]
|
|
240
|
+
if (
|
|
241
|
+
token.bare &&
|
|
242
|
+
token.value.toLowerCase() === "for" &&
|
|
243
|
+
next?.bare === true &&
|
|
244
|
+
LOCKING_STRENGTHS.has(next.value.toLowerCase())
|
|
245
|
+
) {
|
|
246
|
+
return {
|
|
247
|
+
kind: "statement",
|
|
248
|
+
message: "row-locking clauses (FOR UPDATE, FOR SHARE) are refused: they take locks",
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (token.value === "(" && token.kind === "punct") {
|
|
252
|
+
depth++
|
|
253
|
+
const callee = body[index - 1]
|
|
254
|
+
if (callee?.kind === "word") {
|
|
255
|
+
const name = callee.value.toLowerCase()
|
|
256
|
+
if (DENIED_FUNCTION_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
257
|
+
return {
|
|
258
|
+
kind: "function",
|
|
259
|
+
name,
|
|
260
|
+
message: `${name}() is refused: it reaches outside a read-only query`,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else if (token.value === ")" && token.kind === "punct") {
|
|
265
|
+
depth--
|
|
266
|
+
if (depth < 0) {
|
|
267
|
+
return { kind: "statement", message: "the statement closes a parenthesis it never opened" }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (depth !== 0) return { kind: "statement", message: "the statement leaves a parenthesis open" }
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The statement without a final `;` (comments after it included), ready to wrap in a subquery. */
|
|
276
|
+
export function withoutTerminator(sql: string): string {
|
|
277
|
+
const last = lexPostgres(sql).tokens.at(-1)
|
|
278
|
+
return last?.kind === "punct" && last.value === ";" ? sql.slice(0, last.offset) : sql
|
|
279
|
+
}
|