「这是我参与11月更文挑战的第24天,活动详情查看:2021最后一次更文挑战」。
Flutter持久化库drift(原moor)官方文档翻译汇总 - 掘金 (juejin.cn)
本文翻译自 drift 的 官方文档 Migrations (simonbinder.eu)。
肉翻多有不足,不吝赐教。
迁移
明确一下当数据库被创建或更新时发生了什么。
drift 提供了一个迁移 api ,可以在 Database 类中的 schemaVersion getter 升版后用来渐进地应用 schema 的改变。要使用这个 api ,需要覆写 migration getter。这里有一个示例:比方说想要向 todo 项目中添加一个 due date (到期日期)。
class Todos extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 6, max: 10)();
TextColumn get content => text().named('body')();
IntColumn get category => integer().nullable()();
DateTimeColumn get dueDate => dateTime().nullable()(); // new, added column
}
可以如下修改 database 类:
@override
int get schemaVersion => 2; // 升版(因为表已改变)
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) {
return m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from == 1) {
// 添加在版本 1 的基础上变化的 dueDate 属性。
await m.addColumn(todos, todos.dueDate);
}
}
);
// 其余部分保持不变
也可以添加表或删除表 - 看下 Migrator 参考中的所有可用选项。在迁移中不能使用高级查询 API - 调用 select 或类似的方法会抛出异常。
需要迁移时能感觉到 sqlite 有点受限 - 只有创建表和数据列的方法。现有的数据列不能被更改或移除。这里 描述了变通方法,可以和 customStatement 共用来运行语句。
复杂迁移
sqlite 内置了简单的更改语句,如添加数据列或删除整个表。更复杂的迁移需要 12步过程,牵扯创建表的副本和从旧表复制以前的数据。drift 3.4 引入了 TableMigration api 用来自动化大多数步骤,用起来更简单和安全。
为了启动迁移, drift 会为当前的 schema 的表创建新的实例。接下来,会从旧表中复制以前的数据行。大多数情况下,例如改变数据列的类型,不改变数据内容的话,不能只是复制以前的每条数据行。这里可以使用 columnTransformer 为数据行应用单行变换。columnTransformer 是从数据列到 sql 表达式的映射,用来从旧表中复制数据列。例如:如果想要在复制之前转换数据列,可以使用:
columnTransformer: {
todos.category: todos.category.cast<int>(),
}
drift 在内部会使用 INSERT INTO SELECT 语句复制旧数据。这种情况下,语句看上去是这个样子: INSERT INTO temporary_todos_copy SELECT id, title, content, CAST(category AS INT) FROM todos 。 如你所见, drift 使用 columnTransformer 映射转换后的表达式。否则就回退到仅复制数据列。
如果在表迁移中引入了新的数据列,确保这些数据列包含在 TableMigration 的 newColumns 参数中。drift 会确保这些数据列有一个默认值或者有 columnTransfer 中指定的转换。当然,drift 不会试图从旧表中复制 newColumns。
无论你是想用 TableMigration 实现复杂的迁移,还是想运行一系列的语句,都非常建议你编写迁移测试来覆盖你的迁移。这会避免迁移中的错误引发的数据丢失。
这里有一些示例来演示表迁移 api 的通常用法:
改变列的类型
比方说 Todos表中的 category 列原来是非空的 text()列,然后现在要改变为可空的 int 。 简单来说,假定 category 只包含整数,只是保存到了一个 text 列中,就是现在要改变的列。
class Todos extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 6, max: 10)();
TextColumn get content => text().named('body')();
- IntColumn get category => text()();
+ IntColumn get category => integer().nullable()();
}
行开头减号的内容变为行开头加号的内容。
在重新运行构建和增加 schema 版本后,可以写一个迁移:
onUpgrade: (m, old, to) async {
if (old <= yourOldVersion) {
await m.alterTable(
TableMigration(
todos,
columnTransformer: {
todos.category: todos.category.cast<int>(),
}
),
);
}
}
这里重要的内容是 columnTransformer - 从数据列到表达式的映射,用来复制旧数据。映射的值引用旧表,所以可以使用 todos.category.cast() 来复制旧的数据行然后转换各行的 category。所有不在 columntransformer 中出现的列将不做转换,直接从旧表复制。
改变列的约束
当要以当前数据可兼容的方式改变列的约束时(例如:将不可空的列改为可空的列),只需要复制以前的数据,不需要应用任何转换。
await m.alterTable(TableMigration(todos));
删除列
删除一个无外键约束引用的列也很容易:
await m.alterTable(TableMigration(yourTable));
如果要删除一个有外键引用的列,需要首先迁移引用的表。
重命名列
如果用 Dart 重命名一个列,注意最简单的方式就是重命名 getter 和使用 named: TextColumn newName => text().named('old_name')()。这是完全向后兼容的,不需要迁移。
如果 APP 运行在 sqlite 3.25.9 或更高版本(如果使用 sqlite3_flutter_libs的话,已经是这些版本)了,可以使用 Migrator 中的 renameColumn api 。
m.renameColumn(yourTable, 'old_column_name', yourTable.newColumn);
如果想要改变表中实际的列名,可以写一个 columnTransformer 来用一个不同的名称来使用旧的列。
await m.alterTable(
TableMigration(
yourTable,
columnTransformer: {
yourTable.newColumn: const CustomExpression('old_column_name')
},
)
)
迁移后回调
MigrationStrategy 中的 beforeOpen 参数可以用来在数据后创建后移植数据。它在迁移后运行,但在所有其它查询前运行。注意无论数据库是否打开,它都会被调用,而不管一个迁移是否真正运行过。可以使用 details.hadUpgrade 或 details.wasCreated 来检查迁移是否必需。
beforeOpen: (details) async {
if (details.wasCreated) {
final workId = await into(categories).insert(Category(description: 'Work'));
await into(todos).insert(TodoEntry(
content: 'A first todo entry',
category: null,
targetDate: DateTime.now(),
));
await into(todos).insert(
TodoEntry(
content: 'Rework persistence code',
category: workId,
targetDate: DateTime.now().add(const Duration(days: 4)),
));
}
},
需要时也可以激活指令语句:
beforeOpen: (details) async {
if (details.wasCreated) {
// ...
}
await customStatement('PRAGMA foreign_keys = ON');
}
开发中迁移
开发过程中,也有可能经常改变 schema,但是还不想写迁移处理。可以删除 APP 的数据然后重新安装 - 检测到数据库会重新创建所有的表。注意有时只是卸载还不够 - Android 可能会备份数据文件,重新安装 APP 时会重新创建。
也可以每次打开 APP 时删除和重新创建所有表,参考此评论看一下这是如何实现的。
迁移验证
drift 试验性地支持在单元测试中来验证迁移的整合性。
为了支持这个特性, drift 会帮助生成以下内容:
- 数据库 schema 的 json 表现。
- 用于操作旧版本 schema 的测试数据库。
使用这些测试数据库, drift 能帮助测试从任意 schema 版本的迁移和向任意 schema 版本的迁移。
超前的复杂话题
编写 schema 的测试是一个高级话题,在这里需要描述一个相当复杂的准备。如果你卡住了很时间,尽管打开一个相关讨论,不必犹豫。在 drift 的仓库里也有一个可运行的示例。
准备
要使用这个特性, drift 需要知道数据库的所有 schema 。 一个 schema 是所有表的集合、触发器和在数据库使用的索引。
可以使用 CLI tools (命令行界面工具)导出 schema 的 json 表现。在这个指南中,我们假定一个文件布局如下(my_app是工程的根文件夹):
my_app
.../
lib/
database/
database.dart
database.g.dart
test/
generated_migrations/
schema.dart
schema_v1.dart
schema_v2.dart
drift_schemas/
drift_schema_v1.json
drift_schema_v2.json
pubspec.yaml
drift 会生成迁移实现和 schema 的 json 。开始编写 schema,需要在工程中创建一个名为 drift_schemas 的空文件夹。当然也可以根据意愿选择一个不同的名字或者嵌套的子文件夹。
导出 schema
首先,创建一个第一个 schema 的表现:
$ mkdir drift_schemas
$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/drift_schema_v1.json
这会指示生成器查看在 lib/database/database.dart 中定义的数据库,然后提取它的 schema 到新的文件夹中。
改变了数据库的 schema 之后,可以再次运行命令。例如:比方说对表做了一个改变,然后将 shemaVersion 升为 2。然后可以运行:
$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/drift_schema_v2.json
每次改变数据和升版 schemaVersion 之后,都需要运行此命令。记得要重命名 drift_schema_vX.json 文件,X 是数据库的当前 schemaVersion 的值。
生成测试代码
在导出数据库 schema 到文件夹之后,可以生成基于这些 schema 文件的旧版本的数据库类。为了验证, drift 会生成一个小很多的数据库实现,只用来测试迁移。
可以把测试代码放在任何想要放的位置,但是放到 test 下的子目录中会更合理。如果想把测试代码写到 test/generated_migrations/中,应该使用:
$ dart run drift_dev schema generate drift_schemas/ test/generated/
编写测试
上面的准备之后,到了编写一些测试的最后时刻! 例如:测试代码可能看上去是下面这个样子:
import 'package:my_app/database/database.dart';
import 'package:test/test.dart';
import 'package:drift_dev/api/migrations.dart';
// 前面生成的目录
import 'generated/schema.dart';
void main() {
SchemaVerifier verifier;
setUpAll(() {
// GeneratedHelper()由 drift 创建,verifier 是一个 api 。
verifier = SchemaVerifier(GeneratedHelper());
});
test('upgrade from v1 to v2', () async {
// 使用 startAt(1) 来获取含有 v1 版 schema 的所有表的数据库的连接。
final connection = await verifier.startAt(1);
final db = MyDatabase.connect(connection);
// 使用这个运行向 v2 版本的迁移,然后验证数据库是否含有期望的 shcema 。
await verifier.migrateAndValidate(db, 2);
});
}
通常,一个测试看上去如下:
- 使用
verifier.startAt()获取一个带初始 schema 的数据库的连接。 这个数据库包含由Migrator.createAll创建的指定版本( schema )所有的表、索引和触发器。 - 创建应用的数据库(可以将 generate_connect_constructor 设为可用来直接使用
DatabaseConnection) - 调用
verifier.migrateAndValidate(db, version)。 这会初始化一个向目标版本(这里是2)的迁移。不像用startAt创建的数据库,它使用为数据库写的迁移逻辑。
migrateAndValidate 会从 sqlite_schema 中提取所有的 CREATE 语句,然后进行语义上的比较。如果发现有期望外的内容,会抛出一个 SchemaMismatch 异常,使测试失败。
编写可测试的迁移 测试向旧 schema 版本(如:当前版本是
v3,从v1升级到v2)的迁移,onUpgrade处理器必须能够升级到比当前shemaVersion旧的版本。必要时需要检查onUpgrade回调的to参数来为此运行一个不同的迁移。
验证数据整合性
除了在表结构中做的更改之外,在仍存在的迁移运行之后,确保数据还在是有用的。
从 sqlite3 包中,除了连接之外,可以使用 schameAt 来获取原始的 Database 。这可以用来在迁移之前插入数据。在迁移运行之后,可以检查数据是否还在。
注意不能使用 APP 中的常规数据库类来做这个操作,因为它的数据类会一直预期最新的 schema。无论如何,可以指示 drift 生成旧的数据类和其姊妹类的快照来实现这个目的。要使这个特性可用,可以传 --data-classes 和 --companions 命令行参数给 drift_dev schema generate 命令。
然后,就可以用别名导入生成的类:
import 'generated/schema_v1.dart' as v1;
import 'generated/schema_v2.dart' as v2;
下面的代码可以用来手动创建和验证一个指定版本的数据:
void main() {
// ...
test('upgrade from v1 to v2', () async {
final schema = await verifier.schemaAt(1);
// 向 users 表中添加一些数据,在 v1 中该表只有 id 列。
final oldDb = v1.DatabaseAtV1.connect(schema.newConnection());
await oldDb.into(oldDb.users).insert(const v1.UsersCompanion(id: Value(1)));
await oldDb.close();
// 运行迁移,然后确认是否添加了相同的列。
final db = Database(schema.newConnection());
await verifier.migrateAndValidate(db, 2);
await db.close();
// 确认这里还有 user
final migratedDb = v2.DatabaseAtV2.connect(schema.newConnection());
final user = await migratedDb.select(migratedDb.users).getSingle();
expect(user.id, 1);
expect(user.name, 'no name'); // 迁移时的默认值。
await migratedDb.close();
});
}