02-4. 巢狀模型與繼承

⏱️ 閱讀時間: 12 分鐘 🎯 難度: ⭐⭐ (基礎)


🤔 一句話解釋

巢狀模型讓你組合複雜的資料結構,繼承讓你重用共同的欄位定義。


🏠 巢狀模型

基本巢狀

from pydantic import BaseModel
from typing import Optional

class Address(BaseModel):
    """地址"""
    city: str
    street: str
    zip_code: str

class User(BaseModel):
    """使用者(包含地址)"""
    name: str
    email: str
    address: Address  # 巢狀模型

# 使用
user = User(
    name="John",
    email="john@example.com",
    address={
        "city": "台北市",
        "street": "信義路100號",
        "zip_code": "110"
    }
)

# 存取巢狀屬性
print(user.address.city)  # 台北市

選填的巢狀模型

from pydantic import BaseModel
from typing import Optional

class Address(BaseModel):
    city: str
    street: str

class Company(BaseModel):
    name: str
    website: Optional[str] = None

class User(BaseModel):
    name: str
    email: str
    address: Optional[Address] = None      # 整個地址可選
    company: Optional[Company] = None      # 整個公司可選

# 可以不提供巢狀模型
user = User(name="John", email="john@example.com")
print(user.address)  # None

巢狀列表

from pydantic import BaseModel
from typing import List

class Tag(BaseModel):
    name: str
    color: str = "gray"

class Image(BaseModel):
    url: str
    alt: str = ""
    width: Optional[int] = None
    height: Optional[int] = None

class Product(BaseModel):
    name: str
    price: float
    tags: List[Tag] = []          # Tag 列表
    images: List[Image] = []      # Image 列表

# 使用
product = Product(
    name="iPhone 15",
    price=35900,
    tags=[
        {"name": "手機", "color": "blue"},
        {"name": "Apple", "color": "gray"},
    ],
    images=[
        {"url": "https://example.com/1.jpg", "alt": "正面"},
        {"url": "https://example.com/2.jpg", "alt": "背面"},
    ]
)

# 存取
for tag in product.tags:
    print(f"{tag.name}: {tag.color}")

深層巢狀

from pydantic import BaseModel
from typing import List, Optional

class Street(BaseModel):
    name: str
    number: str

class City(BaseModel):
    name: str
    country: str

class Address(BaseModel):
    street: Street
    city: City
    zip_code: str

class Contact(BaseModel):
    type: str  # "home", "work"
    address: Address

class Person(BaseModel):
    name: str
    contacts: List[Contact]

# 使用
person = Person(
    name="John",
    contacts=[
        {
            "type": "home",
            "address": {
                "street": {"name": "Main St", "number": "123"},
                "city": {"name": "New York", "country": "USA"},
                "zip_code": "10001"
            }
        }
    ]
)

# 深層存取
print(person.contacts[0].address.city.name)  # New York

🧬 模型繼承

基本繼承

from pydantic import BaseModel
from datetime import datetime

class BaseEntity(BaseModel):
    """基礎實體(包含 ID 和時間戳)"""
    id: int
    created_at: datetime
    updated_at: Optional[datetime] = None

class User(BaseEntity):
    """使用者(繼承基礎實體)"""
    name: str
    email: str

class Product(BaseEntity):
    """商品(繼承基礎實體)"""
    name: str
    price: float

# User 和 Product 都有 id, created_at, updated_at

請求/回應模型的繼承模式

from pydantic import BaseModel, Field, EmailStr, ConfigDict
from typing import Optional
from datetime import datetime

# ===== 基礎 =====
class UserBase(BaseModel):
    """使用者基礎欄位(共用)"""
    email: EmailStr
    username: str = Field(min_length=3, max_length=50)
    full_name: Optional[str] = None

# ===== 建立 =====
class UserCreate(UserBase):
    """建立使用者(需要密碼)"""
    password: str = Field(min_length=8)

# ===== 更新 =====
class UserUpdate(BaseModel):
    """更新使用者(全部選填)"""
    email: Optional[EmailStr] = None
    username: Optional[str] = Field(None, min_length=3, max_length=50)
    full_name: Optional[str] = None
    password: Optional[str] = Field(None, min_length=8)

