「这是我参与11月更文挑战的第23天,活动详情查看:2021最后一次更文挑战」。
Flutter持久化库drift(原moor)官方文档翻译汇总 - 掘金 (juejin.cn)
本文翻译自 drift 的 官方文档 Advanced queries in Dart (simonbinder.eu)。
肉翻多有不足,不吝赐教。
高级查询(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() 会分别返回 Future 或 List 流。每个 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 BY 和 WHERE
和单表查询类似, orderBy 和 where 也可以用于表结合。上面最开始的示例应用展现了只包含指定条件过滤后和基于 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)]);
// ...
}
因为表结合有多个表,所以需要在 where 和 orderBy 中直接指定所有的表(不像单表查询的回调默认调用正在用的表。)。
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();
}