02-3. 自訂驗證器

⏱️ 閱讀時間: 15 分鐘 🎯 難度: ⭐⭐⭐ (中級)


🤔 一句話解釋

當內建驗證不夠用時,使用自訂驗證器實現任何你想要的驗證邏輯。


🎯 Pydantic v2 驗證器類型

┌─────────────────────────────────────────────┐
│            驗證器執行順序                    │
├─────────────────────────────────────────────┤
│  1. before validator (原始輸入)              │
│         ↓                                   │
│  2. 型別轉換/核心驗證                        │
│         ↓                                   │
│  3. after validator (轉換後的值)             │
│         ↓                                   │
│  4. model_validator (整個模型)               │
└─────────────────────────────────────────────┘

🔧 field_validator - 欄位驗證器

基本用法

from pydantic import BaseModel, field_validator

class User(BaseModel):
    name: str
    age: int

    @field_validator('name')
    @classmethod
    def name_must_not_be_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError('名稱不能為空')
        return v.strip()

    @field_validator('age')
    @classmethod
    def age_must_be_positive(cls, v: int) -> int:
        if v < 0:
            raise ValueError('年齡必須大於 0')
        return v

# 測試
user = User(name="  John  ", age=25)
print(user.name)  # "John" (已去除空白)

驗證多個欄位

from pydantic import BaseModel, field_validator

class User(BaseModel):
    first_name: str
    last_name: str
    email: str

    # 同時驗證多個欄位
    @field_validator('first_name', 'last_name')
    @classmethod
    def names_not_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError('名稱不能為空')
        return v.strip().title()  # 首字大寫

    @field_validator('email')
    @classmethod
    def email_lowercase(cls, v: str) -> str:
        return v.lower().strip()

# 測試
user = User(
    first_name="john",
    last_name="DOE",
    email="JOHN@EXAMPLE.COM"
)
print(user.first_name)  # John
print(user.last_name)   # Doe
print(user.email)       # john@example.com

使用驗證模式

from pydantic import BaseModel, field_validator, ValidationInfo

class Product(BaseModel):
    name: str
    price: float
    discount_price: float = None

    # mode='before': 在型別轉換之前執行
    @field_validator('price', mode='before')
    @classmethod
    def parse_price(cls, v):
        if isinstance(v, str):
            # 移除貨幣符號
            v = v.replace('$', '').replace(',', '')
        return float(v)

    # mode='after': 在型別轉換之後執行(預設)
    @field_validator('price', mode='after')
    @classmethod
    def price_must_be_positive(cls, v: float) -> float:
        if v <= 0:
            raise ValueError('價格必須大於 0')
        return round(v, 2)

# 測試
product = Product(name="iPhone", price="$1,299.99")
print(product.price)  # 1299.99

存取其他欄位的值

from pydantic import BaseModel, field_validator, ValidationInfo

class DateRange(BaseModel):
    start_date: str
    end_date: str

    @field_validator('end_date')
    @classmethod
    def end_after_start(cls, v: str, info: ValidationInfo) -> str:
        # info.data 包含已驗證的欄位
        if 'start_date' in info.data:
            if v < info.data['start_date']:
                raise ValueError('結束日期必須在開始日期之後')
        return v

class PasswordConfirm(BaseModel):
    password: str
    confirm_password: str

    @field_validator('confirm_password')
    @classmethod
    def passwords_match(cls, v: str, info: ValidationInfo) -> str:
        if 'password' in info.data and v != info.data['password']:
            raise ValueError('密碼不一致')
        return v

🏗️ model_validator - 模型驗證器

驗證整個模型

from pydantic import BaseModel, model_validator

class Rectangle(BaseModel):
    width: float
    height: float
    area: float = None

    @model_validator(mode='after')
    def calculate_area(self):
        if self.area is None:
            self.area = self.width * self.height
        elif self.area != self.width * self.height:
            raise ValueError('面積與寬高不符')
        return self

# 測試
rect = Rectangle(width=10, height=5)
print(rect.area)  # 50.0

rect2 = Rectangle(width=10, height=5, area=50)  # ✅ OK
rect3 = Rectangle(width=10, height=5, area=100)  # ❌ ValidationError

before 模式 - 處理原始輸入

from pydantic import BaseModel, model_validator
from typing import Any

class FlexibleUser(BaseModel):
    name: str
    email: str

    @model_validator(mode='before')
    @classmethod
    def handle_string_input(cls, data: Any) -> Any:
        # 如果輸入是字串,嘗試解析
        if isinstance(data, str):
            # 假設格式是 "name:email"
            parts = data.split(':')
            if len(parts) == 2:
                return {'name': parts[0], 'email': parts[1]}
        return data

# 測試
user1 = FlexibleUser(name="John", email="john@example.com")
user2 = FlexibleUser.model_validate("John:john@example.com")