# ===== 回應 =====
class UserResponse(UserBase):
    """使用者回應(不含密碼)"""
    id: int
    is_active: bool
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)

# ===== 完整(含密碼雜湊,內部使用)=====
class UserInDB(UserResponse):
    """資料庫中的使用者"""
    hashed_password: str

多重繼承

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

# Mixin 類別
class TimestampMixin(BaseModel):
    """時間戳記"""
    created_at: datetime
    updated_at: Optional[datetime] = None

class SoftDeleteMixin(BaseModel):
    """軟刪除"""
    is_deleted: bool = False
    deleted_at: Optional[datetime] = None

class AuditMixin(BaseModel):
    """稽核"""
    created_by: Optional[int] = None
    updated_by: Optional[int] = None

# 組合使用
class Article(TimestampMixin, SoftDeleteMixin, AuditMixin):
    """文章(組合多個 Mixin)"""
    id: int
    title: str
    content: str
    author_id: int

# Article 現在有所有 Mixin 的欄位

覆寫父類別欄位

from pydantic import BaseModel, Field

class BaseUser(BaseModel):
    name: str = Field(max_length=50)
    age: int = Field(ge=0)

class StrictUser(BaseUser):
    # 覆寫父類別的欄位,使用更嚴格的驗證
    name: str = Field(min_length=2, max_length=30)
    age: int = Field(ge=18, le=100)  # 必須成年

class AdminUser(BaseUser):
    # 新增欄位
    role: str = "admin"
    permissions: list[str] = []

🔄 泛型模型

基本泛型

from pydantic import BaseModel
from typing import TypeVar, Generic, List

T = TypeVar('T')

class PaginatedResponse(BaseModel, Generic[T]):
    """分頁回應(泛型)"""
    items: List[T]
    total: int
    page: int
    page_size: int
    pages: int

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

class Product(BaseModel):
    id: int
    name: str
    price: float

# 使用泛型
user_page: PaginatedResponse[User] = PaginatedResponse(
    items=[User(id=1, name="John")],
    total=1,
    page=1,
    page_size=10,
    pages=1
)

product_page: PaginatedResponse[Product] = PaginatedResponse(
    items=[Product(id=1, name="iPhone", price=999)],
    total=1,
    page=1,
    page_size=10,
    pages=1
)

API 回應包裝器

from pydantic import BaseModel
from typing import TypeVar, Generic, Optional
from datetime import datetime

T = TypeVar('T')

class APIResponse(BaseModel, Generic[T]):
    """統一 API 回應格式"""
    success: bool = True
    data: Optional[T] = None
    message: Optional[str] = None
    timestamp: datetime = Field(default_factory=datetime.now)

class ErrorResponse(BaseModel):
    """錯誤回應"""
    success: bool = False
    error_code: str
    message: str
    details: Optional[dict] = None
    timestamp: datetime = Field(default_factory=datetime.now)

# 使用
class UserData(BaseModel):
    id: int
    name: str

response: APIResponse[UserData] = APIResponse(
    data=UserData(id=1, name="John"),
    message="使用者建立成功"
)

📝 實戰範例:電商系統

from pydantic import BaseModel, Field, EmailStr, ConfigDict
from typing import List, Optional
from datetime import datetime
from decimal import Decimal
from enum import Enum

