Sequelize v6 教程 02:Model Instances

对应官方文档:Model Instances

1. 本章目标

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

本章沿用上一章的 Sakila Actor 模型,并补充 World City 模型用于数值增减示例。

本文带 await 的片段默认写在 async 函数、Express 异步路由或服务层方法中。当前工程使用 CommonJS 编译模式,不应把它们直接作为文件顶层的 await 语句。

2. 实例是什么

当 Sequelize 查询到 actor 表的一行数据时,默认不会只返回一个普通对象,而是返回 Actor 类的实例:

const actor = await Actor.findByPk(1);

如果查询成功,actor 中既有数据,也有实例方法:

if (actor !== null) {
  console.log(actor.firstName);
  await actor.reload();
  await actor.update({ lastName: "SMITH" });
}

实例通常包含:

因此,不建议通过 console.log(actor) 判断所有业务数据,也不要依赖 _previousDataValues 等内部属性。需要普通对象时使用公开 API。

3. build():只在内存中构造

const actor = Actor.build({
  firstName: "ALICE",
  lastName: "CHEN",
});

这一步不会执行 INSERT。对象只存在于当前 Node.js 进程内存中:

console.log(actor.isNewRecord); // true

只有保存后才会写入 MySQL:

await actor.save();
console.log(actor.isNewRecord); // false
console.log(actor.actorId); // MySQL 返回的自增主键

大致 SQL:

INSERT INTO `actor` (`actor_id`, `first_name`, `last_name`, `last_update`)
VALUES (DEFAULT, 'ALICE', 'CHEN', CURRENT_TIMESTAMP);

注意:实际 SQL 可能使用参数绑定,并因模型默认值和方言细节而不同。

4. create():build() 与 save() 的快捷方式

const actor = await Actor.create({
  firstName: "BOB",
  lastName: "WANG",
});

概念上等价于:

const actor = Actor.build({
  firstName: "BOB",
  lastName: "WANG",
});

await actor.save();

区别是 create() 一次完成构造和持久化,适合普通新增;build() 适合保存前还要执行多步同步处理、调用实例方法或检查实例状态的情况。

4.1 使用 fields 建立写入白名单

假设 HTTP 请求体是:

interface CreateActorBody {
  firstName: string;
  lastName: string;
  actorId?: number;
}

不要未经筛选就把整个请求体传给模型。可以明确允许写入的字段:

const body: CreateActorBody = req.body;

const actor = await Actor.create(
  {
    firstName: body.firstName,
    lastName: body.lastName,
  },
  {
    fields: ["firstName", "lastName"],
  },
);

这既使代码意图明确,也能降低客户端越权修改主键、权限或审计字段的风险。字段白名单不能取代请求校验和权限判断。

5. 读取实例数据

5.1 直接读取属性

const actor = await Actor.findByPk(1);

if (actor !== null) {
  console.log(actor.actorId);
  console.log(actor.firstName);
}

这是 TypeScript 项目中最直观的方式。

5.2 get() 与 toJSON()

if (actor !== null) {
  const firstName = actor.get("firstName");
  const plainActor = actor.get({ plain: true });
  const jsonActor = actor.toJSON();

  console.log(firstName, plainActor, jsonActor);
}

Express 中可以执行 res.json(actor),因为 JSON 序列化会调用 toJSON();但在服务层显式转换为 DTO 往往更安全,因为可以稳定控制对外字段。

interface ActorDto {
  id: number;
  fullName: string;
}

function toActorDto(actor: Actor): ActorDto {
  return {
    id: actor.actorId,
    fullName: `${actor.firstName} ${actor.lastName}`,
  };
}

不要把 Sequelize 实例直接写入 Redis。若以后需要缓存查询结果,应缓存稳定的普通 DTO/JSON,并在成功提交 MySQL 更新后失效或更新缓存。

6. 修改属性与 save()

可以先修改实例属性,再调用 save():

const actor = await Actor.findByPk(1);

if (actor !== null) {
  actor.firstName = "PENELOPE";
  await actor.save();
}

Sequelize 会追踪哪些属性发生了变化,通常只更新变化的列。大致 SQL:

UPDATE `actor`
SET `first_name` = 'PENELOPE'
WHERE `actor_id` = 1;

Sakila 的 last_update 是否自动变化,取决于真实 MySQL DDL、模型设置以及数据库默认行为。应以日志和数据库结构为准。

