[官网文档翻译]Flutter持久化库drift - 高级特性 - 迁移

1,485 阅读9分钟

「这是我参与11月更文挑战的第24天,活动详情查看:2021最后一次更文挑战」。

Flutter持久化库drift(原moor)官方文档翻译汇总 - 掘金 (juejin.cn)

本文翻译自 drift 的 官方文档 Migrations (simonbinder.eu)

肉翻多有不足,不吝赐教。


重要通知: moor 已改名为 drift 。更多信息[中文]。

迁移

明确一下当数据库被创建或更新时发生了什么。

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 映射转换后的表达式。否则就回退到仅复制数据列。

如果在表迁移中引入了新的数据列,确保这些数据列包含在 TableMigrationnewColumns 参数中。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.hadUpgradedetails.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();
  });
}