-
Notifications
You must be signed in to change notification settings - Fork 305
Prepare schema migration/testing code for upcoming database changes #1248
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
Changes from all commits
d7fbff2
5ad630e
9b2d580
d302584
33da884
3cff396
5fb8158
601936d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,11 @@ | ||
import 'dart:io'; | ||
|
||
import 'package:drift/drift.dart'; | ||
import 'package:drift/native.dart'; | ||
import 'package:drift/internal/versioned_schema.dart'; | ||
import 'package:drift/remote.dart'; | ||
import 'package:path/path.dart' as path; | ||
import 'package:path_provider/path_provider.dart'; | ||
import 'package:sqlite3/common.dart'; | ||
|
||
import '../log.dart'; | ||
import 'schema_versions.g.dart'; | ||
|
||
part 'database.g.dart'; | ||
|
||
/// The table of [Account] records in the app's database. | ||
|
@@ -52,30 +51,61 @@ class UriConverter extends TypeConverter<Uri, String> { | |
@override Uri fromSql(String fromDb) => Uri.parse(fromDb); | ||
} | ||
|
||
LazyDatabase _openConnection() { | ||
return LazyDatabase(() async { | ||
// TODO decide if this path is the right one to use | ||
final dbFolder = await getApplicationDocumentsDirectory(); | ||
final file = File(path.join(dbFolder.path, 'db.sqlite')); | ||
return NativeDatabase.createInBackground(file); | ||
}); | ||
// TODO(drift): generate this | ||
VersionedSchema _getSchema({ | ||
required DatabaseConnectionUser database, | ||
required int schemaVersion, | ||
}) { | ||
switch (schemaVersion) { | ||
case 2: | ||
return Schema2(database: database); | ||
default: | ||
throw Exception('unknown schema version: $schemaVersion'); | ||
} | ||
} | ||
|
||
@DriftDatabase(tables: [Accounts]) | ||
class AppDatabase extends _$AppDatabase { | ||
AppDatabase(super.e); | ||
|
||
AppDatabase.live() : this(_openConnection()); | ||
|
||
// When updating the schema: | ||
// * Make the change in the table classes, and bump schemaVersion. | ||
// * Export the new schema and generate test migrations: | ||
// * Export the new schema and generate test migrations with drift: | ||
// $ tools/check --fix drift | ||
// and generate database code with build_runner. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a specific command we can put here, or perhaps refer the reader to some documentation, even if it's just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't include the command for this as there are different ways to generate them, and some are more efficient the the others. We should point to the relevant piece of documentation ("Generated files" in |
||
// See ../../README.md#generated-files for more | ||
// information on using the build_runner. | ||
// * Update [_getSchema] to handle the new schemaVersion. | ||
// * Write a migration in `onUpgrade` below. | ||
// * Write tests. | ||
@override | ||
int get schemaVersion => 2; // See note. | ||
|
||
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); | ||
} | ||
}); | ||
} | ||
|
||
@override | ||
MigrationStrategy get migration { | ||
return MigrationStrategy( | ||
|
@@ -84,25 +114,22 @@ class AppDatabase extends _$AppDatabase { | |
}, | ||
onUpgrade: (Migrator m, int from, int to) async { | ||
if (from > to) { | ||
// TODO(log): log schema downgrade as an error | ||
// This should only ever happen in dev. As a dev convenience, | ||
// drop everything from the database and start over. | ||
for (final entity in allSchemaEntities) { | ||
// This will miss any entire tables (or indexes, etc.) that | ||
// don't exist at this version. For a dev-only feature, that's OK. | ||
await m.drop(entity); | ||
} | ||
await m.createAll(); | ||
// TODO(log): log schema downgrade as an error | ||
assert(debugLog('Downgrading schema from v$from to v$to.')); | ||
await _dropAndCreateAll(m, schemaVersion: to); | ||
return; | ||
} | ||
assert(1 <= from && from <= to && to <= schemaVersion); | ||
|
||
if (from < 2 && 2 <= to) { | ||
await m.addColumn(accounts, accounts.ackedPushToken); | ||
} | ||
// New migrations go here. | ||
} | ||
); | ||
await m.runMigrationSteps(from: from, to: to, | ||
steps: migrationSteps( | ||
from1To2: (m, schema) async { | ||
await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); | ||
Comment on lines
+126
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this change is ultimately useful, but it took me a bit to see why. We should make this point clear in the commit message. Changing from the explicit There's one key thing this is doing, though: the For a lot of migrations that may not matter. But the point is that if in the future we e.g. alter (That isn't the only way to do it in principle — Django takes a different approach, seen in the Zulip server's So that's why it's essential that we start generating this At that point the use of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, and the commit message can be clearer if we move the step-by-step helper change to an NFC commit. |
||
}, | ||
)); | ||
}); | ||
} | ||
|
||
Future<int> createAccount(AccountsCompanion values) async { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
// dart format width=80 | ||
import 'package:drift/internal/versioned_schema.dart' as i0; | ||
import 'package:drift/drift.dart' as i1; | ||
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import | ||
|
||
// GENERATED BY drift_dev, DO NOT MODIFY. | ||
final class Schema2 extends i0.VersionedSchema { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
s/will be/would be/ — otherwise it reads like a warning about something that's still the case after this commit Or clarifying another aspect too:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separately, let's include a link to this comment: because there's additional discussion there. |
||
Schema2({required super.database}) : super(version: 2); | ||
@override | ||
late final List<i1.DatabaseSchemaEntity> entities = [ | ||
accounts, | ||
]; | ||
late final Shape0 accounts = Shape0( | ||
source: i0.VersionedTable( | ||
entityName: 'accounts', | ||
withoutRowId: false, | ||
isStrict: false, | ||
tableConstraints: [ | ||
'UNIQUE(realm_url, user_id)', | ||
'UNIQUE(realm_url, email)', | ||
], | ||
columns: [ | ||
_column_0, | ||
_column_1, | ||
_column_2, | ||
_column_3, | ||
_column_4, | ||
_column_5, | ||
_column_6, | ||
_column_7, | ||
_column_8, | ||
], | ||
attachedDatabase: database, | ||
), | ||
alias: null); | ||
} | ||
|
||
class Shape0 extends i0.VersionedTable { | ||
Shape0({required super.source, required super.alias}) : super.aliased(); | ||
i1.GeneratedColumn<int> get id => | ||
columnsByName['id']! as i1.GeneratedColumn<int>; | ||
i1.GeneratedColumn<String> get realmUrl => | ||
columnsByName['realm_url']! as i1.GeneratedColumn<String>; | ||
i1.GeneratedColumn<int> get userId => | ||
columnsByName['user_id']! as i1.GeneratedColumn<int>; | ||
i1.GeneratedColumn<String> get email => | ||
columnsByName['email']! as i1.GeneratedColumn<String>; | ||
i1.GeneratedColumn<String> get apiKey => | ||
columnsByName['api_key']! as i1.GeneratedColumn<String>; | ||
i1.GeneratedColumn<String> get zulipVersion => | ||
columnsByName['zulip_version']! as i1.GeneratedColumn<String>; | ||
i1.GeneratedColumn<String> get zulipMergeBase => | ||
columnsByName['zulip_merge_base']! as i1.GeneratedColumn<String>; | ||
i1.GeneratedColumn<int> get zulipFeatureLevel => | ||
columnsByName['zulip_feature_level']! as i1.GeneratedColumn<int>; | ||
i1.GeneratedColumn<String> get ackedPushToken => | ||
columnsByName['acked_push_token']! as i1.GeneratedColumn<String>; | ||
} | ||
|
||
i1.GeneratedColumn<int> _column_0(String aliasedName) => | ||
i1.GeneratedColumn<int>('id', aliasedName, false, | ||
hasAutoIncrement: true, | ||
type: i1.DriftSqlType.int, | ||
defaultConstraints: | ||
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); | ||
i1.GeneratedColumn<String> _column_1(String aliasedName) => | ||
i1.GeneratedColumn<String>('realm_url', aliasedName, false, | ||
type: i1.DriftSqlType.string); | ||
i1.GeneratedColumn<int> _column_2(String aliasedName) => | ||
i1.GeneratedColumn<int>('user_id', aliasedName, false, | ||
type: i1.DriftSqlType.int); | ||
i1.GeneratedColumn<String> _column_3(String aliasedName) => | ||
i1.GeneratedColumn<String>('email', aliasedName, false, | ||
type: i1.DriftSqlType.string); | ||
i1.GeneratedColumn<String> _column_4(String aliasedName) => | ||
i1.GeneratedColumn<String>('api_key', aliasedName, false, | ||
type: i1.DriftSqlType.string); | ||
i1.GeneratedColumn<String> _column_5(String aliasedName) => | ||
i1.GeneratedColumn<String>('zulip_version', aliasedName, false, | ||
type: i1.DriftSqlType.string); | ||
i1.GeneratedColumn<String> _column_6(String aliasedName) => | ||
i1.GeneratedColumn<String>('zulip_merge_base', aliasedName, true, | ||
type: i1.DriftSqlType.string); | ||
i1.GeneratedColumn<int> _column_7(String aliasedName) => | ||
i1.GeneratedColumn<int>('zulip_feature_level', aliasedName, false, | ||
type: i1.DriftSqlType.int); | ||
i1.GeneratedColumn<String> _column_8(String aliasedName) => | ||
i1.GeneratedColumn<String>('acked_push_token', aliasedName, true, | ||
type: i1.DriftSqlType.string); | ||
i0.MigrationStepWithVersion migrationSteps({ | ||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, | ||
}) { | ||
return (currentVersion, database) async { | ||
switch (currentVersion) { | ||
case 1: | ||
final schema = Schema2(database: database); | ||
final migrator = i1.Migrator(database, schema); | ||
await from1To2(migrator, schema); | ||
return 2; | ||
default: | ||
throw ArgumentError.value('Unknown migration from $currentVersion'); | ||
} | ||
}; | ||
} | ||
|
||
i1.OnUpgrade stepByStep({ | ||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, | ||
}) => | ||
i0.VersionedSchema.stepByStepHelper( | ||
step: migrationSteps( | ||
from1To2: from1To2, | ||
)); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,7 +40,7 @@ dependencies: | |
convert: ^3.1.1 | ||
crypto: ^3.0.3 | ||
device_info_plus: ^11.2.0 | ||
drift: ^2.5.0 | ||
drift: ^2.23.0 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Confirmed by running There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for confirming! |
||
file_picker: ^8.0.0+1 | ||
firebase_core: ^3.3.0 | ||
firebase_messaging: ^15.0.1 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -98,11 +98,53 @@ void main() { | |
verifier = SchemaVerifier(GeneratedHelper()); | ||
}); | ||
|
||
test('upgrade to v2, empty', () async { | ||
final connection = await verifier.startAt(1); | ||
final db = AppDatabase(connection); | ||
await verifier.migrateAndValidate(db, 2); | ||
await db.close(); | ||
test('downgrading', () async { | ||
final schema = await verifier.schemaAt(2); | ||
|
||
// 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>(); | ||
// 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.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 after.close(); | ||
}); | ||
|
||
group('migrate without data', () { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit in commit message:
should use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separately in same commit message:
The second sentence is explaining why you did do something that this version doesn't do, right? (after #1248 (comment) ) |
||
const versions = GeneratedHelper.versions; | ||
final latestVersion = versions.last; | ||
|
||
int fromVersion = versions.first; | ||
for (final toVersion in versions.skip(1)) { | ||
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; | ||
} | ||
|
||
for (final fromVersion in versions) { | ||
if (fromVersion == latestVersion) break; | ||
test('from v$fromVersion to latest (v$latestVersion)', () async { | ||
final connection = await verifier.startAt(fromVersion); | ||
final db = AppDatabase(connection); | ||
await verifier.migrateAndValidate(db, latestVersion); | ||
await db.close(); | ||
}); | ||
} | ||
}); | ||
|
||
test('upgrade to v2, with data', () async { | ||
|
@@ -130,6 +172,7 @@ void main() { | |
...accountV1.toJson(), | ||
'ackedPushToken': null, | ||
}); | ||
await after.close(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you explain a bit more why this change is needed?
What goes wrong if this line is left out? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It addresses a latent warning that happens when we have multiple tests that use the same
which comes from this method: bool _handleInstantiated() {
if (!_openedDbCount.containsKey(runtimeType) ||
driftRuntimeOptions.dontWarnAboutMultipleDatabases) {
_openedDbCount[runtimeType] = 1;
return true;
}
final count =
_openedDbCount[runtimeType] = _openedDbCount[runtimeType]! + 1;
if (count > 1) {
driftRuntimeOptions.debugPrint(
'WARNING (drift): It looks like you\'ve created the database class '
'$runtimeType multiple times. When these two databases use the same '
'QueryExecutor, race conditions will occur and might corrupt the '
'database. \n'
'Try to follow the advice at https://drift.simonbinder.eu/faq/#using-the-database '
'or, if you know what you\'re doing, set '
'driftRuntimeOptions.dontWarnAboutMultipleDatabases = true\n'
'Here is the stacktrace from when the database was opened a second '
'time:\n${StackTrace.current}\n'
'This warning will only appear on debug builds.',
);
}
return true;
}
It is not an issue at this commit because 1 out of the 2 tests calls With There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great, thanks. Yeah, just mention in the commit message that this otherwise leaks state, and would produce a warning if we had another test after this that also created a database instance. |
||
}); | ||
}); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -378,13 +378,15 @@ run_l10n() { | |
|
||
run_drift() { | ||
local schema_dir=test/model/schemas/ | ||
local migration_helper_path=lib/model/schema_versions.g.dart | ||
local outputs=( "${schema_dir}" "${migration_helper_path}" ) | ||
|
||
# Omitted from this check: | ||
# pubspec.{yaml,lock} tools/check | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The files_check line here needs an update — see that function's doc. |
||
files_check lib/model/database{,.g}.dart "${schema_dir}" \ | ||
files_check lib/model/database{,.g}.dart "${outputs[@]}" \ | ||
|| return 0 | ||
|
||
check_no_uncommitted_or_untracked "${schema_dir}" \ | ||
check_no_uncommitted_or_untracked "${outputs[@]}" \ | ||
|| return | ||
|
||
dart run drift_dev schema dump \ | ||
|
@@ -393,8 +395,11 @@ run_drift() { | |
dart run drift_dev schema generate --data-classes --companions \ | ||
"${schema_dir}" "${schema_dir}" \ | ||
|| return | ||
dart run drift_dev schema steps \ | ||
"${schema_dir}" "${migration_helper_path}" \ | ||
|| return | ||
|
||
check_no_changes "schema updates" "${schema_dir}" | ||
check_no_changes "schema or migration-helper updates" "${outputs[@]}" | ||
} | ||
|
||
filter_flutter_pub_run_output() { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs a mention in the "When updating the schema" comment below.