本文基于 Sequelize v6 官方文档,示例使用 TypeScript、MySQL 与
mysql2。本章最重要的结论是:Sequelize validation 和 MySQL constraint 不是二选一,它们处于不同层次,通常应该互相配合。
完成本章后,你应该能够:
allowNull: false 的特殊双重作用;Validation 在 JavaScript/TypeScript 进程中执行。校验失败时,Sequelize 通常不会向 MySQL 发送对应的 INSERT 或 UPDATE SQL。
例如邮箱格式错误:
email: {
type: DataTypes.STRING(50),
validate: {
isEmail: true,
},
},
这能尽早提供友好错误,但只有经过这套 Sequelize 模型写入时才执行。另一个服务、数据库脚本或管理员直接执行 SQL,都可能绕过它。
Constraint 由 MySQL 执行,例如:
NOT NULL;UNIQUE;PRIMARY KEY;FOREIGN KEY;CHECK。数据库约束必须实际执行 SQL 后才能判断是否违反。它保护所有写入来源,也是并发情况下的最终防线。
| 对比项 | Sequelize Validation | MySQL Constraint |
|---|---|---|
| 执行位置 | Node.js 进程 | MySQL 服务器 |
| 失败前是否发送写 SQL | 通常不发送 | 必须由数据库执行并检查 |
| 错误提示 | 容易定制成业务友好信息 | 偏数据库语义 |
| 能否保护其他写入程序 | 不能 | 能 |
| 能否可靠处理并发唯一性 | 不能单独保证 | 能 |
工程实践:应用校验负责尽早反馈,数据库约束负责最终正确性。
以下示例参考 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 内独立完成并发唯一判断的普通格式校验器。
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。
validate、save 与 create可以只校验实例而不保存:
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,但不应为了“修复报错”随意关闭校验。跳过应用校验后,只有数据库约束还能兜底,而格式和复杂业务规则可能直接失守。
allowNull: false 的特殊性allowNull: false 同时影响两个层次:
null 时进行应用层检查,可能在发送 SQL 前抛出 ValidationError;NOT NULL 约束,防止其他写入来源保存 NULL。近似 DDL:
email VARCHAR(100) NOT NULL
注意:NOT NULL 只拒绝 NULL,不会自动拒绝空字符串 ''。如果业务不允许空字符串,还需要 notEmpty 校验,必要时增加数据库 CHECK 或通过领域字段设计限制。
当字段 allowNull: true 且值为 null 时,内置校验器通常会跳过该值;但自定义校验器的行为需要你自己明确编写和测试。
username: {
type: DataTypes.STRING(40),
allowNull: false,
validate: {
doesNotContainAdmin(value: string): void {
if (value.toLowerCase().includes("admin")) {
throw new Error("用户名不能包含 admin");
}
},
},
},
自定义校验器通过抛出错误表示失败。它适合不需要 I/O 的确定性规则。
校验器也可以返回 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 约束,并捕获唯一冲突。
字段校验适合单字段规则;涉及字段组合时,把校验写到模型选项的 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 的处理存在历史限制,部署前必须核对实际数据库版本。
假设代码先查邮箱是否存在:
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 才是并发下的可靠保证。
邮箱唯一性还受 collation 影响。许多 MySQL _ci 排序规则不区分大小写,Alice@example.com 与 alice@example.com 可能被视为相同;二进制或区分大小写规则则可能不同。模型 setter、字段 collation 和唯一索引必须共同符合业务定义。
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 会自动删除关联数据,威力很大。选择 CASCADE、RESTRICT、SET NULL 前,应明确业务生命周期和字段是否允许 NULL。
MySQL 外键通常要求 InnoDB、兼容的字段类型/符号、索引与字符集配置。例如父列是 SMALLINT UNSIGNED 时,子列也应保持兼容,不能只看 TypeScript 都写成 number。
常见 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 稳定。
批量 API 为了性能,行为与逐条 create/save 不完全相同。使用 bulkCreate 时应显式决定是否校验:
await CustomerAccount.bulkCreate(rows, {
validate: true,
});
需要逐实例 hook 时还可能使用 individualHooks: true,但这会明显增加开销。不要因为单条创建通过测试,就假设批量创建、批量更新和 upsert 会执行完全相同的 setter、validator 与 hook 组合。
上线前应为实际采用的写入 API 编写集成测试,并观察 SQL。大量导入通常还要考虑:
sync({ alter: true })学习时 sequelize.sync() 可以快速理解模型与表的关系。生产环境增加 NOT NULL、唯一索引、外键或 CHECK 时,应使用可审查、可回滚的迁移:
NOT NULL 前先处理历史 NULL;应用发布顺序也很重要。约束变更与新代码可能需要分阶段上线,避免旧版本应用仍写入新规则不允许的数据。
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,
});
NOT NULL、UNIQUE、外键等保护不可破坏的不变量;bulkCreate、update、upsert 分别验证实际行为;allowNull: false 同时带来应用校验和数据库 NOT NULL;customer.email 编写 Sequelize 邮箱格式校验,并设计 MySQL 唯一约束;解释两者分别解决什么问题。city.Population 添加非负整数校验,并写出相应 MySQL CHECK 约束。查阅并记录你的 MySQL 版本是否真正执行 CHECK。film 设计模型级校验:rental_duration 必须大于 0,rental_rate 不能为负;观察校验失败时是否发送 INSERT SQL。startsAt、endsAt 的活动模型编写跨字段校验,要求结束时间晚于开始时间,并讨论是否需要数据库 CHECK。customer.store_id 故意写入不存在的门店主键,捕获并分类 ForeignKeyConstraintError,但不要把数据库原始错误直接返回给客户端。bulkCreate 向 World city 的练习表批量写入包含非法人口的数据,比较 validate: false 与 validate: true 的行为。ValidationError、UniqueConstraintError、ForeignKeyConstraintError 转换为稳定的 HTTP 状态和业务错误码。UNSIGNED、索引和删除策略,并解释为什么 TypeScript 的 number 无法表达这些数据库差异。