diff --git a/drift_dev/lib/api/migrations.dart b/drift_dev/lib/api/migrations.dart index ec23a622e..469f5a868 100644 --- a/drift_dev/lib/api/migrations.dart +++ b/drift_dev/lib/api/migrations.dart @@ -79,15 +79,15 @@ abstract class SchemaVerifier { /// /// Foreign key constraints are disabled for this operation. Future testWithDataIntegrity( - {required SchemaVerifier verifier, - required OldDatabase Function(QueryExecutor) createOld, - required NewDatabase Function(QueryExecutor) createNew, - required GeneratedDatabase Function(QueryExecutor) openTestedDatabase, - required void Function(Batch, OldDatabase) createItems, - required Future Function(NewDatabase) validateItems, - required int oldVersion, - required int newVersion}); + NewDatabase extends GeneratedDatabase>({ + required OldDatabase Function(QueryExecutor) createOld, + required NewDatabase Function(QueryExecutor) createNew, + required GeneratedDatabase Function(QueryExecutor) openTestedDatabase, + required void Function(Batch, OldDatabase) createItems, + required Future Function(NewDatabase) validateItems, + required int oldVersion, + required int newVersion, + }); } /// Utilities verifying that the current schema of the database matches what diff --git a/drift_dev/lib/src/cli/commands/make_migrations.dart b/drift_dev/lib/src/cli/commands/make_migrations.dart index d01b21f35..1e8ee813d 100644 --- a/drift_dev/lib/src/cli/commands/make_migrations.dart +++ b/drift_dev/lib/src/cli/commands/make_migrations.dart @@ -48,7 +48,7 @@ This will generate the following: ${yellow.wrap("}")} 2. A test file containing: - a) Automated tests to validate the correctness of migrations. + a) Automated tests to validate the correctness of migrations. b) A sample data integrity test for the first migration. This test ensures that the initial schema is created correctly and that basic data operations work as expected. This sample test should be adapted for subsequent migrations, especially those involving complex modifications to existing tables. @@ -367,29 +367,33 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); - group('$dbName database', () { - ////////////////////////////////////////////////////////////////////////////// - ////////////////////// GENERATED TESTS - DO NOT MODIFY /////////////////////// - ////////////////////////////////////////////////////////////////////////////// - if (GeneratedHelper.versions.length < 2) return; - for (var i - in List.generate(GeneratedHelper.versions.length - 1, (i) => i)) { - final oldVersion = GeneratedHelper.versions.elementAt(i); - final newVersion = GeneratedHelper.versions.elementAt(i + 1); - test("migrate from v\$oldVersion to v\$newVersion", () async { - final schema = await verifier.schemaAt(oldVersion); - final db = $dbClassName(schema.newConnection()); - await verifier.migrateAndValidate(db, newVersion); - await db.close(); + group('simple database migrations', () { + // These simple tests verify all possible schema updates with a simple (no + // data) migration. This is a quick way to ensure that written database + // migrations properly alter the schema. + final versions = GeneratedHelper.versions; + for (final (i, fromVersion) in versions.indexed) { + group('from \$fromVersion', () { + for (final toVersion in versions.skip(i + 1)) { + test('to \$toVersion', () async { + final schema = await verifier.schemaAt(fromVersion); + final db = Database(schema.newConnection()); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + } }); - } - ////////////////////////////////////////////////////////////////////////////// - /////////////////////// END OF GENERATED TESTS /////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + } + }); + // Simple tests ensure the schema is transformed correctly, but some + // migrations benefit from a test verifying that data is transformed correctly + // too. This is particularly true for migrations that change existing columns + // (e.g. altering their type or constraints). Migrations that only add tables + // or columns typically don't need these advanced tests. + // TODO: Check whether you have migrations that could benefit from these tests + // and adapt this example to your database if necessary: ${firstMigration.testStepByStepMigrationCode(dbName, dbClassName)} - }); - } """; @@ -456,27 +460,23 @@ class _MigrationWriter { /// It will also import the validation models to test data integrity String testStepByStepMigrationCode(String dbName, String dbClassName) { return """ -/// Write data integrity tests for migrations that modify existing tables. -/// These tests are important because the auto-generated tests only check empty schemas. - /// Testing with actual data helps ensure migrations don't corrupt existing information. - /// - /// The following is an example of how to write such a test: test("migration from v$from to v$to does not corrupt data", () async { + // Add data to insert into the old database, and the expected rows after the + // migration. ${tables.map((table) { return """ -final old${table.dbGetterName.pascalCase}Data = []; // TODO: Add expected data at version $from using v$from.${table.nameOfRowClass} -final expectedNew${table.dbGetterName.pascalCase}Data = []; // TODO: Add expected data at version $to using v$to.${table.nameOfRowClass} +final old${table.dbGetterName.pascalCase}Data = []; +final expectedNew${table.dbGetterName.pascalCase}Data = []; """; }).join('\n')} await verifier.testWithDataIntegrity( oldVersion: $from, newVersion: $to, - verifier: verifier, - createOld: (e) => v1.DatabaseAtV$from(e), - createNew: (e) => v2.DatabaseAtV$to(e), - openTestedDatabase: (e) => $dbClassName(e), + createOld: v1.DatabaseAtV$from.new, + createNew: v2.DatabaseAtV$to.new, + openTestedDatabase: $dbClassName.new, createItems: (batch, oldDb) { ${tables.map( (table) { @@ -493,9 +493,6 @@ final expectedNew${table.dbGetterName.pascalCase}Data = a.compareTo(b)).join(', ')}}'; + '[${versions.sorted((a, b) => a.compareTo(b)).join(', ')}]'; buffer ..writeln('default:') ..writeln('throw MissingSchemaException(version, versions);') diff --git a/drift_dev/lib/src/services/schema/verifier_impl.dart b/drift_dev/lib/src/services/schema/verifier_impl.dart index b8b7433d9..5d8b2d6d5 100644 --- a/drift_dev/lib/src/services/schema/verifier_impl.dart +++ b/drift_dev/lib/src/services/schema/verifier_impl.dart @@ -104,22 +104,21 @@ class VerifierImplementation implements SchemaVerifier { @override Future testWithDataIntegrity( - {required SchemaVerifier verifier, - required OldDatabase Function(QueryExecutor p1) createOld, + {required OldDatabase Function(QueryExecutor p1) createOld, required NewDatabase Function(QueryExecutor p1) createNew, required GeneratedDatabase Function(QueryExecutor p1) openTestedDatabase, required void Function(Batch p1, OldDatabase p2) createItems, required Future Function(NewDatabase p1) validateItems, required int oldVersion, required int newVersion}) async { - final schema = await verifier.schemaAt(oldVersion); + final schema = await schemaAt(oldVersion); final oldDb = createOld(schema.newConnection()); await oldDb.batch((batch) => createItems(batch, oldDb)); await oldDb.close(); final db = openTestedDatabase(schema.newConnection()); - await verifier.migrateAndValidate(db, newVersion); + await migrateAndValidate(db, newVersion); await db.close(); final newDb = createNew(schema.newConnection()); diff --git a/examples/migrations_example/test/drift/default/generated/schema.dart b/examples/migrations_example/test/drift/default/generated/schema.dart index 2a9b28ed5..787aebd11 100644 --- a/examples/migrations_example/test/drift/default/generated/schema.dart +++ b/examples/migrations_example/test/drift/default/generated/schema.dart @@ -3,48 +3,48 @@ //@dart=2.12 import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; -import 'schema_v7.dart' as v7; -import 'schema_v5.dart' as v5; +import 'schema_v9.dart' as v9; import 'schema_v8.dart' as v8; -import 'schema_v3.dart' as v3; -import 'schema_v6.dart' as v6; -import 'schema_v2.dart' as v2; import 'schema_v1.dart' as v1; +import 'schema_v2.dart' as v2; +import 'schema_v6.dart' as v6; +import 'schema_v7.dart' as v7; import 'schema_v11.dart' as v11; -import 'schema_v9.dart' as v9; -import 'schema_v10.dart' as v10; import 'schema_v4.dart' as v4; +import 'schema_v5.dart' as v5; +import 'schema_v3.dart' as v3; +import 'schema_v10.dart' as v10; class GeneratedHelper implements SchemaInstantiationHelper { @override GeneratedDatabase databaseForVersion(QueryExecutor db, int version) { switch (version) { - case 7: - return v7.DatabaseAtV7(db); - case 5: - return v5.DatabaseAtV5(db); + case 9: + return v9.DatabaseAtV9(db); case 8: return v8.DatabaseAtV8(db); - case 3: - return v3.DatabaseAtV3(db); - case 6: - return v6.DatabaseAtV6(db); - case 2: - return v2.DatabaseAtV2(db); case 1: return v1.DatabaseAtV1(db); + case 2: + return v2.DatabaseAtV2(db); + case 6: + return v6.DatabaseAtV6(db); + case 7: + return v7.DatabaseAtV7(db); case 11: return v11.DatabaseAtV11(db); - case 9: - return v9.DatabaseAtV9(db); - case 10: - return v10.DatabaseAtV10(db); case 4: return v4.DatabaseAtV4(db); + case 5: + return v5.DatabaseAtV5(db); + case 3: + return v3.DatabaseAtV3(db); + case 10: + return v10.DatabaseAtV10(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; } diff --git a/examples/migrations_example/test/drift/default/migration_test.dart b/examples/migrations_example/test/drift/default/migration_test.dart index 7b27cc4f7..d2bcb0b17 100644 --- a/examples/migrations_example/test/drift/default/migration_test.dart +++ b/examples/migrations_example/test/drift/default/migration_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: unused_local_variable, unused_import import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; import 'package:drift_dev/api/migrations.dart'; import 'package:migrations_example/database.dart'; import 'package:test/test.dart'; @@ -7,6 +8,8 @@ import 'generated/schema.dart'; import 'generated/schema_v1.dart' as v1; import 'generated/schema_v2.dart' as v2; +import 'generated/schema_v4.dart' as v4; +import 'generated/schema_v5.dart' as v5; void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; @@ -16,53 +19,104 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); - group('default database', () { - ////////////////////////////////////////////////////////////////////////////// - ////////////////////// GENERATED TESTS - DO NOT MODIFY /////////////////////// - ////////////////////////////////////////////////////////////////////////////// - if (GeneratedHelper.versions.length < 2) return; - for (var i - in List.generate(GeneratedHelper.versions.length - 1, (i) => i)) { - final oldVersion = GeneratedHelper.versions.elementAt(i); - final newVersion = GeneratedHelper.versions.elementAt(i + 1); - test("migrate from v$oldVersion to v$newVersion", () async { - final schema = await verifier.schemaAt(oldVersion); - final db = Database(schema.newConnection()); - await verifier.migrateAndValidate(db, newVersion); - await db.close(); + group('simple database migrations', () { + // These simple tests verify all possible schema updates with a simple (no + // data) migration. This is a quick way to ensure that written database + // migrations properly alter the schema. + final versions = GeneratedHelper.versions; + for (final (i, fromVersion) in versions.indexed) { + group('from $fromVersion', () { + for (final toVersion in versions.skip(i + 1)) { + test('to $toVersion', () async { + final schema = await verifier.schemaAt(fromVersion); + final db = Database(schema.newConnection()); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + } }); } - ////////////////////////////////////////////////////////////////////////////// - /////////////////////// END OF GENERATED TESTS /////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + }); + + // Simple tests ensure the schema is transformed correctly, but some + // migrations benefit from a test verifying that data is transformed correctly + // too. This is particularly true for migrations that change existing columns + // (e.g. altering their type or constraints). Migrations that only add tables + // or columns typically don't need these advanced tests. + test("migration from v1 to v2 does not corrupt data", () async { + final oldUsersData = [v1.UsersData(id: 1)]; + final expectedNewUsersData = [ + v2.UsersData(id: 1, name: 'no name') + ]; + + await verifier.testWithDataIntegrity( + oldVersion: 1, + newVersion: 2, + createOld: v1.DatabaseAtV1.new, + createNew: v2.DatabaseAtV2.new, + openTestedDatabase: Database.new, + createItems: (batch, oldDb) { + batch.insertAll(oldDb.users, oldUsersData); + }, + validateItems: (newDb) async { + expect(expectedNewUsersData, await newDb.select(newDb.users).get()); + }, + ); + }); + + test('foreign key constraints work after upgrade from v4 to v5', () async { + final schema = await verifier.schemaAt(4); + final db = Database(schema.newConnection()); + await verifier.migrateAndValidate(db, 5); + await db.close(); - /// Write data integrity tests for migrations that modify existing tables. - /// These tests are important because the auto-generated tests only check empty schemas. - /// Testing with actual data helps ensure migrations don't corrupt existing information. - /// - /// The following is an example of how to write such a test: - test("migration from v1 to v2 does not corrupt data", () async { - final oldUsersData = []; // TODO: Add expected data at version 1 using v1.UsersData - final expectedNewUsersData = []; // TODO: Add expected data at version 2 using v2.UsersData + // Test that the foreign key reference introduced in v5 works as expected. + final migratedDb = v5.DatabaseAtV5(schema.newConnection()); + // The `foreign_keys` pragma is a per-connection option and the generated + // versioned classes don't enable it by default. So, enable it manually. + await migratedDb.customStatement('pragma foreign_keys = on;'); + await migratedDb.into(migratedDb.users).insert(v5.UsersCompanion.insert()); + await migratedDb + .into(migratedDb.users) + .insert(v5.UsersCompanion.insert(nextUser: Value(1))); - await verifier.testWithDataIntegrity( - oldVersion: 1, - newVersion: 2, - verifier: verifier, - createOld: (e) => v1.DatabaseAtV1(e), - createNew: (e) => v2.DatabaseAtV2(e), - openTestedDatabase: (e) => Database(e), - createItems: (batch, oldDb) { - batch.insertAll(oldDb.users, oldUsersData); - }, - validateItems: (newDb) async { - expect(expectedNewUsersData, await newDb.select(newDb.users).get()); - }, - ); + // Deleting the first user should now fail due to the constraint + await expectLater(migratedDb.users.deleteWhere((tbl) => tbl.id.equals(1)), + throwsA(isA())); + }); + + test('view works after upgrade from v4 to v5', () async { + final schema = await verifier.schemaAt(4); + + final oldDb = v4.DatabaseAtV4(schema.newConnection()); + await oldDb.batch((batch) { + batch + ..insert(oldDb.users, v4.UsersCompanion.insert(id: Value(1))) + ..insert(oldDb.users, v4.UsersCompanion.insert(id: Value(2))) + ..insert( + oldDb.groups, v4.GroupsCompanion.insert(title: 'Test', owner: 1)); }); + await oldDb.close(); + + // Run the migration and verify that it adds the view. + final db = Database(schema.newConnection()); + await verifier.migrateAndValidate(db, 5); + await db.close(); + + // Make sure the view works! + final migratedDb = v5.DatabaseAtV5(schema.newConnection()); + final viewCount = await migratedDb.select(migratedDb.groupCount).get(); - /// Add additional data integrity tests here + expect( + viewCount, + contains(isA() + .having((e) => e.id, 'id', 1) + .having((e) => e.groupCount, 'groupCount', 1))); + expect( + viewCount, + contains(isA() + .having((e) => e.id, 'id', 2) + .having((e) => e.groupCount, 'groupCount', 0))); + await migratedDb.close(); }); }