Sequelize v6 教程 04:Model Querying - Finders

本文基于 Sequelize v6 官方文档,示例使用 TypeScript、MySQL 与 mysql2。这一章关注“怎样得到查询结果”;复杂的 where、排序、分页和运算符应结合上一篇 Querying Basics 学习。

学习目标

完成本章后,你应该能够:

1. 准备一个可查询的模型

以下模型映射 Sakila 的 actor 表。Sakila 表名、字段名采用下划线风格,而且 actor 不使用 Sequelize 默认的 createdAtupdatedAt 字段,因此需要显式配置。

import {
  CreationOptional,
  DataTypes,
  InferAttributes,
  InferCreationAttributes,
  Model,
  Sequelize,
} from "sequelize";

const sequelize = new Sequelize("sakila", "root", "password", {
  host: "127.0.0.1",
  dialect: "mysql",
  logging: console.log, // 学习阶段建议观察 SQL
});

class Actor extends Model<
  InferAttributes<Actor>,
  InferCreationAttributes<Actor>
> {
  declare actorId: CreationOptional<number>;
  declare firstName: string;
  declare lastName: string;
  declare lastUpdate: CreationOptional<Date>;
}

Actor.init(
  {
    actorId: {
      type: DataTypes.SMALLINT.UNSIGNED,
      autoIncrement: true,
      primaryKey: true,
      field: "actor_id",
    },
    firstName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "first_name",
    },
    lastName: {
      type: DataTypes.STRING(45),
      allowNull: false,
      field: "last_name",
    },
    lastUpdate: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: DataTypes.NOW,
      field: "last_update",
    },
  },
  {
    sequelize,
    tableName: "actor",
    timestamps: false,
  },
);

field 负责把 TypeScript 中的 actorId 映射为 MySQL 中的 actor_id。业务代码可以使用一致的 camelCase,而不必改变现有数据库结构。

2. 查找器返回 Model 实例

大多数查找器返回 Actor 实例。实例既包含字段数据,也包含 save()update()destroy() 等方法。

const actor = await Actor.findByPk(1);

if (actor !== null) {
  console.log(actor.firstName);
  console.log(actor.get({ plain: true }));
}

如果只需要序列化结果,可以调用:

const plainActor = actor?.get({ plain: true });
const jsonActor = actor?.toJSON();

也可以在查询中使用 raw: true 直接得到普通对象:

const actors = await Actor.findAll({
  attributes: ["actorId", "firstName", "lastName"],
  raw: true,
});

raw: true 适合只读列表、统计和导出,但结果没有实例方法,关联查询的结构也可能变得扁平。不要为了“看起来轻量”就在整个项目中全局使用它。

3. findAll:查询零到多行

const actors = await Actor.findAll({
  attributes: ["actorId", "firstName", "lastName"],
  order: [["actorId", "ASC"]],
  limit: 20,
  offset: 0,
});

结果类型是 Actor[]。没有记录时返回空数组 [],不会返回 null

近似 SQL:

SELECT actor_id AS actorId, first_name AS firstName, last_name AS lastName
FROM actor
ORDER BY actor_id ASC
LIMIT 0, 20;

工程建议:列表接口只选择实际需要的列。SELECT * 会增加网络传输、对象构造与序列化成本,还容易在表新增敏感字段后意外把它返回给客户端。

4. findByPk:按主键查一行

const actor = await Actor.findByPk(42, {
  attributes: ["actorId", "firstName", "lastName"],
});

if (actor === null) {
  // 在 HTTP 接口中通常转换为 404
}

近似 SQL:

SELECT actor_id, first_name, last_name
FROM actor
WHERE actor_id = 42
LIMIT 1;

findByPk 表达了明确意图,也会使用模型声明的主键;不要把它误解成“默认查 id 字段”。如果模型主键映射错误,查询也会错误。

5. findOne:按条件取第一行

const actor = await Actor.findOne({
  where: { lastName: "GUINESS" },
  order: [["actorId", "ASC"]],
});

结果是 Actor | nullfindOne 通常生成带 LIMIT 1 的 SQL。

这里的“第一行”只有在指定 order 时才有稳定业务含义。若数据库中可能存在多个 last_name = 'GUINESS' 的演员,又没有排序,那么不要假设每次都会返回同一行。

如果业务上要求条件唯一,例如使用邮箱查询用户,应该在 MySQL 中建立 UNIQUE 约束,而不是依赖 findOne 把重复数据隐藏起来。

6. rejectOnEmpty:把空结果转成异常

默认情况下,findOnefindByPk 查不到时返回 null。设置 rejectOnEmpty: true 后会抛出 SequelizeEmptyResultError

const actor = await Actor.findByPk(42, {
  rejectOnEmpty: true,
});

也可以传入自定义错误实例:

class ActorNotFoundError extends Error {}

const actor = await Actor.findByPk(42, {
  rejectOnEmpty: new ActorNotFoundError("演员不存在"),
});

是否使用它取决于团队的错误处理风格:

7. findOrCreate:找到或创建

const [actor, created] = await Actor.findOrCreate({
  where: {
    firstName: "ALICE",
    lastName: "ZHANG",
  },
  defaults: {
    lastUpdate: new Date(),
  },
});

