diff --git a/example/lib/database.dart b/example/lib/database.dart index 396a7143..c2c64763 100644 --- a/example/lib/database.dart +++ b/example/lib/database.dart @@ -7,7 +7,7 @@ import 'package:sqflite/sqflite.dart' as sqflite; part 'database.g.dart'; -@Database(version: 1, entities: [Task]) +@Database(version: 1, entities: [Task], fallbackToDestructiveMigration: false) abstract class FlutterDatabase extends FloorDatabase { TaskDao get taskDao; } diff --git a/example/lib/database.g.dart b/example/lib/database.g.dart index 5547d034..32efb1b8 100644 --- a/example/lib/database.g.dart +++ b/example/lib/database.g.dart @@ -74,21 +74,34 @@ class _$FlutterDatabase extends FlutterDatabase { await callback?.onOpen?.call(database); }, onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + } on MissingMigrationException catch (_) { + throw StateError( + 'There is no migration supplied to update the database to the current version.' + ' Aborting the migration.', + ); + } await callback?.onUpgrade?.call(database, startVersion, endVersion); }, onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Task` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `message` TEXT NOT NULL)'); - + await _create(database); await callback?.onCreate?.call(database, version); }, ); return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); } + Future _create(sqflite.Database database) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `Task` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `message` TEXT NOT NULL)'); + } + @override TaskDao get taskDao { return _taskDaoInstance ??= _$TaskDao(database, changeListener); diff --git a/floor/lib/src/adapter/migration_adapter.dart b/floor/lib/src/adapter/migration_adapter.dart index efc813c0..6b65d3fe 100644 --- a/floor/lib/src/adapter/migration_adapter.dart +++ b/floor/lib/src/adapter/migration_adapter.dart @@ -1,6 +1,8 @@ import 'package:floor/src/migration.dart'; import 'package:sqflite/sqflite.dart'; +class MissingMigrationException implements Exception {} + abstract class MigrationAdapter { /// Runs the given [migrations] for migrating the database schema and data. static Future runMigrations( @@ -17,10 +19,7 @@ abstract class MigrationAdapter { if (relevantMigrations.isEmpty || relevantMigrations.last.endVersion != endVersion) { - throw StateError( - 'There is no migration supplied to update the database to the current version.' - ' Aborting the migration.', - ); + throw MissingMigrationException(); } for (final migration in relevantMigrations) { diff --git a/floor/lib/src/callback.dart b/floor/lib/src/callback.dart index b0b558bd..4a5a8daa 100644 --- a/floor/lib/src/callback.dart +++ b/floor/lib/src/callback.dart @@ -25,7 +25,30 @@ class Callback { int endVersion, )? onUpgrade; + /// Fired when the [database] failed upgrading caused by [exception] from + /// [startVersion] to [endVersion], before dropping all existing data and re-creating the schema. + final FutureOr Function( + Database database, + int startVersion, + int endVersion, + Exception exception, + )? onDestructiveUpgrade; + + /// Fired when the [database] is downgrading from [startVersion] to + /// [endVersion], before dropping all existing data and re-creating the schema. + final FutureOr Function( + Database database, + int startVersion, + int endVersion, + )? onDestructiveDowngrade; + /// Constructor. - const Callback( - {this.onConfigure, this.onCreate, this.onOpen, this.onUpgrade}); + const Callback({ + this.onConfigure, + this.onCreate, + this.onOpen, + this.onUpgrade, + this.onDestructiveUpgrade, + this.onDestructiveDowngrade, + }); } diff --git a/floor/test/adapter/migration_adapter_test.dart b/floor/test/adapter/migration_adapter_test.dart index 87f0d147..8cb0a685 100644 --- a/floor/test/adapter/migration_adapter_test.dart +++ b/floor/test/adapter/migration_adapter_test.dart @@ -86,7 +86,7 @@ void main() { migrations, ); - expect(actual, throwsStateError); + expect(actual, throwsException); verifyZeroInteractions(mockDatabase); }); @@ -107,7 +107,7 @@ void main() { migrations, ); - expect(actual, throwsStateError); + expect(actual, throwsException); verifyZeroInteractions(mockDatabase); }); } diff --git a/floor_annotation/lib/src/database.dart b/floor_annotation/lib/src/database.dart index d3bf873e..fefd73d8 100644 --- a/floor_annotation/lib/src/database.dart +++ b/floor_annotation/lib/src/database.dart @@ -9,10 +9,14 @@ class Database { /// The views the database manages. final List views; + // Re-create the database if migration fails or is missing. + final bool fallbackToDestructiveMigration; + /// Marks a class as a FloorDatabase. const Database({ required this.version, required this.entities, + this.fallbackToDestructiveMigration = false, this.views = const [], }); } diff --git a/floor_generator/lib/misc/constants.dart b/floor_generator/lib/misc/constants.dart index 689c1f7e..2809496d 100644 --- a/floor_generator/lib/misc/constants.dart +++ b/floor_generator/lib/misc/constants.dart @@ -6,6 +6,8 @@ abstract class AnnotationField { static const databaseVersion = 'version'; static const databaseEntities = 'entities'; static const databaseViews = 'views'; + static const databaseFallbackToDestructiveMigration = + 'fallbackToDestructiveMigration'; static const columnInfoName = 'name'; diff --git a/floor_generator/lib/processor/database_processor.dart b/floor_generator/lib/processor/database_processor.dart index f088d351..0188cd96 100644 --- a/floor_generator/lib/processor/database_processor.dart +++ b/floor_generator/lib/processor/database_processor.dart @@ -53,6 +53,7 @@ class DatabaseProcessor extends Processor { daoGetters, version, databaseTypeConverters, + _getFallBackToDestruction(), allTypeConverters, ); } @@ -178,4 +179,13 @@ class DatabaseProcessor extends Processor { return classElement.hasAnnotation(annotations.DatabaseView) && !classElement.isAbstract; } + + bool _getFallBackToDestruction() { + final isDestructive = _classElement + .getAnnotation(annotations.Database) + ?.getField(AnnotationField.databaseFallbackToDestructiveMigration) + ?.toBoolValue(); + + return isDestructive ?? false; + } } diff --git a/floor_generator/lib/value_object/database.dart b/floor_generator/lib/value_object/database.dart index a830db14..2b562f02 100644 --- a/floor_generator/lib/value_object/database.dart +++ b/floor_generator/lib/value_object/database.dart @@ -16,6 +16,7 @@ class Database { final int version; final Set databaseTypeConverters; final Set allTypeConverters; + final bool fallbackToDestructiveMigration; final bool hasViewStreams; final Set streamEntities; @@ -27,6 +28,7 @@ class Database { this.daoGetters, this.version, this.databaseTypeConverters, + this.fallbackToDestructiveMigration, this.allTypeConverters, ) : streamEntities = daoGetters.expand((dg) => dg.dao.streamEntities).toSet(), @@ -45,6 +47,8 @@ class Database { version == other.version && databaseTypeConverters.equals(other.databaseTypeConverters) && allTypeConverters.equals(other.allTypeConverters) && + fallbackToDestructiveMigration == + other.fallbackToDestructiveMigration && streamEntities.equals(other.streamEntities) && hasViewStreams == hasViewStreams; @@ -58,11 +62,12 @@ class Database { version.hashCode ^ databaseTypeConverters.hashCode ^ allTypeConverters.hashCode ^ + fallbackToDestructiveMigration.hashCode ^ streamEntities.hashCode ^ hasViewStreams.hashCode; @override String toString() { - return 'Database{classElement: $classElement, name: $name, entities: $entities, views: $views, daoGetters: $daoGetters, version: $version, databaseTypeConverters: $databaseTypeConverters, allTypeConverters: $allTypeConverters, streamEntities: $streamEntities, hasViewStreams: $hasViewStreams}'; + return 'Database{classElement: $classElement, name: $name, entities: $entities, views: $views, daoGetters: $daoGetters, version: $version, databaseTypeConverters: $databaseTypeConverters, $version,isFallBackToDestruction: $fallbackToDestructiveMigration, allTypeConverters: $allTypeConverters, streamEntities: $streamEntities, hasViewStreams: $hasViewStreams}'; } } diff --git a/floor_generator/lib/writer/database_writer.dart b/floor_generator/lib/writer/database_writer.dart index e1c91897..aad286fe 100644 --- a/floor_generator/lib/writer/database_writer.dart +++ b/floor_generator/lib/writer/database_writer.dart @@ -19,13 +19,16 @@ class DatabaseWriter implements Writer { Class _generateDatabaseImplementation(final Database database) { final databaseName = database.name; - return Class((builder) => builder - ..name = '_\$$databaseName' - ..extend = refer(databaseName) - ..methods.add(_generateOpenMethod(database)) - ..methods.addAll(_generateDaoGetters(database)) - ..fields.addAll(_generateDaoInstances(database)) - ..constructors.add(_generateConstructor())); + return Class((builder) { + builder + ..name = '_\$$databaseName' + ..extend = refer(databaseName) + ..methods.add(_generateOpenMethod(database)) + ..methods.add(_generateCreateMethod(database)) + ..methods.addAll(_generateDaoGetters(database)) + ..fields.addAll(_generateDaoInstances(database)) + ..constructors.add(_generateConstructor()); + }); } Constructor _generateConstructor() { @@ -68,21 +71,40 @@ class DatabaseWriter implements Writer { }).toList(); } - Method _generateOpenMethod(final Database database) { + Method _generateCreateMethod(final Database database) { + final databaseParameter = Parameter((builder) => builder + ..name = 'database' + ..type = refer('sqflite.Database')); + final createTableStatements = _generateCreateTableSqlStatements( database.entities) .map((statement) => 'await database.execute(${statement.toLiteral()});') .join('\n'); + final createIndexStatements = database.entities .map((entity) => entity.indices.map((index) => index.createQuery())) .expand((statements) => statements) .map((statement) => 'await database.execute(${statement.toLiteral()});') .join('\n'); + final createViewStatements = database.views .map((view) => view.getCreateViewStatement().toLiteral()) .map((statement) => 'await database.execute($statement);') .join('\n'); + return Method((builder) => builder + ..name = '_create' + ..returns = refer('Future') + ..requiredParameters.add(databaseParameter) + ..modifier = MethodModifier.async + ..body = Code(''' + $createTableStatements + $createIndexStatements + $createViewStatements + ''')); + } + + Method _generateOpenMethod(final Database database) { final pathParameter = Parameter((builder) => builder ..name = 'path' ..type = refer('String')); @@ -93,13 +115,59 @@ class DatabaseWriter implements Writer { ..name = 'callback' ..type = refer('Callback?')); - return Method((builder) => builder - ..name = 'open' - ..returns = refer('Future') - ..modifier = MethodModifier.async - ..requiredParameters.addAll([pathParameter, migrationsParameter]) - ..optionalParameters.add(callbackParameter) - ..body = Code(''' + String body; + + if (database.fallbackToDestructiveMigration) { + body = ''' + bool shouldDeleteDatabase = false; + + final databaseOptions = sqflite.OpenDatabaseOptions( + version: ${database.version}, + onConfigure: (database) async { + await database.execute('PRAGMA foreign_keys = ON'); + await callback?.onConfigure?.call(database); + }, + onOpen: (database) async { + await callback?.onOpen?.call(database); + }, + onUpgrade: (database, startVersion, endVersion) async { + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + await callback?.onUpgrade?.call(database, startVersion, endVersion); + } on Exception catch (e) { + await callback?.onDestructiveUpgrade?.call(database, startVersion, endVersion, e); + shouldDeleteDatabase = true; + } + }, + onDowngrade: (database, startVersion, endVersion) async { + await callback?.onDestructiveDowngrade?.call(database, startVersion, endVersion); + shouldDeleteDatabase = true; + }, + onCreate: (database, version) async { + await _create(database); + await callback?.onCreate?.call(database, version); + }, + ); + + final database = await sqfliteDatabaseFactory.openDatabase(path, + options: databaseOptions); + + if (shouldDeleteDatabase) { + await database.close(); + await sqfliteDatabaseFactory.deleteDatabase(path); + return sqfliteDatabaseFactory.openDatabase(path, + options: databaseOptions); + } else { + return database; + } + '''; + } else { + body = ''' final databaseOptions = sqflite.OpenDatabaseOptions( version: ${database.version}, onConfigure: (database) async { @@ -110,20 +178,37 @@ class DatabaseWriter implements Writer { await callback?.onOpen?.call(database); }, onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations(database, startVersion, endVersion, migrations); - + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + } on MissingMigrationException catch (_) { + throw StateError( + 'There is no migration supplied to update the database to the current version.' + ' Aborting the migration.', + ); + } await callback?.onUpgrade?.call(database, startVersion, endVersion); }, onCreate: (database, version) async { - $createTableStatements - $createIndexStatements - $createViewStatements - + await _create(database); await callback?.onCreate?.call(database, version); }, ); return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); - ''')); + '''; + } + + return Method((builder) => builder + ..name = 'open' + ..returns = refer('Future') + ..modifier = MethodModifier.async + ..requiredParameters.addAll([pathParameter, migrationsParameter]) + ..optionalParameters.add(callbackParameter) + ..body = Code(body)); } List _generateCreateTableSqlStatements(final List entities) { diff --git a/floor_generator/test/writer/database_writer_test.dart b/floor_generator/test/writer/database_writer_test.dart index abc90a60..90c87030 100644 --- a/floor_generator/test/writer/database_writer_test.dart +++ b/floor_generator/test/writer/database_writer_test.dart @@ -47,20 +47,117 @@ void main() { await callback?.onOpen?.call(database); }, onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + } on MissingMigrationException catch (_) { + throw StateError( + 'There is no migration supplied to update the database to the current version.' + ' Aborting the migration.', + ); + } await callback?.onUpgrade?.call(database, startVersion, endVersion); }, onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); - + await _create(database); await callback?.onCreate?.call(database, version); }, ); return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); } + + Future _create(sqflite.Database database) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); + } + } + ''')); + }); + + test('open database for simple entity using fallback migration', () async { + final database = await _createDatabase(''' + @Database(version: 1, entities: [Person], fallbackToDestructiveMigration: true) + abstract class TestDatabase extends FloorDatabase {} + + @entity + class Person { + @primaryKey + final int id; + + final String name; + + Person(this.id, this.name); + } + '''); + + final actual = DatabaseWriter(database).write(); + + expect(actual, equalsDart(r''' + class _$TestDatabase extends TestDatabase { + _$TestDatabase([StreamController? listener]) { + changeListener = listener ?? StreamController.broadcast(); + } + + Future open(String path, List migrations, + [Callback? callback]) async { + bool shouldDeleteDatabase = false; + + final databaseOptions = sqflite.OpenDatabaseOptions( + version: 1, + onConfigure: (database) async { + await database.execute('PRAGMA foreign_keys = ON'); + await callback?.onConfigure?.call(database); + }, + onOpen: (database) async { + await callback?.onOpen?.call(database); + }, + onUpgrade: (database, startVersion, endVersion) async { + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + await callback?.onUpgrade?.call(database, startVersion, endVersion); + } on Exception catch (e) { + await callback?.onDestructiveUpgrade + ?.call(database, startVersion, endVersion, e); + shouldDeleteDatabase = true; + } + }, + onDowngrade: (database, startVersion, endVersion) async { + await callback?.onDestructiveDowngrade + ?.call(database, startVersion, endVersion); + shouldDeleteDatabase = true; + }, + onCreate: (database, version) async { + await _create(database); + await callback?.onCreate?.call(database, version); + }, + ); + + final database = await sqfliteDatabaseFactory.openDatabase(path, + options: databaseOptions); + + if (shouldDeleteDatabase) { + await database.close(); + await sqfliteDatabaseFactory.deleteDatabase(path); + return sqfliteDatabaseFactory.openDatabase(path, + options: databaseOptions); + } else { + return database; + } + } + + Future _create(sqflite.Database database) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); + } } ''')); }); @@ -71,17 +168,17 @@ void main() { abstract class TestDatabase extends FloorDatabase { TestDao get testDao; } - + @entity class Person { @primaryKey final int id; - + final String name; - + Person(this.id, this.name); } - + @dao abstract class TestDao { @insert @@ -96,9 +193,9 @@ void main() { _$TestDatabase([StreamController? listener]) { changeListener = listener ?? StreamController.broadcast(); } - + TestDao? _testDaoInstance; - + Future open(String path, List migrations, [Callback? callback]) async { final databaseOptions = sqflite.OpenDatabaseOptions( @@ -111,21 +208,34 @@ void main() { await callback?.onOpen?.call(database); }, onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + } on MissingMigrationException catch (_) { + throw StateError( + 'There is no migration supplied to update the database to the current version.' + ' Aborting the migration.', + ); + } await callback?.onUpgrade?.call(database, startVersion, endVersion); }, onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); - + await _create(database); await callback?.onCreate?.call(database, version); }, ); return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); } - + + Future _create(sqflite.Database database) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); + } + @override TestDao get testDao { return _testDaoInstance ??= _$TestDao(database, changeListener); @@ -138,15 +248,15 @@ void main() { final database = await _createDatabase(''' @Database(version: 1, entities: [Person]) abstract class TestDatabase extends FloorDatabase {} - + @Entity(tableName: 'custom_table_name') class Person { @PrimaryKey(autoGenerate: true) final int? id; - + @ColumnInfo(name: 'custom_name') final String name; - + Person(this.id, this.name); } '''); @@ -158,7 +268,7 @@ void main() { _$TestDatabase([StreamController? listener]) { changeListener = listener ?? StreamController.broadcast(); } - + Future open(String path, List migrations, [Callback? callback]) async { final databaseOptions = sqflite.OpenDatabaseOptions( @@ -171,21 +281,34 @@ void main() { await callback?.onOpen?.call(database); }, onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + } on MissingMigrationException catch (_) { + throw StateError( + 'There is no migration supplied to update the database to the current version.' + ' Aborting the migration.', + ); + } await callback?.onUpgrade?.call(database, startVersion, endVersion); }, onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `custom_table_name` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `custom_name` TEXT NOT NULL)'); - + await _create(database); await callback?.onCreate?.call(database, version); }, ); return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); } - } + + Future _create(sqflite.Database database) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `custom_table_name` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `custom_name` TEXT NOT NULL)'); + } + } ''')); }); @@ -193,23 +316,23 @@ void main() { final database = await _createDatabase(''' @Database(version: 1, entities: [Person], views: [Name]) abstract class TestDatabase extends FloorDatabase {} - + @DatabaseView( 'SELECT custom_name as name FROM person', viewName: 'names') class Name { final String name; - + Name(this.name); } - + @entity class Person { @primaryKey final int id; - + final String name; - + Person(this.id, this.name); } '''); @@ -221,7 +344,7 @@ void main() { _$TestDatabase([StreamController? listener]) { changeListener = listener ?? StreamController.broadcast(); } - + Future open(String path, List migrations, [Callback? callback]) async { final databaseOptions = sqflite.OpenDatabaseOptions( @@ -234,24 +357,37 @@ void main() { await callback?.onOpen?.call(database); }, onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - + try { + await MigrationAdapter.runMigrations( + database, + startVersion, + endVersion, + migrations, + ); + } on MissingMigrationException catch (_) { + throw StateError( + 'There is no migration supplied to update the database to the current version.' + ' Aborting the migration.', + ); + } await callback?.onUpgrade?.call(database, startVersion, endVersion); }, onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); - - await database.execute( - 'CREATE VIEW IF NOT EXISTS `names` AS SELECT custom_name as name FROM person'); - + await _create(database); await callback?.onCreate?.call(database, version); }, ); return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); } - } + + Future _create(sqflite.Database database) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `Person` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY (`id`))'); + + await database.execute( + 'CREATE VIEW IF NOT EXISTS `names` AS SELECT custom_name as name FROM person'); + } + } """)); }); }