diff --git a/drizzle-kit/src/cli/commands/libSqlPushUtils.ts b/drizzle-kit/src/cli/commands/libSqlPushUtils.ts index 31e90c8722..d4908dca0a 100644 --- a/drizzle-kit/src/cli/commands/libSqlPushUtils.ts +++ b/drizzle-kit/src/cli/commands/libSqlPushUtils.ts @@ -11,6 +11,7 @@ import { SQLiteDropTableConvertor, SqliteRenameTableConvertor, } from '../../sqlgenerator'; +import { collectCascadeDependents } from '../../utils/cascade'; export const getOldTableName = ( tableName: string, @@ -29,11 +30,18 @@ export const _moveDataStatements = ( tableName: string, json: SQLiteSchemaSquashed, dataLoss: boolean = false, + cascadeDependents: string[] = [], ) => { const statements: string[] = []; const newTableName = `__new_${tableName}`; + for (const dep of cascadeDependents) { + statements.push( + `CREATE TEMP TABLE \`__bak_${dep}\` AS SELECT * FROM \`${dep}\`;`, + ); + } + // create table statement from a new json2 with proper name const tableColumns = Object.values(json.tables[tableName].columns); const referenceData = Object.values(json.tables[tableName].foreignKeys); @@ -107,6 +115,14 @@ export const _moveDataStatements = ( }), ); } + + for (const dep of cascadeDependents) { + statements.push( + `INSERT OR REPLACE INTO \`${dep}\` SELECT * FROM \`__bak_${dep}\`;`, + ); + statements.push(`DROP TABLE \`__bak_${dep}\`;`); + } + return statements; }; @@ -302,14 +318,17 @@ export const libSqlLogSuggestionsAndReturn = async ( tablesReferencingCurrent.push(...tablesRefs); } - if (!tablesReferencingCurrent.length) { - statementsToExecute.push(..._moveDataStatements(tableName, json2, dataLoss)); + const cascadeDependents = collectCascadeDependents(tableName, json2); + + if (tablesReferencingCurrent.length === 0) { + statementsToExecute.push( + ..._moveDataStatements(tableName, json2, dataLoss, cascadeDependents), + ); continue; } - // recreate table statementsToExecute.push( - ..._moveDataStatements(tableName, json2, dataLoss), + ..._moveDataStatements(tableName, json2, dataLoss, cascadeDependents), ); } else if ( statement.type === 'alter_table_alter_column_set_generated' diff --git a/drizzle-kit/src/cli/commands/sqlitePushUtils.ts b/drizzle-kit/src/cli/commands/sqlitePushUtils.ts index a18b369451..26c365e0dc 100644 --- a/drizzle-kit/src/cli/commands/sqlitePushUtils.ts +++ b/drizzle-kit/src/cli/commands/sqlitePushUtils.ts @@ -11,16 +11,25 @@ import { import type { JsonStatement } from '../../jsonStatements'; import { findAddedAndRemoved, type SQLiteDB } from '../../utils'; +import { collectCascadeDependents } from '../../utils/cascade'; export const _moveDataStatements = ( tableName: string, json: SQLiteSchemaSquashed, dataLoss: boolean = false, + cascadeDependents: string[] = [], ) => { const statements: string[] = []; const newTableName = `__new_${tableName}`; + // backup dependent tables to prevent ON DELETE CASCADE data loss when dropping parent + for (const dep of cascadeDependents) { + statements.push( + `CREATE TEMP TABLE \`__bak_${dep}\` AS SELECT * FROM \`${dep}\`;`, + ); + } + // create table statement from a new json2 with proper name const tableColumns = Object.values(json.tables[tableName].columns); const referenceData = Object.values(json.tables[tableName].foreignKeys); @@ -95,6 +104,14 @@ export const _moveDataStatements = ( ); } + // restore dependents data after parent recreation (works when PRAGMA foreign_keys cannot be disabled, e.g. D1) + for (const dep of cascadeDependents) { + statements.push( + `INSERT OR REPLACE INTO \`${dep}\` SELECT * FROM \`__bak_${dep}\`;`, + ); + statements.push(`DROP TABLE \`__bak_${dep}\`;`); + } + return statements; }; @@ -286,8 +303,12 @@ export const logSuggestionsAndReturn = async ( tablesReferencingCurrent.push(...tablesRefs); } + const cascadeDependents = collectCascadeDependents(tableName, json2); + if (!tablesReferencingCurrent.length) { - statementsToExecute.push(..._moveDataStatements(tableName, json2, dataLoss)); + statementsToExecute.push( + ..._moveDataStatements(tableName, json2, dataLoss, cascadeDependents), + ); continue; } @@ -295,13 +316,13 @@ export const logSuggestionsAndReturn = async ( foreign_keys: number; }>(`PRAGMA foreign_keys;`); - if (pragmaState) { - statementsToExecute.push(`PRAGMA foreign_keys=OFF;`); - } - statementsToExecute.push(..._moveDataStatements(tableName, json2, dataLoss)); - if (pragmaState) { - statementsToExecute.push(`PRAGMA foreign_keys=ON;`); - } + // In environments like Cloudflare D1, PRAGMA foreign_keys cannot be disabled, so we + // both toggle when possible and also use backups to prevent cascade data loss. + if (pragmaState) statementsToExecute.push(`PRAGMA foreign_keys=OFF;`); + statementsToExecute.push( + ..._moveDataStatements(tableName, json2, dataLoss, cascadeDependents), + ); + if (pragmaState) statementsToExecute.push(`PRAGMA foreign_keys=ON;`); } else { const fromJsonStatement = fromJson([statement], 'sqlite', 'push'); statementsToExecute.push( diff --git a/drizzle-kit/src/jsonStatements.ts b/drizzle-kit/src/jsonStatements.ts index 4929519758..4ba797b2db 100644 --- a/drizzle-kit/src/jsonStatements.ts +++ b/drizzle-kit/src/jsonStatements.ts @@ -72,6 +72,7 @@ export interface JsonRecreateTableStatement { compositePKs: string[][]; uniqueConstraints?: string[]; checkConstraints: string[]; + cascadeDependents?: string[]; } export interface JsonRecreateSingleStoreTableStatement { diff --git a/drizzle-kit/src/sqlgenerator.ts b/drizzle-kit/src/sqlgenerator.ts index 64d3c4063c..1237062aec 100644 --- a/drizzle-kit/src/sqlgenerator.ts +++ b/drizzle-kit/src/sqlgenerator.ts @@ -3769,7 +3769,7 @@ class SQLiteRecreateTableConvertor extends Convertor { } convert(statement: JsonRecreateTableStatement): string | string[] { - const { tableName, columns, compositePKs, referenceData, checkConstraints } = statement; + const { tableName, columns, compositePKs, referenceData, checkConstraints, cascadeDependents = [] } = statement; const columnNames = columns.map((it) => `"${it.name}"`).join(', '); const newTableName = `__new_${tableName}`; @@ -3778,6 +3778,10 @@ class SQLiteRecreateTableConvertor extends Convertor { sqlStatements.push(`PRAGMA foreign_keys=OFF;`); + for (const dep of cascadeDependents) { + sqlStatements.push(`CREATE TEMP TABLE \`__bak_${dep}\` AS SELECT * FROM \`${dep}\`;`); + } + // map all possible variants const mappedCheckConstraints: string[] = checkConstraints.map((it) => it.replaceAll(`"${tableName}".`, `"${newTableName}".`).replaceAll(`\`${tableName}\`.`, `\`${newTableName}\`.`) @@ -3821,6 +3825,11 @@ class SQLiteRecreateTableConvertor extends Convertor { }), ); + for (const dep of cascadeDependents) { + sqlStatements.push(`INSERT OR REPLACE INTO \`${dep}\` SELECT * FROM \`__bak_${dep}\`;`); + sqlStatements.push(`DROP TABLE \`__bak_${dep}\`;`); + } + sqlStatements.push(`PRAGMA foreign_keys=ON;`); return sqlStatements; @@ -3836,13 +3845,17 @@ class LibSQLRecreateTableConvertor extends Convertor { } convert(statement: JsonRecreateTableStatement): string[] { - const { tableName, columns, compositePKs, referenceData, checkConstraints } = statement; + const { tableName, columns, compositePKs, referenceData, checkConstraints, cascadeDependents = [] } = statement; const columnNames = columns.map((it) => `"${it.name}"`).join(', '); const newTableName = `__new_${tableName}`; const sqlStatements: string[] = []; + for (const dep of cascadeDependents) { + sqlStatements.push(`CREATE TEMP TABLE \`__bak_${dep}\` AS SELECT * FROM \`${dep}\`;`); + } + const mappedCheckConstraints: string[] = checkConstraints.map((it) => it.replaceAll(`"${tableName}".`, `"${newTableName}".`).replaceAll(`\`${tableName}\`.`, `\`${newTableName}\`.`) .replaceAll(`${tableName}.`, `${newTableName}.`).replaceAll(`'${tableName}'.`, `\`${newTableName}\`.`) @@ -3887,6 +3900,11 @@ class LibSQLRecreateTableConvertor extends Convertor { }), ); + for (const dep of cascadeDependents) { + sqlStatements.push(`INSERT OR REPLACE INTO \`${dep}\` SELECT * FROM \`__bak_${dep}\`;`); + sqlStatements.push(`DROP TABLE \`__bak_${dep}\`;`); + } + sqlStatements.push(`PRAGMA foreign_keys=ON;`); return sqlStatements; diff --git a/drizzle-kit/src/statementCombiner.ts b/drizzle-kit/src/statementCombiner.ts index 7d84a2aa84..95fb4e67e4 100644 --- a/drizzle-kit/src/statementCombiner.ts +++ b/drizzle-kit/src/statementCombiner.ts @@ -6,9 +6,11 @@ import { } from './jsonStatements'; import { SingleStoreSchemaSquashed } from './serializer/singlestoreSchema'; import { SQLiteSchemaSquashed, SQLiteSquasher } from './serializer/sqliteSchema'; +import { collectCascadeDependents } from './utils/cascade'; export const prepareLibSQLRecreateTable = ( table: SQLiteSchemaSquashed['tables'][keyof SQLiteSchemaSquashed['tables']], + json2: SQLiteSchemaSquashed, action?: 'push', ): (JsonRecreateTableStatement | JsonCreateIndexStatement)[] => { const { name, columns, uniqueConstraints, indexes, checkConstraints } = table; @@ -22,6 +24,8 @@ export const prepareLibSQLRecreateTable = ( action === 'push' ? SQLiteSquasher.unsquashPushFK(it) : SQLiteSquasher.unsquashFK(it) ); + const cascadeDependents = collectCascadeDependents(name, json2, action); + const statements: (JsonRecreateTableStatement | JsonCreateIndexStatement)[] = [ { type: 'recreate_table', @@ -31,6 +35,7 @@ export const prepareLibSQLRecreateTable = ( referenceData: fks, uniqueConstraints: Object.values(uniqueConstraints), checkConstraints: Object.values(checkConstraints), + cascadeDependents, }, ]; @@ -42,6 +47,7 @@ export const prepareLibSQLRecreateTable = ( export const prepareSQLiteRecreateTable = ( table: SQLiteSchemaSquashed['tables'][keyof SQLiteSchemaSquashed['tables']], + json2: SQLiteSchemaSquashed, action?: 'push', ): JsonStatement[] => { const { name, columns, uniqueConstraints, indexes, checkConstraints } = table; @@ -55,6 +61,8 @@ export const prepareSQLiteRecreateTable = ( action === 'push' ? SQLiteSquasher.unsquashPushFK(it) : SQLiteSquasher.unsquashFK(it) ); + const cascadeDependents = collectCascadeDependents(name, json2, action); + const statements: JsonStatement[] = [ { type: 'recreate_table', @@ -64,6 +72,7 @@ export const prepareSQLiteRecreateTable = ( referenceData: fks, uniqueConstraints: Object.values(uniqueConstraints), checkConstraints: Object.values(checkConstraints), + cascadeDependents, }, ]; @@ -97,14 +106,14 @@ export const libSQLCombineStatements = ( const statementsForTable = newStatements[tableName]; if (!statementsForTable) { - newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); continue; } if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -142,7 +151,7 @@ export const libSQLCombineStatements = ( if ( !statementsForTable && (columnIsPartOfForeignKey || columnPk) ) { - newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); continue; } @@ -151,7 +160,7 @@ export const libSQLCombineStatements = ( ) { if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -186,7 +195,7 @@ export const libSQLCombineStatements = ( if (!statementsForTable) { newStatements[tableName] = statement.isMulticolumn - ? prepareLibSQLRecreateTable(json2.tables[tableName], action) + ? prepareLibSQLRecreateTable(json2.tables[tableName], json2, action) : [statement]; continue; @@ -205,7 +214,7 @@ export const libSQLCombineStatements = ( if (statement.isMulticolumn) { if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -232,13 +241,13 @@ export const libSQLCombineStatements = ( const statementsForTable = newStatements[tableName]; if (!statementsForTable) { - newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); continue; } if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -258,13 +267,13 @@ export const libSQLCombineStatements = ( const statementsForTable = newStatements[tableName]; if (!statementsForTable) { - newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); continue; } if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareLibSQLRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -335,13 +344,13 @@ export const sqliteCombineStatements = ( const statementsForTable = newStatements[tableName]; if (!statementsForTable) { - newStatements[tableName] = prepareSQLiteRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareSQLiteRecreateTable(json2.tables[tableName], json2, action); continue; } if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareSQLiteRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareSQLiteRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -361,13 +370,13 @@ export const sqliteCombineStatements = ( const statementsForTable = newStatements[tableName]; if (!statementsForTable) { - newStatements[tableName] = prepareSQLiteRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareSQLiteRecreateTable(json2.tables[tableName], json2, action); continue; } if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareSQLiteRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareSQLiteRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); @@ -390,7 +399,7 @@ export const sqliteCombineStatements = ( const statementsForTable = newStatements[tableName]; if (!statementsForTable) { - newStatements[tableName] = prepareSQLiteRecreateTable(json2.tables[tableName], action); + newStatements[tableName] = prepareSQLiteRecreateTable(json2.tables[tableName], json2, action); continue; } @@ -406,7 +415,7 @@ export const sqliteCombineStatements = ( if (!statementsForTable.some(({ type }) => type === 'recreate_table')) { const wasRename = statementsForTable.some(({ type }) => type === 'rename_table'); - const preparedStatements = prepareSQLiteRecreateTable(json2.tables[tableName], action); + const preparedStatements = prepareSQLiteRecreateTable(json2.tables[tableName], json2, action); if (wasRename) { newStatements[tableName].push(...preparedStatements); diff --git a/drizzle-kit/src/utils/cascade.ts b/drizzle-kit/src/utils/cascade.ts new file mode 100644 index 0000000000..47db32e8f2 --- /dev/null +++ b/drizzle-kit/src/utils/cascade.ts @@ -0,0 +1,29 @@ +import { SQLiteSchemaSquashed, SQLiteSquasher } from '../serializer/sqliteSchema'; + +export const collectCascadeDependents = ( + rootTable: string, + json: SQLiteSchemaSquashed, + action?: 'push', +): string[] => { + const result = new Set(); + const queue = [rootTable]; + + while (queue.length) { + const current = queue.pop()!; + for (const table of Object.values(json.tables)) { + for (const fk of Object.values(table.foreignKeys)) { + const data = action === 'push' + ? SQLiteSquasher.unsquashPushFK(fk) + : SQLiteSquasher.unsquashFK(fk); + if (data.tableTo === current && data.onDelete === 'cascade') { + if (!result.has(table.name)) { + result.add(table.name); + queue.push(table.name); + } + } + } + } + } + + return Array.from(result); +}; diff --git a/drizzle-kit/tests/push/sqlite.test.ts b/drizzle-kit/tests/push/sqlite.test.ts index e2c85233a3..90d8b23f82 100644 --- a/drizzle-kit/tests/push/sqlite.test.ts +++ b/drizzle-kit/tests/push/sqlite.test.ts @@ -16,7 +16,7 @@ import { text, uniqueIndex, } from 'drizzle-orm/sqlite-core'; -import { diffTestSchemasPushSqlite, introspectSQLiteToFile } from 'tests/schemaDiffer'; +import { diffTestSchemasPushSqlite } from 'tests/schemaDiffer'; import { expect, test } from 'vitest'; test('nothing changed in schema', async (t) => { @@ -1611,3 +1611,326 @@ test('rename table with composite primary key', async () => { 'ALTER TABLE `products_categories` RENAME TO `products_to_categories`;', ]); }); + +test('recreating parent table with cascade FK wipes child rows (regression)', async () => { + const client = new Database(':memory:'); + client.exec('PRAGMA foreign_keys=ON;'); + + const schema1 = (() => { + const account = sqliteTable('account', { + id: integer('account_id').primaryKey(), + name: text('name'), + }); + + const property = sqliteTable('property', { + id: integer('property_id').primaryKey(), + accountId: integer('account_id').references(() => account.id, { + onDelete: 'cascade', + }), + }); + + return { account, property }; + })(); + + const schema2 = (() => { + const account = sqliteTable('account', { + id: integer('account_id').primaryKey(), + // change column type to force table recreation + name: integer('name'), + }); + + const property = sqliteTable('property', { + id: integer('property_id').primaryKey(), + accountId: integer('account_id').references(() => account.id, { + onDelete: 'cascade', + }), + }); + + return { account, property }; + })(); + + const { sqlStatements } = await diffTestSchemasPushSqlite( + client, + schema1, + schema2, + [], + ); + + // seed data referencing the parent row + client.prepare('INSERT INTO account (account_id, name) VALUES (?, ?)').run(1, 'Alice'); + client.prepare('INSERT INTO property (property_id, account_id) VALUES (?, ?)').run(1, 1); + + // Cloudflare D1 keeps foreign_keys enforcement ON and ignores PRAGMA foreign_keys=OFF. + // Simulate that by applying the generated migration without the FK toggles. + const migrationStatements = sqlStatements.filter( + (st) => !st.toLowerCase().startsWith('pragma foreign_keys'), + ); + + for (const statement of migrationStatements) { + client.exec(statement); + } + + const remainingChildRows = (client + .prepare('SELECT count(*) as cnt FROM property') + .get() as { cnt: number }).cnt; + + // Expected: child rows survive a parent table recreation + expect(remainingChildRows).toBe(1); +}); + +test('recreating parent table cascades through multiple levels (regression)', async () => { + const client = new Database(':memory:'); + client.exec('PRAGMA foreign_keys=ON;'); + + const schema1 = (() => { + const account = sqliteTable('account', { + id: integer('account_id').primaryKey(), + name: text('name'), + }); + + const property = sqliteTable('property', { + id: integer('property_id').primaryKey(), + accountId: integer('account_id').references(() => account.id, { + onDelete: 'cascade', + }), + }); + + const lease = sqliteTable('lease', { + id: integer('lease_id').primaryKey(), + propertyId: integer('property_id').references(() => property.id, { + onDelete: 'cascade', + }), + }); + + return { account, property, lease }; + })(); + + const schema2 = (() => { + const account = sqliteTable('account', { + id: integer('account_id').primaryKey(), + // change column type to force table recreation + name: integer('name'), + }); + + const property = sqliteTable('property', { + id: integer('property_id').primaryKey(), + accountId: integer('account_id').references(() => account.id, { + onDelete: 'cascade', + }), + }); + + const lease = sqliteTable('lease', { + id: integer('lease_id').primaryKey(), + propertyId: integer('property_id').references(() => property.id, { + onDelete: 'cascade', + }), + }); + + return { account, property, lease }; + })(); + + const { sqlStatements } = await diffTestSchemasPushSqlite( + client, + schema1, + schema2, + [], + ); + + // seed data across three levels + client.prepare('INSERT INTO account (account_id, name) VALUES (?, ?)').run(1, 'Alice'); + client.prepare('INSERT INTO property (property_id, account_id) VALUES (?, ?)').run(1, 1); + client.prepare('INSERT INTO lease (lease_id, property_id) VALUES (?, ?)').run(1, 1); + + // Apply migration with foreign keys effectively always ON (D1 behaviour) + const migrationStatements = sqlStatements.filter( + (st) => !st.toLowerCase().startsWith('pragma foreign_keys'), + ); + for (const statement of migrationStatements) { + client.exec(statement); + } + + const remainingProperties = (client + .prepare('SELECT count(*) as cnt FROM property') + .get() as { cnt: number }).cnt; + const remainingLeases = (client.prepare('SELECT count(*) as cnt FROM lease').get() as { + cnt: number; + }).cnt; + + // Expected: downstream children survive the parent table recreation + expect(remainingProperties).toBe(1); + expect(remainingLeases).toBe(1); +}); + +test('recreating parent table with multiple entry points and fan-out preserves dependents', async () => { + const client = new Database(':memory:'); + client.exec('PRAGMA foreign_keys=ON;'); + + const schema1 = (() => { + const account = sqliteTable('account', { + id: integer('account_id').primaryKey(), + name: text('name'), + }); + + const organization = sqliteTable('organization', { + id: integer('org_id').primaryKey(), + name: text('name'), + }); + + const project = sqliteTable( + 'project', + { + id: integer('project_id').primaryKey(), + accountId: integer('account_id').references(() => account.id, { + onDelete: 'cascade', + }), + orgId: integer('org_id').references(() => organization.id, { + onDelete: 'cascade', + }), + title: text('title'), + }, + (table) => ({ + idx: uniqueIndex('project_account_org').on(table.accountId, table.orgId), + }), + ); + + const task = sqliteTable( + 'task', + { + id: integer('task_id').primaryKey(), + projectId: integer('project_id').references(() => project.id, { + onDelete: 'cascade', + }), + summary: text('summary'), + }, + (table) => ({ + idx: uniqueIndex('task_proj_summary').on(table.projectId, table.summary), + }), + ); + + const attachment = sqliteTable('attachment', { + id: integer('attachment_id').primaryKey(), + taskId: integer('task_id').references(() => task.id, { onDelete: 'cascade' }), + projectId: integer('project_id').references(() => project.id, { onDelete: 'cascade' }), + }); + + const tag = sqliteTable('tag', { + id: integer('tag_id').primaryKey(), + projectId: integer('project_id').references(() => project.id, { onDelete: 'cascade' }), + label: text('label'), + }); + + const comment = sqliteTable('comment', { + id: integer('comment_id').primaryKey(), + taskId: integer('task_id').references(() => task.id, { onDelete: 'cascade' }), + body: text('body'), + }); + + return { account, organization, project, task, attachment, tag, comment }; + })(); + + const schema2 = (() => { + const account = sqliteTable('account', { + id: integer('account_id').primaryKey(), + // force recreation by changing type + name: integer('name'), + }); + + const organization = sqliteTable('organization', { + id: integer('org_id').primaryKey(), + name: text('name'), + }); + + const project = sqliteTable( + 'project', + { + id: integer('project_id').primaryKey(), + accountId: integer('account_id').references(() => account.id, { + onDelete: 'cascade', + }), + orgId: integer('org_id').references(() => organization.id, { + onDelete: 'cascade', + }), + title: text('title'), + }, + (table) => ({ + idx: uniqueIndex('project_account_org').on(table.accountId, table.orgId), + }), + ); + + const task = sqliteTable( + 'task', + { + id: integer('task_id').primaryKey(), + projectId: integer('project_id').references(() => project.id, { + onDelete: 'cascade', + }), + summary: text('summary'), + }, + (table) => ({ + idx: uniqueIndex('task_proj_summary').on(table.projectId, table.summary), + }), + ); + + const attachment = sqliteTable('attachment', { + id: integer('attachment_id').primaryKey(), + taskId: integer('task_id').references(() => task.id, { onDelete: 'cascade' }), + projectId: integer('project_id').references(() => project.id, { onDelete: 'cascade' }), + }); + + const tag = sqliteTable('tag', { + id: integer('tag_id').primaryKey(), + projectId: integer('project_id').references(() => project.id, { onDelete: 'cascade' }), + label: text('label'), + }); + + const comment = sqliteTable('comment', { + id: integer('comment_id').primaryKey(), + taskId: integer('task_id').references(() => task.id, { onDelete: 'cascade' }), + body: text('body'), + }); + + return { account, organization, project, task, attachment, tag, comment }; + })(); + + const { sqlStatements } = await diffTestSchemasPushSqlite( + client, + schema1, + schema2, + [], + ); + + client.prepare('INSERT INTO account (account_id, name) VALUES (?, ?)').run(1, 'Alice'); + client.prepare('INSERT INTO organization (org_id, name) VALUES (?, ?)').run(10, 'Org'); + client + .prepare('INSERT INTO project (project_id, account_id, org_id, title) VALUES (?, ?, ?, ?)') + .run(100, 1, 10, 'P1'); + client + .prepare('INSERT INTO task (task_id, project_id, summary) VALUES (?, ?, ?)') + .run(200, 100, 'Task'); + client + .prepare('INSERT INTO attachment (attachment_id, task_id, project_id) VALUES (?, ?, ?)') + .run(300, 200, 100); + client.prepare('INSERT INTO tag (tag_id, project_id, label) VALUES (?, ?, ?)').run(400, 100, 'tag'); + client.prepare('INSERT INTO comment (comment_id, task_id, body) VALUES (?, ?, ?)').run(500, 200, 'c'); + + const migrationStatements = sqlStatements.filter( + (st) => !st.toLowerCase().startsWith('pragma foreign_keys'), + ); + for (const statement of migrationStatements) { + client.exec(statement); + } + + const counts = { + project: client.prepare('SELECT count(*) as c FROM project').get() as { c: number }, + task: client.prepare('SELECT count(*) as c FROM task').get() as { c: number }, + attachment: client.prepare('SELECT count(*) as c FROM attachment').get() as { c: number }, + tag: client.prepare('SELECT count(*) as c FROM tag').get() as { c: number }, + comment: client.prepare('SELECT count(*) as c FROM comment').get() as { c: number }, + }; + + expect(counts.project.c).toBe(1); + expect(counts.task.c).toBe(1); + expect(counts.attachment.c).toBe(1); + expect(counts.tag.c).toBe(1); + expect(counts.comment.c).toBe(1); +});