[官网文档翻译]Flutter持久化库drift - 高级特性 - 高级查询(Dart)

2,275 阅读5分钟

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

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

本文翻译自 drift 的 官方文档 Advanced queries in Dart (simonbinder.eu)

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


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

高级查询(Dart)

在 Dart api 中使用 sql 表结合或自定义表达式。

表结合(join)

drift 支持用 sql 表结合(join)来写查询操作多个表。要使用这个特性,用 select(table) 写一个常规的 select 语句,然后用 .join() 来添加一个结合(join)列表。对于内连和左连,需要指定 ON 表达式。 这里有一个在示例应用中使用的代码用例:

// 定义数据类,包含一个 todo 实体和关联的 category
class EntryWithCategory {
  EntryWithCategory(this.entry, this.category);

  final TodoEntry entry;
  final Category category;
}

// 在数据库类里,我们可以为每个 todo 实体加载 category。
Stream<List<EntryWithCategory>> entriesWithCategory() {
  final query = select(todos).join([
    leftOuterJoin(categories, categories.id.equalsExp(todos.category)),
  ]);

  // 下一段说明如何解析结果。
}

解析结果

对有表结合的 select 语句调用 get()watch() 会分别返回 FutureList 流。每个 TypedResult 代表一条数据行,可以从这里面读取数据。它包含一个 原始数据 的 getter,用来获取原始数据列。但更重要的是, readTable 方法可以用来读对应表数据的数据类。

上面的查询示例中,可以如下读取 todo 实体 和每条数据行对应的 category :

return query.watch().map((rows) {
  return rows.map((row) {
    return EntryWithCategory(
      row.readTable(todos),
      row.readTableOrNull(categories),
    );
  }).toList();
});

注: 如果一条数据行中没有对应表的内容, readTable 会抛出 ArgumentError。例如: todo 实体可能不在任何 category 中。对应这个问题,可以使用 row.readTableOrNull 来加载 catetory 。

自定义列

select 语句不局限于表里的列。可以在查询中包含更复杂的表达式。对于结果中的每条数据行,这些表达式会被数据库引擎求值。

class EntryWithImportance {
  final TodoEntry entry;
  final bool important;

  EntryWithImportance(this.entry, this.important);
}

Future<List<EntryWithImportance>> loadEntries() {
  // 如果一个实体的 content 中含有 import 字符串,就假定它是重要的。
  final isImportant = todos.content.like('%important%');

  return select(todos).addColumns([isImportant]).map((row) {
    final entry = row.readTable(todos);
    final entryIsImportant = row.read(isImportant);
    
    return EntryWithImportance(entry, entryIsImportant);
  }).get();
}

注意 like 校验不会在 Dart 中执行 - 它会被发送给底层数据库引擎,可以对所有数据行进行高效地计算。

别名

有时,查询会参照一个表不止一次。考虑下下面这个示例,用来在导航系统中存放已保存的路线。

class GeoPoints extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
  TextColumn get latitude => text()();
  TextColumn get longitude => text()();
}

class Routes extends Table {

  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();

  // 包含开始坐标点和目的地坐标点的 id。
  IntColumn get start => integer()();
  IntColumn get destination => integer()();
}

现在,比方说想要为每条路线同时加载开始 GeoPoint (坐标点)和目的地 GeoPoint (坐标点),就必须对 geo-points 表使用两次 join :开始坐标点和目的地坐标点。 为了在查询中表现这种情况,就会用到别名:

class RouteWithPoints {
  final Route route;
  final GeoPoint start;
  final GeoPoint destination;

  RouteWithPoints({this.route, this.start, this.destination});
}

// 在数据类中:
Future<List<RouteWithPoints>> loadRoutes() async {
  // 为 geoPoints (坐标点)表创建别名,这样可以引用两次。
  final start = alias(geoPoints, 's');
  final destination = alias(geoPoints, 'd');
 
  final rows = await select(routes).join([
    innerJoin(start, start.id.equalsExp(routes.start)),
    innerJoin(destination, destination.id.equalsExp(routes.destination)),
  ]).get();

  return rows.map((resultRow) {
    return RouteWithPoints(
      route: resultRow.readTable(routes),
      start: resultRow.readTable(start),
      destination: resultRow.readTable(destination),
    );
  }).toList();
}

生成的语句看上去如下:

SELECT 
    routes.id, routes.name, routes.start, routes.destination,
    s.id, s.name, s.latitude, s.longitude,
    d.id, d.name, d.latitude, d.longitude 
FROM routes 
    INNER JOIN geo_points s ON s.id = routes.start
    INNER JOIN geo_points d ON d.id = routes.destination

表结合(join) 中的 ORDER BYWHERE

和单表查询类似, orderBywhere 也可以用于表结合。上面最开始的示例应用展现了只包含指定条件过滤后和基于 category 的 id 排序后的 todo 实体。

Stream<List<EntryWithCategory>> entriesWithCategory(String entryFilter) {
  final query = select(todos).join([
    leftOuterJoin(categories, categories.id.equalsExp(todos.category)),
  ]);
  query.where(todos.content.like(entryFilter));
  query.orderBy([OrderingTerm.asc(categories.id)]);
  // ...
}

因为表结合有多个表,所以需要在 whereorderBy 中直接指定所有的表(不像单表查询的回调默认调用正在用的表。)。

Group by

有时需要运行聚集查询,意味着想要的数据来源于多条数据行。 会有一些共同的问题,包括:

  • 每种 category 有多少 todo 实体?
  • 一个用户每个月做了多少 todo 实体?
  • 一个 todo 实体的平均长度?

这些查询的共同点是数据来源于多条数据行,然后要绑定到单条数据行上。 在 sql 中,可以用"聚集函数"实现, drift 也已内置支持聚集。

附加信息:这里有一个关于 sql 中 group by 的不错的学习指导。

可以使用 count 函数写一个查询来回答第一个问题。我们想要选择所有 category ,然后为每种 category 结合每个 todo 实体。特殊的是结合时要设置 useColumns: false 。这样做是因为我们对 todo 项目的数据列不感兴趣,我们只关心它们有多少条。默认情况下,todo 项目在结合中出现时,drift 会试图读取每个 todo 项目。

final amountOfTodos = todos.id.count();

final query = db.select(categories).join([
  innerJoin(
    todos,
    todos.category.equalsExp(categories.id),
    useColumns: false,
  )
]);
query
  ..addColumns([amountOfTodos])
  ..groupBy([categories.id]);

final result = await query.get();

for (final row in result) {
  print('there are ${row.read(amountOfTodos)} entries in ${row.readTable(todos)}');
}

要找到 todo 实体的平均长度,可使用 avg。这种情况下,因为数据来源于单表( todos ),所以无需使用结合。那这里有一个问题 - 因为我们对每个 todo 项目的数据列不感兴趣,所以在结合中使用了 useColumns: false 。这里我们同样不关注单个 todo 项目,但是这里没有设置这个标志的结合处理。对于这种情况, drift 提供了一个特殊的方法 - 使用 selectOnly 来替换 select 。 "only" 意味着 drift 只返回通过 "addColumns" 添加的数据列。常规查询中,所有的数据列都会被选择,这些数据列也是通常需要的。

Stream<double> averageItemLength() {
  final avgLength = todos.content.length.avg();
  final query = selectOnly(todos)..addColumns([avgLength]);

  return query.map((row) => row.read(avgLength)).watchSingle();
}