| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- #!/usr/bin/env node
- // Flags migration statements that break the version of the app still running.
- //
- // node check-migrations.mjs <migration.sql>...
- //
- // A deploy migrates the database while the previous version may still be
- // serving, and a rollback runs the previous version against the new schema.
- // Both work only while a migration leaves the old code working. Adding tables
- // and nullable columns, or columns with a default, is fine. Dropping, renaming,
- // retyping or tightening something the old code uses is not: stop using it in
- // one release, and change it in a later one.
- //
- // Reads whole statements, not lines, so how a statement is wrapped changes
- // nothing. Prints GitHub annotations, and exits 1 when it finds any errors.
- import { existsSync, readFileSync } from 'node:fs'
- const ID = String.raw`(?:"(?:[^"]|"")*"|[A-Za-z_][\w$]*)`
- const NAME = String.raw`${ID}(?:\s*\.\s*${ID})?`
- const ALTER_TABLE = new RegExp(String.raw`\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?${NAME}\s*\*?\s*`, 'i')
- const ALTER_TYPE = new RegExp(String.raw`\bALTER\s+TYPE\s+${NAME}\s*`, 'i')
- const ALTER_COLUMN = String.raw`^ALTER\s+(?:COLUMN\s+)?${ID}\s+`
- const RUNNING = 'The version still running'
- const TABLE_RULES = [
- [/^DROP\s+(?!CONSTRAINT\b)/i, 'Drops a column', `${RUNNING} selects this column; Prisma reads every column it knows, so every query on the table fails.`],
- [/^RENAME\s+TO\b/i, 'Renames a table', `${RUNNING} uses the old name.`],
- [/^RENAME\s+(?!CONSTRAINT\b)/i, 'Renames a column', `${RUNNING} uses the old name. Add the new column, write both, and drop the old one in a later release.`],
- [new RegExp(`${ALTER_COLUMN}(?:SET\\s+DATA\\s+)?TYPE\\b`, 'i'), 'Changes a column type', `${RUNNING} reads and writes the old type.`],
- [new RegExp(`${ALTER_COLUMN}SET\\s+NOT\\s+NULL\\b`, 'i'), 'Makes a column required', `${RUNNING} may insert rows without it.`],
- [new RegExp(`${ALTER_COLUMN}DROP\\s+DEFAULT\\b`, 'i'), 'Removes a column default', `${RUNNING} leaves the column out of inserts and relies on the default.`],
- ]
- const ADD_COLUMN = /^ADD\s+(?!CONSTRAINT\b|PRIMARY\s+KEY\b|UNIQUE\b|FOREIGN\s+KEY\b|CHECK\b|EXCLUDE\b)/i
- const TYPE_RULES = [
- [/^RENAME\s+VALUE\b/i, 'Renames an enum value', `${RUNNING} reads and writes the old value.`],
- [/^RENAME\b/i, 'Renames a type', `${RUNNING} uses the old name. For an enum, this is usually a value being removed or renamed.`],
- ]
- /**
- * Cuts SQL into clauses, each with the source line of every character:
- * statements end at `;`, and an ALTER TABLE or ALTER TYPE is cut again at each
- * top-level comma, so every action in it is one clause however it is wrapped.
- * Comments are dropped and string literals blanked, so neither can set off or
- * hide a rule. Dollar quotes are read through, so DDL inside a DO block is
- * checked like any other.
- */
- function clauses(sql) {
- const out = []
- let text = ''
- let lines = []
- let line = 1
- let depth = 0
- let kind = null // 'table' or 'type' once the statement is known to alter one
- const put = (s) => {
- text += s
- for (let k = 0; k < s.length; k++) lines.push(line)
- }
- const kindSoFar = () => kind ?? (ALTER_TABLE.test(text) ? 'table' : ALTER_TYPE.test(text) ? 'type' : null)
- const flush = (endsStatement) => {
- if (text.trim()) {
- const clauseKind = kindSoFar()
- out.push({ text, lines, kind: clauseKind })
- kind = clauseKind
- }
- text = ''
- lines = []
- if (endsStatement) {
- kind = null
- depth = 0
- }
- }
- for (let i = 0; i < sql.length; i++) {
- const c = sql[i]
- const next = sql[i + 1]
- if (c === '\n') {
- put(' ')
- line++
- } else if (c === '-' && next === '-') {
- while (i + 1 < sql.length && sql[i + 1] !== '\n') i++
- put(' ')
- } else if (c === '/' && next === '*') {
- i += 2
- while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
- if (sql[i] === '\n') line++
- i++
- }
- i++
- put(' ')
- } else if (c === "'") {
- const startLine = line
- i++
- while (i < sql.length && !(sql[i] === "'" && sql[i + 1] !== "'")) {
- if (sql[i] === '\n') line++
- i += sql[i] === "'" ? 2 : 1
- }
- const endLine = line
- line = startLine
- put("''")
- line = endLine
- } else if (c === '"') {
- let j = i + 1
- while (j < sql.length && !(sql[j] === '"' && sql[j + 1] !== '"')) j += sql[j] === '"' ? 2 : 1
- put(sql.slice(i, j + 1))
- i = j
- } else if (c === '$' && /^\$(?:[A-Za-z_]\w*)?\$/.test(sql.slice(i, i + 64))) {
- i += /^\$(?:[A-Za-z_]\w*)?\$/.exec(sql.slice(i, i + 64))[0].length - 1
- put(' ')
- } else if (c === ';') {
- flush(true)
- } else if (c === ',' && depth === 0 && kindSoFar()) {
- flush(false)
- } else {
- if (c === '(') depth++
- if (c === ')') depth--
- put(c)
- }
- }
- flush(true)
- return out
- }
- let errors = 0
- let warnings = 0
- function flag(level, file, line, title, message) {
- console.log(`::${level} file=${file},line=${line},title=${title}::${message}`)
- if (level === 'error') errors++
- else warnings++
- }
- function check(file) {
- for (const clause of clauses(readFileSync(file, 'utf8'))) {
- const { text, lines } = clause
- const lineAt = (index) => lines[index] ?? lines[0]
- const dropTable = /\bDROP\s+TABLE\b/i.exec(text)
- if (dropTable) flag('error', file, lineAt(dropTable.index), 'Drops a table', `${RUNNING} may read or write this table.`)
- if (!clause.kind) continue
- // The action: what follows ALTER TABLE <name>, or the whole clause after a comma.
- const head = (clause.kind === 'table' ? ALTER_TABLE : ALTER_TYPE).exec(text)
- const from = head ? head.index + head[0].length : text.search(/\S/)
- const action = text.slice(from)
- const rules = clause.kind === 'table' ? TABLE_RULES : TYPE_RULES
- const rule = rules.find(([re]) => re.test(action))
- if (rule) {
- flag('error', file, lineAt(from), rule[1], rule[2])
- } else if (clause.kind === 'table' && ADD_COLUMN.test(action)) {
- const required = /\bNOT\s+NULL\b|\bPRIMARY\s+KEY\b/i.test(action)
- const filled = /\bDEFAULT\b|\bGENERATED\b/i.test(action)
- if (required && !filled) {
- flag('error', file, lineAt(from), 'Adds a required column with no default', `${RUNNING} inserts rows without it. Give it a default, or add it nullable and require it in a later release.`)
- }
- } else if (clause.kind === 'type' && /^ADD\s+VALUE\b/i.test(action)) {
- flag('warning', file, lineAt(from), 'Adds an enum value', `${RUNNING} cannot read rows that hold the new value. Fine as long as nothing writes it before the deploy is done, and nothing needs a rollback after.`)
- }
- }
- }
- const files = process.argv.slice(2)
- for (const file of files) if (existsSync(file)) check(file)
- if (errors > 0) {
- console.log()
- console.log(`${errors} statement(s) would break the version still running during a deploy, and the one a rollback puts back.`)
- console.log('Split the change across releases. If it is safe anyway (nothing uses what is dropped, say), add the')
- console.log('allow-breaking-migration label to the pull request and say why in its description.')
- process.exit(1)
- }
- const note = warnings > 0 ? ` (${warnings} warning(s) above)` : ''
- console.log(`Checked ${files.length} migration file(s): nothing that breaks the running version${note}.`)
|