6.1 检查变更

if (actor !== null) {
  actor.lastName = "GUINESS";

  console.log(actor.changed("lastName")); // true
  console.log(actor.previous("lastName")); // 上一次持久化时的值

  await actor.save();
  console.log(actor.changed("lastName")); // 通常为 false
}

changed() 和 previous() 适合调试、审计逻辑或有条件地执行副作用,但不要依赖未公开的内部存储结构。

6.2 限定 save() 更新字段

if (actor !== null) {
  actor.firstName = "MARY";
  actor.lastName = "JONES";

  await actor.save({ fields: ["firstName"] });
}

这次只持久化 firstName。内存中的 lastName 与数据库可能暂时不同,随后应谨慎处理,必要时调用 reload()。

7. set():批量设置实例属性

if (actor !== null) {
  actor.set({
    firstName: "NICK",
    lastName: "WAHLBERG",
  });

  await actor.save();
}

set() 默认只改内存,不会自动执行 SQL。它与 save() 是两个步骤。

如果数据来自 HTTP 请求,仍应先做校验和字段挑选:

actor.set({
  firstName: body.firstName,
  lastName: body.lastName,
});

不推荐 actor.set(req.body)。这种批量赋值很容易把以后新增的敏感字段暴露给客户端。

8. update():设置并立即保存

const actor = await Actor.findByPk(1);

if (actor !== null) {
  await actor.update({
    firstName: "ED",
    lastName: "CHASE",
  });
}

实例方法 update() 会修改实例并持久化。可以把它理解为常见场景下 set() + save() 的快捷方式。

请区分:

await actor.update({ firstName: "ED" }); // 实例方法,通常带主键条件

await Actor.update(
  { firstName: "ED" },
  { where: { actorId: 1 } },
); // 静态批量方法,必须认真编写 where

静态 Model.update() 返回的不是更新后的模型实例。在 MySQL 中通常可从返回值获得受影响行数;如果需要最新行,应再查询或在已有实例上使用实例方法并按需 reload()。

9. reload():以数据库为准重新读取

const actor = await Actor.findByPk(1);

if (actor !== null) {
  actor.firstName = "NOT_SAVED";
  await actor.reload();
  console.log(actor.firstName); // 恢复为 MySQL 中的值
}

reload() 会重新执行 SELECT,并用数据库结果覆盖当前实例。常见用途:

它并不能解决并发写覆盖问题。并发修改需要根据业务使用事务、行锁或乐观锁等方案。

10. save() 的校验与保存范围

save() 默认运行模型校验。若校验失败,SQL 不会执行:

try {
  await actor.save();
} catch (error: unknown) {
  console.error("保存失败", error);
}

可以通过 validate: false 跳过模型校验,但普通业务代码很少应该这样做:

await actor.save({ validate: false });

即便 Sequelize 校验通过,MySQL 仍可能因唯一约束、外键约束、非空约束或死锁而拒绝写入,所以数据库错误处理不可省略。

11. destroy():删除当前实例对应的记录

const actor = await Actor.findByPk(201);

if (actor !== null) {
  await actor.destroy();
}

大致 SQL:

DELETE FROM `actor` WHERE `actor_id` = 201;

如果该演员已经被 film_actor 外键引用,MySQL 可能拒绝删除。这正是关系数据库约束在保护数据完整性。

工程中应先明确:

不要为了“让删除成功”而随意关闭外键检查。

12. increment() 与 decrement()

World 数据库的 city.Population 是适合演示原子增减的数值列。简化模型如下:

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

class City extends Model<
  InferAttributes<City>,
  InferCreationAttributes<City>
> {
  declare id: CreationOptional<number>;
  declare name: string;
  declare countryCode: string;
  declare district: string;
  declare population: number;
}

City.init(
  {
    id: {
      type: DataTypes.INTEGER.UNSIGNED,
      primaryKey: true,
      autoIncrement: true,
      field: "ID",
    },
    name: { type: DataTypes.STRING(35), allowNull: false, field: "Name" },
    countryCode: {
      type: DataTypes.CHAR(3),
      allowNull: false,
      field: "CountryCode",
    },
    district: {
      type: DataTypes.STRING(20),
      allowNull: false,
      field: "District",
    },
    population: {
      type: DataTypes.INTEGER,
      allowNull: false,
      field: "Population",
    },
  },
  {
    sequelize,
    tableName: "city",
    timestamps: false,
  },
);