wrap 模式 - 包裝核心驗證

from pydantic import BaseModel, model_validator, ValidationError

class SafeModel(BaseModel):
    value: int

    @model_validator(mode='wrap')
    @classmethod
    def wrap_validation(cls, values, handler):
        try:
            return handler(values)
        except ValidationError as e:
            # 可以在這裡處理錯誤
            print(f"Validation failed: {e}")
            raise

🔄 Annotated Validators

使用 Annotated 定義可重用驗證器

from pydantic import BaseModel, AfterValidator, BeforeValidator
from typing import Annotated

# 定義驗證函數
def to_lowercase(v: str) -> str:
    return v.lower()

def strip_whitespace(v: str) -> str:
    return v.strip()

def must_not_be_empty(v: str) -> str:
    if not v:
        raise ValueError('不能為空')
    return v

# 組合成可重用的型別
CleanString = Annotated[
    str,
    BeforeValidator(strip_whitespace),
    AfterValidator(to_lowercase),
    AfterValidator(must_not_be_empty),
]

class User(BaseModel):
    username: CleanString
    email: CleanString

# 測試
user = User(username="  JOHN  ", email="  JOHN@EXAMPLE.COM  ")
print(user.username)  # john
print(user.email)     # john@example.com

常用可重用驗證器

from pydantic import BaseModel, AfterValidator, BeforeValidator, Field
from typing import Annotated
import re

# 去除空白
def strip(v: str) -> str:
    return v.strip() if isinstance(v, str) else v

# 轉小寫
def lower(v: str) -> str:
    return v.lower() if isinstance(v, str) else v

# 轉大寫
def upper(v: str) -> str:
    return v.upper() if isinstance(v, str) else v

# 首字大寫
def title(v: str) -> str:
    return v.title() if isinstance(v, str) else v

# 非空驗證
def not_empty(v: str) -> str:
    if not v or not v.strip():
        raise ValueError('不能為空')
    return v

# 正規表達式驗證
def matches_pattern(pattern: str):
    def validator(v: str) -> str:
        if not re.match(pattern, v):
            raise ValueError(f'格式不符合: {pattern}')
        return v
    return validator

# 組合成常用型別
TrimmedString = Annotated[str, BeforeValidator(strip)]
LowerString = Annotated[str, BeforeValidator(strip), AfterValidator(lower)]
UpperString = Annotated[str, BeforeValidator(strip), AfterValidator(upper)]
TitleString = Annotated[str, BeforeValidator(strip), AfterValidator(title)]
NonEmptyString = Annotated[str, BeforeValidator(strip), AfterValidator(not_empty)]

# 使用
class Person(BaseModel):
    first_name: TitleString
    last_name: TitleString
    nickname: TrimmedString = ""

person = Person(first_name="  john  ", last_name="  DOE  ")
print(person.first_name)  # John
print(person.last_name)   # Doe

🎨 實戰範例

信用卡驗證

from pydantic import BaseModel, field_validator, model_validator
from datetime import date
import re

class CreditCard(BaseModel):
    card_number: str
    expiry_month: int
    expiry_year: int
    cvv: str
    holder_name: str

    @field_validator('card_number')
    @classmethod
    def validate_card_number(cls, v: str) -> str:
        # 移除空格和破折號
        v = re.sub(r'[\s-]', '', v)

        # 檢查是否全為數字
        if not v.isdigit():
            raise ValueError('卡號只能包含數字')

        # 檢查長度(13-19 位)
        if not 13 <= len(v) <= 19:
            raise ValueError('卡號長度不正確')

        # Luhn 演算法驗證
        def luhn_check(card_num):
            digits = [int(d) for d in card_num]
            odd_digits = digits[-1::-2]
            even_digits = digits[-2::-2]
            checksum = sum(odd_digits)
            for d in even_digits:
                checksum += sum(divmod(d * 2, 10))
            return checksum % 10 == 0

        if not luhn_check(v):
            raise ValueError('無效的卡號')

        return v

    @field_validator('expiry_month')
    @classmethod
    def validate_month(cls, v: int) -> int:
        if not 1 <= v <= 12:
            raise ValueError('月份必須在 1-12 之間')
        return v

    @field_validator('expiry_year')
    @classmethod
    def validate_year(cls, v: int) -> int:
        current_year = date.today().year
        if v < current_year:
            raise ValueError('卡片已過期')
        if v > current_year + 20:
            raise ValueError('年份不合理')
        return v

    @field_validator('cvv')
    @classmethod
    def validate_cvv(cls, v: str) -> str:
        if not v.isdigit() or not 3 <= len(v) <= 4:
            raise ValueError('CVV 必須是 3-4 位數字')
        return v

    @field_validator('holder_name')
    @classmethod
    def validate_holder_name(cls, v: str) -> str:
        v = v.strip().upper()
        if not re.match(r'^[A-Z\s]+$', v):
            raise ValueError('持卡人姓名只能包含英文字母')
        return v

    @model_validator(mode='after')
    def check_expiry(self):
        today = date.today()
        if self.expiry_year == today.year and self.expiry_month < today.month:
            raise ValueError('卡片已過期')
        return self

