Skip to content

Fix DB downgrades to match creating from scratch #1428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 24, 2025
113 changes: 50 additions & 63 deletions lib/model/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,65 +68,44 @@ class UriConverter extends TypeConverter<Uri, String> {
@override Uri fromSql(String fromDb) => Uri.parse(fromDb);
}

// TODO(drift): generate this
VersionedSchema _getSchema({
required DatabaseConnectionUser database,
required int schemaVersion,
}) {
switch (schemaVersion) {
case 2:
return Schema2(database: database);
case 3:
return Schema3(database: database);
case 4:
return Schema4(database: database);
case 5:
return Schema5(database: database);
default:
throw Exception('unknown schema version: $schemaVersion');
}
}

@DriftDatabase(tables: [GlobalSettings, Accounts])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);

// When updating the schema:
// * Make the change in the table classes, and bump schemaVersion.
// * Make the change in the table classes, and bump latestSchemaVersion.
// * Export the new schema and generate test migrations with drift:
// $ tools/check --fix drift
// and generate database code with build_runner.
// See ../../README.md#generated-files for more
// information on using the build_runner.
// * Update [_getSchema] to handle the new schemaVersion.
// * Write a migration in `_migrationSteps` below.
// * Write tests.
static const int latestSchemaVersion = 5; // See note.

@override
int get schemaVersion => 5; // See note.

static Future<void> _dropAndCreateAll(Migrator m, {
required int schemaVersion,
}) async {
await m.database.transaction(() async {
final query = m.database.customSelect(
"SELECT name FROM sqlite_master WHERE type='table'");
for (final row in await query.get()) {
final data = row.data;
final tableName = data['name'] as String;
// Skip sqlite-internal tables. See for comparison:
// https://www.sqlite.org/fileformat2.html#intschema
// https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22
if (tableName.startsWith('sqlite_')) continue;
// No need to worry about SQL injection; this table name
// was already a table name in the database, not something
// that should be affected by user data.
await m.database.customStatement('DROP TABLE $tableName');
}
final schema = _getSchema(database: m.database, schemaVersion: schemaVersion);
for (final entity in schema.entities) {
await m.create(entity);
}
});
int get schemaVersion => latestSchemaVersion;

/// Drop all tables, indexes, etc., in the database.
///
/// This includes tables that aren't known to the schema, for example because
/// they were defined by a future (perhaps experimental) version of the app
/// before switching back to the version currently running.
static Future<void> _dropAll(Migrator m) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    db: Cut transaction wrappers within migrations

Makes sense to me. I think drift doesn't set up a transaction for the entire migration.

If we really need one, we can probably wrap the entirety of onUpgrade (the line responsible for PRAGMA user_version = can be found in VersionedSchema.runMigrationSteps, which is called through Migrator.runMigrationSteps). While the transaction might prevent corrupting the database if a migration goes wrong, it's not so useful unless the app can tolerate/recover from said migration failure, which is not a goal of this PR.

final query = m.database.customSelect(
"SELECT name FROM sqlite_master WHERE type='table'");
for (final row in await query.get()) {
final data = row.data;
final tableName = data['name'] as String;
// Skip sqlite-internal tables. See for comparison:
// https://www.sqlite.org/fileformat2.html#intschema
// https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22
if (tableName.startsWith('sqlite_')) continue;
// No need to worry about SQL injection; this table name
// was already a table name in the database, not something
// that should be affected by user data.
await m.database.customStatement('DROP TABLE $tableName');
}
}

static final MigrationStepWithVersion _migrationSteps = migrationSteps(
Expand All @@ -145,37 +124,45 @@ class AppDatabase extends _$AppDatabase {
// This migration ensures there is a row in GlobalSettings.
// (If the app already ran at schema 3 or 4, there will be;
// if not, there won't be before this point.)
await m.database.transaction(() async {
final rows = await m.database.select(schema.globalSettings).get();
if (rows.isEmpty) {
await m.database.into(schema.globalSettings).insert(
// No field values; just use the defaults for both fields.
// (This is like `GlobalSettingsCompanion.insert()`, but
// without dependence on the current schema.)
RawValuesInsertable({}));
}
});
final rows = await m.database.select(schema.globalSettings).get();
if (rows.isEmpty) {
await m.database.into(schema.globalSettings).insert(
// No field values; just use the defaults for both fields.
// (This is like `GlobalSettingsCompanion.insert()`, but
// without dependence on the current schema.)
RawValuesInsertable({}));
}
},
);

