SQLAlchemy 系列(三):声明式映射与 Schema——让类型、默认值和约束一致

SQLAlchemy 系列(三):声明式映射与 Schema------让类型、默认值和约束一致

目标:使用 SQLAlchemy 2.x 类型化声明建立可迁移模型,分清 Python 默认值、服务端默认值、ORM 关系和数据库约束。

验证基线:Python 3.11、SQLAlchemy 2.0.51;数据库差异以 SQLite 与 PostgreSQL 官方方言文档复核。最后复核:2026-07-31。

3.1 一份可扩展的基础模型

python 复制代码
from datetime import datetime
from decimal import Decimal

from sqlalchemy import (
    CheckConstraint, DateTime, ForeignKey, MetaData,
    Numeric, String, UniqueConstraint, func,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

naming_convention = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}

class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=naming_convention)

class Customer(Base):
    __tablename__ = "customer"
    __table_args__ = (
        UniqueConstraint("email"),
        CheckConstraint("length(name) > 0", name="name_not_empty"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320))
    name: Mapped[str] = mapped_column(String(100))
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )

class Product(Base):
    __tablename__ = "product"

    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str] = mapped_column(String(64), unique=True)
    price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    stock: Mapped[int] = mapped_column(
        default=0,
    )

Mapped[str] 可参与类型与 nullable 推断:Mapped[str] 通常非空,Mapped[str | None] 通常可空。关键约束仍建议显式表达,并以实际 DDL 为准。

3.2 默认值执行在哪里

写法 执行位置 适用
default=callable SQLAlchemy 发 INSERT 时 应用侧默认
server_default=... 数据库 多个写入方共享的默认
Python 构造函数赋值 对象创建时 领域初始化
python 复制代码
created_at: Mapped[datetime] = mapped_column(
    DateTime(timezone=True),
    server_default=func.now(),
)

服务端默认值只在 INSERT 没提供该列时生效。它不是 onupdate,也不是自动审计方案。迁移脚本必须同步创建相同默认值。

3.3 金额、时间和枚举

  • 金额使用 Decimal + Numeric(precision, scale),不要用 float
  • 时间明确 UTC/时区策略,并检查目标驱动返回值;
  • Enum 要考虑新增、重命名和删除值的迁移成本;
  • 字符串长度、排序规则和大小写唯一性由目标数据库决定。
python 复制代码
unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))

应用校验负责友好错误,数据库约束负责并发下的最终一致性。

3.4 外键与 relationship 是两件事

python 复制代码
class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(
        ForeignKey("customer.id", ondelete="RESTRICT"),
        index=True,
    )

ForeignKey 在数据库层维护引用完整性;relationship() 在 ORM 层提供对象导航。只定义 relationship 不能替代外键,只定义外键也不会自动产生适合业务的级联策略。

3.5 主键选择

主键决定 ORM Identity Map 的对象身份:

  • 自增整数简单、紧凑;
  • UUID 适合分布式生成,但索引布局与存储要评估;
  • 自然键可能随业务变化;
  • 复合主键增加关系、缓存和 API 复杂度。

无论选择哪种,业务唯一性通常还需要独立唯一约束,例如支付回调的 provider + idempotency_key

3.6 JSON 与可变值

普通 JSON 列的原地修改不一定被 ORM 检测:

python 复制代码
order.payload["source"] = "mobile"

可以整体替换:

python 复制代码
order.payload = {**order.payload, "source": "mobile"}

或使用 Mutable 扩展。JSON 适合边界灵活的数据,不应成为逃避列、约束和关联建模的万能容器。

3.7 反射遗留数据库

python 复制代码
from sqlalchemy import MetaData, Table

metadata = MetaData()
legacy_order = Table(
    "legacy_order",
    metadata,
    autoload_with=engine,
)

反射读取当前 Schema,不等于建立迁移历史。遗留表缺少声明主键时,ORM 映射还需要明确对象身份。自动反射结果必须核对类型、约束、schema 和大小写规则。

3.8 create_all() 与迁移

Base.metadata.create_all() 只创建缺失对象,不负责可靠识别重命名、拆列、回填和兼容窗口。生产环境使用 Alembic:

text 复制代码
模型意图 → revision 候选 → 人工审查 → 历史库升级测试 → 部署

命名约定让约束在迁移中可稳定引用,避免数据库生成不可预测名称。

