Sequelize v6 教程 06:Validations & Constraints

本文基于 Sequelize v6 官方文档,示例使用 TypeScript、MySQL 与 mysql2。本章最重要的结论是:Sequelize validation 和 MySQL constraint 不是二选一,它们处于不同层次,通常应该互相配合。

学习目标

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

1. Validation 和 Constraint 的根本区别

Validation:Sequelize 应用层校验

Validation 在 JavaScript/TypeScript 进程中执行。校验失败时,Sequelize 通常不会向 MySQL 发送对应的 INSERTUPDATE SQL。

例如邮箱格式错误:

email: {
  type: DataTypes.STRING(50),
  validate: {
    isEmail: true,
  },
},

这能尽早提供友好错误,但只有经过这套 Sequelize 模型写入时才执行。另一个服务、数据库脚本或管理员直接执行 SQL,都可能绕过它。

Constraint:MySQL 数据库约束

Constraint 由 MySQL 执行,例如:

数据库约束必须实际执行 SQL 后才能判断是否违反。它保护所有写入来源,也是并发情况下的最终防线。

对比项 Sequelize Validation MySQL Constraint
执行位置 Node.js 进程 MySQL 服务器
失败前是否发送写 SQL 通常不发送 必须由数据库执行并检查
错误提示 容易定制成业务友好信息 偏数据库语义
能否保护其他写入程序 不能
能否可靠处理并发唯一性 不能单独保证

工程实践:应用校验负责尽早反馈,数据库约束负责最终正确性。

2. 准备 Customer 模型

以下示例参考 Sakila customer,为了教学加入用户名,并简化部分原表字段。不要直接对已有 Sakila 表运行 sync({ alter: true })

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

const sequelize = new Sequelize("sakila", "root", "password", {
  host: "127.0.0.1",
  dialect: "mysql",
  logging: console.log,
});

class CustomerAccount extends Model<
  InferAttributes<CustomerAccount>,
  InferCreationAttributes<CustomerAccount>
> {
  declare customerId: CreationOptional<number>;
  declare username: string;
  declare email: string;
  declare age: number | null;
  declare active: CreationOptional<boolean>;
}

CustomerAccount.init(
  {
    customerId: {
      type: DataTypes.INTEGER.UNSIGNED,
      autoIncrement: true,
      primaryKey: true,
      field: "customer_id",
    },
    username: {
      type: DataTypes.STRING(40),
      allowNull: false,
      unique: "uq_customer_account_username",
      validate: {
        notEmpty: {
          msg: "用户名不能为空",
        },
        len: {
          args: [3, 40],
          msg: "用户名长度必须是 3 到 40 个字符",
        },
      },
    },
    email: {
      type: DataTypes.STRING(100),
      allowNull: false,
      unique: "uq_customer_account_email",
      validate: {
        isEmail: {
          msg: "邮箱格式不正确",
        },
      },
    },
    age: {
      type: DataTypes.INTEGER.UNSIGNED,
      allowNull: true,
      validate: {
        min: {
          args: [0],
          msg: "年龄不能小于 0",
        },
        max: {
          args: [150],
          msg: "年龄不能大于 150",
        },
      },
    },
    active: {
      type: DataTypes.BOOLEAN,
      allowNull: false,
      defaultValue: true,
    },
  },
  {
    sequelize,
    tableName: "customer_account",
    timestamps: true,
  },
);

模型声明中的 unique 最终意图是数据库唯一约束;它不是一个能够在 Node.js 内独立完成并发唯一判断的普通格式校验器。

3. 内置校验器

Sequelize v6 的许多内置校验器来自 validator.js。常见配置包括:

someField: {
  type: DataTypes.STRING,
  validate: {
    isEmail: true,
    isUrl: true,
    isIP: true,
    isUUID: 4,
    isAlpha: true,
    isAlphanumeric: true,
    isNumeric: true,
    isLowercase: true,
    isUppercase: true,
    notEmpty: true,
    contains: "expected text",
    len: [3, 40],
    isIn: [["small", "medium", "large"]],
    notIn: [["forbidden"]],
  },
},

这段只是语法清单,不应把互相矛盾的校验器同时放在一个真实字段上。每个字段只配置业务确实需要的规则。

部分校验器面向字符串。即使 TypeScript 类型写成 string,仍要考虑数据是否来自未校验的 HTTP 请求;TypeScript 类型在运行时不会自动检查 JSON。

4. 触发校验:validatesavecreate

可以只校验实例而不保存:

const customer = CustomerAccount.build({
  username: "ab",
  email: "not-an-email",
  age: 200,
});

try {
  await customer.validate();
} catch (error: unknown) {
  if (error instanceof ValidationError) {
    for (const item of error.errors) {
      console.log(item.path, item.message);
    }
  }
}

create()save() 默认会执行校验:

const customer = await CustomerAccount.create({
  username: "alice",
  email: "alice@example.com",
  age: 28,
});

