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 行为、数据库类型、默认值、约束和迁移脚本表达同一意图。任何只存在于应用内存中的完整性规则,都必须面对并发写入是否能绕过的问题。