《Python编程实战》6.1 SQLAlchemy 2.x ORM 与类型化模型

SQLAlchemy 2.1.4 实战:用 DeclarativeBase 与 Mapped[] 定义类型化 ORM 模型,改用 select() 2.x 风格查询,讲清关系映射、加载策略与 ORM/Pydantic 模型分层的工程取舍,代码在 SQLite 上真跑过。

本节目标:用 SQLAlchemy 2.x 新风格把数据库表写成带类型标注的 ORM 模型,掌握 select() 查询与关系映射,并想清「ORM 模型」和「接口模型」为什么要分层。
适用版本:Python 3.12+(实测 3.14.6);SQLAlchemy 2.1.4

6.1 SQLAlchemy 2.x ORM 与类型化模型

5.3 节的 FastAPI 服务在关闭时已经能优雅回收资源,但数据还躺在内存字典里。真实后端的第一个数据访问决策是:用不用 ORM、用哪一代风格。这一节不重复 SQL 语法,而是把 SQLAlchemy 2.x 的「类型化建模」讲成一条能直接落进项目的路径。

先明确版本口径:社区口头说的「SQLAlchemy 2.0」指的是 2.x 的新风格(DeclarativeBase、Mapped[]、select()),本机实测版本是 2.1.4。下文所有示例都按 2.1.4 跑过,不再写「2.0.x」。

6.1.1 1.x 风格与 2.x 风格的分界

SQLAlchemy 1.4 起就同时提供两套写法,2.x 把新风格扶正。区别集中在三点:

维度1.x 旧风格2.x 新风格
基类declarative_base() 工厂函数class Base(DeclarativeBase)
字段Column(String(50)),类型与属性脱节Mapped[str] = mapped_column(String(50))
查询session.query(User).filter(...)session.scalars(select(User).where(...))

新风格的价值在于类型检查器能读懂模型:Mapped[str] 让 mypy 知道 user.name 是 str 而不是 Any,IDE 补全和重构才靠谱。下面是一个最小的两层模型。

6.1.2 定义类型化模型

from __future__ import annotations

from sqlalchemy import ForeignKey, String, create_engine, select
from sqlalchemy.orm import (
    DeclarativeBase, Mapped, mapped_column, relationship, Session,
)


class Base(DeclarativeBase):
    """所有模型的公共基类,元数据挂在 Base.metadata 上。"""


class Author(Base):
    __tablename__ = "author"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    # 一对多:作者有多本书
    books: Mapped[list["Book"]] = relationship(back_populates="author")


class Book(Base):
    __tablename__ = "book"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(100))
    price: Mapped[int]
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
    author: Mapped[Author] = relationship(back_populates="books")


engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)

几个容易踩的点:

  • Mapped[int] 不带默认值时,主键列会被推断为 NOT NULL;非空约束跟着类型走。
  • 关系两端都要写,back_populates 把 Author.books 和 Book.author 绑定成一对镜像,双向赋值会自动同步。
  • Mapped[list["Book"]] 里的字符串前向引用配合文件顶部的 from __future__ import annotations,解决两个类互相引用的顺序问题。
  • 只有 price: Mapped[int] 没写 mapped_column 也能建列,SQLAlchemy 会按类型推导;要指定长度、索引、默认值时才显式调用。

6.1.3 插入与查询

建表之后先灌数据,再按 select() 查询。注意 session.scalars() 返回的是 ORM 对象本身,不是 Row 元组。

with Session(engine) as session:
    a1, a2 = Author(name="Leeting"), Author(name="Ada")
    session.add_all([a1, a2])
    session.flush()  # 拿到自增主键,但还没提交
    session.add_all([
        Book(title="Py 实战", price=59, author_id=a1.id),
        Book(title="Py 入门", price=49, author_id=a1.id),
        Book(title="计算纲要", price=99, author_id=a2.id),
    ])
    session.commit()

with Session(engine) as session:
    stmt = select(Book).where(Book.price >= 50).order_by(Book.price.desc())
    for book in session.scalars(stmt):
        print(book.id, book.title, book.price)

实测输出(SQLite,SQLAlchemy 2.1.4):

3 计算纲要 99
1 Py 实战 59

select(Book) 只取 Book 一张表;.where() 接受的是列表达式而不是字符串,所以 Book.price >= 50 由 Python 直接写、类型检查器能校验,没有拼 SQL 字符串的注入风险。

6.1.4 更多查询形态

真实项目里远不止按条件取行,还有主键直取、联表、聚合和分页。下面这几段都在同一份数据上跑过。

with Session(engine) as session:
    # 按主键取单个对象,命中身份映射时不再发 SQL
    print("get:", session.get(Book, 1).title)

    # 联表:按关联表的字段过滤
    stmt = (select(Book).join(Author)
            .where(Author.name == "Leeting").order_by(Book.price))
    print("join:", [(b.title, b.price) for b in session.scalars(stmt)])

    # 聚合:分组统计作者的书数与总价
    stmt = (select(Author.name, func.count(Book.id), func.sum(Book.price))
            .join(Book).group_by(Author.name))
    print("agg:", session.execute(stmt).all())

    # 计数与分页
    print("count:", session.scalar(select(func.count()).select_from(Book)))
    page = session.scalars(select(Book).order_by(Book.id).limit(2).offset(0)).all()
    print("page1:", [b.title for b in page])

实测输出:

get: Py 实战
join: [('Py 入门', 49), ('Py 实战', 59)]
agg: [('Ada', 1, 99), ('Leeting', 2, 108)]
count: 3
page1: ['Py 实战', 'Py 入门']

