驗證規則
必填欄位、唯一約束與 CEL 驅動的規則,在平臺層攔住壞資料 —— 錯誤訊息由你掌控。
**驗證在每條寫入路徑上都會執行。**REST、Console 表單、ObjectQL —— 同一套規則處處生效,壞資料沒有後門可鑽。自 16.0 起也包括多行更新:批次更新會對每一條匹配的記錄逐行執行規則。驗證規則是作用於單條記錄的確定性、同步、無副作用謂詞:僅憑這次寫入(更新時再加上更新前的記錄)即可判定,不做任何 I/O。
驗證是分層的 —— 能表達規則的最低層就是該用的層:
| 層 | 表達什麼 | 示例 |
|---|---|---|
| 欄位修飾符 | 是否必填、是否唯一 | required: true、unique: true |
| 欄位約束 | 單欄位形態 | min、max、maxLength、format |
| 條件修飾符 | 依賴上下文的必填 | requiredWhen: P`record.amount > 10000` |
| 物件級驗證規則 | 跨欄位業務邏輯 | "折扣不能超過總額" |
| 唯一索引 | 組合 / 限定範圍的唯一性 | { fields: ['code', 'organization'], unique: true } |
| 生命週期 Hook | 任意驗證程式碼、刪除守衛 | beforeInsert / beforeUpdate / beforeDelete |
欄位級: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 —— 預設 ['insert', 'update']。delete 事件已在 16.0 中移除:求值器從未在刪除路徑上執行(刪除沒有可供驗證的記錄載荷),它一直是靜默空操作 —— 刪除守衛請用 beforeDelete Hook |
priority | 否 | 0–9999,數字越小越先執行(預設 100) |
active | 否 | 不刪除即可開關(預設 true) |
六種規則型別
| 型別 | 檢查什麼 | 關鍵配置 |
|---|---|---|
script | 任意 CEL 謂詞 | condition(TRUE = 無效) |
state_machine | 允許的狀態遷移 | field、transitions 對映、可選 initialStates |
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: [],
},
initialStates: ['draft'], // 16.0 新增:记录允许被“创建”为哪些状态
events: ['insert', 'update'], // 包含 'insert' 才会检查 initialStates
}
// 跨字段 —— 日期先后有序
{
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'],
}16.0 新增:state_machine 規則可以宣告 initialStates —— 記錄允許被建立為哪些狀態。插入時狀態欄位的值不在列表內會被拒絕(invalid_initial_state)。transitions 只約束更新,而 select 欄位允許任何已宣告的選項作為初始值,所以不加 initialStates 時記錄可能"生在流程中間"(比如一建立就是 approved)。省略它則保持舊行為(插入時不檢查初始狀態)。規則的 events 必須包含 insert,該檢查才會執行。
在更新時的條件裡,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,刪除時的守衛則放進 beforeDelete 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 讓廉價的格式檢查先跑 | 構造過於複雜的條件 |
| 用真實資料測試規則 | 攔住正當的邊界情況 |