@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.
@@ -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
+ }