几个要点:

  • session.get(Model, pk) 优先于 select().where(pk==...):它先查身份映射,同一个 session 内命中缓存就不再发 SQL。
  • join() 的对象是 ORM 实体,SQLAlchemy 会按外键自动推断 ON 条件,不用手写 book.author_id = author.id。
  • 聚合查询用 session.execute() 而非 scalars():前者返回 Row 元组,正好匹配多列结果;scalars() 只取第一列。
  • 分页必须带 order_by:没有稳定排序时 limit/offset 的结果顺序不确定,翻页会漏行或重复。
  • func.count()、func.sum() 等聚合函数来自 sqlalchemy.func,会被翻译成对应的 SQL 方言函数。

6.1.5 加载策略:惰性 vs 预加载

关系字段默认是惰性加载(lazy):访问 author.books 的那一刻才发第二条 SQL。这在单条记录上没问题,一旦在循环里访问就退化成 N+1(6.2 节专门治理)。想提前加载有两种常见选择:

from sqlalchemy.orm import selectinload, joinedload

# 方案 A:selectinload —— 额外发一条 WHERE author_id IN (...) 的查询
authors = session.scalars(
    select(Author).options(selectinload(Author.books))
).all()

# 方案 B:joinedload —— 用 LEFT OUTER JOIN 一条 SQL 取回,结果需去重
authors = session.scalars(
    select(Author).options(joinedload(Author.books))
).unique().all()

两者的取舍:

策略SQL 条数结果形状适用
selectinload2(主表 + IN 查询)无重复行一对多、多对多,推荐默认
joinedload1(JOIN)有笛卡尔膨胀,需 .unique()多对一、一对一
惰性1 + N按需只读单条时够用

在 SQLite 上对 10 个作者、50 本书的实测:惰性 11 条 SQL、selectinload 2 条、joinedload 1 条。数字本身不重要,重要的是加载策略是模型层就能声明的工程决策,不必等到线上慢查询才回头补。

6.1.6 ORM 模型与接口模型必须分层

新手常把 ORM 对象直接当响应体返回,这在工程上是隐患:ORM 对象带着 author_id、关系代理、甚至未加载的惰性字段,一旦 Pydantic 尝试序列化就会触发意外查询,也可能把不该暴露的列吐给客户端。

正确做法是两套模型、显式转换:

from pydantic import BaseModel, ConfigDict


class BookOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)  # 允许从 ORM 对象构造
    id: int
    title: str
    price: int


def to_book_out(book: Book) -> BookOut:
    return BookOut.model_validate(book)

from_attributes=True 让 Pydantic 按属性名从任意对象读取字段。分层之后,数据库 schema 的变更和 API 契约的变更就能各自演进——加一列不会自动漏到接口,删一列不会让接口崩。7.1 节会把接口模型的设计展开。

6.1.7 建模时的几个工程约定

  • 表名单数、小写下划线:author / book,不要 AuthorTable。
  • 主键统一用代理键:自增整数或 UUID,别拿业务字段当主键。
  • 时间列放数据库侧默认:server_default=func.now() 比 Python 侧 datetime.now() 更稳,避免多实例时钟漂移。
  • 约束尽量落到库上:nullable=False、unique=True、外键,能交给数据库的校验不要只写在应用层。
  • 不要 create_all 上生产:它只建不改,结构演进交给 6.3 节的 Alembic。

6.1.8 用内存 SQLite 测试模型层

4.1 节讲过 fixture 分层,落到数据访问层就是一个「每个用例一套干净 schema」的 fixture。内存 SQLite 建库几乎零成本,非常适合单测:

import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session


@pytest.fixture
def session() -> Session:
    engine = create_engine("sqlite+pysqlite:///:memory:")
    Base.metadata.create_all(engine)
    with Session(engine) as s:
        yield s


def test_insert_and_query(session: Session) -> None:
    session.add(Book(title="Py 实战"))
    session.commit()
    assert session.scalar(select(Book.title)) == "Py 实战"

实测(pytest 9.1.1):

1 passed in 0.72s

要点:

  • :memory: 是进程内、随连接销毁的库,天然隔离,无需清表。
  • fixture 里 yield 出 session,用例结束自动 close(对应 5.2 节的依赖清理思路)。
  • 想验证真实数据库方言(如 PG 的 JSONB),把 fixture 换成 4.2 节的容器化测试环境即可,模型代码一行不用改。

延伸阅读:Python 数据库与 ORM 完全指南 从选型讲到异步驱动,本节聚焦的是「新风格建模怎么落进项目」。

小结

  • SQLAlchemy 2.1.4 的新风格用 class Base(DeclarativeBase) 取代 declarative_base(),用 Mapped[] 让模型带上可被类型检查器读懂的标注。
  • 字段写成 Mapped[str] = mapped_column(String(50)),主键、非空、长度都跟着类型与参数走。
  • 查询统一走 select() + session.scalars(),条件用 Python 表达式而非 SQL 字符串。
  • 关系用 relationship + back_populates 双向绑定,加载策略(惰性 / selectinload / joinedload)在模型层就能声明。
  • ORM 模型管表、Pydantic 模型管接口,必须分层并用 from_attributes 显式转换,不要直接返回 ORM 对象。
  • create_all 只用于测试和原型,生产结构演进交给迁移工具。

模型能建、能查了,但「一次请求里 session 开在哪、事务边界划在哪、连接池够不够、查询为什么慢」这些运行时问题还没碰。下一节把 session 生命周期、事务、连接池和 N+1 治理一次讲透。

阅读导航:上一节:生命周期、优雅关闭与健康探针 · 下一节:事务、连接池与 N+1 治理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时