# ===== Enums =====
class OrderStatus(str, Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

class PaymentMethod(str, Enum):
    CREDIT_CARD = "credit_card"
    BANK_TRANSFER = "bank_transfer"
    COD = "cod"

# ===== 基礎模型 =====
class TimestampMixin(BaseModel):
    created_at: datetime
    updated_at: Optional[datetime] = None

# ===== 地址 =====
class AddressBase(BaseModel):
    recipient: str = Field(min_length=1, max_length=100)
    phone: str = Field(pattern=r"^09\d{8}$")
    city: str
    district: str
    street: str
    zip_code: str = Field(pattern=r"^\d{3,5}$")
    is_default: bool = False

class AddressCreate(AddressBase):
    pass

class AddressResponse(AddressBase):
    id: int
    model_config = ConfigDict(from_attributes=True)

# ===== 商品 =====
class ProductBase(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    description: Optional[str] = None
    price: Decimal = Field(ge=0, decimal_places=2)
    stock: int = Field(ge=0)

class ProductCreate(ProductBase):
    category_id: int

class ProductResponse(ProductBase, TimestampMixin):
    id: int
    category_id: int
    is_active: bool
    model_config = ConfigDict(from_attributes=True)

# ===== 訂單項目 =====
class OrderItemBase(BaseModel):
    product_id: int
    quantity: int = Field(ge=1)
    unit_price: Decimal

class OrderItemCreate(BaseModel):
    product_id: int
    quantity: int = Field(ge=1)

class OrderItemResponse(OrderItemBase):
    id: int
    product: ProductResponse  # 巢狀商品資訊

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

    model_config = ConfigDict(from_attributes=True)

# ===== 訂單 =====
class OrderBase(BaseModel):
    shipping_address_id: int
    payment_method: PaymentMethod
    note: Optional[str] = Field(None, max_length=500)

class OrderCreate(OrderBase):
    items: List[OrderItemCreate] = Field(min_length=1)
    coupon_code: Optional[str] = None

class OrderResponse(OrderBase, TimestampMixin):
    id: int
    order_number: str
    status: OrderStatus
    items: List[OrderItemResponse]
    shipping_address: AddressResponse
    subtotal: Decimal
    shipping_fee: Decimal
    discount: Decimal
    total: Decimal
    paid_at: Optional[datetime] = None
    shipped_at: Optional[datetime] = None
    delivered_at: Optional[datetime] = None

    model_config = ConfigDict(from_attributes=True)

# ===== 使用者 =====
class UserBase(BaseModel):
    email: EmailStr
    username: str = Field(min_length=3, max_length=50)
    full_name: Optional[str] = None
    phone: Optional[str] = Field(None, pattern=r"^09\d{8}$")

class UserCreate(UserBase):
    password: str = Field(min_length=8)

class UserResponse(UserBase, TimestampMixin):
    id: int
    is_active: bool
    addresses: List[AddressResponse] = []

    model_config = ConfigDict(from_attributes=True)

class UserWithOrders(UserResponse):
    """使用者(含訂單歷史)"""
    orders: List[OrderResponse] = []

✅ 重點總結

巢狀模型

class Inner(BaseModel):
    value: str

class Outer(BaseModel):
    inner: Inner              # 必填巢狀
    optional: Optional[Inner] = None  # 選填巢狀
    items: List[Inner] = []   # 巢狀列表

繼承模式

# 基礎欄位
class Base(BaseModel):
    common_field: str

# 建立用(加密碼)
class Create(Base):
    password: str

# 更新用(全選填)
class Update(BaseModel):
    field: Optional[str] = None

# 回應用(加 ID、時間)
class Response(Base):
    id: int
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)

泛型

from typing import TypeVar, Generic

T = TypeVar('T')

class Wrapper(BaseModel, Generic[T]):
    data: T

# 使用
Wrapper[User](data=user)

🎤 面試這樣答

Q: 為什麼要把 Create、Update、Response 分成不同的模型?

答案:

因為不同操作需要不同的欄位:

  1. Create:需要所有必填欄位,可能包含密碼
  2. Update:所有欄位都是選填,只更新提供的欄位
  3. Response:包含 ID、時間戳,但不包含敏感資料如密碼

分離模型可以:

  • 更安全(不會不小心回傳密碼)
  • 更清晰(每個操作的需求一目了然)
  • 更好的文件(Swagger 會顯示正確的欄位)

🤓 小測驗

  1. 如何讓巢狀模型變成選填?

  2. model_config = ConfigDict(from_attributes=True) 的作用?

  3. 如何建立可重用的分頁回應模型?


上一篇: 02-3. 自訂驗證器 下一篇: 02-5. 序列化與反序列化


最後更新:2025-12-17

0%