执行增减:

const city = await City.findByPk(1);

if (city !== null) {
  await city.increment("population", { by: 1000 });
  await city.reload();
  console.log(city.population);
}

大致 SQL 使用列自身计算:

UPDATE `city`
SET `Population` = `Population` + 1000
WHERE `ID` = 1;

这比“先在 Node.js 中读取数值,再计算并保存”更能避免并发请求互相覆盖。decrement() 的用法相同。

不同数据库方言对 increment() 后返回值的处理可能不同。在 MySQL 中,如果后续逻辑依赖最新值,明确调用 reload() 更容易理解。

13. JSON 字段的变更检测陷阱

虽然 Sakila 和 World 的核心表没有典型 JSON 列,但实际项目经常使用 MySQL JSON。假设实例属性 settings 是对象,直接修改内部属性可能无法被识别为变化:

user.settings.theme = "dark";
await user.save();

更稳妥的方法是替换整个对象:

user.settings = {
  ...user.settings,
  theme: "dark",
};

await user.save();

也可以在明确理解后手动标记:

user.changed("settings", true);
await user.save();

这是因为 ORM 的变更追踪通常更容易识别顶层引用变化,而不是任意深度的对象突变。

14. 多步写操作需要事务

单个实例 save() 只保证一条写入的数据库行为。若业务需要“新增租赁记录并更新库存状态”同时成功,就必须使用同一个事务:

await sequelize.transaction(async (transaction) => {
  const rental = await Rental.create(
    {
      rentalDate: new Date(),
      inventoryId,
      customerId,
      staffId,
    },
    { transaction },
  );

  await inventory.update(
    { available: false },
    { transaction },
  );

  console.log(rental.rentalId);
});

关键点:事务对象必须传给事务内的每一个 Sequelize 操作。漏传一次,该 SQL 就会使用事务外的连接,导致“部分提交”。模型实例本身不会自动让多个 save() 形成一个事务。

15. 实例生命周期中的常见坑

15.1 长时间持有实例

实例只代表某次读取时的数据库状态。不要在全局变量中长期保存它,否则内容会过期,也可能意外保留大量关联数据。

15.2 先查再改并不天然安全

const city = await City.findByPk(1);
city.population += 1;
await city.save();

两个请求同时执行时可能丢失更新。简单计数使用 increment();复杂一致性逻辑使用事务和合适的锁或乐观锁。

15.3 save() 不是“保存整个 JavaScript 对象”

它只会按照模型定义、变更状态和 fields 选项生成 SQL。未定义属性不会自动成为数据库列。

15.4 日志中不要输出敏感实例

模型实例可能包含密码哈希、令牌、内部备注等字段。日志和 API 响应都应该输出经过挑选的 DTO,而不是整个实例。

16. 本章检查清单

17. 练习题(暂不提供答案)

  1. 使用 Actor.build() 构造一名 Sakila 演员,在保存前后分别检查 isNewRecord 和 actorId,并记录执行了几条 SQL。
  2. 使用 Actor.create() 新增一名演员,只允许写入 firstName 和 lastName。尝试在输入中加入 actorId,验证字段白名单的效果。
  3. 查询 Sakila 中的一名演员,修改姓和名,但只使用 save({ fields: [...] }) 保存其中一项;随后使用 reload() 比较内存值和数据库值。
  4. 对某个 Actor 实例使用 changed() 和 previous(),记录修改前、保存前、保存后三个阶段的结果。
  5. 使用 World 的 City 模型,将某城市人口原子增加 500,再 reload() 并验证结果。不要使用“读取后 + 500 再保存”的写法。
  6. 查询一条 World city 记录,将其转换为只包含 id、name、population 的普通 DTO,并说明为什么这个对象比模型实例更适合返回给前端。
  7. 尝试删除一名已参与电影的 Sakila 演员,观察 MySQL 外键约束错误,并记录应用层应如何把该错误转换为业务响应。
  8. 为 Sakila rental 表设计一个“归还影片”的实例更新流程:设置 returnDate,保存后重新读取;列出并发归还时可能出现的业务问题。
  9. 设计一个事务练习:在 Sakila 中新增一条 payment 后更新相关客户统计字段(统计字段可放在自建练习表),故意让第二步失败,验证第一步是否回滚。

18. 官方参考