* Classify one statement. `createdTables` holds tables created in the same * migration — ops against a brand-new table have no old rows and no live * traffic, so they are always safe and skipped. `sawCommit` tracks whether a * `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts).
(sql: string, createdTables: Set<string>, sawCommit: boolean)
| 192 | * `COMMIT;` breakpoint preceded a CONCURRENTLY index (see migrate.ts). |
| 193 | */ |
| 194 | function classify(sql: string, createdTables: Set<string>, sawCommit: boolean): RawMatch[] { |
| 195 | const s = sql.replace(/\s+/g, ' ').trim() |
| 196 | const matches: RawMatch[] = [] |
| 197 | |
| 198 | const alterTable = s.match(/\bALTER TABLE (?:IF EXISTS )?(?:ONLY )?("?[.\w]+"?)/i) |
| 199 | const targetTable = alterTable ? bareName(alterTable[1]) : null |
| 200 | const onNewTable = targetTable !== null && createdTables.has(targetTable) |
| 201 | |
| 202 | if (/^CREATE (?:UNIQUE )?INDEX\b/i.test(s)) { |
| 203 | const on = s.match(/\bON ("?[.\w]+"?)/i) |
| 204 | const indexTable = on ? bareName(on[1]) : null |
| 205 | const concurrent = /\bCONCURRENTLY\b/i.test(s) |
| 206 | if (!(indexTable && createdTables.has(indexTable))) { |
| 207 | if (!concurrent) { |
| 208 | matches.push({ |
| 209 | kind: 'error', |
| 210 | rule: 'index-not-concurrent', |
| 211 | message: |
| 212 | 'CREATE INDEX on an existing table write-locks it for the whole build. Use CREATE INDEX CONCURRENTLY IF NOT EXISTS after a COMMIT; breakpoint (see packages/db/scripts/migrate.ts).', |
| 213 | }) |
| 214 | } else if (!/\bIF NOT EXISTS\b/i.test(s)) { |
| 215 | matches.push({ |
| 216 | kind: 'error', |
| 217 | rule: 'concurrent-index-not-idempotent', |
| 218 | message: |
| 219 | 'CREATE INDEX CONCURRENTLY must be IF NOT EXISTS — a failed build replays from the top and a partial INVALID index would be skipped forever.', |
| 220 | }) |
| 221 | } else if (!sawCommit) { |
| 222 | matches.push({ |
| 223 | kind: 'error', |
| 224 | rule: 'concurrent-index-no-commit', |
| 225 | message: |
| 226 | 'CREATE INDEX CONCURRENTLY cannot run inside the migration transaction. Precede it with a COMMIT; breakpoint and SET lock_timeout = 0 (see packages/db/scripts/migrate.ts).', |
| 227 | }) |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | if ( |
| 233 | !onNewTable && |
| 234 | /\bADD COLUMN\b/i.test(s) && |
| 235 | /\bNOT NULL\b/i.test(s) && |
| 236 | !/\bDEFAULT\b/i.test(s) |
| 237 | ) { |
| 238 | matches.push({ |
| 239 | kind: 'error', |
| 240 | rule: 'add-not-null-no-default', |
| 241 | message: |
| 242 | 'ADD COLUMN NOT NULL with no DEFAULT breaks old inserts (and fails on existing rows). Add it nullable or with a DEFAULT, backfill, then SET NOT NULL in a later migration once code populates it.', |
| 243 | }) |
| 244 | } |
| 245 | |
| 246 | if (/\bRENAME COLUMN\b/i.test(s) || /^ALTER TABLE\b[^;]*\bRENAME TO\b/i.test(s)) { |
| 247 | matches.push({ |
| 248 | kind: 'error', |
| 249 | rule: 'rename', |
| 250 | message: |
| 251 | 'RENAME of a column/table breaks old code reading the old name. Add the new column/table, dual-write in code, then drop the old one in a later deploy.', |