ReviewOS

also looking at this

stacks/bun-query-builder

fix(schema): emit inline FOREIGN KEY on SQLite CREATE TABLE (#1019)

#1020
Merged glennmichael123 wants to merge fix/inline-foreign-keys into main
6 files +239 -10
packages/bun-query-builder/test/foreign-keys-inline.test.tsadded+145-0
Changes to packages/bun-query-builder/test/foreign-keys-inline.test.ts
@@ -0,0 +1,145 @@
1import type { TablePlan } from '../src/migrations'
2import { describe, expect, it } from 'bun:test'
3import { 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
27function 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
60describe('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})