訂單驗證

from pydantic import BaseModel, Field, field_validator, model_validator
from typing import List, Optional
from decimal import Decimal
from datetime import datetime
from enum import Enum

class OrderStatus(str, Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    product_name: str = Field(min_length=1)
    quantity: int = Field(ge=1, le=999)
    unit_price: Decimal = Field(ge=0, decimal_places=2)

    @property
    def subtotal(self) -> Decimal:
        return self.unit_price * self.quantity

class ShippingAddress(BaseModel):
    recipient: str = Field(min_length=1, max_length=100)
    phone: str = Field(pattern=r'^09\d{8}$')
    city: str
    district: str
    address: str = Field(min_length=5, max_length=200)
    zip_code: str = Field(pattern=r'^\d{3,5}$')

class CreateOrderRequest(BaseModel):
    items: List[OrderItem] = Field(min_length=1, max_length=50)
    shipping_address: ShippingAddress
    coupon_code: Optional[str] = Field(None, pattern=r'^[A-Z0-9]{6,12}$')
    note: Optional[str] = Field(None, max_length=500)

    @field_validator('items')
    @classmethod
    def validate_items(cls, v: List[OrderItem]) -> List[OrderItem]:
        # 檢查是否有重複的商品
        product_ids = [item.product_id for item in v]
        if len(product_ids) != len(set(product_ids)):
            raise ValueError('訂單中有重複的商品')
        return v

    @model_validator(mode='after')
    def validate_order_total(self):
        # 計算總金額
        total = sum(item.subtotal for item in self.items)

        # 檢查最低訂單金額
        if total < Decimal('100'):
            raise ValueError('訂單金額必須至少 100 元')

        # 檢查最高訂單金額
        if total > Decimal('1000000'):
            raise ValueError('訂單金額超過上限')

        return self

    @property
    def total_amount(self) -> Decimal:
        return sum(item.subtotal for item in self.items)

    @property
    def total_items(self) -> int:
        return sum(item.quantity for item in self.items)

⚠️ 常見錯誤

1. 忘記 @classmethod

# ❌ 錯誤
class User(BaseModel):
    name: str

    @field_validator('name')
    def validate_name(cls, v):  # 缺少 @classmethod
        return v

# ✅ 正確
class User(BaseModel):
    name: str

    @field_validator('name')
    @classmethod
    def validate_name(cls, v):
        return v

2. 驗證器順序

# 注意:欄位驗證器按照欄位定義順序執行
class User(BaseModel):
    password: str
    confirm: str  # 這個驗證器執行時,password 已經驗證過了

    @field_validator('confirm')
    @classmethod
    def check_confirm(cls, v, info):
        if 'password' in info.data:  # password 已可用
            ...
        return v

3. 修改 self 在 model_validator

# ❌ mode='before' 不能存取 self
@model_validator(mode='before')
@classmethod
def validate(cls, values):  # values 是 dict
    return values

# ✅ mode='after' 可以存取 self
@model_validator(mode='after')
def validate(self):  # 是實例方法
    self.field = self.field.upper()
    return self

✅ 重點總結

驗證器類型

驗證器用途mode
field_validator單一欄位before/after
model_validator整個模型before/after/wrap
Annotated可重用驗證BeforeValidator/AfterValidator

執行順序

  1. model_validator(mode='before') - 原始輸入
  2. field_validator(mode='before') - 型別轉換前
  3. 核心型別驗證和轉換
  4. field_validator(mode='after') - 型別轉換後
  5. model_validator(mode='after') - 所有欄位驗證後

🎤 面試這樣答

Q: Pydantic 中 field_validator 和 model_validator 有什麼區別?

答案:

field_validator 用於驗證單一欄位,只能存取該欄位的值和已經驗證過的其他欄位。

model_validator 用於驗證整個模型,可以存取所有欄位,適合做跨欄位驗證,例如確認密碼是否一致。

@field_validator('email')
@classmethod
def validate_email(cls, v):
    return v.lower()

@model_validator(mode='after')
def check_passwords_match(self):
    if self.password != self.confirm_password:
        raise ValueError('密碼不一致')
    return self

🤓 小測驗

  1. @field_validator 需要搭配什麼裝飾器?

  2. 如何在 field_validator 中存取其他欄位的值?

  3. mode=‘before’ 和 mode=‘after’ 的差別?


上一篇: 02-2. 欄位驗證與約束 下一篇: 02-4. 巢狀模型與繼承


最後更新:2025-12-17

0%