@fabrika/platform-node 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
// What an application migration may hold (ADR-0017): every statement is paved, a deviation the approver reviews,
|
|
2
|
+
// or refused. A refusal is an escape from the schema, the transaction or the platform, or a form the classifier
|
|
3
|
+
// does not recognise: an allowlist at every level, so a form a later Postgres adds is refused, not executed.
|
|
4
|
+
// Runtime-neutral: Control classifies a commit's migrations in a Worker, the runner before it applies them.
|
|
5
|
+
|
|
6
|
+
import { FILE_TABLE_PREFIX, fileScanAlterTable, legacyStatusCheckName } from './file-table-ddl'
|
|
7
|
+
import { lexSql, type SqlToken, topLevelStatements, upperWord } from './migration-sql'
|
|
8
|
+
import { PG_CATALOG_FUNCTIONS, SQL_KEYWORDS } from './pg-catalog-names'
|
|
9
|
+
import {
|
|
10
|
+
afterName,
|
|
11
|
+
closingParenthesis,
|
|
12
|
+
ddlForeignSchema,
|
|
13
|
+
dmlForeignSchema,
|
|
14
|
+
frameworkReference,
|
|
15
|
+
functionUse,
|
|
16
|
+
isName,
|
|
17
|
+
isPunct,
|
|
18
|
+
nameReferences,
|
|
19
|
+
nameText,
|
|
20
|
+
nestedStatement,
|
|
21
|
+
objectNameAt,
|
|
22
|
+
parenthesizedTarget,
|
|
23
|
+
refusedFunctionCall,
|
|
24
|
+
skipWords,
|
|
25
|
+
splitAtCommas,
|
|
26
|
+
statementKind,
|
|
27
|
+
unicodeIdentifier,
|
|
28
|
+
wordAtDepthZero,
|
|
29
|
+
} from './sql-names'
|
|
30
|
+
|
|
31
|
+
/** A database object a migration declares, named as the catalog check names it (`trigger deals.audit`). */
|
|
32
|
+
export type DatabaseObjectKind = 'function' | 'procedure' | 'trigger' | 'view' | 'materialized view' | 'rule' | 'policy'
|
|
33
|
+
|
|
34
|
+
export interface DatabaseObject {
|
|
35
|
+
readonly kind: DatabaseObjectKind
|
|
36
|
+
/** `<name>`, or `<table>.<name>` for a trigger, a rule and a policy. A routine has one signature, so its name is enough. */
|
|
37
|
+
readonly name: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type RoutineKind = 'function' | 'procedure'
|
|
41
|
+
|
|
42
|
+
/** Database code and unusual expressions: allowed, and shown to the approver as needing closer review. */
|
|
43
|
+
export type DeviationKind = DatabaseObjectKind | 'do' | 'expression'
|
|
44
|
+
|
|
45
|
+
/** One deviation. Its approval key is `kind` + `object`: a line moves when the file is edited, the key does not. */
|
|
46
|
+
export interface MigrationDeviation {
|
|
47
|
+
readonly kind: DeviationKind
|
|
48
|
+
/**
|
|
49
|
+
* The object's name; `block <n>` for the n-th `DO` of the file;
|
|
50
|
+
* `<where> calls <function>` for an expression, where `<where>` is a column, a constraint, a table or an index.
|
|
51
|
+
*/
|
|
52
|
+
readonly object: string
|
|
53
|
+
readonly file: string
|
|
54
|
+
readonly line: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MigrationRefusal {
|
|
58
|
+
/** What is refused, phrased to precede "is not allowed". */
|
|
59
|
+
readonly reason: string
|
|
60
|
+
readonly file: string
|
|
61
|
+
readonly line: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How one statement changes the set of declared objects, in file order. */
|
|
65
|
+
export type ManifestChange =
|
|
66
|
+
| { readonly change: 'create'; readonly object: DatabaseObject }
|
|
67
|
+
| { readonly change: 'drop'; readonly object: DatabaseObject }
|
|
68
|
+
| { readonly change: 'drop table'; readonly table: string }
|
|
69
|
+
| { readonly change: 'rename table'; readonly from: string; readonly to: string }
|
|
70
|
+
| { readonly change: 'rename'; readonly from: DatabaseObject; readonly to: DatabaseObject }
|
|
71
|
+
/** A routine's create also carries its argument list, as written, so a second signature for the name is refused. */
|
|
72
|
+
| { readonly change: 'create routine'; readonly object: DatabaseObject; readonly args: string; readonly line: number }
|
|
73
|
+
|
|
74
|
+
export interface MigrationClassification {
|
|
75
|
+
readonly file: string
|
|
76
|
+
/** One per refused statement, in order. A file with a refusal does not run. */
|
|
77
|
+
readonly refused: readonly MigrationRefusal[]
|
|
78
|
+
readonly deviations: readonly MigrationDeviation[]
|
|
79
|
+
/** The objects the file creates. */
|
|
80
|
+
readonly objects: readonly DatabaseObject[]
|
|
81
|
+
readonly changes: readonly ManifestChange[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ClassifyMigrationOptions {
|
|
85
|
+
readonly file: string
|
|
86
|
+
/** The functions and procedures the whole migration set declares in the schema: a body or an expression may call them. */
|
|
87
|
+
readonly functions?: ReadonlySet<string>
|
|
88
|
+
/** The framework's own bundle, which alone may name the framework's `fabrika_*` tables. */
|
|
89
|
+
readonly framework?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MigrationFile {
|
|
93
|
+
readonly name: string
|
|
94
|
+
readonly sql: string
|
|
95
|
+
readonly framework?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MigrationSetClassification {
|
|
99
|
+
readonly files: readonly MigrationClassification[]
|
|
100
|
+
/** The manifest: every object the files declare and do not drop, in order. The catalog check admits exactly these. */
|
|
101
|
+
readonly objects: readonly DatabaseObject[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const LANGUAGES: ReadonlySet<string> = new Set(['sql', 'plpgsql'])
|
|
105
|
+
/** `serial` creates a sequence the catalog check cannot tie to a declaration; an identity column can be. */
|
|
106
|
+
const SERIAL_TYPES: ReadonlySet<string> = new Set(['serial', 'serial2', 'serial4', 'serial8', 'smallserial', 'bigserial'])
|
|
107
|
+
const TABLE_CONSTRAINTS: ReadonlySet<string> = new Set(['CONSTRAINT', 'CHECK', 'UNIQUE', 'PRIMARY', 'FOREIGN', 'EXCLUDE', 'LIKE'])
|
|
108
|
+
|
|
109
|
+
/** What an application migration may drop: its own tables, indexes and types, and its database code. */
|
|
110
|
+
const DROPPABLE: ReadonlySet<string> = new Set([
|
|
111
|
+
'TABLE',
|
|
112
|
+
'INDEX',
|
|
113
|
+
'TYPE',
|
|
114
|
+
'TRIGGER',
|
|
115
|
+
'FUNCTION',
|
|
116
|
+
'PROCEDURE',
|
|
117
|
+
'ROUTINE',
|
|
118
|
+
'AGGREGATE',
|
|
119
|
+
'RULE',
|
|
120
|
+
'VIEW',
|
|
121
|
+
'MATERIALIZED',
|
|
122
|
+
'POLICY',
|
|
123
|
+
'SEQUENCE',
|
|
124
|
+
'DOMAIN',
|
|
125
|
+
'OPERATOR',
|
|
126
|
+
'STATISTICS',
|
|
127
|
+
'EXTENSION',
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
/** The `ALTER [COLUMN] <name>` actions a migration may take. */
|
|
131
|
+
const COLUMN_ACTIONS: readonly (readonly string[])[] = [
|
|
132
|
+
['TYPE'],
|
|
133
|
+
['SET', 'DATA'],
|
|
134
|
+
['SET', 'DEFAULT'],
|
|
135
|
+
['DROP', 'DEFAULT'],
|
|
136
|
+
['SET', 'NOT'],
|
|
137
|
+
['DROP', 'NOT'],
|
|
138
|
+
['ADD', 'GENERATED'],
|
|
139
|
+
['SET', 'GENERATED'],
|
|
140
|
+
['DROP', 'IDENTITY'],
|
|
141
|
+
['RESTART'],
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
/** Statements a function or `DO` body may begin with, PL/pgSQL control flow included; an assignment also may. */
|
|
145
|
+
const BODY_STATEMENTS: ReadonlySet<string> = new Set([
|
|
146
|
+
'SELECT',
|
|
147
|
+
'INSERT',
|
|
148
|
+
'UPDATE',
|
|
149
|
+
'DELETE',
|
|
150
|
+
'WITH',
|
|
151
|
+
'VALUES',
|
|
152
|
+
'PERFORM',
|
|
153
|
+
'RETURN',
|
|
154
|
+
'RAISE',
|
|
155
|
+
'IF',
|
|
156
|
+
'ELSIF',
|
|
157
|
+
'ELSEIF',
|
|
158
|
+
'ELSE',
|
|
159
|
+
'END',
|
|
160
|
+
'BEGIN',
|
|
161
|
+
'DECLARE',
|
|
162
|
+
'LOOP',
|
|
163
|
+
'WHILE',
|
|
164
|
+
'FOR',
|
|
165
|
+
'FOREACH',
|
|
166
|
+
'EXIT',
|
|
167
|
+
'CONTINUE',
|
|
168
|
+
'CASE',
|
|
169
|
+
'WHEN',
|
|
170
|
+
'GET',
|
|
171
|
+
'ASSERT',
|
|
172
|
+
'NULL',
|
|
173
|
+
'OPEN',
|
|
174
|
+
'FETCH',
|
|
175
|
+
'MOVE',
|
|
176
|
+
'CLOSE',
|
|
177
|
+
'CALL',
|
|
178
|
+
'EXCEPTION',
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
/** The first rows a rule's action may begin with. */
|
|
182
|
+
const RULE_ACTIONS: ReadonlySet<string> = new Set(['NOTHING', 'SELECT', 'INSERT', 'UPDATE', 'DELETE'])
|
|
183
|
+
|
|
184
|
+
interface Context {
|
|
185
|
+
readonly schema: string
|
|
186
|
+
readonly functions: ReadonlySet<string>
|
|
187
|
+
readonly framework: boolean
|
|
188
|
+
doBlocks: number
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Thrown inside one statement's classification; the statement then contributes only its refusal. */
|
|
192
|
+
class Refused extends Error {
|
|
193
|
+
constructor(readonly reason: string, readonly line: number) {
|
|
194
|
+
super(reason)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What one statement adds when it is not refused. */
|
|
199
|
+
interface Collected {
|
|
200
|
+
readonly deviations: { readonly kind: DeviationKind; readonly object: string; readonly line: number }[]
|
|
201
|
+
readonly changes: ManifestChange[]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function deviate(out: Collected, kind: DeviationKind, object: string, line: number): void {
|
|
205
|
+
out.deviations.push({ kind, object, line })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function declareObject(out: Collected, kind: DeviationKind & DatabaseObjectKind, name: string, line: number): void {
|
|
209
|
+
deviate(out, kind, name, line)
|
|
210
|
+
out.changes.push({ change: 'create', object: { kind, name } })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The name at `index` in the schema: unqualified or qualified with `schema`, else refused. */
|
|
214
|
+
function ownName(tokens: readonly SqlToken[], index: number, context: Context, label: string): string {
|
|
215
|
+
const object = objectNameAt(tokens, index)
|
|
216
|
+
const line = tokens[index]?.line ?? tokens[0]?.line ?? 1
|
|
217
|
+
if (object === undefined) throw new Refused(label, line)
|
|
218
|
+
if (object.qualifier !== undefined && object.qualifier !== context.schema) throw new Refused(`${label} naming the schema ${object.qualifier}`, line)
|
|
219
|
+
return object.name
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function refuseForeignDdl(tokens: readonly SqlToken[], context: Context, label: string): void {
|
|
223
|
+
const reached = ddlForeignSchema(tokens, context.schema)
|
|
224
|
+
if (reached !== undefined) throw new Refused(`${label} naming the schema ${reached}`, tokens[0]?.line ?? 1)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A query, a condition or a backfill: no schema but the application's, only allowlisted calls and nested statements. */
|
|
228
|
+
function checkQuery(tokens: readonly SqlToken[], context: Context, label: string): void {
|
|
229
|
+
const nested = nestedStatement(tokens)
|
|
230
|
+
if (nested !== undefined) throw new Refused(`${nested.statement} nested in a statement`, nested.line)
|
|
231
|
+
const target = parenthesizedTarget(tokens)
|
|
232
|
+
if (target !== undefined) throw new Refused(`a parenthesized ${target.statement} target`, target.line)
|
|
233
|
+
const reached = dmlForeignSchema(tokens, context.schema)
|
|
234
|
+
if (reached !== undefined) throw new Refused(`${label} reaching the schema ${reached}`, tokens[0]?.line ?? 1)
|
|
235
|
+
const call = refusedFunctionCall(tokens, context.schema, context.functions)
|
|
236
|
+
if (call !== undefined) throw new Refused(`a call of ${call.name}`, call.line)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A stored expression's calls: a paved built-in is plain, a reviewed or declared function a deviation, anything else refused. */
|
|
240
|
+
function checkExpression(tokens: readonly SqlToken[], where: string, context: Context, out: Collected): void {
|
|
241
|
+
for (const reference of nameReferences(tokens)) {
|
|
242
|
+
if (reference.role !== 'call') continue
|
|
243
|
+
const use = functionUse(reference, context.schema, context.functions)
|
|
244
|
+
if (use === undefined) throw new Refused(`a call of ${reference.name}`, reference.line)
|
|
245
|
+
if (use !== 'paved') deviate(out, 'expression', `${where} calls ${reference.name}`, reference.line)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Whether the name at `index` (dotted, subscripted) is the target of a PL/pgSQL assignment. */
|
|
250
|
+
function isAssignment(tokens: readonly SqlToken[], index: number): boolean {
|
|
251
|
+
let at = index + 1
|
|
252
|
+
while (isPunct(tokens[at], '.') && isName(tokens[at + 1])) at += 2
|
|
253
|
+
while (isPunct(tokens[at], '[')) {
|
|
254
|
+
let depth = 0
|
|
255
|
+
for (; at < tokens.length; at++) {
|
|
256
|
+
if (isPunct(tokens[at], '[')) depth++
|
|
257
|
+
else if (isPunct(tokens[at], ']') && --depth === 0) break
|
|
258
|
+
}
|
|
259
|
+
at++
|
|
260
|
+
}
|
|
261
|
+
return (isPunct(tokens[at], ':') && isPunct(tokens[at + 1], '=')) || isPunct(tokens[at], '=')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The first statement of a function or `DO` body that does not begin with an allowlisted word or an assignment. A
|
|
266
|
+
* declaration section (`DECLARE` … `BEGIN`) holds declarations; `THEN` and `ELSE` begin a statement except inside a
|
|
267
|
+
* `CASE` expression.
|
|
268
|
+
*/
|
|
269
|
+
function refusedBodyStatement(tokens: readonly SqlToken[]): { readonly statement: string; readonly line: number } | undefined {
|
|
270
|
+
let start = true
|
|
271
|
+
let declaring = false
|
|
272
|
+
const cases: ('statement' | 'expression')[] = []
|
|
273
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
274
|
+
const token = tokens[index]
|
|
275
|
+
const word = upperWord(token)
|
|
276
|
+
if (start) {
|
|
277
|
+
if (isPunct(token, ';')) continue
|
|
278
|
+
start = false
|
|
279
|
+
if (isPunct(token, '<') && isPunct(tokens[index + 1], '<')) {
|
|
280
|
+
while (index < tokens.length && !(isPunct(tokens[index], '>') && isPunct(tokens[index + 1], '>'))) index++
|
|
281
|
+
index++
|
|
282
|
+
start = true
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
const allowed = (declaring && word !== 'BEGIN') || (word !== undefined && BODY_STATEMENTS.has(word))
|
|
286
|
+
|| (isName(token) && isAssignment(tokens, index))
|
|
287
|
+
if (!allowed) return { statement: word ?? statementKind(tokens.slice(index)), line: token?.line ?? 1 }
|
|
288
|
+
if (word === 'CASE') {
|
|
289
|
+
cases.push('statement')
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
} else if (word === 'CASE') {
|
|
293
|
+
cases.push('expression')
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
const innermost = cases[cases.length - 1]
|
|
297
|
+
if (word === 'DECLARE') declaring = true
|
|
298
|
+
if (word === 'BEGIN') declaring = false
|
|
299
|
+
if (isPunct(token, ';') || word === 'BEGIN' || word === 'DECLARE') start = true
|
|
300
|
+
else if (word === 'LOOP') start = upperWord(tokens[index - 1]) !== 'END'
|
|
301
|
+
else if (word === 'THEN' || word === 'ELSE') start = innermost !== 'expression'
|
|
302
|
+
else if (word === 'END' && innermost === 'expression') cases.pop()
|
|
303
|
+
else if (word === 'END' && upperWord(tokens[index + 1]) === 'CASE') {
|
|
304
|
+
cases.pop()
|
|
305
|
+
index++
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The tokens of a literal body, on the lines of the file, or `undefined` when the classifier cannot read it. */
|
|
312
|
+
function literalBody(token: SqlToken | undefined): SqlToken[] | undefined {
|
|
313
|
+
let text: string | undefined
|
|
314
|
+
if (token?.kind === 'dollar') text = token.body
|
|
315
|
+
else if (token?.kind === 'string') text = token.text
|
|
316
|
+
if (text === undefined || token === undefined) return undefined
|
|
317
|
+
const { tokens, unterminated } = lexSql(text)
|
|
318
|
+
if (unterminated !== undefined) return undefined
|
|
319
|
+
const offset = token.line - 1
|
|
320
|
+
return tokens.map((inner) => ({ ...inner, line: inner.line + offset }))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A function or `DO` body: no Unicode-escaped name, no framework table, no dynamic `EXECUTE`, only allowlisted
|
|
325
|
+
* statements, no schema but the application's, and only allowlisted calls. A literal inside a body stays data: no
|
|
326
|
+
* statement a body may hold runs a string as SQL.
|
|
327
|
+
*/
|
|
328
|
+
function checkBody(tokens: readonly SqlToken[], context: Context): void {
|
|
329
|
+
const unicode = unicodeIdentifier(tokens)
|
|
330
|
+
if (unicode !== undefined) throw new Refused('a Unicode-escaped identifier (U&"…")', unicode.line)
|
|
331
|
+
const framework = context.framework ? undefined : frameworkReference(tokens)
|
|
332
|
+
if (framework !== undefined) throw new Refused(`a reference to the framework's ${framework.name}`, framework.line)
|
|
333
|
+
const execute = tokens.find((token) => upperWord(token) === 'EXECUTE')
|
|
334
|
+
if (execute !== undefined) throw new Refused('a dynamic EXECUTE in a body', execute.line)
|
|
335
|
+
const statement = refusedBodyStatement(tokens)
|
|
336
|
+
if (statement !== undefined) throw new Refused(`${statement.statement} in a body`, statement.line)
|
|
337
|
+
checkQuery(tokens, context, 'a body')
|
|
338
|
+
refuseForeignDdl(declarations(tokens), context, 'a declaration')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The tokens of every PL/pgSQL declaration section (`DECLARE` … `BEGIN`): each name there is a variable or a type. */
|
|
342
|
+
function declarations(tokens: readonly SqlToken[]): SqlToken[] {
|
|
343
|
+
const declared: SqlToken[] = []
|
|
344
|
+
let declaring = false
|
|
345
|
+
for (const token of tokens) {
|
|
346
|
+
const word = upperWord(token)
|
|
347
|
+
if (word === 'DECLARE') declaring = true
|
|
348
|
+
else if (word === 'BEGIN') declaring = false
|
|
349
|
+
else if (declaring) declared.push(token)
|
|
350
|
+
}
|
|
351
|
+
return declared
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function checkLanguage(language: string | undefined, line: number): void {
|
|
355
|
+
if (language === undefined) throw new Refused('a body without LANGUAGE', line)
|
|
356
|
+
if (!LANGUAGES.has(language)) throw new Refused(`LANGUAGE ${language}`, line)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** `CREATE [OR REPLACE] FUNCTION|PROCEDURE name (args) … AS <body> | RETURN <expression>`. */
|
|
360
|
+
function createFunction(tokens: readonly SqlToken[], at: number, kind: 'function' | 'procedure', context: Context, out: Collected): void {
|
|
361
|
+
const label = `CREATE ${kind.toUpperCase()}`
|
|
362
|
+
const line = tokens[0]?.line ?? 1
|
|
363
|
+
const name = ownName(tokens, at, context, label)
|
|
364
|
+
const named = afterName(tokens, at)
|
|
365
|
+
const close = named === undefined || !isPunct(tokens[named], '(') ? undefined : closingParenthesis(tokens, named)
|
|
366
|
+
if (named === undefined || close === undefined) throw new Refused(`${label} without an argument list`, line)
|
|
367
|
+
refuseShadowing(name, kind, line)
|
|
368
|
+
const header: SqlToken[] = [...tokens.slice(0, close + 1)]
|
|
369
|
+
let language: string | undefined
|
|
370
|
+
let body: SqlToken[] | undefined
|
|
371
|
+
for (let index = close + 1; index < tokens.length; index++) {
|
|
372
|
+
const token = tokens[index]
|
|
373
|
+
const word = upperWord(token)
|
|
374
|
+
if (isPunct(token, '(')) {
|
|
375
|
+
const end = closingParenthesis(tokens, index) ?? tokens.length - 1
|
|
376
|
+
header.push(...tokens.slice(index, end + 1))
|
|
377
|
+
index = end
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
if (word === 'SECURITY' && upperWord(tokens[index + 1]) === 'DEFINER') throw new Refused('SECURITY DEFINER', token?.line ?? line)
|
|
381
|
+
if (word === 'SET') throw new Refused(`a SET clause on a ${kind}`, token?.line ?? line)
|
|
382
|
+
if (word === 'SUPPORT' || word === 'TRANSFORM') throw new Refused(`${word} on a ${kind}`, token?.line ?? line)
|
|
383
|
+
if (word === 'BEGIN') throw new Refused('BEGIN ATOMIC; write the body dollar-quoted', token?.line ?? line)
|
|
384
|
+
if (word === 'LANGUAGE') {
|
|
385
|
+
language = nameText(tokens[index + 1])
|
|
386
|
+
if (language === undefined) throw new Refused('a LANGUAGE written as a string', token?.line ?? line)
|
|
387
|
+
checkLanguage(language, token?.line ?? line)
|
|
388
|
+
index++
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
if (word === 'AS') {
|
|
392
|
+
const next = tokens[index + 2]
|
|
393
|
+
body = literalBody(tokens[index + 1])
|
|
394
|
+
if (body === undefined || isPunct(next, ',') || next?.kind === 'string') {
|
|
395
|
+
throw new Refused(`a ${kind} body the classifier cannot read; write it dollar-quoted`, token?.line ?? line)
|
|
396
|
+
}
|
|
397
|
+
index++
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (word === 'RETURN') {
|
|
401
|
+
body = tokens.slice(index)
|
|
402
|
+
language ??= 'sql'
|
|
403
|
+
break
|
|
404
|
+
}
|
|
405
|
+
if (token !== undefined) header.push(token)
|
|
406
|
+
}
|
|
407
|
+
if (body === undefined) throw new Refused(`a ${kind} without a body`, line)
|
|
408
|
+
checkLanguage(language, line)
|
|
409
|
+
refuseForeignDdl(header, context, label)
|
|
410
|
+
// An argument default runs at every call that omits the argument, so it is a query like the body.
|
|
411
|
+
const args = tokens.slice(named + 1, close)
|
|
412
|
+
checkQuery(args, context, 'an argument default')
|
|
413
|
+
checkBody(body, context)
|
|
414
|
+
deviate(out, kind, name, line)
|
|
415
|
+
out.changes.push({ change: 'create routine', object: { kind, name }, args: `${kind} (${argumentText(args)})`, line })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** An argument list as written, without its defaults: two creates of one routine must write the same one. */
|
|
419
|
+
function argumentText(args: readonly SqlToken[]): string {
|
|
420
|
+
return splitAtCommas(args)
|
|
421
|
+
.map((argument) => {
|
|
422
|
+
const end = argument.findIndex((token) => upperWord(token) === 'DEFAULT' || isPunct(token, '='))
|
|
423
|
+
return (end === -1 ? argument : argument.slice(0, end)).map((token) => (token.kind === 'punct' ? token.char : (nameText(token) ?? ''))).join(' ')
|
|
424
|
+
})
|
|
425
|
+
.join(', ')
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A routine may not take the name of a `pg_catalog` function or of a keyword: Postgres resolves a call across every
|
|
430
|
+
* schema on the path by argument types, so declaring `query_to_xml()` would admit a call of the built-in.
|
|
431
|
+
*/
|
|
432
|
+
const shadows = (name: string): boolean => PG_CATALOG_FUNCTIONS.has(name) || SQL_KEYWORDS.has(name.toLowerCase())
|
|
433
|
+
|
|
434
|
+
function refuseShadowing(name: string, kind: RoutineKind, line: number): void {
|
|
435
|
+
if (shadows(name)) {
|
|
436
|
+
throw new Refused(`a ${kind} named ${name}, which a pg_catalog function or a keyword already names`, line)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** `CREATE [OR REPLACE] [CONSTRAINT] TRIGGER name … ON table … [WHEN (…)] EXECUTE FUNCTION fn(…)`. */
|
|
441
|
+
function createTrigger(tokens: readonly SqlToken[], at: number, context: Context, out: Collected): void {
|
|
442
|
+
const line = tokens[0]?.line ?? 1
|
|
443
|
+
const name = nameText(tokens[at])
|
|
444
|
+
const on = wordAtDepthZero(tokens, 'ON', at + 1)
|
|
445
|
+
if (name === undefined || on === undefined) throw new Refused('CREATE TRIGGER', line)
|
|
446
|
+
const table = ownName(tokens, on + 1, context, 'CREATE TRIGGER')
|
|
447
|
+
const when = wordAtDepthZero(tokens, 'WHEN', on)
|
|
448
|
+
const whenClose = when === undefined || !isPunct(tokens[when + 1], '(') ? undefined : closingParenthesis(tokens, when + 1)
|
|
449
|
+
const header = when === undefined || whenClose === undefined ? tokens : [...tokens.slice(0, when), ...tokens.slice(whenClose + 1)]
|
|
450
|
+
refuseForeignDdl(header, context, 'CREATE TRIGGER')
|
|
451
|
+
if (when !== undefined && whenClose !== undefined) checkQuery(tokens.slice(when + 2, whenClose), context, 'a trigger condition')
|
|
452
|
+
const execute = wordAtDepthZero(tokens, 'EXECUTE', on)
|
|
453
|
+
const fn = execute === undefined ? undefined : objectNameAt(tokens, execute + 2)
|
|
454
|
+
if (execute === undefined || fn === undefined) throw new Refused('CREATE TRIGGER without EXECUTE FUNCTION', line)
|
|
455
|
+
const use = functionUse(
|
|
456
|
+
{ qualifier: fn.qualifier, name: fn.name, parts: fn.qualifier === undefined ? 1 : 2, role: 'call', line },
|
|
457
|
+
context.schema,
|
|
458
|
+
context.functions,
|
|
459
|
+
)
|
|
460
|
+
if (use === undefined) throw new Refused(`the trigger function ${fn.name}`, line)
|
|
461
|
+
declareObject(out, 'trigger', `${table}.${name}`, line)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The tokens of a view's query without a trailing `WITH [CASCADED | LOCAL] CHECK OPTION` or `WITH [NO] DATA`. */
|
|
465
|
+
function viewQuery(tokens: readonly SqlToken[]): readonly SqlToken[] {
|
|
466
|
+
for (let index = tokens.length - 1; index >= 0; index--) {
|
|
467
|
+
if (upperWord(tokens[index]) !== 'WITH') continue
|
|
468
|
+
const rest = tokens.slice(index + 1).map((token) => upperWord(token) ?? '')
|
|
469
|
+
const option = rest[rest.length - 1] === 'OPTION' && rest[rest.length - 2] === 'CHECK' && rest.length <= 3
|
|
470
|
+
const data = rest[rest.length - 1] === 'DATA' && rest.length <= 2
|
|
471
|
+
if (option || data) return tokens.slice(0, index)
|
|
472
|
+
}
|
|
473
|
+
return tokens
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** `CREATE [OR REPLACE] [RECURSIVE | MATERIALIZED] VIEW [IF NOT EXISTS] name … AS <query>`. */
|
|
477
|
+
function createView(tokens: readonly SqlToken[], at: number, kind: 'view' | 'materialized view', context: Context, out: Collected): void {
|
|
478
|
+
const line = tokens[0]?.line ?? 1
|
|
479
|
+
const label = kind === 'view' ? 'CREATE VIEW' : 'CREATE MATERIALIZED VIEW'
|
|
480
|
+
const named = skipWords(tokens, at, 'IF', 'NOT', 'EXISTS')
|
|
481
|
+
const name = ownName(tokens, named, context, label)
|
|
482
|
+
const as = wordAtDepthZero(tokens, 'AS', named)
|
|
483
|
+
if (as === undefined) throw new Refused(`${label} without a query`, line)
|
|
484
|
+
const header = tokens.slice(0, as)
|
|
485
|
+
const tablespace = header.find((token) => upperWord(token) === 'TABLESPACE')
|
|
486
|
+
if (tablespace !== undefined) throw new Refused(`${label} … TABLESPACE`, tablespace.line)
|
|
487
|
+
refuseForeignDdl(header, context, label)
|
|
488
|
+
const query = viewQuery(tokens.slice(as + 1))
|
|
489
|
+
const first = upperWord(query[0])
|
|
490
|
+
if (!(first === 'SELECT' || first === 'WITH' || first === 'VALUES' || isPunct(query[0], '('))) {
|
|
491
|
+
throw new Refused(`${label} … AS ${statementKind(query)}`, line)
|
|
492
|
+
}
|
|
493
|
+
checkQuery(query, context, 'a view')
|
|
494
|
+
declareObject(out, kind, name, line)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** `CREATE [OR REPLACE] RULE name AS ON event TO table [WHERE …] DO [ALSO | INSTEAD] action`. */
|
|
498
|
+
function createRule(tokens: readonly SqlToken[], at: number, context: Context, out: Collected): void {
|
|
499
|
+
const line = tokens[0]?.line ?? 1
|
|
500
|
+
const name = nameText(tokens[at])
|
|
501
|
+
const to = wordAtDepthZero(tokens, 'TO', at)
|
|
502
|
+
const doIndex = to === undefined ? undefined : wordAtDepthZero(tokens, 'DO', to)
|
|
503
|
+
if (name === undefined || to === undefined || doIndex === undefined) throw new Refused('CREATE RULE', line)
|
|
504
|
+
const table = ownName(tokens, to + 1, context, 'CREATE RULE')
|
|
505
|
+
const where = wordAtDepthZero(tokens, 'WHERE', to)
|
|
506
|
+
const headerEnd = where !== undefined && where < doIndex ? where : doIndex
|
|
507
|
+
refuseForeignDdl(tokens.slice(0, headerEnd), context, 'CREATE RULE')
|
|
508
|
+
let action = skipWords(tokens, skipWords(tokens, doIndex + 1, 'ALSO'), 'INSTEAD')
|
|
509
|
+
if (isPunct(tokens[action], '(')) action++
|
|
510
|
+
const first = upperWord(tokens[action])
|
|
511
|
+
if (first === undefined || !RULE_ACTIONS.has(first)) throw new Refused(`${first ?? statementKind(tokens.slice(action))} in a rule action`, line)
|
|
512
|
+
checkQuery(tokens.slice(headerEnd), context, 'a rule')
|
|
513
|
+
declareObject(out, 'rule', `${table}.${name}`, line)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** `CREATE POLICY name ON table … [USING (…)] [WITH CHECK (…)]`. */
|
|
517
|
+
function createPolicy(tokens: readonly SqlToken[], at: number, context: Context, out: Collected): void {
|
|
518
|
+
const line = tokens[0]?.line ?? 1
|
|
519
|
+
const name = nameText(tokens[at])
|
|
520
|
+
if (name === undefined || upperWord(tokens[at + 1]) !== 'ON') throw new Refused('CREATE POLICY', line)
|
|
521
|
+
const table = ownName(tokens, at + 2, context, 'CREATE POLICY')
|
|
522
|
+
const using = wordAtDepthZero(tokens, 'USING', at)
|
|
523
|
+
const withCheck = wordAtDepthZero(tokens, 'WITH', at)
|
|
524
|
+
const starts = [using, withCheck].filter((index) => index !== undefined)
|
|
525
|
+
const expressions = starts.length === 0 ? tokens.length : Math.min(...starts)
|
|
526
|
+
refuseForeignDdl(tokens.slice(0, expressions), context, 'CREATE POLICY')
|
|
527
|
+
checkQuery(tokens.slice(expressions), context, 'a policy')
|
|
528
|
+
declareObject(out, 'policy', `${table}.${name}`, line)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** `DO [LANGUAGE l] <body>`: runs once, inside the migration's transaction. */
|
|
532
|
+
function classifyDo(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
533
|
+
const line = tokens[0]?.line ?? 1
|
|
534
|
+
let language = 'plpgsql'
|
|
535
|
+
let literal: SqlToken | undefined
|
|
536
|
+
for (let index = 1; index < tokens.length; index++) {
|
|
537
|
+
if (upperWord(tokens[index]) === 'LANGUAGE') {
|
|
538
|
+
const named = nameText(tokens[index + 1])
|
|
539
|
+
if (named === undefined) throw new Refused('a LANGUAGE written as a string', line)
|
|
540
|
+
language = named
|
|
541
|
+
index++
|
|
542
|
+
} else if (literal === undefined) literal = tokens[index]
|
|
543
|
+
else throw new Refused('DO with more than a body and a LANGUAGE', line)
|
|
544
|
+
}
|
|
545
|
+
const body = literalBody(literal)
|
|
546
|
+
if (body === undefined) throw new Refused('a DO body the classifier cannot read; write it dollar-quoted', line)
|
|
547
|
+
checkLanguage(language, line)
|
|
548
|
+
checkBody(body, context)
|
|
549
|
+
context.doBlocks++
|
|
550
|
+
deviate(out, 'do', `block ${context.doBlocks}`, line)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** One column definition or table constraint of `CREATE TABLE` or `ALTER TABLE … ADD`. */
|
|
554
|
+
function classifyElement(element: readonly SqlToken[], table: string, context: Context, out: Collected): void {
|
|
555
|
+
const head = upperWord(element[0])
|
|
556
|
+
if (head !== undefined && TABLE_CONSTRAINTS.has(head)) {
|
|
557
|
+
const where = head === 'CONSTRAINT' ? `${table}.${nameText(element[1]) ?? ''}` : table
|
|
558
|
+
checkExpression(element, where, context, out)
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const column = nameText(element[0])
|
|
562
|
+
if (column === undefined) return
|
|
563
|
+
const type = nameText(element[1])
|
|
564
|
+
if (type !== undefined && SERIAL_TYPES.has(type)) throw new Refused(`a ${type} column; use GENERATED ALWAYS AS IDENTITY`, element[0]?.line ?? 1)
|
|
565
|
+
checkExpression(element.slice(1), `${table}.${column}`, context, out)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** `CREATE TABLE [IF NOT EXISTS] <name> (<columns and constraints>)` and nothing after it; `LIKE` without `INCLUDING`. */
|
|
569
|
+
function createTable(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
570
|
+
const line = tokens[0]?.line ?? 1
|
|
571
|
+
const nameAt = skipWords(tokens, 2, 'IF', 'NOT', 'EXISTS')
|
|
572
|
+
const named = afterName(tokens, nameAt)
|
|
573
|
+
const close = named === undefined || !isPunct(tokens[named], '(') ? undefined : closingParenthesis(tokens, named)
|
|
574
|
+
if (named === undefined || close === undefined) throw new Refused('CREATE TABLE other than a column list', line)
|
|
575
|
+
if (close !== tokens.length - 1) {
|
|
576
|
+
const after = tokens[close + 1]
|
|
577
|
+
throw new Refused(`CREATE TABLE … ${upperWord(after) ?? statementKind(tokens.slice(close + 1))}`, line)
|
|
578
|
+
}
|
|
579
|
+
refuseForeignDdl(tokens, context, 'CREATE TABLE')
|
|
580
|
+
const table = ownName(tokens, nameAt, context, 'CREATE TABLE')
|
|
581
|
+
for (const element of splitAtCommas(tokens.slice(named + 1, close))) {
|
|
582
|
+
// `LIKE … INCLUDING` copies defaults, constraints and indexes from a table the classifier has not read.
|
|
583
|
+
if (upperWord(element[0]) === 'LIKE' && element.some((token) => upperWord(token) === 'INCLUDING')) {
|
|
584
|
+
throw new Refused('CREATE TABLE … LIKE … INCLUDING', line)
|
|
585
|
+
}
|
|
586
|
+
classifyElement(element, table, context, out)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [name] ON [ONLY] table [USING method] (…) …`. */
|
|
591
|
+
function createIndex(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
592
|
+
const line = tokens[0]?.line ?? 1
|
|
593
|
+
refuseForeignDdl(tokens, context, 'CREATE INDEX')
|
|
594
|
+
let index = skipWords(tokens, skipWords(tokens, skipWords(tokens, 1, 'UNIQUE'), 'INDEX'), 'CONCURRENTLY')
|
|
595
|
+
index = skipWords(tokens, index, 'IF', 'NOT', 'EXISTS')
|
|
596
|
+
let name: string | undefined
|
|
597
|
+
if (upperWord(tokens[index]) !== 'ON') {
|
|
598
|
+
name = nameText(tokens[index])
|
|
599
|
+
index++
|
|
600
|
+
}
|
|
601
|
+
if (upperWord(tokens[index]) !== 'ON') throw new Refused('CREATE INDEX', line)
|
|
602
|
+
index = skipWords(tokens, index + 1, 'ONLY')
|
|
603
|
+
const table = ownName(tokens, index, context, 'CREATE INDEX')
|
|
604
|
+
index = afterName(tokens, index) ?? index
|
|
605
|
+
if (upperWord(tokens[index]) === 'USING') index += 2
|
|
606
|
+
checkExpression(tokens.slice(index), name ?? table, context, out)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Why one `ALTER TABLE` clause is outside the allowlist; its expressions are classified as a column's or a table's. */
|
|
610
|
+
function alterClause(clause: readonly SqlToken[], table: string, context: Context, out: Collected): void {
|
|
611
|
+
const line = clause[0]?.line ?? 1
|
|
612
|
+
const [action, next] = [upperWord(clause[0]), upperWord(clause[1])]
|
|
613
|
+
if (action === 'ADD') {
|
|
614
|
+
if (next !== undefined && TABLE_CONSTRAINTS.has(next)) {
|
|
615
|
+
classifyElement(clause.slice(1), table, context, out)
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
classifyElement(clause.slice(skipWords(clause, skipWords(clause, 1, 'COLUMN'), 'IF', 'NOT', 'EXISTS')), table, context, out)
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
if (action === 'DROP') return
|
|
622
|
+
if (action === 'VALIDATE' && next === 'CONSTRAINT') return
|
|
623
|
+
if (action === 'ALTER' && next !== 'CONSTRAINT') {
|
|
624
|
+
const columnAt = next === 'COLUMN' ? 2 : 1
|
|
625
|
+
const column = nameText(clause[columnAt])
|
|
626
|
+
const actionAt = afterName(clause, columnAt)
|
|
627
|
+
const columnAction = actionAt === undefined
|
|
628
|
+
? undefined
|
|
629
|
+
: COLUMN_ACTIONS.find((words) => words.every((word, offset) => upperWord(clause[actionAt + offset]) === word))
|
|
630
|
+
if (actionAt === undefined || column === undefined || columnAction === undefined) {
|
|
631
|
+
throw new Refused(`ALTER TABLE … ALTER COLUMN … ${upperWord(clause[actionAt ?? 2]) ?? ''}`.trimEnd(), line)
|
|
632
|
+
}
|
|
633
|
+
const rest = clause.slice(actionAt + columnAction.length)
|
|
634
|
+
// A default persists in the schema; `TYPE … USING` runs once over the rows, as a backfill does.
|
|
635
|
+
if (columnAction[1] === 'DEFAULT' && columnAction[0] === 'SET') checkExpression(rest, `${table}.${column}`, context, out)
|
|
636
|
+
else if (columnAction[0] === 'TYPE' || columnAction[1] === 'DATA') checkQuery(rest, context, 'ALTER TABLE … USING')
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
throw new Refused(`ALTER TABLE … ${action ?? 'an action'}`, line)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function alterTable(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
643
|
+
const line = tokens[0]?.line ?? 1
|
|
644
|
+
if (upperWord(tokens[2]) === 'ALL' && upperWord(tokens[3]) === 'IN') throw new Refused('ALTER TABLE ALL IN TABLESPACE', line)
|
|
645
|
+
refuseForeignDdl(tokens, context, 'ALTER TABLE')
|
|
646
|
+
const start = skipWords(tokens, skipWords(tokens, 2, 'IF', 'EXISTS'), 'ONLY')
|
|
647
|
+
const named = afterName(tokens, start)
|
|
648
|
+
if (named === undefined) throw new Refused('ALTER TABLE', line)
|
|
649
|
+
const table = ownName(tokens, start, context, 'ALTER TABLE')
|
|
650
|
+
const rest = tokens.slice(isPunct(tokens[named], '*') ? named + 1 : named)
|
|
651
|
+
// RENAME TO, RENAME [COLUMN] a TO b and RENAME CONSTRAINT a TO b each stand alone.
|
|
652
|
+
if (upperWord(rest[0]) === 'RENAME') {
|
|
653
|
+
const to = upperWord(rest[1]) === 'TO' ? nameText(rest[2]) : undefined
|
|
654
|
+
if (to !== undefined) out.changes.push({ change: 'rename table', from: table, to })
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
for (const clause of splitAtCommas(rest)) alterClause(clause, table, context, out)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function alterIndexOrType(tokens: readonly SqlToken[], kind: string, context: Context): void {
|
|
661
|
+
const line = tokens[0]?.line ?? 1
|
|
662
|
+
if (kind === 'INDEX') {
|
|
663
|
+
const named = afterName(tokens, skipWords(tokens, 2, 'IF', 'EXISTS'))
|
|
664
|
+
const isRename = named !== undefined && upperWord(tokens[named]) === 'RENAME' && upperWord(tokens[named + 1]) === 'TO'
|
|
665
|
+
if (!isRename) throw new Refused('ALTER INDEX other than RENAME TO', line)
|
|
666
|
+
} else {
|
|
667
|
+
const named = afterName(tokens, 2)
|
|
668
|
+
const action = named === undefined ? undefined : upperWord(tokens[named])
|
|
669
|
+
const isValue = named !== undefined && (action === 'ADD' || action === 'RENAME') && upperWord(tokens[named + 1]) === 'VALUE'
|
|
670
|
+
if (!isValue) throw new Refused('ALTER TYPE other than ADD VALUE or RENAME VALUE', line)
|
|
671
|
+
}
|
|
672
|
+
refuseForeignDdl(tokens, context, `ALTER ${kind}`)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** The kinds of database code `ALTER … RENAME TO` may rename, and what each names in the manifest. */
|
|
676
|
+
const RENAMEABLE: Readonly<Record<string, readonly DatabaseObjectKind[]>> = {
|
|
677
|
+
FUNCTION: ['function'],
|
|
678
|
+
PROCEDURE: ['procedure'],
|
|
679
|
+
ROUTINE: ['function', 'procedure'],
|
|
680
|
+
VIEW: ['view'],
|
|
681
|
+
TRIGGER: ['trigger'],
|
|
682
|
+
RULE: ['rule'],
|
|
683
|
+
POLICY: ['policy'],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* `ALTER FUNCTION|PROCEDURE|ROUTINE name [(args)] RENAME TO new`, `ALTER [MATERIALIZED] VIEW [IF EXISTS] name
|
|
688
|
+
* RENAME TO new` and `ALTER TRIGGER|RULE|POLICY name ON table RENAME TO new`: the renamed object is a deviation under
|
|
689
|
+
* its new name, and the manifest follows it. Any other `ALTER` of database code is refused.
|
|
690
|
+
*/
|
|
691
|
+
function alterRename(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
692
|
+
const line = tokens[0]?.line ?? 1
|
|
693
|
+
const materialized = upperWord(tokens[1]) === 'MATERIALIZED' && upperWord(tokens[2]) === 'VIEW'
|
|
694
|
+
const kind = materialized ? 'VIEW' : (upperWord(tokens[1]) ?? '')
|
|
695
|
+
const kinds: readonly DatabaseObjectKind[] = materialized ? ['materialized view'] : Object.hasOwn(RENAMEABLE, kind) ? (RENAMEABLE[kind] ?? []) : []
|
|
696
|
+
const label = statementKind(tokens)
|
|
697
|
+
const at = skipWords(tokens, materialized ? 3 : 2, 'IF', 'EXISTS')
|
|
698
|
+
const onTable = kind === 'TRIGGER' || kind === 'RULE' || kind === 'POLICY'
|
|
699
|
+
let name: string
|
|
700
|
+
let after: number
|
|
701
|
+
if (onTable) {
|
|
702
|
+
const own = nameText(tokens[at])
|
|
703
|
+
if (own === undefined || upperWord(tokens[at + 1]) !== 'ON') throw new Refused(label, line)
|
|
704
|
+
name = `${ownName(tokens, at + 2, context, label)}.${own}`
|
|
705
|
+
after = afterName(tokens, at + 2) ?? tokens.length
|
|
706
|
+
} else {
|
|
707
|
+
name = ownName(tokens, at, context, label)
|
|
708
|
+
after = afterName(tokens, at) ?? tokens.length
|
|
709
|
+
if (isPunct(tokens[after], '(')) after = (closingParenthesis(tokens, after) ?? tokens.length - 1) + 1
|
|
710
|
+
}
|
|
711
|
+
const target = nameText(tokens[after + 2])
|
|
712
|
+
if (
|
|
713
|
+
kinds.length === 0 || upperWord(tokens[after]) !== 'RENAME' || upperWord(tokens[after + 1]) !== 'TO' || target === undefined
|
|
714
|
+
|| after + 3 !== tokens.length
|
|
715
|
+
) {
|
|
716
|
+
throw new Refused(`${label} other than RENAME TO`, line)
|
|
717
|
+
}
|
|
718
|
+
refuseForeignDdl(tokens, context, label)
|
|
719
|
+
if (onTable) {
|
|
720
|
+
const renamed = `${name.slice(0, name.indexOf('.'))}.${target}`
|
|
721
|
+
for (const objectKind of kinds) out.changes.push({ change: 'rename', from: { kind: objectKind, name }, to: { kind: objectKind, name: renamed } })
|
|
722
|
+
deviate(out, kinds[0] ?? 'trigger', renamed, line)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
for (const objectKind of kinds) {
|
|
726
|
+
if (objectKind === 'function' || objectKind === 'procedure') refuseShadowing(target, objectKind, line)
|
|
727
|
+
out.changes.push({ change: 'rename', from: { kind: objectKind, name }, to: { kind: objectKind, name: target } })
|
|
728
|
+
}
|
|
729
|
+
deviate(out, kinds[0] ?? 'view', target, line)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The objects a `DROP` removes from the manifest. */
|
|
733
|
+
function dropChanges(tokens: readonly SqlToken[], kind: string, context: Context): ManifestChange[] {
|
|
734
|
+
const objectKinds: Readonly<Record<string, readonly DatabaseObjectKind[]>> = {
|
|
735
|
+
FUNCTION: ['function'],
|
|
736
|
+
PROCEDURE: ['procedure'],
|
|
737
|
+
ROUTINE: ['function', 'procedure'],
|
|
738
|
+
VIEW: ['view'],
|
|
739
|
+
}
|
|
740
|
+
const materialized = kind === 'MATERIALIZED'
|
|
741
|
+
const names = skipWords(tokens, materialized ? 3 : 2, 'IF', 'EXISTS')
|
|
742
|
+
if (kind === 'TRIGGER' || kind === 'RULE' || kind === 'POLICY') {
|
|
743
|
+
const name = nameText(tokens[names])
|
|
744
|
+
const table = objectNameAt(tokens, names + 2)
|
|
745
|
+
if (name === undefined || table === undefined || upperWord(tokens[names + 1]) !== 'ON') return []
|
|
746
|
+
const objectKind: DatabaseObjectKind = kind === 'TRIGGER' ? 'trigger' : kind === 'RULE' ? 'rule' : 'policy'
|
|
747
|
+
return [{ change: 'drop', object: { kind: objectKind, name: `${table.name}.${name}` } }]
|
|
748
|
+
}
|
|
749
|
+
const kinds: readonly DatabaseObjectKind[] = materialized ? ['materialized view'] : Object.hasOwn(objectKinds, kind) ? (objectKinds[kind] ?? []) : []
|
|
750
|
+
const changes: ManifestChange[] = []
|
|
751
|
+
for (const part of splitAtCommas(tokens.slice(names))) {
|
|
752
|
+
const object = objectNameAt(part, 0)
|
|
753
|
+
if (object === undefined || (object.qualifier !== undefined && object.qualifier !== context.schema)) continue
|
|
754
|
+
if (kind === 'TABLE') changes.push({ change: 'drop table', table: object.name })
|
|
755
|
+
for (const objectKind of kinds) changes.push({ change: 'drop', object: { kind: objectKind, name: object.name } })
|
|
756
|
+
}
|
|
757
|
+
return changes
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function classifyDrop(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
761
|
+
const line = tokens[0]?.line ?? 1
|
|
762
|
+
const kind = upperWord(tokens[1])
|
|
763
|
+
if (kind === undefined || !DROPPABLE.has(kind)) throw new Refused(statementKind(tokens), line)
|
|
764
|
+
// An extension may live in another schema; CASCADE would drop what other schemas built on it.
|
|
765
|
+
if (kind === 'EXTENSION' && tokens.some((token) => upperWord(token) === 'CASCADE')) throw new Refused('DROP EXTENSION … CASCADE', line)
|
|
766
|
+
refuseForeignDdl(tokens, context, statementKind(tokens))
|
|
767
|
+
out.changes.push(...dropChanges(tokens, kind, context))
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function classifyCreate(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
771
|
+
const line = tokens[0]?.line ?? 1
|
|
772
|
+
const at = skipWords(tokens, 1, 'OR', 'REPLACE')
|
|
773
|
+
const replace = at > 1
|
|
774
|
+
const kind = upperWord(tokens[at])
|
|
775
|
+
const next = upperWord(tokens[at + 1])
|
|
776
|
+
if (!replace && kind === 'TABLE') return createTable(tokens, context, out)
|
|
777
|
+
if (!replace && (kind === 'INDEX' || (kind === 'UNIQUE' && next === 'INDEX'))) return createIndex(tokens, context, out)
|
|
778
|
+
if (!replace && kind === 'TYPE') {
|
|
779
|
+
const named = afterName(tokens, 2)
|
|
780
|
+
const isEnum = named !== undefined && upperWord(tokens[named]) === 'AS' && upperWord(tokens[named + 1]) === 'ENUM'
|
|
781
|
+
if (!isEnum) throw new Refused('CREATE TYPE other than AS ENUM', line)
|
|
782
|
+
return refuseForeignDdl(tokens, context, 'CREATE TYPE')
|
|
783
|
+
}
|
|
784
|
+
if (kind === 'FUNCTION') return createFunction(tokens, at + 1, 'function', context, out)
|
|
785
|
+
if (kind === 'PROCEDURE') return createFunction(tokens, at + 1, 'procedure', context, out)
|
|
786
|
+
if (kind === 'TRIGGER') return createTrigger(tokens, at + 1, context, out)
|
|
787
|
+
if (kind === 'CONSTRAINT' && next === 'TRIGGER') return createTrigger(tokens, at + 2, context, out)
|
|
788
|
+
if (kind === 'VIEW') return createView(tokens, at + 1, 'view', context, out)
|
|
789
|
+
if (kind === 'RECURSIVE' && next === 'VIEW') return createView(tokens, at + 2, 'view', context, out)
|
|
790
|
+
if (!replace && kind === 'MATERIALIZED' && next === 'VIEW') return createView(tokens, at + 2, 'materialized view', context, out)
|
|
791
|
+
if (kind === 'RULE') return createRule(tokens, at + 1, context, out)
|
|
792
|
+
if (!replace && kind === 'POLICY') return createPolicy(tokens, at + 1, context, out)
|
|
793
|
+
if (!replace && kind === 'SEQUENCE') throw new Refused('CREATE SEQUENCE; use GENERATED ALWAYS AS IDENTITY', line)
|
|
794
|
+
if (kind === 'EVENT') throw new Refused('CREATE EVENT TRIGGER', line)
|
|
795
|
+
throw new Refused(statementKind(tokens), line)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** The targets `COMMENT ON` may name, all objects of the application's schema; `ON` marks one named on a table. */
|
|
799
|
+
const COMMENT_TARGETS: Readonly<Record<string, 'name' | 'routine' | 'column' | 'on'>> = {
|
|
800
|
+
TABLE: 'name',
|
|
801
|
+
VIEW: 'name',
|
|
802
|
+
INDEX: 'name',
|
|
803
|
+
COLUMN: 'column',
|
|
804
|
+
FUNCTION: 'routine',
|
|
805
|
+
PROCEDURE: 'routine',
|
|
806
|
+
ROUTINE: 'routine',
|
|
807
|
+
CONSTRAINT: 'on',
|
|
808
|
+
TRIGGER: 'on',
|
|
809
|
+
POLICY: 'on',
|
|
810
|
+
RULE: 'on',
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* `COMMENT ON <target> IS '…' | NULL` for an object of the application's schema. A name that resolves to the catalog
|
|
815
|
+
* (`pg_…`, a `pg_catalog` function) and a global target (a role, a database) are refused.
|
|
816
|
+
*/
|
|
817
|
+
function classifyComment(tokens: readonly SqlToken[], context: Context): void {
|
|
818
|
+
const line = tokens[0]?.line ?? 1
|
|
819
|
+
const materialized = upperWord(tokens[2]) === 'MATERIALIZED' && upperWord(tokens[3]) === 'VIEW'
|
|
820
|
+
const target = materialized ? 'VIEW' : (upperWord(tokens[2]) ?? '')
|
|
821
|
+
const form = Object.hasOwn(COMMENT_TARGETS, target) ? COMMENT_TARGETS[target] : undefined
|
|
822
|
+
if (form === undefined) throw new Refused(`COMMENT ON ${statementKind(tokens.slice(2))}`, line)
|
|
823
|
+
let at = materialized ? 4 : 3
|
|
824
|
+
const names: string[] = []
|
|
825
|
+
if (form === 'on') {
|
|
826
|
+
const name = nameText(tokens[at])
|
|
827
|
+
if (name === undefined || upperWord(tokens[at + 1]) !== 'ON') throw new Refused(`COMMENT ON ${target}`, line)
|
|
828
|
+
names.push(ownName(tokens, at + 2, context, `COMMENT ON ${target}`))
|
|
829
|
+
at = afterName(tokens, at + 2) ?? tokens.length
|
|
830
|
+
} else if (form === 'column') {
|
|
831
|
+
const parts: string[] = []
|
|
832
|
+
while (isName(tokens[at])) {
|
|
833
|
+
parts.push(nameText(tokens[at]) ?? '')
|
|
834
|
+
if (!isPunct(tokens[at + 1], '.')) break
|
|
835
|
+
at += 2
|
|
836
|
+
}
|
|
837
|
+
at++
|
|
838
|
+
if (parts.length === 3 && parts[0] !== context.schema) throw new Refused(`COMMENT ON COLUMN naming the schema ${parts[0]}`, line)
|
|
839
|
+
if (parts.length < 2 || parts.length > 3) throw new Refused('COMMENT ON COLUMN', line)
|
|
840
|
+
names.push(parts[parts.length - 2] ?? '')
|
|
841
|
+
} else {
|
|
842
|
+
const name = ownName(tokens, at, context, `COMMENT ON ${target}`)
|
|
843
|
+
if (form === 'routine' && PG_CATALOG_FUNCTIONS.has(name)) throw new Refused(`COMMENT ON a pg_catalog function ${name}`, line)
|
|
844
|
+
names.push(name)
|
|
845
|
+
at = afterName(tokens, at) ?? tokens.length
|
|
846
|
+
if (form === 'routine' && isPunct(tokens[at], '(')) at = (closingParenthesis(tokens, at) ?? tokens.length - 1) + 1
|
|
847
|
+
}
|
|
848
|
+
const catalog = names.find((name) => name.startsWith('pg_'))
|
|
849
|
+
if (catalog !== undefined) throw new Refused(`COMMENT ON ${catalog}, which resolves to pg_catalog`, line)
|
|
850
|
+
const value = tokens[at + 1]
|
|
851
|
+
const isText = (value?.kind === 'string' || value?.kind === 'dollar' || upperWord(value) === 'NULL') && at + 2 === tokens.length
|
|
852
|
+
if (upperWord(tokens[at]) !== 'IS' || !isText) throw new Refused(`COMMENT ON ${target} other than IS '…'`, line)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The framework names a statement may use: a file collection's table (`fabrika_files_<name>`, `fileTableSql`) is
|
|
857
|
+
* created by the application's own migration, with its status check and its indexes, and `fileScanSql` replaces an
|
|
858
|
+
* earlier status check. Every other framework name is refused.
|
|
859
|
+
*/
|
|
860
|
+
function fileTableNames(tokens: readonly SqlToken[]): ReadonlySet<string> {
|
|
861
|
+
const [first, second] = [upperWord(tokens[0]), upperWord(tokens[1])]
|
|
862
|
+
if (first === 'ALTER' && second === 'TABLE') return fileScanNames(tokens)
|
|
863
|
+
if (first !== 'CREATE') return new Set()
|
|
864
|
+
if (second === 'TABLE') {
|
|
865
|
+
const table = nameText(tokens[skipWords(tokens, 2, 'IF', 'NOT', 'EXISTS')])
|
|
866
|
+
return table?.startsWith(FILE_TABLE_PREFIX) === true ? new Set([table, `${table}_status`]) : new Set()
|
|
867
|
+
}
|
|
868
|
+
if (second !== 'INDEX' && !(second === 'UNIQUE' && upperWord(tokens[2]) === 'INDEX')) return new Set()
|
|
869
|
+
const on = wordAtDepthZero(tokens, 'ON')
|
|
870
|
+
if (on === undefined) return new Set()
|
|
871
|
+
const table = nameText(tokens[skipWords(tokens, on + 1, 'ONLY')])
|
|
872
|
+
if (table?.startsWith(FILE_TABLE_PREFIX) !== true) return new Set()
|
|
873
|
+
const index = nameText(tokens[on - 1])
|
|
874
|
+
return index?.startsWith(`${table}_`) === true ? new Set([table, index]) : new Set([table])
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* The names of `@fabrika/db`'s `fileScanSql` statement, which replaces the status check of a file table an earlier
|
|
879
|
+
* `fileTableSql` created and adds the scan columns: that exact statement, and no other `ALTER` of a file table.
|
|
880
|
+
*/
|
|
881
|
+
function fileScanNames(tokens: readonly SqlToken[]): ReadonlySet<string> {
|
|
882
|
+
const table = fileScanAlterTable(tokens)
|
|
883
|
+
return table === undefined ? new Set() : new Set([table, legacyStatusCheckName(table), `${table}_status`])
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function classifyStatement(tokens: readonly SqlToken[], context: Context, out: Collected): void {
|
|
887
|
+
const line = tokens[0]?.line ?? 1
|
|
888
|
+
const unicode = unicodeIdentifier(tokens)
|
|
889
|
+
if (unicode !== undefined) throw new Refused('a Unicode-escaped identifier (U&"…")', unicode.line)
|
|
890
|
+
const framework = context.framework ? undefined : frameworkReference(tokens, fileTableNames(tokens))
|
|
891
|
+
if (framework !== undefined) throw new Refused(`a reference to the framework's ${framework.name}`, framework.line)
|
|
892
|
+
const first = upperWord(tokens[0])
|
|
893
|
+
if (first === 'INSERT' || first === 'UPDATE' || first === 'DELETE') return checkQuery(tokens, context, first)
|
|
894
|
+
if (first === 'COMMENT' && upperWord(tokens[1]) === 'ON') return classifyComment(tokens, context)
|
|
895
|
+
if (first === 'DO') return classifyDo(tokens, context, out)
|
|
896
|
+
if (first === 'CREATE') return classifyCreate(tokens, context, out)
|
|
897
|
+
if (first === 'DROP') return classifyDrop(tokens, context, out)
|
|
898
|
+
if (first === 'TRUNCATE') return refuseForeignDdl(tokens, context, 'TRUNCATE')
|
|
899
|
+
const second = upperWord(tokens[1])
|
|
900
|
+
if (first === 'ALTER' && second === 'TABLE') return alterTable(tokens, context, out)
|
|
901
|
+
if (first === 'ALTER' && (second === 'INDEX' || second === 'TYPE')) return alterIndexOrType(tokens, second, context)
|
|
902
|
+
if (first === 'ALTER' && second !== undefined && (Object.hasOwn(RENAMEABLE, second) || second === 'MATERIALIZED')) {
|
|
903
|
+
return alterRename(tokens, context, out)
|
|
904
|
+
}
|
|
905
|
+
throw new Refused(statementKind(tokens), line)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Classifies one application migration. Paved: `CREATE TABLE` (a column list and nothing after it), `ALTER TABLE`
|
|
910
|
+
* column, constraint and rename clauses, `CREATE [UNIQUE] INDEX`, `ALTER INDEX … RENAME TO`, `CREATE TYPE … AS ENUM`,
|
|
911
|
+
* `ALTER TYPE … ADD|RENAME VALUE`, `DROP`, `TRUNCATE`, `COMMENT ON` and `INSERT`/`UPDATE`/`DELETE` backfills.
|
|
912
|
+
* Deviations: functions and procedures (one signature per name), triggers, views, rules, policies, their
|
|
913
|
+
* `ALTER … RENAME TO`, `DO`, and a stored expression that calls anything but a paved built-in. Refused: a schema
|
|
914
|
+
* other than `schema` (`pg_catalog` relations included), a framework `fabrika_*` name, `SECURITY DEFINER`, a function
|
|
915
|
+
* `SET` clause, a language other than `sql` and `plpgsql`, a body that is not literal or that holds dynamic `EXECUTE`
|
|
916
|
+
* or a statement outside its allowlist, a call outside the function allowlist, and every form not named here:
|
|
917
|
+
* `COPY`, `GRANT`, `REVOKE`, roles, extensions, event triggers, domains (codegen cannot bind one), `SELECT`, `WITH`
|
|
918
|
+
* and `MERGE` among them. Transaction control and
|
|
919
|
+
* `SET`/`RESET` are refused for every plan before this runs (`refusedStatement`).
|
|
920
|
+
*/
|
|
921
|
+
export function classifyMigration(sql: string, schema: string, options: ClassifyMigrationOptions): MigrationClassification {
|
|
922
|
+
const context: Context = { schema, functions: options.functions ?? new Set(), framework: options.framework === true, doBlocks: 0 }
|
|
923
|
+
const refused: MigrationRefusal[] = []
|
|
924
|
+
const deviations: MigrationDeviation[] = []
|
|
925
|
+
const changes: ManifestChange[] = []
|
|
926
|
+
const keys = new Set<string>()
|
|
927
|
+
const { unterminated } = lexSql(sql)
|
|
928
|
+
if (unterminated !== undefined) {
|
|
929
|
+
refused.push({ reason: 'a literal, a quoted name or a comment that never closes', file: options.file, line: unterminated })
|
|
930
|
+
}
|
|
931
|
+
for (const { tokens, line } of topLevelStatements(sql)) {
|
|
932
|
+
const out: Collected = { deviations: [], changes: [] }
|
|
933
|
+
try {
|
|
934
|
+
classifyStatement(tokens, context, out)
|
|
935
|
+
} catch (error) {
|
|
936
|
+
if (!(error instanceof Refused)) throw error
|
|
937
|
+
refused.push({ reason: error.reason, file: options.file, line: error.line || line })
|
|
938
|
+
continue
|
|
939
|
+
}
|
|
940
|
+
for (const deviation of out.deviations) {
|
|
941
|
+
const key = `${deviation.kind} ${deviation.object}`
|
|
942
|
+
if (keys.has(key)) continue
|
|
943
|
+
keys.add(key)
|
|
944
|
+
deviations.push({ ...deviation, file: options.file })
|
|
945
|
+
}
|
|
946
|
+
changes.push(...out.changes)
|
|
947
|
+
}
|
|
948
|
+
const objects = changes.flatMap((change) => (change.change === 'create' || change.change === 'create routine' ? [change.object] : []))
|
|
949
|
+
return { file: options.file, refused, deviations, objects, changes }
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** The names of the functions and procedures a migration creates or renames to in `schema`, read without classifying them. */
|
|
953
|
+
export function declaredFunctions(sql: string, schema: string): string[] {
|
|
954
|
+
const names: string[] = []
|
|
955
|
+
for (const { tokens } of topLevelStatements(sql)) {
|
|
956
|
+
const first = upperWord(tokens[0])
|
|
957
|
+
const at = first === 'CREATE' ? skipWords(tokens, 1, 'OR', 'REPLACE') : 1
|
|
958
|
+
const kind = upperWord(tokens[at])
|
|
959
|
+
if ((first !== 'CREATE' && first !== 'ALTER') || (kind !== 'FUNCTION' && kind !== 'PROCEDURE' && (first === 'CREATE' || kind !== 'ROUTINE'))) {
|
|
960
|
+
continue
|
|
961
|
+
}
|
|
962
|
+
const object = objectNameAt(tokens, at + 1)
|
|
963
|
+
if (object === undefined || (object.qualifier !== undefined && object.qualifier !== schema)) continue
|
|
964
|
+
if (first === 'CREATE') {
|
|
965
|
+
names.push(object.name)
|
|
966
|
+
continue
|
|
967
|
+
}
|
|
968
|
+
const rename = tokens.findIndex((token, index) => upperWord(token) === 'RENAME' && upperWord(tokens[index + 1]) === 'TO')
|
|
969
|
+
const target = rename === -1 ? undefined : nameText(tokens[rename + 2])
|
|
970
|
+
if (target !== undefined) names.push(target)
|
|
971
|
+
}
|
|
972
|
+
// A name a built-in or a keyword already holds is refused where it is declared, and never admits a call.
|
|
973
|
+
return names.filter((name) => !shadows(name))
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const objectKey = (object: DatabaseObject): string => `${object.kind} ${object.name}`
|
|
977
|
+
const ON_TABLE: ReadonlySet<DatabaseObjectKind> = new Set(['trigger', 'rule', 'policy'])
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The manifest of a migration set, folded in file order: a create adds an object (a replace keeps it), a drop removes
|
|
981
|
+
* it, dropping a table removes its triggers, rules and policies, and renaming a table renames them. A `CASCADE` may
|
|
982
|
+
* drop more than the manifest removes; a stale entry only admits an object that no longer exists.
|
|
983
|
+
*/
|
|
984
|
+
export function migrationManifest(classifications: readonly MigrationClassification[]): DatabaseObject[] {
|
|
985
|
+
const objects = new Map<string, DatabaseObject>()
|
|
986
|
+
const onTable = (table: string): DatabaseObject[] =>
|
|
987
|
+
[...objects.values()].filter((object) => ON_TABLE.has(object.kind) && object.name.startsWith(`${table}.`))
|
|
988
|
+
for (const { changes } of classifications) {
|
|
989
|
+
for (const change of changes) {
|
|
990
|
+
if (change.change === 'create' || change.change === 'create routine') objects.set(objectKey(change.object), change.object)
|
|
991
|
+
else if (change.change === 'drop') objects.delete(objectKey(change.object))
|
|
992
|
+
else if (change.change === 'drop table') { for (const object of onTable(change.table)) objects.delete(objectKey(object)) }
|
|
993
|
+
else if (change.change === 'rename') {
|
|
994
|
+
if (objects.delete(objectKey(change.from))) objects.set(objectKey(change.to), change.to)
|
|
995
|
+
} else {
|
|
996
|
+
for (const object of onTable(change.from)) {
|
|
997
|
+
objects.delete(objectKey(object))
|
|
998
|
+
const renamed: DatabaseObject = { kind: object.kind, name: `${change.to}${object.name.slice(change.from.length)}` }
|
|
999
|
+
objects.set(objectKey(renamed), renamed)
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return [...objects.values()]
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Classifies a whole migration set in order. A body or an expression may call a function any file of the set
|
|
1009
|
+
* declares; the manifest is what the catalog check admits.
|
|
1010
|
+
*/
|
|
1011
|
+
export function classifyMigrations(files: readonly MigrationFile[], schema: string): MigrationSetClassification {
|
|
1012
|
+
const functions = new Set(files.flatMap((file) => declaredFunctions(file.sql, schema)))
|
|
1013
|
+
const classified = files.map((file) => classifyMigration(file.sql, schema, { file: file.name, functions, framework: file.framework === true }))
|
|
1014
|
+
const withOverloads = refuseOverloads(classified)
|
|
1015
|
+
return { files: withOverloads, objects: migrationManifest(withOverloads) }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* An application routine has one signature: a create whose argument list differs from the one the name already has
|
|
1020
|
+
* is refused, so the manifest and the catalog check can name a routine by its name alone. A replace writes the same
|
|
1021
|
+
* list; a drop or a rename frees the name.
|
|
1022
|
+
*/
|
|
1023
|
+
function refuseOverloads(classified: readonly MigrationClassification[]): MigrationClassification[] {
|
|
1024
|
+
const routines = new Map<string, string>()
|
|
1025
|
+
return classified.map((file) => {
|
|
1026
|
+
const refused: MigrationRefusal[] = [...file.refused]
|
|
1027
|
+
for (const change of file.changes) {
|
|
1028
|
+
if (change.change === 'create routine') {
|
|
1029
|
+
const existing = routines.get(change.object.name)
|
|
1030
|
+
if (existing !== undefined && existing !== change.args) {
|
|
1031
|
+
refused.push({ reason: `a second signature for ${change.object.name}; an application routine has one`, file: file.file, line: change.line })
|
|
1032
|
+
}
|
|
1033
|
+
routines.set(change.object.name, change.args)
|
|
1034
|
+
} else if (change.change === 'drop' && (change.object.kind === 'function' || change.object.kind === 'procedure')) {
|
|
1035
|
+
routines.delete(change.object.name)
|
|
1036
|
+
} else if (change.change === 'rename' && (change.from.kind === 'function' || change.from.kind === 'procedure')) {
|
|
1037
|
+
const args = routines.get(change.from.name)
|
|
1038
|
+
routines.delete(change.from.name)
|
|
1039
|
+
if (args !== undefined) routines.set(change.to.name, args)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return { ...file, refused }
|
|
1043
|
+
})
|
|
1044
|
+
}
|