console.log(actor.actorId);
console.log(created); // true 表示本次创建;false 表示查到已有记录

返回的是二元组 [instance, created]where 是查找条件;只有需要创建时,defaults 才参与新记录构造。如果同一字段同时出现在二者中,创建时通常以 where 中的值为准。

并发下不能只相信“先查后插”

两个请求可能同时判断记录不存在,然后同时插入。要保证业务唯一性,必须建立 MySQL 唯一约束。例如若业务定义姓名组合唯一(Sakila 原表并没有这个约束),应由数据库约束最终兜底。

findOrCreate 在内部会使用事务来处理常见竞争情况,但它不能替代正确的数据模型。生产实践是:

  1. 在数据库建立唯一约束;
  2. 使用 findOrCreate 表达意图;
  3. 正确处理 UniqueConstraintError 等竞争结果。

8. findAndCountAll:分页数据与总数

const page = 2;
const pageSize = 20;

const result = await Actor.findAndCountAll({
  where: { lastName: "GUINESS" },
  order: [["actorId", "ASC"]],
  limit: pageSize,
  offset: (page - 1) * pageSize,
});

console.log(result.count);
console.log(result.rows);

它通常执行两条 SQL:一条 COUNT,一条查询当前页。不要把它理解为“一次数据库访问”。

SELECT count(*) AS count FROM actor WHERE last_name = 'GUINESS';
SELECT ... FROM actor
WHERE last_name = 'GUINESS'
ORDER BY actor_id ASC
LIMIT 20, 20;

使用关联 include 后,需要重点检查计数是否因一对多 JOIN 而重复。常见做法是根据查询结构评估 distinct: true,并观察实际 SQL:

const result = await Actor.findAndCountAll({
  distinct: true,
  limit: 20,
  offset: 0,
  // include: [...]
});

另外,只有 include 中设置了 required: true 的关联会参与计数条件。复杂报表中,分开写明确的 count()findAll() 往往更容易控制和优化。

大 offset 的性能问题

OFFSET 200000 仍可能要求 MySQL 扫描并丢弃大量记录。面向不断向后翻页的列表,可以考虑基于稳定索引的游标分页:

import { Op } from "sequelize";

const actors = await Actor.findAll({
  where: {
    actorId: { [Op.gt]: 2000 },
  },
  order: [["actorId", "ASC"]],
  limit: 20,
});

9. findAndCountAllcount 的类型变化

没有 group 时,count 通常是一个数字。加入 group 后,count 会变成分组计数结果数组,而不是单个总数:

const result = await Actor.findAndCountAll({
  attributes: ["lastName"],
  group: ["lastName"],
});

console.log(result.count); // 分组计数数组

因此通用分页函数不能在完全不看查询参数的情况下假设 count 永远是 number

10. countmaxminsum

Sequelize 提供常用聚合查找器:

const actorCount = await Actor.count();
const largestActorId = await Actor.max("actorId");
const smallestActorId = await Actor.min("actorId");
const actorIdSum = await Actor.sum("actorId");

它们都可以接收 where

const guinessCount = await Actor.count({
  where: { lastName: "GUINESS" },
});

近似 SQL:

SELECT count(*) AS count FROM actor WHERE last_name = 'GUINESS';
SELECT max(actor_id) AS max FROM actor;

注意 MySQL DECIMAL 的精度策略。金额聚合结果在不同配置和驱动选项下可能以字符串表示;不要为了方便直接用 JavaScript number 做高精度金额计算。

11. 在 Express service 中使用查找器

路由层负责 HTTP,service 层负责业务规则,可以让“没有找到”的含义更明确:

export async function getActorById(actorId: number): Promise<Actor> {
  const actor = await Actor.findByPk(actorId);

  if (actor === null) {
    throw new ActorNotFoundError(`演员 ${actorId} 不存在`);
  }

  return actor;
}

不要从 controller 把整个 req.query 原样传给 Sequelize。应先校验和转换允许的分页、排序、筛选字段,否则容易产生不可控查询,甚至引入 SQL 注入风险。Sequelize 参数化查询能保护“值”,但动态列名和排序字段仍应使用白名单。

12. 查询性能检查清单

13. 小结

练习题(暂不提供答案)

  1. 使用 Sakila actor 表定义 TypeScript 模型,查询 actor_id = 10 的演员;查不到时抛出自定义错误。
  2. 使用 findAll 查询姓氏为 DAVIS 的演员,只返回主键、名和姓,并按主键降序排列。
  3. 使用 findOne 查询 World 数据库 city 表中人口最多的城市,并解释为什么必须设置 order
  4. 为 Sakila 演员列表编写每页 20 条的 findAndCountAll 查询,返回总数、页码、每页数量和列表。
  5. 把上一题改为基于 actor_id 的游标分页,比较它与大 offset 分页的适用场景。
  6. 使用 World country 表分别统计国家总数、最大人口和最小非零人口,观察 Sequelize 生成的 SQL。
  7. 设计一个使用 findOrCreate 创建 World city 记录的示例,并说明要防止并发重复,数据库应建立什么唯一约束。
  8. 分别用默认实例结果和 raw: true 查询 5 个演员,比较返回结构、可用方法和适用场景。

官方文档