验证规则
必填字段、唯一约束与 CEL 驱动的规则,在平台层拦住坏数据 —— 错误消息由你掌控。
验证规则
**验证在每条写入路径上都会执行。**REST、Console 表单、ObjectQL —— 同一套规则处处生效,坏数据没有后门可钻。验证规则是作用于单条记录的确定性、同步、无副作用谓词:仅凭这次写入(更新时再加上更新前的记录)即可判定,不做任何 I/O。
验证是分层的 —— 能表达规则的最低层就是该用的层:
| 层 | 表达什么 | 示例 |
|---|---|---|
| 字段修饰符 | 是否必填、是否唯一 | required: true、unique: true |
| 字段约束 | 单字段形态 | min、max、maxLength、format |
| 条件修饰符 | 依赖上下文的必填 | requiredWhen: P`record.amount > 10000` |
| 对象级验证规则 | 跨字段业务逻辑 | "折扣不能超过总额" |
| 唯一索引 | 组合 / 限定范围的唯一性 | { fields: ['code', 'organization'], unique: true } |
| 生命周期 Hook | 任意验证代码 | beforeInsert / beforeUpdate |
字段级:required、unique 与约束
通用修饰符适用于所有字段类型:
import { ObjectSchema, Field } from '@objectstack/spec/data';
fields: {
email: Field.email({ label: 'Contact Email', required: true, unique: true }),
quantity: Field.number({ label: 'Quantity', min: 1, max: 9999 }),
code: Field.text({ label: 'Code', minLength: 3, maxLength: 20 }),
}| 修饰符 | 行为 |
|---|---|
required: true | 在运行时拒绝 null / undefined |
unique: true | 数据库层唯一约束 —— 而不是有竞态的应用层检查 |
min / max | 数值范围(number、currency、percent、rating、slider……) |
minLength / maxLength | 文本类型的字符长度上下限 |
format | 内置形态检查 —— email、url、phone 字段类型默认自带 |
readonly: true | 更新时由服务端强制 —— 非系统写入该字段会被静默丢弃(插入不受限) |
字段类型本身也自带免费验证:email 检查 local@domain 形态,url 要求带协议,select 的值必须匹配某个选项,lookup 强制被引用记录存在,json 必须能解析。每种默认行为见字段类型参考。
条件式必填 / 只读 / 可见
是否必填可以通过 CEL 谓词依赖记录的其余部分:
import { P } from '@objectstack/spec';
po_number: Field.text({
label: 'PO Number',
requiredWhen: P`record.amount > 10000`,
}),visibleWhen、readonlyWhen、requiredWhen 都接受一个 CEL 谓词 —— 见公式。
对象级验证规则
跨字段业务逻辑放在对象的 validations 数组里:
export const Order = ObjectSchema.create({
name: 'order',
fields: {
amount: Field.currency({ label: 'Amount', required: true }),
status: Field.select({ label: 'Status', options: [ /* ... */ ] }),
},
validations: [
{
name: 'amount_positive',
type: 'script',
severity: 'error',
message: 'Amount must be greater than zero',
// CEL 谓词 —— TRUE 表示记录无效。
condition: 'record.amount <= 0',
events: ['insert', 'update'],
},
],
});警告:对
script规则来说,condition是失败谓词 —— 它求值为 TRUE 时,验证失败。要针对坏状态来写判断:record.amount <= 0拒绝非正数金额。
公共属性
每种规则类型都共享这套基础形态:
| 属性 | 必填 | 说明 |
|---|---|---|
name | 是 | 唯一的规则名(snake_case) |
message | 是 | 面向用户的错误消息 |
type | 是 | script、state_machine、format、cross_field、json_schema、conditional |
severity | 否 | error(阻止保存,默认)、warning(允许保存)、info |
events | 否 | insert、update、delete —— 默认 ['insert', 'update'] |
priority | 否 | 0–9999,数字越小越先执行(默认 100) |
active | 否 | 不删除即可开关(默认 true) |
六种规则类型
| 类型 | 检查什么 | 关键配置 |
|---|---|---|
script | 任意 CEL 谓词 | condition(TRUE = 无效) |
state_machine | 允许的状态迁移 | field、transitions 映射 |
format | 单字段匹配正则或内置格式 | field、regex 或 format: 'email' | 'url' | 'phone' | 'json' |
cross_field | 字段之间的关系 | fields、condition |
json_schema | JSON 字段匹配 JSON Schema | field、schema |
conditional | 仅在谓词成立时应用嵌套规则 | when、then、可选 otherwise |
两条你会反复用到的规则:
// 状态机 —— 强制状态流转
{
name: 'order_status_transitions',
type: 'state_machine',
severity: 'error',
message: 'Invalid status transition',
field: 'status',
transitions: {
draft: ['submitted', 'cancelled'],
submitted: ['approved', 'rejected', 'cancelled'],
approved: ['completed'],
rejected: ['draft'],
cancelled: [],
completed: [],
},
events: ['update'],
}
// 跨字段 —— 日期先后有序
{
name: 'date_range_valid',
type: 'cross_field',
severity: 'error',
message: 'End date must be after start date',
fields: ['start_date', 'end_date'],
condition: 'record.end_date <= record.start_date',
events: ['insert', 'update'],
}在更新时的条件里,previous 持有变更前的快照 —— record.stage != previous.stage 可检测到变更。
唯一性:用索引,不用规则
没有 uniqueness 验证类型 —— 这是有意为之。先 SELECT 再 INSERT 的检查天然有竞态(TOCTOU);数据库唯一约束没有。唯一性要在数据层强制:
// 字段级
email: Field.email({ label: 'Contact Email', unique: true }),
// 组合 / 限定范围的唯一性用索引
indexes: [
{ fields: ['code', 'organization'], unique: true },
// 需要限定范围 / 条件约束时加 `partial`
]同样的逻辑也排除了异步 / 远程验证(那是客户端表单的关注点,放在写路径上还是 SSRF 与延迟隐患)和自定义处理器 —— 任意验证代码应该放进 beforeInsert / beforeUpdate 生命周期 Hook。
自定义错误消息
message 就是规则触发时用户看到的内容 —— 出现在 Console 表单和 API 错误响应中。要写得可执行:
| 弱 | 强 |
|---|---|
| "Invalid input" | "Close date is required for closed deals" |
| "Validation failed" | "Phone must match format: +1-XXX-XXX-XXXX" |
| "Error" | "Discount cannot exceed total" |
用 severity 来校准:error 阻止保存,warning 显示消息但放行保存,info 纯提示。
**提示:**无法求值的谓词(解析错误、未绑定变量)会被当作损坏的规则处理 —— 记录日志后跳过,而不是拦下每一次写入。
最佳实践
| 应该 | 不应该 |
|---|---|
| 在尽可能低的层做验证(字段 → 规则 → Hook) | 用 Hook 做字段修饰符就能表达的事 |
| 写清晰、可执行的消息 | 在多层重复同一个检查 |
用 priority 让廉价的格式检查先跑 | 构造过于复杂的条件 |
| 用真实数据测试规则 | 拦住正当的边界情况 |