3.9 模型验收

  • 重复邮箱被数据库唯一约束拒绝;
  • 负数价格或库存按业务规则被检查约束拒绝;
  • 金额往返后仍是 Decimal
  • 服务端时间默认值在数据库中存在;
  • 外键删除策略已做集成测试;
  • 输出并审查 SQLite 与 PostgreSQL DDL;
  • 模型 metadata 是单一、可被 Alembic 导入的集合。

3.10 order-lab 完整模型

下面把客户、商品、订单和订单项组合成一个可运行的 2.x 声明式模型。

python 复制代码
from __future__ import annotations

from datetime import datetime
from decimal import Decimal
from enum import StrEnum

from sqlalchemy import (
    CheckConstraint,
    DateTime,
    ForeignKey,
    Index,
    MetaData,
    Numeric,
    String,
    UniqueConstraint,
    func,
)
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    mapped_column,
    relationship,
)

convention = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}

class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=convention)

class OrderStatus(StrEnum):
    PENDING = "PENDING"
    PAID = "PAID"
    CANCELLED = "CANCELLED"

class Customer(Base):
    __tablename__ = "customer"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320), unique=True)
    name: Mapped[str] = mapped_column(String(100))
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
    )
    orders: Mapped[list[Order]] = relationship(
        back_populates="customer",
    )

