Skip to content

Selects

摘要

在 Dart 中从表中选择行或单个列。


本页介绍了如何使用 drift 的 dart_api 编写 SELECT 语句。为了使示例更易于理解,它们引用了构成待办事项列表应用程序基础的两个常用表:

对于你在数据库类的 @DriftDatabase 注解中指定的每个表,都会生成一个相应的表 getter。该 getter 可用于运行语句:

dart
@DriftDatabase(tables: [TodoItems, Categories])
class MyDatabase extends _$MyDatabase {
  // a getter for all todo items - it selects all rows in the todoItems table
  Future<List<TodoItem>> get allTodoItems => select(todoItems).get();

  // watches all todo entries in a given category. The stream will automatically
  // emit new items whenever the underlying data changes.
  Stream<List<TodoItem>> watchEntriesInCategory(Category c) {
    return (select(todoItems)..where((t) => t.category.equals(c.id))).watch();
  }
}

Drift 使编写查询变得简单而安全。本页介绍了如何编写基本的 select 查询,还解释了如何使用连接和子查询进行高级查询。

简单的 select

你可以通过以 select(tableName) 开头来创建 select 语句,其中表名是 drift 为你生成的字段。数据库中使用的每个表都将有一个匹配的字段来对其运行查询。任何查询都可以使用 get() 运行一次,或者使用 watch() 将其转换为自动更新的流

Where

你可以通过调用 where() 来对查询应用过滤器。where 方法接受一个函数,该函数应将给定的表映射到布尔值的 Expression。创建此类表达式的常用方法是在表达式上使用 equals。整数列也可以与 isBiggerThanisSmallerThan 进行比较。你可以使用 a & b, a | ba.not() 来组合表达式。有关表达式的更多详细信息,请参阅本指南

Limit

你可以通过在查询上调用 limit 来限制返回的结果数量。该方法接受要返回的行数和可选的偏移量。

dart
Future<List<TodoItem>> limitTodos(int limit, {int? offset}) {
  return (select(todoItems)..limit(limit, offset: offset)).get();
}

Ordering

你可以在 select 语句上使用 orderBy 方法。它需要一个函数列表,这些函数从表中提取各个排序列。你可以使用任何表达式作为排序列 - 有关更多详细信息,请参阅本指南

你还可以通过将 OrderingTermmode 属性设置为 OrderingMode.desc 来反转顺序。

单个值

如果你知道一个查询永远不会返回多于一行,那么将结果包装在 List 中可能会很繁琐。Drift 允许你使用 getSinglewatchSingle 来解决这个问题:

如果存在具有所提供 id 的条目,它将被发送到流中。否则,null 将被添加到流中。如果与 watchSingle 一起使用的查询曾经返回多于一个条目(在这种情况下是不可能的),则会添加一个错误。

Mapping

在调用 watchget(或单个变体)之前,你可以使用 map 来转换结果。

Deferring get vs watch

如果你想让你的查询既可以作为 Future 也可以作为 Stream 使用,你可以使用 Selectable 抽象基类之一来优化你的返回类型;

这些基类没有查询构建或 map 方法,向消费者表明它们是完整的结果。

Joins

Drift 支持 sql 连接来编写对多个表进行操作的查询。要使用该功能,请使用 select(table) 启动一个常规的 select 语句,然后使用 .join() 添加一个连接列表。对于内部连接和左外连接,需要指定一个 ON 表达式。

dart
// We define a data class to contain both a todo entry and the associated
// category.
class EntryWithCategory {
  EntryWithCategory(this.entry, this.category);

  // The classes are generated by drift for each of the tables involved in the
  // join.
  final TodoItem entry;
  final Category? category;
}

