Skip to content

Commit 195992a

Browse files
committed
fix(db): classify only executable SQL, following literals that EXECUTE runs
1 parent 7063440 commit 195992a

2 files changed

Lines changed: 64 additions & 9 deletions

File tree

‎scripts/check-migrations-safety.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,45 @@ describe('migration safety audit', () => {
190190
expect(`${stdout}${stderr}`).toContain('sequence-owned-by-existing-table')
191191
}, 30_000)
192192

193+
it('does not read operations out of literals, comments, or dollar-quoted data', async () => {
194+
const dir = migrationsDir(
195+
[
196+
'-- Next release: ALTER TABLE "jobs" RENAME COLUMN "a" TO "b".',
197+
'COMMENT ON TABLE "jobs" IS $note$Then ALTER TABLE jobs DROP COLUMN old$note$;--> statement-breakpoint',
198+
'COMMENT ON COLUMN "jobs"."id" IS \'kept until ALTER TABLE jobs DROP COLUMN id\';',
199+
].join('\n')
200+
)
201+
const { code, stdout, stderr } = await runAudit('--dir', dir)
202+
const output = `${stdout}${stderr}`
203+
204+
expect(output).not.toContain('drop-column')
205+
expect(output).not.toContain('rename')
206+
expect(code).toBe(0)
207+
}, 30_000)
208+
209+
it('still flags a real destructive operation', async () => {
210+
const dir = migrationsDir('ALTER TABLE "jobs" DROP COLUMN "old";')
211+
const { code, stdout, stderr } = await runAudit('--dir', dir)
212+
213+
expect(code).toBe(1)
214+
expect(`${stdout}${stderr}`).toContain('drop-column')
215+
}, 30_000)
216+
217+
/** Migration 0076 renames a column this way; the literal is SQL the block runs, not data. */
218+
it.each([
219+
['rename', "DO $$ BEGIN EXECUTE 'ALTER TABLE jobs RENAME COLUMN metadata TO data'; END $$;"],
220+
['owner-to', "DO $$ BEGIN EXECUTE E'ALTER TABLE jobs OWNER TO postgres'; END $$;"],
221+
])(
222+
'reads dynamic SQL that EXECUTE runs (%s)',
223+
async (rule, sql) => {
224+
const { code, stdout, stderr } = await runAudit('--dir', migrationsDir(sql))
225+
226+
expect(code).toBe(1)
227+
expect(`${stdout}${stderr}`).toContain(rule)
228+
},
229+
30_000
230+
)
231+
193232
/**
194233
* A table created in another schema must not exempt the same-named existing table in `public`
195234
* from the downtime checks: its old rows still need a default for a NOT NULL column.

‎scripts/check-migrations-safety.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,30 @@ function qualifiedName(raw: string): string {
7272
/**
7373
* The statement with string literals and comments blanked, so only executable SQL is matched.
7474
* An escape string (`E'…'`) is read with its backslash escapes, so `\'` does not end it early.
75-
* A dollar-quoted body stays only where it runs, after `DO` or a function's `AS`, with its own
76-
* literals blanked in turn; dollar-quoted data is blanked like any other string.
75+
* A dollar-quoted body stays only where it runs, after `DO` or a function's `AS`, and a literal
76+
* stays where `EXECUTE` runs it as dynamic SQL, each with its own literals blanked in turn. Every
77+
* other literal is data. Dynamic SQL assembled by concatenation or `format()` is not followed.
7778
*/
7879
function executableSql(sql: string): string {
7980
return sql.replace(
80-
/\$(\w*)\$([\s\S]*?)\$\1\$|(?<![\w$])[Ee]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g,
81-
(_match, tag: string | undefined, body: string | undefined, offset: number) => {
82-
if (body === undefined) return ' '
83-
const runs = /\b(?:DO|AS)\s*$|\bDO\s+LANGUAGE\s+\w+\s*$/i.test(sql.slice(0, offset))
84-
return runs ? `$${tag}$${executableSql(body)}$${tag}$` : ' '
81+
/\$(\w*)\$([\s\S]*?)\$\1\$|(?<![\w$])[Ee]'((?:[^'\\]|\\[\s\S]|'')*)'|'((?:[^']|'')*)'|--[^\n]*|\/\*[\s\S]*?\*\//g,
82+
(
83+
_match: string,
84+
tag: string | undefined,
85+
body: string | undefined,
86+
escaped: string | undefined,
87+
plain: string | undefined,
88+
offset: number
89+
) => {
90+
const before = sql.slice(0, offset)
91+
if (body !== undefined) {
92+
const runs = /\b(?:DO|AS)\s*$|\bDO\s+LANGUAGE\s+\w+\s*$/i.test(before)
93+
return runs ? `$${tag}$${executableSql(body)}$${tag}$` : ' '
94+
}
95+
if (!/\bEXECUTE\s*$/i.test(before)) return ' '
96+
if (escaped !== undefined) return ` ${executableSql(escaped.replace(/\\(.)/g, '$1'))} `
97+
if (plain !== undefined) return ` ${executableSql(plain.replace(/''/g, "'"))} `
98+
return ' '
8599
}
86100
)
87101
}
@@ -254,10 +268,12 @@ interface RawMatch {
254268
* migration, by schema-qualified name so a same-named table in another schema
255269
* is not mistaken for one — ops against a brand-new table have no old rows and no live
256270
* traffic, so they are always safe and skipped. `sawCommit` tracks whether a
257-
* `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts).
271+
* `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts). Only
272+
* executable SQL is classified, so words in literals, comments and dollar-quoted
273+
* data never count as operations.
258274
*/
259275
function classify(sql: string, createdTables: Set<string>, sawCommit: boolean): RawMatch[] {
260-
const s = sql.replace(/\s+/g, ' ').trim()
276+
const s = executableSql(sql).replace(/\s+/g, ' ').trim()
261277
const matches: RawMatch[] = []
262278

263279
const alterTable = s.match(

0 commit comments

Comments
 (0)