class Product(Base):
    __tablename__ = "product"
    __table_args__ = (
        CheckConstraint("price >= 0", name="price_nonnegative"),
        CheckConstraint("stock >= 0", name="stock_nonnegative"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    sku: Mapped[str] = mapped_column(String(64), unique=True)
    name: Mapped[str] = mapped_column(String(200))
    price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    stock: Mapped[int] = mapped_column(default=0)

class Order(Base):
    __tablename__ = "orders"
    __table_args__ = (
        Index("ix_orders_customer_created", "customer_id", "created_at"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(
        ForeignKey("customer.id", ondelete="RESTRICT")
    )
    status: Mapped[str] = mapped_column(
        String(32),
        default=OrderStatus.PENDING.value,
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
    )
    customer: Mapped[Customer] = relationship(
        back_populates="orders",
    )
    items: Mapped[list[OrderItem]] = relationship(
        back_populates="order",
        cascade="all, delete-orphan",
    )

class OrderItem(Base):
    __tablename__ = "order_item"
    __table_args__ = (
        CheckConstraint("quantity > 0", name="quantity_positive"),
        CheckConstraint("unit_price >= 0", name="price_nonnegative"),
        UniqueConstraint("order_id", "product_id"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(
        ForeignKey("orders.id", ondelete="CASCADE")
    )
    product_id: Mapped[int] = mapped_column(
        ForeignKey("product.id", ondelete="RESTRICT")
    )
    quantity: Mapped[int]
    unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    order: Mapped[Order] = relationship(back_populates="items")
    product: Mapped[Product] = relationship()

这里把状态保存为受控字符串,方便跨数据库演示。若采用数据库原生 Enum,必须把增加/删除枚举值纳入 Alembic 和发布兼容计划。

3.11 Nullable 推断实验

python 复制代码
class NullableDemo(Base):
    __tablename__ = "nullable_demo"

    id: Mapped[int] = mapped_column(primary_key=True)
    required_text: Mapped[str]
    optional_text: Mapped[str | None]

检查生成列:

python 复制代码
assert NullableDemo.__table__.c.required_text.nullable is False
assert NullableDemo.__table__.c.optional_text.nullable is True

显式配置与类型标注冲突时,运行时 Schema 配置和静态类型理解可能不一致:

python 复制代码
legacy_value: Mapped[str | None] = mapped_column(nullable=False)

这种写法有时用于渐进迁移,但应写注释和测试,避免读者误判。

3.12 Python 默认值与服务端默认值实验

python 复制代码
class DefaultDemo(Base):
    __tablename__ = "default_demo"

    id: Mapped[int] = mapped_column(primary_key=True)
    app_value: Mapped[int] = mapped_column(default=10)
    db_value: Mapped[int] = mapped_column(server_default="20")

刚构造对象时:

python 复制代码
demo = DefaultDemo()
print(demo.app_value)
print(demo.db_value)

两个属性都可能尚未表现为最终值。default=10 主要在 SQLAlchemy 形成 INSERT 时参与;server_default 由数据库执行。flush 后,通过 RETURNING 或刷新才得到数据库生成结果。

python 复制代码
session.add(demo)
session.flush()
assert demo.app_value == 10
assert demo.db_value == 20

不要根据"构造对象后属性是不是立刻有值"猜测默认值位置。

3.13 时间与金额往返测试

python 复制代码
from datetime import UTC, datetime
from decimal import Decimal

product = Product(
    sku="BOOK-001",
    name="SQLAlchemy Guide",
    price=Decimal("99.90"),
    stock=10,
)
session.add(product)
session.commit()

loaded = session.get(Product, product.id)
assert loaded.price == Decimal("99.90")
assert isinstance(loaded.price, Decimal)

时间测试要检查:

  • 写入值是否带 timezone;
  • 驱动返回值是否带 tzinfo
  • 数据库存储类型是什么;
  • JSON/API 输出是否统一 UTC;
  • SQLite 测试是否掩盖 PostgreSQL 行为。

3.14 自定义类型的边界

以规范化 SKU 为例:

python 复制代码
from sqlalchemy.types import TypeDecorator

class NormalizedSKU(TypeDecorator[str]):
    impl = String(64)
    cache_ok = True

    def process_bind_param(self, value, dialect):
        if value is None:
            return None
        return value.strip().upper()

    def process_result_value(self, value, dialect):
        return value

cache_ok=True 表示该类型实例的配置可安全参与 SQL 编译缓存键。自定义类型不适合承载需要外部服务或复杂业务上下文的转换;它应保持确定、快速和可测试。

3.15 索引不是"查询字段都加"

订单列表常用:

sql 复制代码
WHERE customer_id = ?
ORDER BY created_at DESC, id DESC

候选复合索引应围绕真实谓词与排序设计,而不是分别给每列单列索引。需要在 PostgreSQL 用实际数据量检查:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...

索引会增加写入、存储和维护成本;低选择性状态列的单列索引未必有价值,可能更适合部分索引或与其他列组合。

3.16 Schema 失败实验

每条关键约束都要有失败测试:

python 复制代码
def test_duplicate_email_is_rejected(session):
    session.add(Customer(email="a@example.com", name="A"))
    session.commit()

    session.add(Customer(email="a@example.com", name="B"))
    with pytest.raises(IntegrityError):
        session.flush()
    session.rollback()

def test_negative_stock_is_rejected(session):
    session.add(
        Product(
            sku="BROKEN",
            name="Broken",
            price=Decimal("1.00"),
            stock=-1,
        )
    )
    with pytest.raises(IntegrityError):
        session.flush()
    session.rollback()

测试数据库必须真正启用相应约束。否则测试通过只能证明测试环境没有执行规则。

3.17 模型组织建议

中型项目可采用:

text 复制代码
src/order_lab/db/
├── base.py          # Base、naming convention
├── customer.py
├── product.py
├── order.py
└── __init__.py      # 显式导入模型,确保 metadata 注册

Alembic env.py 导入 Base.metadata 前,必须让所有模型模块完成注册。若部分模型没有导入,autogenerate 可能误判对应表需要删除。

3.18 小结

高质量映射不是"类属性看起来整洁",而是 Python 类型、ORM 行为、数据库类型、默认值、约束和迁移脚本表达同一意图。任何只存在于应用内存中的完整性规则,都必须面对并发写入是否能绕过的问题。

官方参考

相关推荐
会博通·代码搬运工1 小时前
会博通API对接实战:工程企业文档分布式采集系统的技术实现与Python SDK详解
开发语言·分布式·python·线性代数·矩阵·架构·电子档案合规
空堂与归1 小时前
大模型再火,也得先过 class 这一关
python
Muselit1 小时前
Python 泛型:把 list[User] 讲明白
python·fastapi
2501_916007471 小时前
Python实现HTTPS爬虫的完整指南:使用requests、BeautifulSoup、Selenium和Scrapy
爬虫·python·ios·小程序·https·uni-app·iphone
石小千1 小时前
排查MongoDB慢日志问题
数据库·mongodb
安_1 小时前
如何为 RAG 项目选择向量数据库?Milvus、Pinecone、Chroma 怎么选?
数据库·milvus
lupai1 小时前
短信接口快速接入与调用实战指南
java·开发语言·数据库
gptAI_plus1 小时前
别把整个仓库塞给 AI:用 Python 生成安全的代码上下文清单
python·chatgpt
吃饱了得干活1 小时前
Agent 记忆系统:从短期记忆到长期记忆
python·langchain·agent