Future<void> _createLatestSchema(Migrator m) async {
await m.createAll();
// Corresponds to `from4to5` above.
await into(globalSettings).insert(GlobalSettingsCompanion());
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I briefly looked into beforeOpen since it is introduced as a way to "populate data after the database has been created", but I think this PR's approach is still better. Because it fully utilizes when the row needs to be inserted (beforeOpen is "called whenever the database is opened, regardless of whether a migration actually ran or not").


@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
// Corresponds to `from4to5` above.
await into(globalSettings).insert(GlobalSettingsCompanion());
},
onCreate: _createLatestSchema,
onUpgrade: (Migrator m, int from, int to) async {
if (from > to) {
// This should only ever happen in dev. As a dev convenience,
// drop everything from the database and start over.
// TODO(log): log schema downgrade as an error
assert(debugLog('Downgrading schema from v$from to v$to.'));
await _dropAndCreateAll(m, schemaVersion: to);

// In the actual app, the target schema version is always
// the latest version as of the code that's being run.
// Migrating to earlier versions is useful only for isolating steps
// in migration tests; we can forego that for testing downgrades.
assert(to == latestSchemaVersion);

await _dropAll(m);
await _createLatestSchema(m);
return;
}
assert(1 <= from && from <= to && to <= schemaVersion);
assert(1 <= from && from <= to && to <= latestSchemaVersion);

await m.runMigrationSteps(from: from, to: to, steps: _migrationSteps);
});
Expand Down
16 changes: 10 additions & 6 deletions test/model/database_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,41 +127,45 @@ void main() {
});

test('downgrading', () async {
final schema = await verifier.schemaAt(2);
final toVersion = AppDatabase.latestSchemaVersion;
final schema = await verifier.schemaAt(toVersion);

// This simulates the scenario during development when running the app
// with a future schema version that has additional tables and columns.
final before = AppDatabase(schema.newConnection());
await before.customStatement('CREATE TABLE test_extra (num int)');
await before.customStatement('ALTER TABLE accounts ADD extra_column int');
await check(verifier.migrateAndValidate(
before, 2, validateDropped: true)).throws<SchemaMismatch>();
before, toVersion, validateDropped: true)).throws<SchemaMismatch>();
// Override the schema version by modifying the underlying value
// drift internally keeps track of in the database.
// TODO(drift): Expose a better interface for testing this.
await before.customStatement('PRAGMA user_version = 999;');
await before.customStatement('PRAGMA user_version = ${toVersion + 1};');
await before.close();

// Simulate starting up the app, with an older schema version that
// does not have the extra tables and columns.
final after = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(after, 2, validateDropped: true);
await verifier.migrateAndValidate(after, toVersion, validateDropped: true);
// Check that a custom migration/setup step of ours got run too.
check(await after.getGlobalSettings()).themeSetting.isNull();
await after.close();
});

group('migrate without data', () {
const versions = GeneratedHelper.versions;
final latestVersion = versions.last;

int fromVersion = versions.first;
int prev = versions.first;
for (final toVersion in versions.skip(1)) {
final fromVersion = prev;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! Thanks for fixing that. This reminds me of the use of IIFEs that pass arguments to the enclosed scope.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, exactly — that's another way to handle the same sort of situation.

test('from v$fromVersion to v$toVersion', () async {
final connection = await verifier.startAt(fromVersion);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, toVersion);
await db.close();
});
fromVersion = toVersion;
prev = toVersion;
}

for (final fromVersion in versions) {
Expand Down