diff --git a/examples/app/drift_schemas/drift_schema_v3.json b/examples/app/drift_schemas/drift_schema_v3.json new file mode 100644 index 00000000..b0b298d0 --- /dev/null +++ b/examples/app/drift_schemas/drift_schema_v3.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.0.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"categories","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"ColumnType.integer","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment","primary-key"]},{"name":"name","getter_name":"name","moor_type":"ColumnType.text","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"ColumnType.integer","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const ColorConverter()","dart_type_name":"Color"}}],"is_virtual":false}},{"id":1,"references":[0],"type":"table","data":{"name":"todo_entries","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"ColumnType.integer","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment","primary-key"]},{"name":"description","getter_name":"description","moor_type":"ColumnType.text","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"category","getter_name":"category","moor_type":"ColumnType.integer","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES categories (id)","default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"due_date","getter_name":"dueDate","moor_type":"ColumnType.datetime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false}},{"id":2,"references":[1],"type":"table","data":{"name":"text_entries","was_declared_in_moor":true,"columns":[{"name":"description","getter_name":"description","moor_type":"ColumnType.text","nullable":false,"customConstraints":"","default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":true,"create_virtual_stmt":"CREATE VIRTUAL TABLE text_entries USING fts5 (\n description,\n content=todo_entries,\n content_rowid=id\n);"}},{"id":3,"references":[1,2],"type":"trigger","data":{"on":1,"refences_in_body":[2,1],"name":"todos_insert","sql":"CREATE TRIGGER todos_insert AFTER INSERT ON todo_entries BEGIN INSERT INTO text_entries (\"rowid\", description) VALUES (new.id, new.description);END"}},{"id":4,"references":[1,2],"type":"trigger","data":{"on":1,"refences_in_body":[2,1],"name":"todos_delete","sql":"CREATE TRIGGER todos_delete AFTER DELETE ON todo_entries BEGIN INSERT INTO text_entries (text_entries, \"rowid\", description) VALUES ('delete', old.id, old.description);END"}},{"id":5,"references":[1,2],"type":"trigger","data":{"on":1,"refences_in_body":[2,1],"name":"todos_update","sql":"CREATE TRIGGER todos_update AFTER UPDATE ON todo_entries BEGIN INSERT INTO text_entries (text_entries, \"rowid\", description) VALUES ('delete', new.id, new.description);INSERT INTO text_entries (\"rowid\", description) VALUES (new.id, new.description);END"}}]} \ No newline at end of file diff --git a/examples/app/lib/database/database.dart b/examples/app/lib/database/database.dart index 8178b02e..e35a99f5 100644 --- a/examples/app/lib/database/database.dart +++ b/examples/app/lib/database/database.dart @@ -16,15 +16,29 @@ class AppDatabase extends _$AppDatabase { : super.connect(connection); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration { return MigrationStrategy( onUpgrade: ((m, from, to) async { - if (from == 1) { - // The todoEntries.dueDate column was added in version 2. - await m.addColumn(todoEntries, todoEntries.dueDate); + for (var step = from + 1; step <= to; step++) { + switch (step) { + case 2: + // The todoEntries.dueDate column was added in version 2. + await m.addColumn(todoEntries, todoEntries.dueDate); + break; + case 3: + // New triggers were added in version 3: + await m.create(todosDelete); + await m.create(todosUpdate); + + // Also, the `REFERENCES` constraint was added to + // [TodoEntries.category]. Run a table migration to rebuild all + // column constraints without loosing data. + await m.alterTable(TableMigration(todoEntries)); + break; + } } }), beforeOpen: (details) async { diff --git a/examples/app/lib/database/database.g.dart b/examples/app/lib/database/database.g.dart index 785429d1..ef8a0fa9 100644 --- a/examples/app/lib/database/database.g.dart +++ b/examples/app/lib/database/database.g.dart @@ -623,6 +623,12 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final Trigger todosInsert = Trigger( 'CREATE TRIGGER todos_insert AFTER INSERT ON todo_entries BEGIN INSERT INTO text_entries ("rowid", description) VALUES (new.id, new.description);END', 'todos_insert'); + late final Trigger todosDelete = Trigger( + 'CREATE TRIGGER todos_delete AFTER DELETE ON todo_entries BEGIN INSERT INTO text_entries (text_entries, "rowid", description) VALUES (\'delete\', old.id, old.description);END', + 'todos_delete'); + late final Trigger todosUpdate = Trigger( + 'CREATE TRIGGER todos_update AFTER UPDATE ON todo_entries BEGIN INSERT INTO text_entries (text_entries, "rowid", description) VALUES (\'delete\', new.id, new.description);INSERT INTO text_entries ("rowid", description) VALUES (new.id, new.description);END', + 'todos_update'); Selectable _categoriesWithCount() { return customSelect( 'SELECT c.*, (SELECT COUNT(*) FROM todo_entries WHERE category = c.id) AS amount FROM categories AS c UNION ALL SELECT NULL, NULL, NULL, (SELECT COUNT(*) FROM todo_entries WHERE category IS NULL)', @@ -663,8 +669,14 @@ abstract class _$AppDatabase extends GeneratedDatabase { Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => - [categories, todoEntries, textEntries, todosInsert]; + List get allSchemaEntities => [ + categories, + todoEntries, + textEntries, + todosInsert, + todosDelete, + todosUpdate + ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( [ @@ -675,6 +687,20 @@ abstract class _$AppDatabase extends GeneratedDatabase { TableUpdate('text_entries', kind: UpdateKind.insert), ], ), + WritePropagation( + on: TableUpdateQuery.onTableName('todo_entries', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('text_entries', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('todo_entries', + limitUpdateKind: UpdateKind.update), + result: [ + TableUpdate('text_entries', kind: UpdateKind.insert), + ], + ), ], ); } diff --git a/examples/app/lib/database/sql.drift b/examples/app/lib/database/sql.drift index 4fb0774e..4fde2997 100644 --- a/examples/app/lib/database/sql.drift +++ b/examples/app/lib/database/sql.drift @@ -15,17 +15,14 @@ CREATE TRIGGER todos_insert AFTER INSERT ON todo_entries BEGIN INSERT INTO text_entries(rowid, description) VALUES (new.id, new.description); END; --- todo: Investigate why these two triggers are causing problems -/* -CREATE TRIGGER todos_delete AFTER INSERT ON todo_entries BEGIN - INSERT INTO text_entries(text_entries, rowid, description) VALUES ('delete', new.id, new.description); +CREATE TRIGGER todos_delete AFTER DELETE ON todo_entries BEGIN + INSERT INTO text_entries(text_entries, rowid, description) VALUES ('delete', old.id, old.description); END; -CREATE TRIGGER todos_update AFTER INSERT ON todo_entries BEGIN +CREATE TRIGGER todos_update AFTER UPDATE ON todo_entries BEGIN INSERT INTO text_entries(text_entries, rowid, description) VALUES ('delete', new.id, new.description); INSERT INTO text_entries(rowid, description) VALUES (new.id, new.description); END; -*/ -- Queries can also be defined here, they're then added as methods to the database. _categoriesWithCount: SELECT c.*, diff --git a/examples/app/test/generated_migrations/schema.dart b/examples/app/test/generated_migrations/schema.dart index cfa20213..ba846dde 100644 --- a/examples/app/test/generated_migrations/schema.dart +++ b/examples/app/test/generated_migrations/schema.dart @@ -3,6 +3,7 @@ import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations.dart'; import 'schema_v2.dart' as v2; +import 'schema_v3.dart' as v3; import 'schema_v1.dart' as v1; class GeneratedHelper implements SchemaInstantiationHelper { @@ -11,10 +12,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { switch (version) { case 2: return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); case 1: return v1.DatabaseAtV1(db); default: - throw MissingSchemaException(version, const {2, 1}); + throw MissingSchemaException(version, const {2, 3, 1}); } } } diff --git a/examples/app/test/generated_migrations/schema_v1.dart b/examples/app/test/generated_migrations/schema_v1.dart index 7869557e..74a808ad 100644 --- a/examples/app/test/generated_migrations/schema_v1.dart +++ b/examples/app/test/generated_migrations/schema_v1.dart @@ -78,13 +78,18 @@ class TodoEntries extends Table with TableInfo { bool get dontWriteConstraints => false; } -class text_entriesTable extends Table with TableInfo, VirtualTableInfo { +class TextEntries extends Table with TableInfo, VirtualTableInfo { @override final GeneratedDatabase attachedDatabase; final String? _alias; - text_entriesTable(this.attachedDatabase, [this._alias]); + TextEntries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); @override - List get $columns => []; + List get $columns => [description]; @override String get aliasedName => _alias ?? 'text_entries'; @override @@ -97,8 +102,8 @@ class text_entriesTable extends Table with TableInfo, VirtualTableInfo { } @override - text_entriesTable createAlias(String alias) { - return text_entriesTable(attachedDatabase, alias); + TextEntries createAlias(String alias) { + return TextEntries(attachedDatabase, alias); } @override @@ -113,12 +118,13 @@ class DatabaseAtV1 extends GeneratedDatabase { DatabaseAtV1.connect(DatabaseConnection c) : super.connect(c); late final Categories categories = Categories(this); late final TodoEntries todoEntries = TodoEntries(this); - late final text_entriesTable textEntries = text_entriesTable(this); + late final TextEntries textEntries = TextEntries(this); late final Trigger todosInsert = Trigger( 'CREATE TRIGGER todos_insert AFTER INSERT ON todo_entries BEGIN\n INSERT INTO text_entries(rowid, description) VALUES (new.id, new.description);\nEND;', 'todos_insert'); @override - Iterable get allTables => allSchemaEntities.whereType(); + Iterable> get allTables => + allSchemaEntities.whereType>(); @override List get allSchemaEntities => [categories, todoEntries, textEntries, todosInsert]; diff --git a/examples/app/test/generated_migrations/schema_v2.dart b/examples/app/test/generated_migrations/schema_v2.dart index 52ce3e47..9e1c699b 100644 --- a/examples/app/test/generated_migrations/schema_v2.dart +++ b/examples/app/test/generated_migrations/schema_v2.dart @@ -81,13 +81,18 @@ class TodoEntries extends Table with TableInfo { bool get dontWriteConstraints => false; } -class text_entriesTable extends Table with TableInfo, VirtualTableInfo { +class TextEntries extends Table with TableInfo, VirtualTableInfo { @override final GeneratedDatabase attachedDatabase; final String? _alias; - text_entriesTable(this.attachedDatabase, [this._alias]); + TextEntries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); @override - List get $columns => []; + List get $columns => [description]; @override String get aliasedName => _alias ?? 'text_entries'; @override @@ -100,8 +105,8 @@ class text_entriesTable extends Table with TableInfo, VirtualTableInfo { } @override - text_entriesTable createAlias(String alias) { - return text_entriesTable(attachedDatabase, alias); + TextEntries createAlias(String alias) { + return TextEntries(attachedDatabase, alias); } @override @@ -116,12 +121,13 @@ class DatabaseAtV2 extends GeneratedDatabase { DatabaseAtV2.connect(DatabaseConnection c) : super.connect(c); late final Categories categories = Categories(this); late final TodoEntries todoEntries = TodoEntries(this); - late final text_entriesTable textEntries = text_entriesTable(this); + late final TextEntries textEntries = TextEntries(this); late final Trigger todosInsert = Trigger( 'CREATE TRIGGER todos_insert AFTER INSERT ON todo_entries BEGIN\n INSERT INTO text_entries(rowid, description) VALUES (new.id, new.description);\nEND;', 'todos_insert'); @override - Iterable get allTables => allSchemaEntities.whereType(); + Iterable> get allTables => + allSchemaEntities.whereType>(); @override List get allSchemaEntities => [categories, todoEntries, textEntries, todosInsert]; diff --git a/examples/app/test/generated_migrations/schema_v3.dart b/examples/app/test/generated_migrations/schema_v3.dart new file mode 100644 index 00000000..9b98a77d --- /dev/null +++ b/examples/app/test/generated_migrations/schema_v3.dart @@ -0,0 +1,150 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +//@dart=2.12 +import 'package:drift/drift.dart'; + +class Categories extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Categories(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn color = GeneratedColumn( + 'color', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [id, name, color]; + @override + String get aliasedName => _alias ?? 'categories'; + @override + String get actualTableName => 'categories'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Categories createAlias(String alias) { + return Categories(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => false; +} + +class TodoEntries extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TodoEntries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: 'REFERENCES categories (id)'); + late final GeneratedColumn dueDate = GeneratedColumn( + 'due_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [id, description, category, dueDate]; + @override + String get aliasedName => _alias ?? 'todo_entries'; + @override + String get actualTableName => 'todo_entries'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + TodoEntries createAlias(String alias) { + return TodoEntries(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => false; +} + +class TextEntries extends Table with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TextEntries(this.attachedDatabase, [this._alias]); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); + @override + List get $columns => [description]; + @override + String get aliasedName => _alias ?? 'text_entries'; + @override + String get actualTableName => 'text_entries'; + @override + Set get $primaryKey => {}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + TextEntries createAlias(String alias) { + return TextEntries(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(description, content=todo_entries, content_rowid=id)'; +} + +class DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + DatabaseAtV3.connect(DatabaseConnection c) : super.connect(c); + late final Categories categories = Categories(this); + late final TodoEntries todoEntries = TodoEntries(this); + late final TextEntries textEntries = TextEntries(this); + late final Trigger todosInsert = Trigger( + 'CREATE TRIGGER todos_insert AFTER INSERT ON todo_entries BEGIN INSERT INTO text_entries ("rowid", description) VALUES (new.id, new.description);END', + 'todos_insert'); + late final Trigger todosDelete = Trigger( + 'CREATE TRIGGER todos_delete AFTER DELETE ON todo_entries BEGIN INSERT INTO text_entries (text_entries, "rowid", description) VALUES (\'delete\', old.id, old.description);END', + 'todos_delete'); + late final Trigger todosUpdate = Trigger( + 'CREATE TRIGGER todos_update AFTER UPDATE ON todo_entries BEGIN INSERT INTO text_entries (text_entries, "rowid", description) VALUES (\'delete\', new.id, new.description);INSERT INTO text_entries ("rowid", description) VALUES (new.id, new.description);END', + 'todos_update'); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + categories, + todoEntries, + textEntries, + todosInsert, + todosDelete, + todosUpdate + ]; + @override + int get schemaVersion => 3; +} diff --git a/examples/app/test/migration_test.dart b/examples/app/test/migration_test.dart index 4b228286..f920687f 100644 --- a/examples/app/test/migration_test.dart +++ b/examples/app/test/migration_test.dart @@ -1,4 +1,5 @@ import 'package:app/database/database.dart'; +import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,6 +14,60 @@ void main() { verifier = SchemaVerifier(GeneratedHelper()); }); + group('schema integrity is kept', () { + const currentVersion = 3; + + // This loop tests all possible schema upgrades. It uses drift APIs to + // ensure that the schema is in the expected format after an upgrade, but + // simple tests like these can't ensure that your migration doesn't loose + // data. + for (var start = 1; start < currentVersion; start++) { + group('from v$start', () { + for (var target = start + 1; target <= currentVersion; target++) { + test('to v$target', () async { + // Use startAt() to obtain a database connection with all tables + // from the old schema set up. + final connection = await verifier.startAt(start); + final db = AppDatabase.forTesting(connection); + addTearDown(db.close); + + // Use this to run a migration and then validate that the database + // has the expected schema. + await verifier.migrateAndValidate(db, target); + }); + } + }); + } + }); + + // For specific schema upgrades, you can also write manual tests to ensure + // that running the migration does not loose data. + test('upgrading from v1 to v2 does not loose data', () async { + // Use startAt(1) to obtain a usable database + final connection = await verifier.schemaAt(1); + connection.rawDatabase.execute( + 'INSERT INTO todo_entries (description) VALUES (?)', + ['My manually added entry'], + ); + + final db = AppDatabase.forTesting(connection.newConnection()); + addTearDown(db.close); + await verifier.migrateAndValidate(db, 2); + + // Make sure that the row is still there after migrating + expect( + db.todoEntries.select().get(), + completion( + [ + const TodoEntry( + id: 1, + description: 'My manually added entry', + ) + ], + ), + ); + }); + test('upgrade from v1 to v2', () async { // Use startAt(1) to obtain a database connection with all tables // from the v1 schema.