// in the database class, we can then load the category for each entry
Stream<List<EntryWithCategory>> entriesWithCategory() {
  final query = select(todoItems).join([
    leftOuterJoin(categories, categories.id.equalsExp(todoItems.category)),
  ]);

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

当然,你也可以连接多个表:

dart
/// Searches for todo entries in the same category as the ones having
/// `titleQuery` in their titles.
Future<List<TodoItem>> otherTodosInSameCategory(String titleQuery) async {
  // Since we're adding the same table twice (once to filter for the title,
  // and once to find other todos in same category), we need a way to
  // distinguish the two tables. So, we're giving one of them a special name:
  final otherTodos = alias(todoItems, 'inCategory');

  final query = select(otherTodos).join([
    // In joins, `useColumns: false` tells drift to not add columns of the
    // joined table to the result set. This is useful here, since we only join
    // the tables so that we can refer to them in the where clause.
    innerJoin(
      categories,
      categories.id.equalsExp(otherTodos.category),
      useColumns: false,
    ),
    innerJoin(
      todoItems,
      todoItems.category.equalsExp(categories.id),
      useColumns: false,
    ),
  ])..where(todoItems.title.contains(titleQuery));

  return query.map((row) => row.readTable(otherTodos)).get();
}

解析结果

在带有连接的 select 语句上调用 get()watch 分别返回 FutureStream of List<TypedResult>。每个 TypedResult 表示可以从中读取数据的一行。它包含一个 rawData getter 来获取原始列。但更重要的是,readTable 方法可用于从表中读取数据类。

在上面的示例查询中,我们像这样从每一行中读取了待办事项条目和类别:

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

注意:当表中不存在行时,readTable 将抛出 ArgumentError。例如,待办事项条目可能不属于任何类别。为了解决这个问题,我们使用 row.readTableOrNull 来加载类别。

自定义列

Select 语句不限于表中的列。你还可以在查询中包含更复杂的表达式。对于结果中的每一行,这些表达式将由数据库引擎进行评估。

dart
Future<List<(TodoItem, bool)>> loadEntries() {
  // assume that an entry is important if it has the string "important" somewhere in its content
  final isImportant = todoItems.content.like('%important%');

  return select(todoItems).addColumns([isImportant]).map((row) {
    final entry = row.readTable(todoItems);
    final entryIsImportant = row.read(isImportant)!;

    return (entry, entryIsImportant);
  }).get();
}

请注意,like 检查不是在 Dart 中执行的 - 它被发送到底层数据库引擎,该引擎可以有效地为所有行计算它。

别名

有时,一个查询会多次引用一个表。考虑以下示例来存储导航系统的已保存路线:

现在,假设我们还想为每条路线加载起点和终点的 GeoPoint 对象。我们必须在 geo-points 表上使用两次连接:一次用于起点,一次用于终点。为了在查询中表达这一点,可以使用别名:

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

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

// inside the database class:
Future<List<RouteWithPoints>> loadRoutes() async {
  // create aliases for the geoPoints table so that we can reference it twice
  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();
}

生成的语句如下所示:

sql
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

ORDER BYWHERE on joins

与对单个表的查询类似,orderBywhere 也可以用于连接。上面的初始示例被扩展为仅包含具有指定过滤器的待办事项条目,并根据类别的 id 对结果进行排序:

dart
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

有时,你需要运行_聚合_数据的查询,这意味着你感兴趣的数据来自多行。常见问题包括

  • 每个类别中有多少个待办事项条目?
  • 用户每个月完成了多少个条目?
  • 待办事项条目的平均长度是多少?

这些查询的共同点是,需要将来自多行的数据组合成单行。在 sql 中,这可以通过“聚合函数”来实现,drift 对其有内置支持

附加信息:此处提供了有关 sql 中 group by 的一个很好的教程 here

为了编写一个回答我们第一个问题的查询,我们可以使用 count 函数。我们将选择所有类别并为每个类别连接每个待办事项条目。特殊之处在于我们在连接上设置了 useColumns: false。我们这样做是因为我们对待办事项的列不感兴趣。我们只关心有多少个。默认情况下,drift 会在连接中出现每个待办事项时尝试读取它。

dart
Future<void> countTodosInCategories() async {
  final amountOfTodos = todoItems.id.count();

  final query = select(categories).join([
    innerJoin(
      todoItems,
      todoItems.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(categories)}',
    );
  }
}

为了找到待办事项条目的平均长度,我们使用 avg。在这种情况下,我们甚至不必使用 join,因为所有数据都来自单个表(todos)。但这有一个问题 - 在连接中,我们使用了 useColumns: false,因为我们对每个待办事项的列不感兴趣。在这里,我们也不关心单个项目,但是没有连接可以设置该标志。Drift 为这种情况提供了一个特殊的方法 - 我们不使用 select,而是使用 selectOnly。“only”表示 drift 只会报告我们通过“addColumns”添加的列。在常规 select 中,将选择表中的所有列,这通常是你所需要的。

使用 select 作为 insert

在 SQL 中,可以使用 INSERT INTO SELECT 语句有效地将 SELECT 语句中的行插入到表中。可以使用 insertFromSelect 方法在 drift 中构造这些语句。此示例显示了如何使用该方法构造一个语句,该语句为每个以前未分配类别的待办事项条目创建一个新类别:

dart
Future<void> createCategoryForUnassignedTodoEntries() async {
  final newDescription = Variable<String>('category for: ') + todoItems.title;
  final query = selectOnly(todoItems)
    ..where(todoItems.category.isNull())
    ..addColumns([newDescription]);

  await into(
    categories,
  ).insertFromSelect(query, columns: {categories.name: newDescription});
}

insertFromSelect 的第一个参数是要用作源的 select 语句。然后,columns 映射将行插入其中的表中的列映射到 select 语句中的列。在示例中,newDescription 表达式作为列添加到查询中。然后,使用映射条目 categories.description: newDescription,以便新类别行的 description 列设置为该表达式。

子查询

从 drift 2.11 开始,你可以使用 Subquery 将现有的 select 语句用作更复杂的连接的一部分。

此代码段使用 Subquery 来计算每个类别中前 10 个待办事项(按其标题长度)的数量。它首先为前 10 个项目创建一个 select 语句(但不执行它),然后将此 select 语句连接到一个按类别分组的更大的 select 语句上:

dart
Future<List<(Category, int)>> amountOfLengthyTodoItemsPerCategory() async {
  final longestTodos = Subquery(
    select(todoItems)
      ..orderBy([(row) => OrderingTerm.desc(row.title.length)])
      ..limit(10),
    's',
  );

  // In the main query, we want to count how many entries in longestTodos were
  // found for each category. But we can't access todos.title directly since
  // we're not selecting from `todos`. Instead, we'll use Subquery.ref to read
  // from a column in a subquery.
  final itemCount = longestTodos.ref(todoItems.title).count();
  final query =
      select(categories).join([
          innerJoin(
            longestTodos,
            // Again using .ref() here to access the category in the outer select
            // statement.
            longestTodos.ref(todoItems.category).equalsExp(categories.id),
            useColumns: false,
          ),
        ])
        ..addColumns([itemCount])
        ..groupBy([categories.id]);

  final rows = await query.get();

  return [
    for (final row in rows) (row.readTable(categories), row.read(itemCount)!),
  ];
}

任何语句都可以用作子查询。但请注意,与子查询表达式不同,完整子查询不能使用外部 select 语句中的表。

JSON 支持

sqlite3 对 JSON 运算符有很好的支持,这些运算符在 drift 中也可用(在附加的 'package:drift/extensions/json1.dart' 导入下)。当存储最好用 JSON 表示的动态结构时,或者当你有需要支持的现有结构(也许是因为你正在从基于文档的存储迁移)时,JSON 支持会很有帮助。

例如,考虑一个最初使用 JSON 结构存储联系人的通讯录应用程序:

dart
import 'dart:convert';

import 'package:drift/drift.dart';
import 'package:drift/extensions/json1.dart';
import 'package:json_annotation/json_annotation.dart';

@JsonSerializable()
class ContactData {
  final String name;
  final List<String> phoneNumbers;

  ContactData(this.name, this.phoneNumbers);

  factory ContactData.fromJson(Map<String, Object?> json) =>
      _$ContactDataFromJson(json);

  Map<String, Object?> toJson() => _$ContactDataToJson(this);
}

为了轻松地将此联系人表示存储在 drift 数据库中,可以使用 JSON 列:

还要注意 name 列:它使用 generatedAsjsonExtract 函数来动态地从 JSON 值中提取 name 字段。JSON 路径参数的完整语法在 sqlite3 网站上有解释。

为了使示例更复杂,让我们看另一个存储通话记录的表:

假设我们想为每个通话找到联系人,如果有任何一个具有匹配的电话号码。为了在 SQL 中表达这一点,每个 contacts 行都必须以某种方式扩展为每个存储的电话号码的一行。幸运的是,sqlite3 中的 json_each 函数可以做到这一点,并且 drift 公开了它:

dart
Future<List<(Call, Contact)>> callsWithContact() async {
  final phoneNumbersForContact = contacts.data.jsonEach(
    this,
    r'$.phoneNumbers',
  );
  final phoneNumberQuery = selectOnly(phoneNumbersForContact)
    ..addColumns([phoneNumbersForContact.value]);

  final query = select(calls).join([
    innerJoin(contacts, calls.phoneNumber.isInQuery(phoneNumberQuery)),
  ]);

  return query
      .map((row) => (row.readTable(calls), row.readTable(contacts)))
      .get();
}

没有表的 select

有些查询根本不需要 FROM 子句,而是直接选择一些表达式。例如,一个只使用子查询表达式的 select,如此处查询表中是否存在任何行:

selectExpressions API 类似于 selectOnly,只是它根本不需要任何表。相反,传递给 selectExpressions 的列表中的表达式在独立的 select 语句中进行评估,并且可以从评估查询时返回的 TypedResult 类中解析。

复合 select

使用复合 select,可以一次返回多个 select 语句的结果。可以使用不同的运算符对查询应用集合运算,即:

  1. UNION ALLUNION:分别在 select 中返回两个 select 语句的结果,包含或过滤重复项。
  2. EXCEPT:返回第一个 select 语句中未出现在第二个查询中的所有行。
  3. INTERSECT:返回两个 select 语句都返回的所有行。

例如,考虑用于跟踪在表文章中引入的待办事项的表。在这里,一个表存储待办事项,另一个表定义可用于对这些项目进行分组的类别。现在,也许你想查询每个类别分配了多少个项目,以及不在任何类别中的项目数量。第一个查询可以使用 groupBy 对类别和一个子查询来计算关联的待办事项来编写。但是,当对类别表进行分组时,将没有“null”组。因此,在一个查询中解决所有问题的一种方法是编写另一个查询并使用 unionAll

此查询将为每个类别返回一行,计算关联的待办事项。此外,它还包括一个没有类别描述的最后一行,报告类别之外的待办事项的数量。

对于所有这些运算符,所有涉及的查询都必须返回兼容的行。这是因为查询最终作为单个结果集报告,因此它们必须返回相同的列类型。可以对复合 select 语句应用 LIMITORDER BY 子句,但只能对第一个语句(调用 unionunionAllexceptintersect 的语句)应用。