diff --git a/packages/jao/lib/src/db/adapters/sqlite.dart b/packages/jao/lib/src/db/adapters/sqlite.dart index 5e4c153..98a6e0b 100644 --- a/packages/jao/lib/src/db/adapters/sqlite.dart +++ b/packages/jao/lib/src/db/adapters/sqlite.dart @@ -7,6 +7,7 @@ library; import 'dart:async'; import 'dart:io'; import 'package:sqlite3/sqlite3.dart' as sqlite; +import '../../migrations/schema.dart' show ForeignKeyDefinition, OnDeleteAction; import '../connection.dart'; /// SQLite SQL dialect. @@ -653,6 +654,8 @@ List generateTableRecreationSql({ String? newDefault, bool dropDefault = false, String? renameTo, + ForeignKeyDefinition? addForeignKey, + String? dropConstraintName, }) { const dialect = SqliteDialect(); final statements = []; @@ -717,6 +720,7 @@ List generateTableRecreationSql({ // Add foreign key constraints for (final constraint in currentSchema.constraints) { if (constraint.type == ConstraintType.foreignKey) { + if (dropConstraintName != null && constraint.name == dropConstraintName) continue; final fkColumn = constraint.columns.first; // If the FK column was renamed, use the new name final effectiveFkColumn = fkColumn == columnName && renameTo != null ? renameTo : fkColumn; @@ -737,6 +741,24 @@ List generateTableRecreationSql({ } } + if (addForeignKey case final fk?) { + final fkBuffer = StringBuffer(); + fkBuffer.write('FOREIGN KEY (${dialect.quoteIdentifier(fk.column)}) '); + fkBuffer.write('REFERENCES ${dialect.quoteIdentifier(fk.referencedTable)}'); + fkBuffer.write('(${dialect.quoteIdentifier(fk.referencedColumn)})'); + final onDelete = switch (fk.onDelete) { + OnDeleteAction.cascade => 'CASCADE', + OnDeleteAction.restrict => 'RESTRICT', + OnDeleteAction.setNull => 'SET NULL', + OnDeleteAction.setDefault => 'SET DEFAULT', + OnDeleteAction.noAction => null, + }; + if (onDelete != null) { + fkBuffer.write(' ON DELETE $onDelete'); + } + columnDefs.add(fkBuffer.toString()); + } + statements.add('CREATE TABLE $quotedTable (\n ${columnDefs.join(',\n ')}\n)'); statements.add( diff --git a/packages/jao/lib/src/migrations/migration.dart b/packages/jao/lib/src/migrations/migration.dart index bc23f27..8009899 100644 --- a/packages/jao/lib/src/migrations/migration.dart +++ b/packages/jao/lib/src/migrations/migration.dart @@ -400,11 +400,14 @@ class MigrationRunner { if (adapter is SqliteAdapter) { await pool.withConnection((conn) async { for (final operation in operations) { - if (operation is AlterColumn) { - final tableName = operation.modification.table; - if (!sqliteSchemas.containsKey(tableName)) { - sqliteSchemas[tableName] = await adapter.getTableSchema(conn, tableName); - } + final tableName = switch (operation) { + AlterColumn op => op.modification.table, + AddForeignKey op => op.table, + DropConstraint op => op.table, + _ => null, + }; + if (tableName case final tableName? when !sqliteSchemas.containsKey(tableName)) { + sqliteSchemas[tableName] = await adapter.getTableSchema(conn, tableName); } } }); @@ -421,6 +424,37 @@ class MigrationRunner { // always call forward (down() sets up forward as the rollback action) await operation.forward(tx as DatabaseConnection); } + } else if (operation is DropConstraint && adapter is SqliteAdapter) { + final currentSchema = sqliteSchemas[operation.table]!; + + // Find the FK column from the constraint name + final fkConstraint = currentSchema.constraints.where((c) => c.name == operation.constraintName).firstOrNull; + + if (fkConstraint case final fkConstraint? when fkConstraint.type == ConstraintType.foreignKey) { + final statements = generateTableRecreationSql( + tableName: operation.table, + currentSchema: currentSchema, + columnName: fkConstraint.columns.first, + dropConstraintName: operation.constraintName, + ); + + for (final statement in statements) { + await tx.execute(statement); + } + } + } else if (operation is AddForeignKey && adapter is SqliteAdapter) { + final currentSchema = sqliteSchemas[operation.table]!; + + final statements = generateTableRecreationSql( + tableName: operation.table, + currentSchema: currentSchema, + columnName: operation.foreignKey.column, + addForeignKey: operation.foreignKey, + ); + + for (final statement in statements) { + await tx.execute(statement); + } } else if (operation is AlterColumn && adapter is SqliteAdapter) { final mod = operation.modification; final currentSchema = sqliteSchemas[mod.table]!; diff --git a/packages/jao/test/db/adapters/sqlite_adapter_test.dart b/packages/jao/test/db/adapters/sqlite_adapter_test.dart index 7463f5d..8b78726 100644 --- a/packages/jao/test/db/adapters/sqlite_adapter_test.dart +++ b/packages/jao/test/db/adapters/sqlite_adapter_test.dart @@ -964,6 +964,148 @@ void main() { }); }); + group('generateTableRecreationSql addForeignKey', () { + test('adds new foreign key constraint to recreated table', () { + final schema = TableSchema( + name: 'posts', + columns: [ + const ColumnSchema(name: 'id', type: 'INTEGER', nullable: false, isPrimaryKey: true), + const ColumnSchema(name: 'title', type: 'TEXT', nullable: false), + const ColumnSchema(name: 'author_id', type: 'INTEGER', nullable: false), + ], + ); + + final statements = generateTableRecreationSql( + tableName: 'posts', + currentSchema: schema, + columnName: 'author_id', + addForeignKey: const ForeignKeyDefinition( + column: 'author_id', + referencedTable: 'users', + referencedColumn: 'id', + onDelete: OnDeleteAction.cascade, + ), + ); + + expect(statements.length, equals(4)); + expect(statements[0], contains('RENAME TO')); + final createTable = statements[1]; + expect(createTable, contains('FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE')); + expect(statements[2], contains('INSERT INTO')); + expect(statements[3], contains('DROP TABLE')); + }); + + test('preserves existing foreign keys when adding a new one', () { + final schema = TableSchema( + name: 'comments', + columns: [ + const ColumnSchema(name: 'id', type: 'INTEGER', nullable: false, isPrimaryKey: true), + const ColumnSchema(name: 'post_id', type: 'INTEGER', nullable: false), + const ColumnSchema(name: 'user_id', type: 'INTEGER', nullable: false), + ], + constraints: [ + const ConstraintSchema( + name: 'fk_comments_post_id', + type: ConstraintType.foreignKey, + columns: ['post_id'], + referencedTable: 'posts', + referencedColumns: ['id'], + onDelete: 'CASCADE', + ), + ], + ); + + final statements = generateTableRecreationSql( + tableName: 'comments', + currentSchema: schema, + columnName: 'user_id', + addForeignKey: const ForeignKeyDefinition( + column: 'user_id', + referencedTable: 'users', + referencedColumn: 'id', + onDelete: OnDeleteAction.cascade, + ), + ); + + final createTable = statements[1]; + expect(createTable, contains('FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE')); + expect(createTable, contains('FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE')); + }); + }); + + group('generateTableRecreationSql dropConstraintName', () { + test('excludes named constraint from recreated table', () { + final schema = TableSchema( + name: 'posts', + columns: [ + const ColumnSchema(name: 'id', type: 'INTEGER', nullable: false, isPrimaryKey: true), + const ColumnSchema(name: 'title', type: 'TEXT', nullable: false), + const ColumnSchema(name: 'author_id', type: 'INTEGER', nullable: false), + ], + constraints: [ + const ConstraintSchema( + name: 'fk_posts_author_id', + type: ConstraintType.foreignKey, + columns: ['author_id'], + referencedTable: 'users', + referencedColumns: ['id'], + onDelete: 'CASCADE', + ), + ], + ); + + final statements = generateTableRecreationSql( + tableName: 'posts', + currentSchema: schema, + columnName: 'author_id', + dropConstraintName: 'fk_posts_author_id', + ); + + final createTable = statements[1]; + expect(createTable, isNot(contains('FOREIGN KEY'))); + }); + + test('preserves other foreign keys when dropping one', () { + final schema = TableSchema( + name: 'comments', + columns: [ + const ColumnSchema(name: 'id', type: 'INTEGER', nullable: false, isPrimaryKey: true), + const ColumnSchema(name: 'post_id', type: 'INTEGER', nullable: false), + const ColumnSchema(name: 'user_id', type: 'INTEGER', nullable: false), + ], + constraints: [ + const ConstraintSchema( + name: 'fk_comments_post_id', + type: ConstraintType.foreignKey, + columns: ['post_id'], + referencedTable: 'posts', + referencedColumns: ['id'], + onDelete: 'CASCADE', + ), + const ConstraintSchema( + name: 'fk_comments_user_id', + type: ConstraintType.foreignKey, + columns: ['user_id'], + referencedTable: 'users', + referencedColumns: ['id'], + onDelete: 'CASCADE', + ), + ], + ); + + final statements = generateTableRecreationSql( + tableName: 'comments', + currentSchema: schema, + columnName: 'post_id', + dropConstraintName: 'fk_comments_post_id', + ); + + final createTable = statements[1]; + expect(createTable, isNot(contains('FOREIGN KEY ("post_id")'))); + expect(createTable, contains('FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE')); + }); + }); + group('File-based SQLite database operations', () { const adapter = SqliteAdapter(); late String testDbPath; diff --git a/packages/jao/test/migrations/migration_runner_test.dart b/packages/jao/test/migrations/migration_runner_test.dart index 96e0722..5957daa 100644 --- a/packages/jao/test/migrations/migration_runner_test.dart +++ b/packages/jao/test/migrations/migration_runner_test.dart @@ -206,6 +206,92 @@ class MakeDescriptionNullable extends Migration { } } +class CreateAuthorsTable extends Migration { + @override + String get name => '030_create_authors'; + + @override + void up(MigrationBuilder builder) { + builder.createTable('authors', (table) { + table.id(); + table.string('name'); + }); + } + + @override + void down(MigrationBuilder builder) { + builder.dropTable('authors'); + } +} + +class CreateBooksTable extends Migration { + @override + String get name => '031_create_books'; + + @override + void up(MigrationBuilder builder) { + builder.createTable('books', (table) { + table.id(); + table.string('title'); + table.integer('author_id'); + }); + } + + @override + void down(MigrationBuilder builder) { + builder.dropTable('books'); + } +} + +class AddBookAuthorFk extends Migration { + @override + String get name => '032_add_book_author_fk'; + + @override + void up(MigrationBuilder builder) { + builder.addForeignKey('books', 'author_id', 'authors', referencedColumn: 'id'); + } + + @override + void down(MigrationBuilder builder) { + builder.dropConstraint('books', 'fk_books_author_id'); + } +} + +class CreateBooksWithFkTable extends Migration { + @override + String get name => '033_create_books_with_fk'; + + @override + void up(MigrationBuilder builder) { + builder.createTable('books_with_fk', (table) { + table.id(); + table.string('title'); + table.foreignKey('author_id', 'authors'); + }); + } + + @override + void down(MigrationBuilder builder) { + builder.dropTable('books_with_fk'); + } +} + +class DropBookAuthorFk extends Migration { + @override + String get name => '034_drop_book_author_fk'; + + @override + void up(MigrationBuilder builder) { + builder.dropConstraint('books_with_fk', 'fk_books_with_fk_author_id'); + } + + @override + void down(MigrationBuilder builder) { + builder.addForeignKey('books_with_fk', 'author_id', 'authors', referencedColumn: 'id'); + } +} + void main() { group('MigrationBuilder', () { group('createTable()', () { @@ -1039,6 +1125,72 @@ void main() { expect(descCol.nullable, isFalse); }); }); + + group('SQLite table recreation (AddForeignKey)', () { + test('AddForeignKey adds FK via table recreation', () async { + await runner.migrate([CreateAuthorsTable(), CreateBooksTable(), AddBookAuthorFk()]); + + final tableSql = await pool.withConnection((conn) async { + final result = await conn.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='books'"); + return result.first['sql'] as String; + }); + + expect(tableSql, contains('FOREIGN KEY')); + expect(tableSql, contains('"authors"')); + }); + + test('AddForeignKey preserves data during table recreation', () async { + await runner.migrate([CreateAuthorsTable(), CreateBooksTable()]); + + await pool.withConnection((conn) async { + await conn.execute("INSERT INTO authors (name) VALUES ('Author 1')"); + await conn.execute("INSERT INTO books (title, author_id) VALUES ('Book 1', 1)"); + await conn.execute("INSERT INTO books (title, author_id) VALUES ('Book 2', 1)"); + }); + + await runner.migrate([CreateAuthorsTable(), CreateBooksTable(), AddBookAuthorFk()]); + + final rows = await pool.withConnection((conn) => conn.query('SELECT * FROM books ORDER BY id')); + expect(rows.length, equals(2)); + expect(rows[0]['title'], equals('Book 1')); + expect(rows[1]['title'], equals('Book 2')); + }); + }); + + group('SQLite table recreation (DropConstraint)', () { + test('DropConstraint removes FK via table recreation', () async { + await runner.migrate([CreateAuthorsTable(), CreateBooksWithFkTable()]); + + var tableSql = await pool.withConnection((conn) async { + final result = await conn.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='books_with_fk'"); + return result.first['sql'] as String; + }); + expect(tableSql, contains('FOREIGN KEY'), reason: 'FK should exist before drop'); + + await runner.migrate([CreateAuthorsTable(), CreateBooksWithFkTable(), DropBookAuthorFk()]); + + tableSql = await pool.withConnection((conn) async { + final result = await conn.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='books_with_fk'"); + return result.first['sql'] as String; + }); + expect(tableSql, isNot(contains('FOREIGN KEY')), reason: 'FK should be removed after migration'); + }); + + test('DropConstraint preserves data during table recreation', () async { + await runner.migrate([CreateAuthorsTable(), CreateBooksWithFkTable()]); + + await pool.withConnection((conn) async { + await conn.execute("INSERT INTO authors (name) VALUES ('Author 1')"); + await conn.execute("INSERT INTO books_with_fk (title, author_id) VALUES ('Book 1', 1)"); + }); + + await runner.migrate([CreateAuthorsTable(), CreateBooksWithFkTable(), DropBookAuthorFk()]); + + final rows = await pool.withConnection((conn) => conn.query('SELECT * FROM books_with_fk ORDER BY id')); + expect(rows.length, equals(1)); + expect(rows[0]['title'], equals('Book 1')); + }); + }); }); group('MigrationResult', () {