05|日常 CRUD 高频接口

CRUD 是 Create、Read、Update、Delete。真正容易出错的不是方法名,而是返回值、NULL、默认 Scope、更新范围和并发语义。

1. 创建:create()

业务场景:创建首个管理员。

const admin = await AdminUser.create({
  username,
  password_hash,
  display_name,
  email,
  locked_until: null,
  last_login_at: null,
});

大致 SQL:

INSERT INTO admin_user
  (username, password_hash, display_name, email,
   locked_until, last_login_at, ...)
VALUES (?, ?, ?, ?, ?, ?, ...);

返回值是已创建的 AdminUser 实例,不是受影响行数。

注意:TypeScript 允许省略 CreationOptional 字段;数据库默认值仍应是最终保证。

2. build() + save()

const admin = AdminUser.build({
  username,
  password_hash,
  display_name,
  email: null,
  locked_until: null,
  last_login_at: null,
});

// 此时通常还没有 INSERT
await admin.save();

build() 只构造实例,save() 才持久化。适合保存前还要同步设置多个属性或检查实例状态的场景;普通创建优先用更直观的 create()。

3. 批量创建:bulkCreate()

await AdminLoginAudit.bulkCreate(rows, {
  validate: true,
  transaction,
});

适合一次插入多行,通常比循环 create() 少往返。但必须明确:

不要为了方便把任何用户数组直接传给 bulkCreate()。

4. findOrCreate() 与并发

const [admin, created] = await AdminUser.findOrCreate({
  where: { username },
  defaults: { /* 创建字段 */ },
});

返回元组:实例和“本次是否创建”。它适合“存在就取,不存在就建”的业务,但正确性仍依赖数据库唯一索引。不要自己写:

if (!(await AdminUser.findOne({ where: { username } }))) {
  await AdminUser.create(...);
}

两个并发请求可能都查到不存在。数据库唯一约束必须兜底。

5. upsert()

const [instance, created] = await SomeModel.upsert({
  natural_key: key,
  value,
});

表示插入,发生主键/唯一键冲突时更新。适用于确实拥有稳定唯一键且业务语义就是“覆盖”的配置或同步数据。

Sequelize v6 的返回值是 [instance, created]。created 的类型是 boolean | null:某些方言或执行路径无法可靠判断本次究竟是插入还是更新,此时会返回 null,不要把它无条件当作布尔值。

它不适合管理员注册:重复用户名通常应报告冲突,而不是悄悄覆盖现有密码或邮箱。

6. 按主键查询:findByPk()

const admin = await AdminUser.findByPk(admin_id);

返回类型:

Promise<AdminUser | null>

必须处理 null:

if (admin === null) {
  throw new AppError(/* ... */);
}

大致 SQL:

SELECT ... FROM admin_user WHERE id = ? LIMIT 1;

本项目 ID 是字符串,不要用 Number(admin_id) 转换。

7. 查询一条:findOne()

当前登录查询:

const admin = await AdminUser.scope("withPassword").findOne({
  where: { username },
});

同样返回 AdminUser | null。如果条件可能匹配多行,它只返回数据库选中的第一行;没有 order 时“第一行”不应被当作稳定业务规则。

唯一用户名查询是合理场景,因为数据库唯一索引确保最多一行。

8. 查询多条:findAll()

const admins = await AdminUser.findAll({
  where: { account_status: AdminAccountStatus.Active },
  limit: 100,
});

返回 AdminUser[],查不到时返回空数组,不返回 null。

Web 接口不要无上限 findAll()。随着数据增长,它会占用数据库、网络和 Node.js 内存。

9. count() 与“是否存在”

const count = await AdminUser.count({
  where: { account_status: AdminAccountStatus.Active },
});

对应 COUNT(*)。如果只关心是否存在,完整统计所有匹配行可能多做工作,可查询一条主键:

const row = await AdminUser.findOne({
  attributes: ["id"],
  where: { username },
});
const exists = row !== null;

但“先查存在”不能代替唯一约束。

10. 分页:findAndCountAll()

const result = await AdminUser.findAndCountAll({
  where,
  order: [
    ["created_at", "DESC"],
    ["id", "DESC"],
  ],
  limit: page_size,
  offset,
});

返回:

{
  rows: AdminUser[];
  count: number;
}

它通常发出两条 SQL:一条 count,一条分页查询。以后加入一对多 include 时可能重复计数,需要理解 distinct 和生成 SQL。

上面的 count: number 适用于没有 group 的常见分页。传入 group 后,Sequelize v6 的 count 是分组计数数组,不再是单个数字。

11. 实例更新:update()

当前失败登录记录:

await current_admin.update(
  {
    failed_login_count: next_failed_count,
    locked_until,
  },
  { transaction },
);

