4
0

check-migrations.mjs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. #!/usr/bin/env node
  2. // Flags migration statements that break the version of the app still running.
  3. //
  4. // node check-migrations.mjs <migration.sql>...
  5. //
  6. // A deploy migrates the database while the previous version may still be
  7. // serving, and a rollback runs the previous version against the new schema.
  8. // Both work only while a migration leaves the old code working. Adding tables
  9. // and nullable columns, or columns with a default, is fine. Dropping, renaming,
  10. // retyping or tightening something the old code uses is not: stop using it in
  11. // one release, and change it in a later one.
  12. //
  13. // Reads whole statements, not lines, so how a statement is wrapped changes
  14. // nothing. Prints GitHub annotations, and exits 1 when it finds any errors.
  15. import { existsSync, readFileSync } from 'node:fs'
  16. const ID = String.raw`(?:"(?:[^"]|"")*"|[A-Za-z_][\w$]*)`
  17. const NAME = String.raw`${ID}(?:\s*\.\s*${ID})?`
  18. const ALTER_TABLE = new RegExp(String.raw`\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?${NAME}\s*\*?\s*`, 'i')
  19. const ALTER_TYPE = new RegExp(String.raw`\bALTER\s+TYPE\s+${NAME}\s*`, 'i')
  20. const ALTER_COLUMN = String.raw`^ALTER\s+(?:COLUMN\s+)?${ID}\s+`
  21. const RUNNING = 'The version still running'
  22. const TABLE_RULES = [
  23. [/^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.`],
  24. [/^RENAME\s+TO\b/i, 'Renames a table', `${RUNNING} uses the old name.`],
  25. [/^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.`],
  26. [new RegExp(`${ALTER_COLUMN}(?:SET\\s+DATA\\s+)?TYPE\\b`, 'i'), 'Changes a column type', `${RUNNING} reads and writes the old type.`],
  27. [new RegExp(`${ALTER_COLUMN}SET\\s+NOT\\s+NULL\\b`, 'i'), 'Makes a column required', `${RUNNING} may insert rows without it.`],
  28. [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.`],
  29. ]
  30. const ADD_COLUMN = /^ADD\s+(?!CONSTRAINT\b|PRIMARY\s+KEY\b|UNIQUE\b|FOREIGN\s+KEY\b|CHECK\b|EXCLUDE\b)/i
  31. const TYPE_RULES = [
  32. [/^RENAME\s+VALUE\b/i, 'Renames an enum value', `${RUNNING} reads and writes the old value.`],
  33. [/^RENAME\b/i, 'Renames a type', `${RUNNING} uses the old name. For an enum, this is usually a value being removed or renamed.`],
  34. ]
  35. /**
  36. * Cuts SQL into clauses, each with the source line of every character:
  37. * statements end at `;`, and an ALTER TABLE or ALTER TYPE is cut again at each
  38. * top-level comma, so every action in it is one clause however it is wrapped.
  39. * Comments are dropped and string literals blanked, so neither can set off or
  40. * hide a rule. Dollar quotes are read through, so DDL inside a DO block is
  41. * checked like any other.
  42. */
  43. function clauses(sql) {
  44. const out = []
  45. let text = ''
  46. let lines = []
  47. let line = 1
  48. let depth = 0
  49. let kind = null // 'table' or 'type' once the statement is known to alter one
  50. const put = (s) => {
  51. text += s
  52. for (let k = 0; k < s.length; k++) lines.push(line)
  53. }
  54. const kindSoFar = () => kind ?? (ALTER_TABLE.test(text) ? 'table' : ALTER_TYPE.test(text) ? 'type' : null)
  55. const flush = (endsStatement) => {
  56. if (text.trim()) {
  57. const clauseKind = kindSoFar()
  58. out.push({ text, lines, kind: clauseKind })
  59. kind = clauseKind
  60. }
  61. text = ''
  62. lines = []
  63. if (endsStatement) {
  64. kind = null
  65. depth = 0
  66. }
  67. }
  68. for (let i = 0; i < sql.length; i++) {
  69. const c = sql[i]
  70. const next = sql[i + 1]
  71. if (c === '\n') {
  72. put(' ')
  73. line++
  74. } else if (c === '-' && next === '-') {
  75. while (i + 1 < sql.length && sql[i + 1] !== '\n') i++
  76. put(' ')
  77. } else if (c === '/' && next === '*') {
  78. i += 2
  79. while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
  80. if (sql[i] === '\n') line++
  81. i++
  82. }
  83. i++
  84. put(' ')
  85. } else if (c === "'") {
  86. const startLine = line
  87. i++
  88. while (i < sql.length && !(sql[i] === "'" && sql[i + 1] !== "'")) {
  89. if (sql[i] === '\n') line++
  90. i += sql[i] === "'" ? 2 : 1
  91. }
  92. const endLine = line
  93. line = startLine
  94. put("''")
  95. line = endLine
  96. } else if (c === '"') {
  97. let j = i + 1
  98. while (j < sql.length && !(sql[j] === '"' && sql[j + 1] !== '"')) j += sql[j] === '"' ? 2 : 1
  99. put(sql.slice(i, j + 1))
  100. i = j
  101. } else if (c === '$' && /^\$(?:[A-Za-z_]\w*)?\$/.test(sql.slice(i, i + 64))) {
  102. i += /^\$(?:[A-Za-z_]\w*)?\$/.exec(sql.slice(i, i + 64))[0].length - 1
  103. put(' ')
  104. } else if (c === ';') {
  105. flush(true)
  106. } else if (c === ',' && depth === 0 && kindSoFar()) {
  107. flush(false)
  108. } else {
  109. if (c === '(') depth++
  110. if (c === ')') depth--
  111. put(c)
  112. }
  113. }
  114. flush(true)
  115. return out
  116. }
  117. let errors = 0
  118. let warnings = 0
  119. function flag(level, file, line, title, message) {
  120. console.log(`::${level} file=${file},line=${line},title=${title}::${message}`)
  121. if (level === 'error') errors++
  122. else warnings++
  123. }
  124. function check(file) {
  125. for (const clause of clauses(readFileSync(file, 'utf8'))) {
  126. const { text, lines } = clause
  127. const lineAt = (index) => lines[index] ?? lines[0]
  128. const dropTable = /\bDROP\s+TABLE\b/i.exec(text)
  129. if (dropTable) flag('error', file, lineAt(dropTable.index), 'Drops a table', `${RUNNING} may read or write this table.`)
  130. if (!clause.kind) continue
  131. // The action: what follows ALTER TABLE <name>, or the whole clause after a comma.
  132. const head = (clause.kind === 'table' ? ALTER_TABLE : ALTER_TYPE).exec(text)
  133. const from = head ? head.index + head[0].length : text.search(/\S/)
  134. const action = text.slice(from)
  135. const rules = clause.kind === 'table' ? TABLE_RULES : TYPE_RULES
  136. const rule = rules.find(([re]) => re.test(action))
  137. if (rule) {
  138. flag('error', file, lineAt(from), rule[1], rule[2])
  139. } else if (clause.kind === 'table' && ADD_COLUMN.test(action)) {
  140. const required = /\bNOT\s+NULL\b|\bPRIMARY\s+KEY\b/i.test(action)
  141. const filled = /\bDEFAULT\b|\bGENERATED\b/i.test(action)
  142. if (required && !filled) {
  143. 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.`)
  144. }
  145. } else if (clause.kind === 'type' && /^ADD\s+VALUE\b/i.test(action)) {
  146. 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.`)
  147. }
  148. }
  149. }
  150. const files = process.argv.slice(2)
  151. for (const file of files) if (existsSync(file)) check(file)
  152. if (errors > 0) {
  153. console.log()
  154. console.log(`${errors} statement(s) would break the version still running during a deploy, and the one a rollback puts back.`)
  155. console.log('Split the change across releases. If it is safe anyway (nothing uses what is dropped, say), add the')
  156. console.log('allow-breaking-migration label to the pull request and say why in its description.')
  157. process.exit(1)
  158. }
  159. const note = warnings > 0 ? ` (${warnings} warning(s) above)` : ''
  160. console.log(`Checked ${files.length} migration file(s): nothing that breaks the running version${note}.`)