Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/jao/lib/src/db/adapters/sqlite.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -653,6 +654,8 @@ List<String> generateTableRecreationSql({
String? newDefault,
bool dropDefault = false,
String? renameTo,
ForeignKeyDefinition? addForeignKey,
String? dropConstraintName,
}) {
const dialect = SqliteDialect();
final statements = <String>[];
Expand Down Expand Up @@ -717,6 +720,7 @@ List<String> 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;
Expand All @@ -737,6 +741,24 @@ List<String> 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(
Expand Down
44 changes: 39 additions & 5 deletions packages/jao/lib/src/migrations/migration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
Expand All @@ -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]!;
Expand Down
142 changes: 142 additions & 0 deletions packages/jao/test/db/adapters/sqlite_adapter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading