@fabrika/platform-node 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/application-postgres-grants.ts +117 -0
- package/src/application-postgres-migrations.ts +152 -0
- package/src/application-postgres-roles.ts +239 -0
- package/src/application-schema.ts +21 -0
- package/src/blob-s3.ts +88 -2
- package/src/database-code.ts +164 -0
- package/src/file-table-ddl.ts +96 -0
- package/src/index.ts +21 -0
- package/src/migration-classify.ts +1044 -0
- package/src/migration-sql.ts +204 -0
- package/src/pg-catalog-names.ts +359 -0
- package/src/postgres-migration-error.ts +4 -0
- package/src/postgres-migrations.ts +270 -25
- package/src/postgres-test-provisioner.ts +96 -0
- package/src/sql-names.ts +740 -0
- package/src/sql-postgres.ts +56 -22
- package/src/sql.ts +52 -0
package/src/sql-names.ts
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
// What the names of one SQL statement reach: schemas, relations, framework tables and functions. Runtime-neutral,
|
|
2
|
+
// like the lexer: the migration classifier, the runner and `ctx.sql` (`@fabrika/platform-node/sql`) share it.
|
|
3
|
+
|
|
4
|
+
import { type SqlToken, upperWord } from './migration-sql'
|
|
5
|
+
|
|
6
|
+
export const isName = (token: SqlToken | undefined): boolean => token?.kind === 'word' || token?.kind === 'quoted'
|
|
7
|
+
export const isPunct = (token: SqlToken | undefined, char: string): boolean => token?.kind === 'punct' && token.char === char
|
|
8
|
+
|
|
9
|
+
/** Postgres' NAMEDATALEN - 1: a longer identifier is truncated, quoted or not. */
|
|
10
|
+
const MAX_IDENTIFIER_BYTES = 63
|
|
11
|
+
const encoder = new TextEncoder()
|
|
12
|
+
|
|
13
|
+
function truncateIdentifier(name: string): string {
|
|
14
|
+
if (encoder.encode(name).length <= MAX_IDENTIFIER_BYTES) return name
|
|
15
|
+
let truncated = ''
|
|
16
|
+
let bytes = 0
|
|
17
|
+
for (const char of name) {
|
|
18
|
+
bytes += encoder.encode(char).length
|
|
19
|
+
if (bytes > MAX_IDENTIFIER_BYTES) break
|
|
20
|
+
truncated += char
|
|
21
|
+
}
|
|
22
|
+
return truncated
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A name as Postgres stores it: an unquoted one with only ASCII folded to lower case, either truncated to 63 bytes. */
|
|
26
|
+
export function nameText(token: SqlToken | undefined): string | undefined {
|
|
27
|
+
if (token?.kind === 'word') return truncateIdentifier(token.text.replace(/[A-Z]/g, (char) => char.toLowerCase()))
|
|
28
|
+
if (token?.kind === 'quoted') return truncateIdentifier(token.text)
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The index past a name at `index`, schema-qualified or not, or `undefined` when no name is there. */
|
|
33
|
+
export function afterName(tokens: readonly SqlToken[], index: number): number | undefined {
|
|
34
|
+
if (!isName(tokens[index])) return undefined
|
|
35
|
+
return isPunct(tokens[index + 1], '.') && isName(tokens[index + 2]) ? index + 3 : index + 1
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A possibly schema-qualified object name: `qualifier` is set only when the statement wrote one. */
|
|
39
|
+
export interface ObjectName {
|
|
40
|
+
readonly qualifier: string | undefined
|
|
41
|
+
readonly name: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The name at `index` (`name` or `schema.name`), or `undefined`. */
|
|
45
|
+
export function objectNameAt(tokens: readonly SqlToken[], index: number): ObjectName | undefined {
|
|
46
|
+
const first = nameText(tokens[index])
|
|
47
|
+
if (first === undefined) return undefined
|
|
48
|
+
const second = isPunct(tokens[index + 1], '.') ? nameText(tokens[index + 2]) : undefined
|
|
49
|
+
return second === undefined ? { qualifier: undefined, name: first } : { qualifier: first, name: second }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The index past the words `words` at `index`, or `index` itself when they are not all there. */
|
|
53
|
+
export function skipWords(tokens: readonly SqlToken[], index: number, ...words: readonly string[]): number {
|
|
54
|
+
return words.every((word, offset) => upperWord(tokens[index + offset]) === word) ? index + words.length : index
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The token lists between the commas at parenthesis depth 0 of `tokens`. */
|
|
58
|
+
export function splitAtCommas(tokens: readonly SqlToken[]): SqlToken[][] {
|
|
59
|
+
const parts: SqlToken[][] = [[]]
|
|
60
|
+
let depth = 0
|
|
61
|
+
for (const token of tokens) {
|
|
62
|
+
if (isPunct(token, '(')) depth++
|
|
63
|
+
else if (isPunct(token, ')')) depth--
|
|
64
|
+
if (depth === 0 && isPunct(token, ',')) parts.push([])
|
|
65
|
+
else parts[parts.length - 1]?.push(token)
|
|
66
|
+
}
|
|
67
|
+
return parts
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The index of the `)` that closes the `(` at `open`, or `undefined`. */
|
|
71
|
+
export function closingParenthesis(tokens: readonly SqlToken[], open: number): number | undefined {
|
|
72
|
+
let depth = 0
|
|
73
|
+
for (let index = open; index < tokens.length; index++) {
|
|
74
|
+
if (isPunct(tokens[index], '(')) depth++
|
|
75
|
+
else if (isPunct(tokens[index], ')') && --depth === 0) return index
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The index of the first word `word` at parenthesis depth 0 at or after `from`, or `undefined`. */
|
|
81
|
+
export function wordAtDepthZero(tokens: readonly SqlToken[], word: string, from = 0): number | undefined {
|
|
82
|
+
let depth = 0
|
|
83
|
+
for (let index = from; index < tokens.length; index++) {
|
|
84
|
+
if (isPunct(tokens[index], '(')) depth++
|
|
85
|
+
else if (isPunct(tokens[index], ')')) depth--
|
|
86
|
+
else if (depth === 0 && upperWord(tokens[index]) === word) return index
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a statement is, for a refusal to name: its first word, and after `CREATE`, `ALTER` or `DROP` (past `OR
|
|
93
|
+
* REPLACE`) the kind of object. A statement that does not begin with a word is named by its first token.
|
|
94
|
+
*/
|
|
95
|
+
export function statementKind(tokens: readonly SqlToken[]): string {
|
|
96
|
+
const first = upperWord(tokens[0])
|
|
97
|
+
if (first === undefined) {
|
|
98
|
+
const token = tokens[0]
|
|
99
|
+
if (token?.kind === 'punct') return token.char
|
|
100
|
+
if (token?.kind === 'quoted') return `"${token.text}"`
|
|
101
|
+
return 'a statement'
|
|
102
|
+
}
|
|
103
|
+
if (first !== 'CREATE' && first !== 'ALTER' && first !== 'DROP') return first
|
|
104
|
+
const kind = upperWord(tokens[skipWords(tokens, 1, 'OR', 'REPLACE')])
|
|
105
|
+
return kind === undefined ? first : `${first} ${kind}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The first schema a DDL statement names other than `schema`, `pg_catalog` included: every qualified name counts,
|
|
110
|
+
* since a DDL statement has no table alias to qualify a column with, and so does any unqualified `pg_…` name, which
|
|
111
|
+
* resolves to the catalog (`LIKE pg_authid`, `REFERENCES pg_class`).
|
|
112
|
+
*/
|
|
113
|
+
export function ddlForeignSchema(tokens: readonly SqlToken[], schema: string): string | undefined {
|
|
114
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
115
|
+
const name = nameText(tokens[index])
|
|
116
|
+
if (name === undefined) continue
|
|
117
|
+
if (isPunct(tokens[index + 1], '.')) {
|
|
118
|
+
if (name !== schema) return name
|
|
119
|
+
index += 2
|
|
120
|
+
} else if (name.startsWith('pg_')) return 'pg_catalog'
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** How a name is used where it stands; `operator` is the operator `OPERATOR(schema.op)` names. */
|
|
126
|
+
export type NameRole = 'relation' | 'call' | 'type' | 'operator' | 'other'
|
|
127
|
+
|
|
128
|
+
/** One name of a statement: `qualifier` is the first part of a two- or three-part name. */
|
|
129
|
+
export interface NameReference {
|
|
130
|
+
readonly qualifier: string | undefined
|
|
131
|
+
readonly name: string
|
|
132
|
+
readonly parts: number
|
|
133
|
+
readonly role: NameRole
|
|
134
|
+
readonly line: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Words after which a name is a relation that a column list may follow (`INSERT INTO t (a)`), never a call. */
|
|
138
|
+
const RELATION_WITH_COLUMNS: ReadonlySet<string> = new Set(['INTO', 'UPDATE', 'TABLE', 'REFERENCES'])
|
|
139
|
+
|
|
140
|
+
/** Words after which a name is a relation, or a table function when `(` follows it (`FROM generate_series(1, 3)`). */
|
|
141
|
+
const RELATION_OR_FUNCTION: ReadonlySet<string> = new Set(['FROM', 'JOIN', 'USING'])
|
|
142
|
+
|
|
143
|
+
/** Words that end a `FROM`/`USING` list at its own depth, so a later `,` separates no relations. */
|
|
144
|
+
const LIST_ENDS: ReadonlySet<string> = new Set([
|
|
145
|
+
'WHERE',
|
|
146
|
+
'GROUP',
|
|
147
|
+
'HAVING',
|
|
148
|
+
'WINDOW',
|
|
149
|
+
'ORDER',
|
|
150
|
+
'LIMIT',
|
|
151
|
+
'OFFSET',
|
|
152
|
+
'RETURNING',
|
|
153
|
+
'FETCH',
|
|
154
|
+
'FOR',
|
|
155
|
+
'UNION',
|
|
156
|
+
'EXCEPT',
|
|
157
|
+
'INTERSECT',
|
|
158
|
+
'SET',
|
|
159
|
+
'VALUES',
|
|
160
|
+
'SELECT',
|
|
161
|
+
'INTO',
|
|
162
|
+
'LOOP',
|
|
163
|
+
'THEN',
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
/** Functions whose `FROM` is an argument (`EXTRACT(epoch FROM t.at)`), not a relation list. */
|
|
167
|
+
const FROM_ARGUMENT_FUNCTIONS: ReadonlySet<string> = new Set(['EXTRACT', 'SUBSTRING', 'TRIM', 'OVERLAY', 'POSITION', 'NORMALIZE'])
|
|
168
|
+
|
|
169
|
+
/** Words a `(` may follow without making them a call: syntax, not functions. */
|
|
170
|
+
const NOT_CALLS: ReadonlySet<string> = new Set([
|
|
171
|
+
'ALL',
|
|
172
|
+
'ALSO',
|
|
173
|
+
'AND',
|
|
174
|
+
'ANY',
|
|
175
|
+
'ARRAY',
|
|
176
|
+
'AS',
|
|
177
|
+
'ASSERT',
|
|
178
|
+
'BETWEEN',
|
|
179
|
+
'BY',
|
|
180
|
+
'CASE',
|
|
181
|
+
'CAST',
|
|
182
|
+
'CHECK',
|
|
183
|
+
'COALESCE',
|
|
184
|
+
'CONFLICT',
|
|
185
|
+
'CUBE',
|
|
186
|
+
'CURRENT_TIME',
|
|
187
|
+
'CURRENT_TIMESTAMP',
|
|
188
|
+
'CURSOR',
|
|
189
|
+
'DEFAULT',
|
|
190
|
+
'DISTINCT',
|
|
191
|
+
'DO',
|
|
192
|
+
'ELSE',
|
|
193
|
+
'ELSIF',
|
|
194
|
+
'ELSEIF',
|
|
195
|
+
'ESCAPE',
|
|
196
|
+
'EXCEPT',
|
|
197
|
+
'EXISTS',
|
|
198
|
+
'FILTER',
|
|
199
|
+
'FROM',
|
|
200
|
+
'GREATEST',
|
|
201
|
+
'GROUPING',
|
|
202
|
+
'HAVING',
|
|
203
|
+
'IDENTITY',
|
|
204
|
+
'IF',
|
|
205
|
+
'ILIKE',
|
|
206
|
+
'IN',
|
|
207
|
+
'INCLUDE',
|
|
208
|
+
'INSTEAD',
|
|
209
|
+
'INTERSECT',
|
|
210
|
+
'IS',
|
|
211
|
+
'JOIN',
|
|
212
|
+
'KEY',
|
|
213
|
+
'LATERAL',
|
|
214
|
+
'LEAST',
|
|
215
|
+
'LIKE',
|
|
216
|
+
'LIMIT',
|
|
217
|
+
'LOCALTIME',
|
|
218
|
+
'LOCALTIMESTAMP',
|
|
219
|
+
'MATERIALIZED',
|
|
220
|
+
'NEXT',
|
|
221
|
+
'NOT',
|
|
222
|
+
'NULLIF',
|
|
223
|
+
'OFFSET',
|
|
224
|
+
'ON',
|
|
225
|
+
'OR',
|
|
226
|
+
'OVER',
|
|
227
|
+
'PERFORM',
|
|
228
|
+
'QUERY',
|
|
229
|
+
'RETURN',
|
|
230
|
+
'RETURNING',
|
|
231
|
+
'ROLLUP',
|
|
232
|
+
'ROW',
|
|
233
|
+
'ROWS',
|
|
234
|
+
'SELECT',
|
|
235
|
+
'SET',
|
|
236
|
+
'SETS',
|
|
237
|
+
'SIMILAR',
|
|
238
|
+
'SOME',
|
|
239
|
+
'TABLE',
|
|
240
|
+
'THEN',
|
|
241
|
+
'TO',
|
|
242
|
+
'UNION',
|
|
243
|
+
'UNIQUE',
|
|
244
|
+
'USING',
|
|
245
|
+
'VALUES',
|
|
246
|
+
'WHEN',
|
|
247
|
+
'WHERE',
|
|
248
|
+
'WHILE',
|
|
249
|
+
'WITH',
|
|
250
|
+
'WITHIN',
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
/** Type names a modifier in parentheses follows (`varchar(80)`, `timestamp(3)`): a type, not a call. */
|
|
254
|
+
const MODIFIED_TYPES: ReadonlySet<string> = new Set([
|
|
255
|
+
'BIT',
|
|
256
|
+
'BPCHAR',
|
|
257
|
+
'CHAR',
|
|
258
|
+
'CHARACTER',
|
|
259
|
+
'DEC',
|
|
260
|
+
'DECIMAL',
|
|
261
|
+
'FLOAT',
|
|
262
|
+
'INTERVAL',
|
|
263
|
+
'NUMERIC',
|
|
264
|
+
'TIME',
|
|
265
|
+
'TIMESTAMP',
|
|
266
|
+
'TIMESTAMPTZ',
|
|
267
|
+
'TIMETZ',
|
|
268
|
+
'VARBIT',
|
|
269
|
+
'VARCHAR',
|
|
270
|
+
'VARYING',
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
/** `columns`: a column list may follow; `function`: a table function may; `list`: a `FROM` list, which `(` may group. */
|
|
274
|
+
type RelationSlot = 'columns' | 'function' | 'list' | undefined
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Every name of a DML statement, a query or an expression, with its role. A name in a relation position (after
|
|
278
|
+
* `FROM`, `JOIN`, `USING`, `INTO`, `UPDATE`, `TABLE`, `REFERENCES`, or a `,` of a `FROM` list) is a relation; a name
|
|
279
|
+
* that `(` follows is a call, unless it is syntax or a type with a modifier; a name after `::` or `AS` is a type. A
|
|
280
|
+
* two-part name anywhere else is a column of a table or an alias (`d.note`, `NEW.stage`).
|
|
281
|
+
*/
|
|
282
|
+
export function nameReferences(tokens: readonly SqlToken[]): NameReference[] {
|
|
283
|
+
const references: NameReference[] = []
|
|
284
|
+
const lists: { fromList: boolean; opener: string | undefined }[] = [{ fromList: false, opener: undefined }]
|
|
285
|
+
let slot: RelationSlot
|
|
286
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
287
|
+
const token = tokens[index]
|
|
288
|
+
const top = lists[lists.length - 1] ?? { fromList: false, opener: undefined }
|
|
289
|
+
const word = upperWord(token)
|
|
290
|
+
if (isPunct(token, ';')) {
|
|
291
|
+
lists.splice(0, lists.length, { fromList: false, opener: undefined })
|
|
292
|
+
slot = undefined
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
if (isPunct(token, '(')) {
|
|
296
|
+
// `FROM (a JOIN b)` and `FROM ONLY (t)` group relations: the first name inside is still one.
|
|
297
|
+
const grouping = slot === 'list'
|
|
298
|
+
lists.push({ fromList: grouping, opener: upperWord(tokens[index - 1]) })
|
|
299
|
+
slot = grouping ? 'list' : undefined
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (isPunct(token, ')')) {
|
|
303
|
+
if (lists.length > 1) lists.pop()
|
|
304
|
+
slot = undefined
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (isPunct(token, ',')) {
|
|
308
|
+
slot = top.fromList ? 'list' : undefined
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
if (word !== undefined && LIST_ENDS.has(word)) top.fromList = false
|
|
312
|
+
if (word !== undefined && RELATION_OR_FUNCTION.has(word)) {
|
|
313
|
+
// `EXCLUDE USING gist (…)` names an index method, which is neither a relation nor a call.
|
|
314
|
+
if (word === 'USING' && upperWord(tokens[index - 1]) === 'EXCLUDE') {
|
|
315
|
+
index++
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
const argument = word === 'FROM'
|
|
319
|
+
&& (upperWord(tokens[index - 1]) === 'DISTINCT' || (top.opener !== undefined && FROM_ARGUMENT_FUNCTIONS.has(top.opener)))
|
|
320
|
+
// `USING (…)` is a join's column list or a policy's expression, never a group of relations.
|
|
321
|
+
slot = argument ? undefined : word === 'USING' ? 'function' : 'list'
|
|
322
|
+
if (word !== 'JOIN') top.fromList = slot !== undefined
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
// `(expression).f(…)` calls `f` through field selection; no schema rule can read its target, so it is refused.
|
|
326
|
+
if (isPunct(token, '.') && isPunct(tokens[index - 1], ')') && isName(tokens[index + 1]) && isPunct(tokens[index + 2], '(')) {
|
|
327
|
+
references.push({ qualifier: '(…)', name: `(…).${nameText(tokens[index + 1]) ?? ''}`, parts: 2, role: 'call', line: token?.line ?? 0 })
|
|
328
|
+
index++
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
if (word === 'OPERATOR' && isPunct(tokens[index + 1], '(')) {
|
|
332
|
+
const close = closingParenthesis(tokens, index + 1) ?? tokens.length - 1
|
|
333
|
+
const inner = tokens.slice(index + 2, close)
|
|
334
|
+
const qualified = isName(inner[0]) && isPunct(inner[1], '.')
|
|
335
|
+
const symbol = inner.slice(qualified ? 2 : 0).map((part) => (part.kind === 'punct' ? part.char : '')).join('')
|
|
336
|
+
references.push({
|
|
337
|
+
qualifier: qualified ? nameText(inner[0]) : undefined,
|
|
338
|
+
name: symbol,
|
|
339
|
+
parts: qualified ? 2 : 1,
|
|
340
|
+
role: 'operator',
|
|
341
|
+
line: token?.line ?? 0,
|
|
342
|
+
})
|
|
343
|
+
slot = undefined
|
|
344
|
+
index = close
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
if (word !== undefined && RELATION_WITH_COLUMNS.has(word)) {
|
|
348
|
+
slot = 'columns'
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (slot !== undefined && (word === 'ONLY' || word === 'LATERAL' || word === 'STRICT')) continue
|
|
352
|
+
const inSlot = slot
|
|
353
|
+
slot = undefined
|
|
354
|
+
if (!isName(token)) continue
|
|
355
|
+
const parts: string[] = [nameText(token) ?? '']
|
|
356
|
+
let end = index
|
|
357
|
+
while (parts.length < 3 && isPunct(tokens[end + 1], '.') && isName(tokens[end + 2])) {
|
|
358
|
+
parts.push(nameText(tokens[end + 2]) ?? '')
|
|
359
|
+
end += 2
|
|
360
|
+
}
|
|
361
|
+
const castTarget = (isPunct(tokens[index - 1], ':') && isPunct(tokens[index - 2], ':')) || upperWord(tokens[index - 1]) === 'AS'
|
|
362
|
+
const followedByParenthesis = isPunct(tokens[end + 1], '(')
|
|
363
|
+
const syntax = parts.length === 1 && token?.kind === 'word' && word !== undefined && (NOT_CALLS.has(word) || MODIFIED_TYPES.has(word))
|
|
364
|
+
// PL/pgSQL `v t%ROWTYPE` and `v t.c%TYPE` read a relation; `ext.t 'literal'` is a typed literal.
|
|
365
|
+
const percentType = isPunct(tokens[end + 1], '%') && (upperWord(tokens[end + 2]) === 'TYPE' || upperWord(tokens[end + 2]) === 'ROWTYPE')
|
|
366
|
+
const typedLiteral = parts.length > 1 && tokens[end + 1]?.kind === 'string'
|
|
367
|
+
let role: NameRole = 'other'
|
|
368
|
+
if (castTarget || typedLiteral) role = 'type'
|
|
369
|
+
else if (percentType) role = 'relation'
|
|
370
|
+
else if (followedByParenthesis && !syntax && inSlot !== 'columns') role = 'call'
|
|
371
|
+
else if (inSlot !== undefined) role = 'relation'
|
|
372
|
+
references.push({
|
|
373
|
+
qualifier: parts.length > 1 ? parts[0] : undefined,
|
|
374
|
+
name: parts[parts.length - 1] ?? '',
|
|
375
|
+
parts: parts.length,
|
|
376
|
+
role,
|
|
377
|
+
line: token?.line ?? 0,
|
|
378
|
+
})
|
|
379
|
+
index = end
|
|
380
|
+
}
|
|
381
|
+
return references
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The first schema a DML statement, a query or a function body reaches other than `schema`, `pg_catalog` included: a
|
|
386
|
+
* qualified relation, call or type, or a three-part name. An unqualified relation whose name starts with `pg_`
|
|
387
|
+
* resolves to the catalog and counts as `pg_catalog`.
|
|
388
|
+
*/
|
|
389
|
+
export function dmlForeignSchema(tokens: readonly SqlToken[], schema: string): string | undefined {
|
|
390
|
+
for (const reference of nameReferences(tokens)) {
|
|
391
|
+
if (reference.qualifier === undefined) {
|
|
392
|
+
if (reference.role === 'relation' && reference.name.startsWith('pg_')) return 'pg_catalog'
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
// A field-selection call has no schema to reach; the call allowlist refuses it.
|
|
396
|
+
if (reference.qualifier === schema || reference.qualifier === '(…)') continue
|
|
397
|
+
if (reference.role === 'operator') {
|
|
398
|
+
if (reference.qualifier !== 'pg_catalog') return reference.qualifier
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
if (reference.role !== 'other' || reference.parts === 3) return reference.qualifier
|
|
402
|
+
}
|
|
403
|
+
return undefined
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Words that begin a statement. Of these only `NESTED_STATEMENTS` may stand in parentheses inside another one. */
|
|
407
|
+
const STATEMENT_WORDS: ReadonlySet<string> = new Set([
|
|
408
|
+
'ABORT',
|
|
409
|
+
'ALTER',
|
|
410
|
+
'ANALYZE',
|
|
411
|
+
'BEGIN',
|
|
412
|
+
'CALL',
|
|
413
|
+
'CHECKPOINT',
|
|
414
|
+
'CLUSTER',
|
|
415
|
+
'COMMENT',
|
|
416
|
+
'COMMIT',
|
|
417
|
+
'COPY',
|
|
418
|
+
'CREATE',
|
|
419
|
+
'DELETE',
|
|
420
|
+
'DISCARD',
|
|
421
|
+
'DO',
|
|
422
|
+
'DROP',
|
|
423
|
+
'EXECUTE',
|
|
424
|
+
'EXPLAIN',
|
|
425
|
+
'GRANT',
|
|
426
|
+
'IMPORT',
|
|
427
|
+
'INSERT',
|
|
428
|
+
'LISTEN',
|
|
429
|
+
'LOAD',
|
|
430
|
+
'LOCK',
|
|
431
|
+
'MERGE',
|
|
432
|
+
'NOTIFY',
|
|
433
|
+
'PREPARE',
|
|
434
|
+
'REFRESH',
|
|
435
|
+
'REINDEX',
|
|
436
|
+
'RELEASE',
|
|
437
|
+
'RESET',
|
|
438
|
+
'REVOKE',
|
|
439
|
+
'ROLLBACK',
|
|
440
|
+
'SAVEPOINT',
|
|
441
|
+
'SECURITY',
|
|
442
|
+
'SELECT',
|
|
443
|
+
'SET',
|
|
444
|
+
'TABLE',
|
|
445
|
+
'TRUNCATE',
|
|
446
|
+
'UPDATE',
|
|
447
|
+
'VACUUM',
|
|
448
|
+
'VALUES',
|
|
449
|
+
'WITH',
|
|
450
|
+
])
|
|
451
|
+
const NESTED_STATEMENTS: ReadonlySet<string> = new Set(['SELECT', 'VALUES', 'WITH', 'TABLE', 'INSERT', 'UPDATE', 'DELETE'])
|
|
452
|
+
|
|
453
|
+
/** The statement a `WITH name [(…)] AS [[NOT] MATERIALIZED] (…), …` at `index` ends in, or `undefined` if it is no CTE list. */
|
|
454
|
+
function withTerminal(tokens: readonly SqlToken[], index: number): SqlToken | undefined {
|
|
455
|
+
let at = skipWords(tokens, index + 1, 'RECURSIVE')
|
|
456
|
+
for (;;) {
|
|
457
|
+
if (!isName(tokens[at])) return undefined
|
|
458
|
+
at++
|
|
459
|
+
if (isPunct(tokens[at], '(')) at = (closingParenthesis(tokens, at) ?? tokens.length) + 1
|
|
460
|
+
if (upperWord(tokens[at]) !== 'AS') return undefined
|
|
461
|
+
at = skipWords(tokens, skipWords(tokens, at + 1, 'NOT'), 'MATERIALIZED')
|
|
462
|
+
if (!isPunct(tokens[at], '(')) return undefined
|
|
463
|
+
at = (closingParenthesis(tokens, at) ?? tokens.length) + 1
|
|
464
|
+
if (!isPunct(tokens[at], ',')) return tokens[at]
|
|
465
|
+
at++
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The first nested statement a nested position may not hold: in parentheses (a CTE body, a subquery) and after a
|
|
471
|
+
* CTE list, anything but a query or `INSERT`/`UPDATE`/`DELETE`, whose names the same checks read. `MERGE` among them.
|
|
472
|
+
*/
|
|
473
|
+
export function nestedStatement(tokens: readonly SqlToken[]): { readonly statement: string; readonly line: number } | undefined {
|
|
474
|
+
for (let index = 0; index + 1 < tokens.length; index++) {
|
|
475
|
+
const next = isPunct(tokens[index], '(') ? tokens[index + 1] : upperWord(tokens[index]) === 'WITH' ? withTerminal(tokens, index) : undefined
|
|
476
|
+
const word = next?.kind === 'word' ? upperWord(next) : undefined
|
|
477
|
+
if (word !== undefined && STATEMENT_WORDS.has(word) && !NESTED_STATEMENTS.has(word)) return { statement: word, line: next?.line ?? 1 }
|
|
478
|
+
if (upperWord(tokens[index]) === 'WITH' && next !== undefined && word === undefined) return { statement: 'WITH', line: next.line }
|
|
479
|
+
}
|
|
480
|
+
return undefined
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The first data-changing statement whose target is in parentheses (`UPDATE ONLY (t)`, `DELETE FROM (t)`): the
|
|
485
|
+
* classifier reads a target as the name after the keyword, so it refuses the form rather than read through a group.
|
|
486
|
+
*/
|
|
487
|
+
export function parenthesizedTarget(tokens: readonly SqlToken[]): { readonly statement: string; readonly line: number } | undefined {
|
|
488
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
489
|
+
const word = upperWord(tokens[index])
|
|
490
|
+
let at: number
|
|
491
|
+
if (word === 'UPDATE' || word === 'INTO') at = index + 1
|
|
492
|
+
else if (word === 'DELETE' && upperWord(tokens[index + 1]) === 'FROM') at = index + 2
|
|
493
|
+
else continue
|
|
494
|
+
at = skipWords(tokens, at, 'ONLY')
|
|
495
|
+
if (isPunct(tokens[at], '(')) return { statement: word, line: tokens[at]?.line ?? 1 }
|
|
496
|
+
}
|
|
497
|
+
return undefined
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The relations a DML statement or a query names, in order: what its static text reads or writes. */
|
|
501
|
+
export function dmlRelations(tokens: readonly SqlToken[]): NameReference[] {
|
|
502
|
+
return nameReferences(tokens).filter((reference) => reference.role === 'relation')
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Whether a name is the framework's (`fabrika_…`): its tables share the application's schema and hold its secrets,
|
|
507
|
+
* leases and deliveries. Case-insensitive, so a quoted `"FABRIKA_x"` counts too.
|
|
508
|
+
*/
|
|
509
|
+
export function isFrameworkName(name: string): boolean {
|
|
510
|
+
return name.toLowerCase().startsWith('fabrika_')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The first framework name among `tokens` that `allowed` does not hold, with its line. */
|
|
514
|
+
export function frameworkReference(
|
|
515
|
+
tokens: readonly SqlToken[],
|
|
516
|
+
allowed: ReadonlySet<string> = new Set(),
|
|
517
|
+
): { readonly name: string; readonly line: number } | undefined {
|
|
518
|
+
for (const token of tokens) {
|
|
519
|
+
const name = nameText(token)
|
|
520
|
+
if (name !== undefined && isFrameworkName(name) && !allowed.has(name)) return { name, line: token.line }
|
|
521
|
+
}
|
|
522
|
+
return undefined
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** `U&"…"`: an identifier whose escapes the lexer does not decode, so a name rule cannot read it. */
|
|
526
|
+
export function unicodeIdentifier(tokens: readonly SqlToken[]): { readonly line: number } | undefined {
|
|
527
|
+
for (let index = 0; index + 2 < tokens.length; index++) {
|
|
528
|
+
const token = tokens[index]
|
|
529
|
+
if (token?.kind === 'word' && token.text.toUpperCase() === 'U' && isPunct(tokens[index + 1], '&') && tokens[index + 2]?.kind === 'quoted') {
|
|
530
|
+
return { line: token.line }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return undefined
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Reviewed `pg_catalog` functions a stored expression may call without a deviation: no side effect, no read of
|
|
538
|
+
* another row, no SQL run from a string. Trusted by name, resolved unqualified.
|
|
539
|
+
*/
|
|
540
|
+
const PAVED_FUNCTIONS: ReadonlySet<string> = new Set([
|
|
541
|
+
'now',
|
|
542
|
+
'gen_random_uuid',
|
|
543
|
+
'lower',
|
|
544
|
+
'upper',
|
|
545
|
+
'length',
|
|
546
|
+
'char_length',
|
|
547
|
+
'octet_length',
|
|
548
|
+
'btrim',
|
|
549
|
+
'ltrim',
|
|
550
|
+
'rtrim',
|
|
551
|
+
'trim',
|
|
552
|
+
'abs',
|
|
553
|
+
'round',
|
|
554
|
+
'trunc',
|
|
555
|
+
'floor',
|
|
556
|
+
'ceil',
|
|
557
|
+
'ceiling',
|
|
558
|
+
'date_trunc',
|
|
559
|
+
'timezone',
|
|
560
|
+
'date_part',
|
|
561
|
+
'extract',
|
|
562
|
+
'array_length',
|
|
563
|
+
'cardinality',
|
|
564
|
+
'to_tsvector',
|
|
565
|
+
'to_tsquery',
|
|
566
|
+
'int2',
|
|
567
|
+
'int4',
|
|
568
|
+
'int8',
|
|
569
|
+
'float4',
|
|
570
|
+
'float8',
|
|
571
|
+
'numeric',
|
|
572
|
+
'text',
|
|
573
|
+
'varchar',
|
|
574
|
+
'bpchar',
|
|
575
|
+
'bool',
|
|
576
|
+
'date',
|
|
577
|
+
'time',
|
|
578
|
+
'timetz',
|
|
579
|
+
'timestamp',
|
|
580
|
+
'timestamptz',
|
|
581
|
+
'interval',
|
|
582
|
+
])
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Reviewed `pg_catalog` functions a function body, a view or a backfill may call, and a stored expression may call as
|
|
586
|
+
* a deviation: pure string, number, date, JSON, array, text-search and aggregate functions, `pg_sleep`, and the
|
|
587
|
+
* built-in trigger function. None runs SQL from a string, touches a file, a large object, a lock, a setting, another
|
|
588
|
+
* database or a sequence named by a string.
|
|
589
|
+
*/
|
|
590
|
+
const REVIEWED_FUNCTIONS: ReadonlySet<string> = new Set([
|
|
591
|
+
'concat',
|
|
592
|
+
'concat_ws',
|
|
593
|
+
'format',
|
|
594
|
+
'substring',
|
|
595
|
+
'substr',
|
|
596
|
+
'replace',
|
|
597
|
+
'split_part',
|
|
598
|
+
'strpos',
|
|
599
|
+
'position',
|
|
600
|
+
'overlay',
|
|
601
|
+
'left',
|
|
602
|
+
'right',
|
|
603
|
+
'lpad',
|
|
604
|
+
'rpad',
|
|
605
|
+
'repeat',
|
|
606
|
+
'reverse',
|
|
607
|
+
'initcap',
|
|
608
|
+
'translate',
|
|
609
|
+
'regexp_replace',
|
|
610
|
+
'regexp_match',
|
|
611
|
+
'regexp_matches',
|
|
612
|
+
'regexp_split_to_array',
|
|
613
|
+
'starts_with',
|
|
614
|
+
'md5',
|
|
615
|
+
'sha256',
|
|
616
|
+
'encode',
|
|
617
|
+
'to_char',
|
|
618
|
+
'to_date',
|
|
619
|
+
'to_timestamp',
|
|
620
|
+
'to_number',
|
|
621
|
+
'mod',
|
|
622
|
+
'power',
|
|
623
|
+
'sqrt',
|
|
624
|
+
'exp',
|
|
625
|
+
'ln',
|
|
626
|
+
'log',
|
|
627
|
+
'sign',
|
|
628
|
+
'div',
|
|
629
|
+
'random',
|
|
630
|
+
'age',
|
|
631
|
+
'clock_timestamp',
|
|
632
|
+
'statement_timestamp',
|
|
633
|
+
'transaction_timestamp',
|
|
634
|
+
'make_date',
|
|
635
|
+
'make_time',
|
|
636
|
+
'make_timestamp',
|
|
637
|
+
'make_timestamptz',
|
|
638
|
+
'make_interval',
|
|
639
|
+
'date_bin',
|
|
640
|
+
'justify_days',
|
|
641
|
+
'justify_hours',
|
|
642
|
+
'isfinite',
|
|
643
|
+
'to_json',
|
|
644
|
+
'to_jsonb',
|
|
645
|
+
'json_build_object',
|
|
646
|
+
'jsonb_build_object',
|
|
647
|
+
'json_build_array',
|
|
648
|
+
'jsonb_build_array',
|
|
649
|
+
'json_agg',
|
|
650
|
+
'jsonb_agg',
|
|
651
|
+
'json_object_agg',
|
|
652
|
+
'jsonb_object_agg',
|
|
653
|
+
'jsonb_set',
|
|
654
|
+
'jsonb_insert',
|
|
655
|
+
'jsonb_strip_nulls',
|
|
656
|
+
'jsonb_typeof',
|
|
657
|
+
'json_typeof',
|
|
658
|
+
'jsonb_array_length',
|
|
659
|
+
'json_array_length',
|
|
660
|
+
'jsonb_array_elements',
|
|
661
|
+
'jsonb_array_elements_text',
|
|
662
|
+
'jsonb_each',
|
|
663
|
+
'jsonb_each_text',
|
|
664
|
+
'jsonb_object_keys',
|
|
665
|
+
'jsonb_extract_path',
|
|
666
|
+
'jsonb_extract_path_text',
|
|
667
|
+
'jsonb_pretty',
|
|
668
|
+
'row_to_json',
|
|
669
|
+
'array_agg',
|
|
670
|
+
'array_append',
|
|
671
|
+
'array_prepend',
|
|
672
|
+
'array_cat',
|
|
673
|
+
'array_remove',
|
|
674
|
+
'array_replace',
|
|
675
|
+
'array_position',
|
|
676
|
+
'array_positions',
|
|
677
|
+
'array_to_string',
|
|
678
|
+
'string_to_array',
|
|
679
|
+
'array_upper',
|
|
680
|
+
'array_lower',
|
|
681
|
+
'unnest',
|
|
682
|
+
'generate_series',
|
|
683
|
+
'generate_subscripts',
|
|
684
|
+
'count',
|
|
685
|
+
'sum',
|
|
686
|
+
'avg',
|
|
687
|
+
'min',
|
|
688
|
+
'max',
|
|
689
|
+
'bool_and',
|
|
690
|
+
'bool_or',
|
|
691
|
+
'every',
|
|
692
|
+
'string_agg',
|
|
693
|
+
'row_number',
|
|
694
|
+
'rank',
|
|
695
|
+
'dense_rank',
|
|
696
|
+
'lag',
|
|
697
|
+
'lead',
|
|
698
|
+
'first_value',
|
|
699
|
+
'last_value',
|
|
700
|
+
'num_nonnulls',
|
|
701
|
+
'num_nulls',
|
|
702
|
+
'ts_rank',
|
|
703
|
+
'ts_headline',
|
|
704
|
+
'plainto_tsquery',
|
|
705
|
+
'phraseto_tsquery',
|
|
706
|
+
'websearch_to_tsquery',
|
|
707
|
+
'setweight',
|
|
708
|
+
'pg_sleep',
|
|
709
|
+
'suppress_redundant_updates_trigger',
|
|
710
|
+
])
|
|
711
|
+
|
|
712
|
+
/** How a call is admitted: `paved` and `reviewed` built-ins, `expression`-only ones, and the migrations' own functions. */
|
|
713
|
+
export type FunctionUse = 'paved' | 'reviewed' | 'declared'
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* How a call of `call` is admitted, or `undefined` when it is not. A function the migrations declare in `schema` may be
|
|
717
|
+
* called unqualified or qualified with `schema`; a built-in only unqualified, since a qualified one names `pg_catalog`.
|
|
718
|
+
*/
|
|
719
|
+
export function functionUse(call: NameReference, schema: string, functions: ReadonlySet<string>): FunctionUse | undefined {
|
|
720
|
+
if (call.parts > 2 || (call.qualifier !== undefined && call.qualifier !== schema)) return undefined
|
|
721
|
+
if (functions.has(call.name)) return 'declared'
|
|
722
|
+
if (call.qualifier !== undefined) return undefined
|
|
723
|
+
if (PAVED_FUNCTIONS.has(call.name)) return 'paved'
|
|
724
|
+
if (REVIEWED_FUNCTIONS.has(call.name)) return 'reviewed'
|
|
725
|
+
return undefined
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The first call among `tokens` that a body, a query or a backfill may not make: anything but a paved or reviewed
|
|
730
|
+
* built-in, or a function the migrations declare in `schema`. An allowlist, because a built-in such as
|
|
731
|
+
* `query_to_xml`, `dblink` or `lo_import` runs SQL from a string or reaches past the schema, which no name rule sees.
|
|
732
|
+
*/
|
|
733
|
+
export function refusedFunctionCall(tokens: readonly SqlToken[], schema: string, functions: ReadonlySet<string>): NameReference | undefined {
|
|
734
|
+
for (const reference of nameReferences(tokens)) {
|
|
735
|
+
if (reference.role !== 'call') continue
|
|
736
|
+
const use = functionUse(reference, schema, functions)
|
|
737
|
+
if (use === undefined) return reference
|
|
738
|
+
}
|
|
739
|
+
return undefined
|
|
740
|
+
}
|