also looking at this
fix(schema): emit inline FOREIGN KEY on SQLite CREATE TABLE (#1019)
#1020
6 files
+239
-10
| @@ -0,0 +1,145 @@ | ||
| 1 | import type { TablePlan } from '../src/migrations' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { generateSql } from '../src/migrations' | |
| 4 | ||
| 5 | // stacksjs/bun-query-builder#1019 — Foreign keys must reach the | |
| 6 | // generated DDL via *some* path on every supported dialect. Prior | |
| 7 | // to this fix: | |
| 8 | // | |
| 9 | // - All three drivers' `renderColumn` silently ignored | |
| 10 | // `column.references`, so inline FKs never emitted at all. | |
| 11 | // - SQLite's `addForeignKey` produced `ALTER TABLE … ADD | |
| 12 | // CONSTRAINT FOREIGN KEY …`, which SQLite cannot execute. | |
| 13 | // The generated file landed on disk and either failed at | |
| 14 | // migrate time or had to be stripped by the consumer. | |
| 15 | // | |
| 16 | // After this fix: | |
| 17 | // | |
| 18 | // - SQLite emits FKs inline on `CREATE TABLE` via `renderColumn` | |
| 19 | // (the only path SQLite supports). Its `addForeignKey` returns | |
| 20 | // an empty string so the orchestrator skips the unrunnable | |
| 21 | // ALTER pass entirely. | |
| 22 | // - MySQL / PostgreSQL keep the deferred-ALTER strategy — inline | |
| 23 | // FKs would fail when `plan.tables` is iterated in an order | |
| 24 | // that references a forward-defined table (e.g. alphabetical: | |
| 25 | // `comments` before `users`). | |
| 26 | ||
| 27 | function makePlan(dialect: 'sqlite' | 'mysql' | 'postgres'): any { | |
| 28 | return { | |
| 29 | dialect, | |
| 30 | tables: [ | |
| 31 | { | |
| 32 | table: 'users', | |
| 33 | primaryKey: 'id', | |
| 34 | columns: [ | |
| 35 | { name: 'id', type: 'bigint', isPrimaryKey: true, isUnique: false, isNullable: false, hasDefault: false }, | |
| 36 | ] satisfies TablePlan['columns'], | |
| 37 | indexes: [], | |
| 38 | }, | |
| 39 | { | |
| 40 | table: 'posts', | |
| 41 | primaryKey: 'id', | |
| 42 | columns: [ | |
| 43 | { name: 'id', type: 'bigint', isPrimaryKey: true, isUnique: false, isNullable: false, hasDefault: false }, | |
| 44 | { | |
| 45 | name: 'user_id', | |
| 46 | type: 'bigint', | |
| 47 | isPrimaryKey: false, | |
| 48 | isUnique: false, | |
| 49 | isNullable: false, | |
| 50 | hasDefault: false, | |
| 51 | references: { table: 'users', column: 'id', onDelete: 'cascade' }, | |
| 52 | }, | |
| 53 | ] satisfies TablePlan['columns'], | |
| 54 | indexes: [], | |
| 55 | }, | |
| 56 | ], | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | describe('CREATE TABLE foreign-key emission (stacksjs/bun-query-builder#1019)', () => { | |
| 61 | it('sqlite emits inline REFERENCES — the only path that works', () => { | |
| 62 | const sql = generateSql(makePlan('sqlite')).join('\n') | |
| 63 | ||
| 64 | expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS "posts"[\s\S]*"user_id"\s+INTEGER\b[\s\S]*?REFERENCES\s+"users"\("id"\)\s+ON DELETE CASCADE/) | |
| 65 | // No separate ALTER pass — SQLite can't execute ADD CONSTRAINT. | |
| 66 | expect(sql).not.toContain('ALTER TABLE') | |
| 67 | expect(sql).not.toContain('ADD CONSTRAINT') | |
| 68 | }) | |
| 69 | ||
| 70 | it('mysql defers FKs to ALTER TABLE ADD CONSTRAINT after all CREATE TABLEs', () => { | |
| 71 | const sql = generateSql(makePlan('mysql')).join('\n') | |
| 72 | ||
| 73 | // The CREATE TABLE block for posts must NOT carry inline | |
| 74 | // REFERENCES — emitting inline would fail when iteration order | |
| 75 | // lands `posts` before `users`. Match only the create-table | |
| 76 | // statement body (between `CREATE TABLE … (` and the matching | |
| 77 | // closing `);`) and assert REFERENCES is absent inside it. | |
| 78 | const createPosts = sql.match(/CREATE TABLE[^;]*posts[^;]*;/) | |
| 79 | expect(createPosts).toBeTruthy() | |
| 80 | expect(createPosts?.[0]).not.toContain('REFERENCES') | |
| 81 | // The deferred ALTER pass still emits the FK. | |
| 82 | expect(sql).toContain('ALTER TABLE') | |
| 83 | expect(sql).toContain('FOREIGN KEY') | |
| 84 | expect(sql).toContain('REFERENCES `users`(`id`)') | |
| 85 | expect(sql).toContain('ON DELETE CASCADE') | |
| 86 | }) | |
| 87 | ||
| 88 | it('postgres defers FKs to ALTER TABLE ADD CONSTRAINT after all CREATE TABLEs', () => { | |
| 89 | const sql = generateSql(makePlan('postgres')).join('\n') | |
| 90 | ||
| 91 | const createPosts = sql.match(/CREATE TABLE[^;]*posts[^;]*;/) | |
| 92 | expect(createPosts).toBeTruthy() | |
| 93 | expect(createPosts?.[0]).not.toContain('REFERENCES') | |
| 94 | expect(sql).toContain('ALTER TABLE') | |
| 95 | expect(sql).toContain('FOREIGN KEY') | |
| 96 | expect(sql).toContain('REFERENCES "users"("id")') | |
| 97 | expect(sql).toContain('ON DELETE CASCADE') | |
| 98 | }) | |
| 99 | ||
| 100 | it('sqlite inline FK honours onUpdate when supplied', () => { | |
| 101 | const plan: any = { | |
| 102 | dialect: 'sqlite', | |
| 103 | tables: [{ | |
| 104 | table: 'orders', | |
| 105 | primaryKey: 'id', | |
| 106 | columns: [ | |
| 107 | { name: 'id', type: 'bigint', isPrimaryKey: true, isUnique: false, isNullable: false, hasDefault: false }, | |
| 108 | { | |
| 109 | name: 'customer_id', | |
| 110 | type: 'bigint', | |
| 111 | isPrimaryKey: false, | |
| 112 | isUnique: false, | |
| 113 | isNullable: false, | |
| 114 | hasDefault: false, | |
| 115 | references: { table: 'customers', column: 'id', onDelete: 'restrict', onUpdate: 'cascade' }, | |
| 116 | }, | |
| 117 | ], | |
| 118 | indexes: [], | |
| 119 | }], | |
| 120 | } | |
| 121 | const sql = generateSql(plan).join('\n') | |
| 122 | ||
| 123 | expect(sql).toContain('REFERENCES "customers"("id")') | |
| 124 | expect(sql).toContain('ON DELETE RESTRICT') | |
| 125 | expect(sql).toContain('ON UPDATE CASCADE') | |
| 126 | }) | |
| 127 | ||
| 128 | it('columns without references do not get a stray REFERENCES clause', () => { | |
| 129 | const basePlan: any = { | |
| 130 | tables: [{ | |
| 131 | table: 'logs', | |
| 132 | primaryKey: 'id', | |
| 133 | columns: [ | |
| 134 | { name: 'id', type: 'bigint', isPrimaryKey: true, isUnique: false, isNullable: false, hasDefault: false }, | |
| 135 | { name: 'message', type: 'text', isPrimaryKey: false, isUnique: false, isNullable: true, hasDefault: false }, | |
| 136 | ], | |
| 137 | indexes: [], | |
| 138 | }], | |
| 139 | } | |
| 140 | for (const dialect of ['sqlite', 'mysql', 'postgres'] as const) { | |
| 141 | const sql = generateSql({ ...basePlan, dialect }).join('\n') | |
| 142 | expect(sql).not.toContain('REFERENCES') | |
| 143 | } | |
| 144 | }) | |
| 145 | }) | |