校验失败时,日志中不应出现对应的 INSERT SQL。观察这一点有助于理解应用校验和数据库约束的执行边界。

可以只验证指定字段:

await customer.validate({ fields: ["email"] });

虽然部分 API 支持 validate: false,但不应为了“修复报错”随意关闭校验。跳过应用校验后,只有数据库约束还能兜底,而格式和复杂业务规则可能直接失守。

5. allowNull: false 的特殊性

allowNull: false 同时影响两个层次:

  1. Sequelize 在字段值为 null 时进行应用层检查,可能在发送 SQL 前抛出 ValidationError
  2. 创建表或迁移时对应 MySQL NOT NULL 约束,防止其他写入来源保存 NULL

近似 DDL:

email VARCHAR(100) NOT NULL

注意:NOT NULL 只拒绝 NULL,不会自动拒绝空字符串 ''。如果业务不允许空字符串,还需要 notEmpty 校验,必要时增加数据库 CHECK 或通过领域字段设计限制。

当字段 allowNull: true 且值为 null 时,内置校验器通常会跳过该值;但自定义校验器的行为需要你自己明确编写和测试。

6. 自定义字段校验器

6.1 同步校验器

username: {
  type: DataTypes.STRING(40),
  allowNull: false,
  validate: {
    doesNotContainAdmin(value: string): void {
      if (value.toLowerCase().includes("admin")) {
        throw new Error("用户名不能包含 admin");
      }
    },
  },
},

自定义校验器通过抛出错误表示失败。它适合不需要 I/O 的确定性规则。

6.2 异步校验器

校验器也可以返回 Promise:

email: {
  type: DataTypes.STRING(100),
  allowNull: false,
  validate: {
    async isNotBlockedDomain(value: string): Promise<void> {
      const domain = value.split("@")[1];
      const blocked = await BlockedEmailDomain.findOne({
        where: { domain },
      });

      if (blocked !== null) {
        throw new Error("该邮箱域名不可使用");
      }
    },
  },
},

异步校验要谨慎:

若目标是保证邮箱唯一,不要写异步“查重校验器”作为最终保证,应该建立 MySQL UNIQUE 约束,并捕获唯一冲突。

7. 模型级校验:同时检查多个字段

字段校验适合单字段规则;涉及字段组合时,把校验写到模型选项的 validate 中:

class Promotion extends Model<
  InferAttributes<Promotion>,
  InferCreationAttributes<Promotion>
> {
  declare promotionId: CreationOptional<number>;
  declare startsAt: Date;
  declare endsAt: Date;
}

Promotion.init(
  {
    promotionId: {
      type: DataTypes.INTEGER.UNSIGNED,
      autoIncrement: true,
      primaryKey: true,
    },
    startsAt: {
      type: DataTypes.DATE,
      allowNull: false,
    },
    endsAt: {
      type: DataTypes.DATE,
      allowNull: false,
    },
  },
  {
    sequelize,
    tableName: "promotion",
    validate: {
      endsAfterStart(this: Promotion): void {
        if (this.endsAt <= this.startsAt) {
          throw new Error("结束时间必须晚于开始时间");
        }
      },
    },
  },
);

模型级校验错误的 path 可能对应校验器名称,而不是单个数据库字段。API 错误响应层应把内部错误转换成稳定结构,不要让前端依赖 Sequelize 的所有内部字段。

如果该时间关系对所有写入来源都必须成立,还应考虑 MySQL CHECK (ends_at > starts_at)。MySQL 8.0.16 以前对 CHECK 的处理存在历史限制,部署前必须核对实际数据库版本。

8. 唯一约束与并发

假设代码先查邮箱是否存在:

const existing = await CustomerAccount.findOne({
  where: { email: input.email },
});

if (existing === null) {
  await CustomerAccount.create(input);
}

两个并发请求可能同时查到 null,随后都尝试插入。这就是典型的 TOCTOU(检查时间与使用时间之间存在窗口)。

正确思路:

ALTER TABLE customer_account
ADD CONSTRAINT uq_customer_account_email UNIQUE (email);

然后捕获数据库返回的冲突:

import { UniqueConstraintError } from "sequelize";

try {
  await CustomerAccount.create(input);
} catch (error: unknown) {
  if (error instanceof UniqueConstraintError) {
    throw new Error("邮箱已经被使用");
  }

  throw error;
}

提前查询可以优化用户体验,但 UNIQUE 才是并发下的可靠保证。

MySQL 字符集排序规则影响唯一性

邮箱唯一性还受 collation 影响。许多 MySQL _ci 排序规则不区分大小写,Alice@example.comalice@example.com 可能被视为相同;二进制或区分大小写规则则可能不同。模型 setter、字段 collation 和唯一索引必须共同符合业务定义。

9. 外键约束

Sakila 中 customer.store_id 应引用 store.store_id。外键确保不存在“指向不存在门店的顾客”。近似 DDL:

ALTER TABLE customer
ADD CONSTRAINT fk_customer_store
FOREIGN KEY (store_id) REFERENCES store (store_id)
ON UPDATE CASCADE
ON DELETE RESTRICT;