优点是目标行已明确,返回更新后的同一实例。大致 SQL:

UPDATE admin_user
SET failed_login_count = ?, locked_until = ?
WHERE id = ?;

它仍然不自动解决“事务外读取、稍后覆盖”的并发问题。当前代码通过事务内重新查询和 FOR UPDATE 解决。

12. 静态更新:Model.update()

当前成功登录:

await AdminUser.update(
  {
    failed_login_count: 0,
    locked_until: null,
    last_login_at: logged_in_at,
  },
  { where: { id: admin.id }, transaction },
);

MySQL 下返回的重点通常是受影响行数,不应误以为它直接返回更新后的 AdminUser。因此当前代码随后:

await admin.reload({ transaction });

使旧实例重新读取数据库。

最危险的错误是漏掉 where。更新与删除前都应明确目标范围,必要时把主键条件作为 Service 的硬约束。

13. 修改属性后 save()

admin.display_name = new_display_name;
await admin.save();

Sequelize 会追踪实例发生变化的字段,通常只更新变化列。可以检查:

admin.changed("display_name");

但读取到实例后长时间等待再 save(),可能覆盖其他请求期间的变化。并发敏感场景需要条件更新、乐观锁或行锁。

14. reload()

await admin.reload({ transaction });

重新执行 SELECT,把数据库最新值写回该实例。适合:

它是额外一次查询,不应无条件到处调用。

15. increment() / decrement()

await AdminUser.increment("failed_login_count", {
  by: 1,
  where: { id: admin_id },
});

大致 SQL:

UPDATE admin_user
SET failed_login_count = failed_login_count + 1
WHERE id = ?;

这比“先读 number、JS +1、再写回”更不容易丢失更新。但当前锁定逻辑还要基于新计数同时设置 locked_until,所以事务行锁方案更容易把复合业务规则讲清楚。

16. 删除:destroy()

实例删除:

await admin.destroy({ transaction });

静态删除:

const deleted_count = await AdminUser.destroy({
  where: { id: admin_id },
  transaction,
});

静态方法返回删除行数。漏写或写错 where 可能造成大范围删除。

物理删除会真正移除数据。软删除通常通过 deleted_at 或 Sequelize paranoid 实现,但会影响唯一索引、所有查询和合规要求,不应只为“以后可能用”而提前引入。

17. 字段选择与 raw

const admins = await AdminUser.findAll({
  attributes: ["id", "username", "display_name"],
});

只取所需字段可减少数据传输和敏感字段风险。

const rows = await AdminUser.findAll({ raw: true });

raw: true 在运行时返回普通对象而非 Model 实例,不能调用 save()、reload() 等实例方法;复杂关联下返回形状也不同。不要仅为了“看起来像 JSON”就使用它。

这里还有一个 Sequelize v6 的类型边界:findAll() 的部分重载即使收到 raw: true,静态结果仍可能显示为 AdminUser[],而 attributes 也不会自动推导成精确投影类型。因此 rows[0]?.save() 可能通过 TypeScript,却在运行时报“不是函数”。只读投影应定义专用 Row/DTO 类型,并在查询边界做明确映射或运行时校验,不能把这一处库类型当作运行时保证。

18. 初学者常见错误

  1. 忘记处理 findOne() / findByPk() 的 null。
  2. 认为 findAll() 查不到时返回 null。
  3. 误以为静态 update() 返回更新后实例。
  4. 更新/删除漏写安全的 where。
  5. 无上限 findAll() 用在列表接口。
  6. 用“先查再创建”代替唯一索引。
  7. 读取计数后在 JS 自增,产生并发丢失更新。
  8. 使用 raw: true 后仍调用实例方法。
  9. 把 upsert() 用在应报告冲突的业务。
  10. 批量写入时忽略校验、Hook 和事务语义。

19. 动手练习

  1. 写一个按 ID 查询管理员的函数,返回 DTO,并正确处理 null。
  2. 写一个 Active 管理员列表,限制 20 条并只选择安全字段。
  3. 运行静态 update(),打印其真实返回值,再 reload() 比较实例。
  4. 用两个并发 Promise 演示“读取后 +1 再 save”可能丢失更新,并与事务行锁版本对比。
  5. 为 destroy() 设计一层 Service 防护:只能按明确 ID 删除。

20. 复盘问题

  1. create() 与 build() + save() 有什么区别?
  2. findOne() 和 findAll() 的空结果分别是什么?
  3. Model.update() 为什么常常需要后续查询或 reload()?
  4. increment() 解决了哪类并发问题,又不能自动解决什么复合规则?
  5. 为什么 findOrCreate() 仍需要数据库唯一索引?

下一章把查询进一步展开:WHERE 操作符、排序、分页和用户输入安全。