Skip to content

Commit 7a00e3c

Browse files
committed
db: Drop all tables on downgrade
We previously missed tables that are not known to the schema. This becomes an issue if a new table is added at a newer schema level. When we go back to an earlier schema, that new table remains in the database, so subsequent attempts to upgrade to the later schema level that adds the table will fail, because it already exists. Fixes: #1172 Signed-off-by: Zixuan James Li <[email protected]>
1 parent fa11187 commit 7a00e3c

File tree

2 files changed

+75
-7
lines changed

2 files changed

+75
-7
lines changed

lib/model/database.dart

+43-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 'package:drift/drift.dart';
2+
import 'package:drift/internal/versioned_schema.dart';
23
import 'package:drift/remote.dart';
34
import 'package:sqlite3/common.dart';
45

6+
import '../log.dart';
57
import 'schema_versions.g.dart';
68

79
part 'database.g.dart';
@@ -49,6 +51,19 @@ class UriConverter extends TypeConverter<Uri, String> {
4951
@override Uri fromSql(String fromDb) => Uri.parse(fromDb);
5052
}
5153

54+
// TODO(upstream): generate this
55+
VersionedSchema _getSchema({
56+
required DatabaseConnectionUser database,
57+
required int schemaVersion,
58+
}) {
59+
switch (schemaVersion) {
60+
case 2:
61+
return Schema2(database: database);
62+
default:
63+
throw Exception('unknown schema version: $schemaVersion');
64+
}
65+
}
66+
5267
@DriftDatabase(tables: [Accounts])
5368
class AppDatabase extends _$AppDatabase {
5469
AppDatabase(super.e);
@@ -65,6 +80,31 @@ class AppDatabase extends _$AppDatabase {
6580
@override
6681
int get schemaVersion => 2; // See note.
6782

83+
Future<void> _dropAndCreateAll(Migrator m, {
84+
required int schemaVersion,
85+
}) async {
86+
await m.database.transaction(() async {
87+
final query = m.database.customSelect(
88+
"SELECT name FROM sqlite_master WHERE type='table'");
89+
for (final row in await query.get()) {
90+
final data = row.data;
91+
final tableName = data['name'] as String;
92+
// Skip sqlite-internal tables. See for comparison:
93+
// https://www.sqlite.org/fileformat2.html#intschema
94+
// https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22
95+
if (tableName.startsWith('sqlite_')) continue;
96+
// No need to worry about SQL injection; this table name
97+
// was already a table name in the database, not something
98+
// that should be affected by user data.
99+
await m.database.customStatement('DROP TABLE $tableName');
100+
}
101+
final schema = _getSchema(database: m.database, schemaVersion: schemaVersion);
102+
for (final entity in schema.entities) {
103+
await m.create(entity);
104+
}
105+
});
106+
}
107+
68108
@override
69109
MigrationStrategy get migration {
70110
return MigrationStrategy(
@@ -73,15 +113,11 @@ class AppDatabase extends _$AppDatabase {
73113
},
74114
onUpgrade: (Migrator m, int from, int to) async {
75115
if (from > to) {
76-
// TODO(log): log schema downgrade as an error
77116
// This should only ever happen in dev. As a dev convenience,
78117
// drop everything from the database and start over.
79-
for (final entity in allSchemaEntities) {
80-
// This will miss any entire tables (or indexes, etc.) that
81-
// don't exist at this version. For a dev-only feature, that's OK.
82-
await m.drop(entity);
83-
}
84-
await m.createAll();
118+
// TODO(log): log schema downgrade as an error
119+
assert(debugLog('Downgrading schema from v$from to v$to.'));
120+
await _dropAndCreateAll(m, schemaVersion: to);
85121
return;
86122
}
87123
assert(1 <= from && from <= to && to <= schemaVersion);

test/model/database_test.dart

+32
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:checks/checks.dart';
2+
import 'package:drift/backends.dart';
23
import 'package:drift/drift.dart';
34
import 'package:drift/native.dart';
45
import 'package:drift_dev/api/migrations_native.dart';
@@ -98,6 +99,37 @@ void main() {
9899
verifier = SchemaVerifier(GeneratedHelper());
99100
});
100101

102+
test('downgrading', () async {
103+
final schema = await verifier.schemaAt(2);
104+
105+
// Prepare the database by creating extra tables and columns unknown to
106+
// us. This represent the scenario during developement when checking out
107+
// a branch that does not contain some changes to the schema on another
108+
// branch.
109+
final before = AppDatabase(schema.newConnection());
110+
await before.customStatement('CREATE TABLE test_extra (num int)');
111+
await before.customStatement('ALTER TABLE accounts ADD extra_column int');
112+
await check(verifier.migrateAndValidate(
113+
before, 2, validateDropped: true)).throws<SchemaMismatch>();
114+
await before.close();
115+
116+
// We need a new connection because migrations are only run when the
117+
// database is first opened (see [DelegatedDatabase.ensureOpen]), and
118+
// running custom statements opens it.
119+
final after = AppDatabase(schema.newConnection());
120+
// Trick the drift into believing that the schema version is
121+
// higher than it actually is, so that a downgrade migration is triggered.
122+
// This relies on some relevant implementation details:
123+
// - [DelegatedDatabase._runMigrations]
124+
// - [VerifierImplementation.migrateAndValidate]
125+
// TODO(upstream): Expose a better interface for testing this.
126+
await ((after.executor as DelegatedDatabase)
127+
.delegate.versionDelegate as DynamicVersionDelegate).setSchemaVersion(999);
128+
129+
await verifier.migrateAndValidate(after, 2, validateDropped: true);
130+
await after.close();
131+
});
132+
101133
group('migrate without data', () {
102134
const versions = GeneratedHelper.versions;
103135
final latestVersion = versions.last;

0 commit comments

Comments
 (0)