Sequelize 关联可以帮助声明和查询关系,但“定义了 belongsTo”与“线上数据库一定存在正确外键”不是同一件事。应检查迁移和实际 DDL。

ON DELETE CASCADE 会自动删除关联数据,威力很大。选择 CASCADERESTRICTSET NULL 前,应明确业务生命周期和字段是否允许 NULL

MySQL 外键通常要求 InnoDB、兼容的字段类型/符号、索引与字符集配置。例如父列是 SMALLINT UNSIGNED 时,子列也应保持兼容,不能只看 TypeScript 都写成 number

10. 错误分类与 HTTP 响应

常见 Sequelize 错误包括:

import {
  DatabaseError,
  ForeignKeyConstraintError,
  UniqueConstraintError,
  ValidationError,
} from "sequelize";

在 Express 错误处理层可以分类:

function mapDatabaseError(error: unknown): {
  status: number;
  code: string;
  message: string;
} | null {
  if (error instanceof ValidationError) {
    return {
      status: 400,
      code: "VALIDATION_FAILED",
      message: "提交的数据不符合要求",
    };
  }

  if (error instanceof UniqueConstraintError) {
    return {
      status: 409,
      code: "RESOURCE_CONFLICT",
      message: "数据已存在",
    };
  }

  if (error instanceof ForeignKeyConstraintError) {
    return {
      status: 409,
      code: "RELATION_CONFLICT",
      message: "关联数据不存在或仍被使用",
    };
  }

  if (error instanceof DatabaseError) {
    return {
      status: 500,
      code: "DATABASE_ERROR",
      message: "数据库操作失败",
    };
  }

  return null;
}

生产环境不要把原始 SQL、连接信息、表结构和完整数据库错误直接返回给客户端。详细错误写入受控日志,对外使用稳定错误码和安全信息。

HTTP 状态码也不是绝对模板,例如某些团队会把字段校验使用 422。重要的是团队统一,并保持业务 code 稳定。

11. 批量操作的校验陷阱

批量 API 为了性能,行为与逐条 create/save 不完全相同。使用 bulkCreate 时应显式决定是否校验:

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

需要逐实例 hook 时还可能使用 individualHooks: true,但这会明显增加开销。不要因为单条创建通过测试,就假设批量创建、批量更新和 upsert 会执行完全相同的 setter、validator 与 hook 组合。

上线前应为实际采用的写入 API 编写集成测试,并观察 SQL。大量导入通常还要考虑:

12. 迁移,而不是在生产环境依赖 sync({ alter: true })

学习时 sequelize.sync() 可以快速理解模型与表的关系。生产环境增加 NOT NULL、唯一索引、外键或 CHECK 时,应使用可审查、可回滚的迁移:

应用发布顺序也很重要。约束变更与新代码可能需要分阶段上线,避免旧版本应用仍写入新规则不允许的数据。

13. Validation 不能替代接口输入校验

Sequelize 校验靠近持久化层,但 Express 接口仍应在入口校验:

HTTP 输入校验 -> 业务规则 -> Sequelize validation -> MySQL constraint

原因包括:

不要写成 Model.create(req.body)。应该使用白名单 DTO:

interface CreateCustomerInput {
  username: string;
  email: string;
  age: number | null;
}

await CustomerAccount.create({
  username: input.username,
  email: input.email,
  age: input.age,
});

14. 生产实践检查清单

15. 小结

练习题(暂不提供答案)

  1. 为 Sakila customer.email 编写 Sequelize 邮箱格式校验,并设计 MySQL 唯一约束;解释两者分别解决什么问题。
  2. 为 World city.Population 添加非负整数校验,并写出相应 MySQL CHECK 约束。查阅并记录你的 MySQL 版本是否真正执行 CHECK
  3. 使用 Sakila film 设计模型级校验:rental_duration 必须大于 0,rental_rate 不能为负;观察校验失败时是否发送 INSERT SQL。
  4. 为一个包含 startsAtendsAt 的活动模型编写跨字段校验,要求结束时间晚于开始时间,并讨论是否需要数据库 CHECK
  5. 对 Sakila customer.store_id 故意写入不存在的门店主键,捕获并分类 ForeignKeyConstraintError,但不要把数据库原始错误直接返回给客户端。
  6. 启动两个并发创建请求,尝试写入相同用户名;先只使用“创建前查重”,再增加唯一约束,记录结果差异。
  7. 使用 bulkCreate 向 World city 的练习表批量写入包含非法人口的数据,比较 validate: falsevalidate: true 的行为。
  8. 设计一个 Express 错误映射函数,把 ValidationErrorUniqueConstraintErrorForeignKeyConstraintError 转换为稳定的 HTTP 状态和业务错误码。
  9. 检查 Sakila 中一组真实外键字段的类型、UNSIGNED、索引和删除策略,并解释为什么 TypeScript 的 number 无法表达这些数据库差异。

官方文档