feat(数据库): 完善第四阶段SQLAlchemy课程与综合项目

This commit is contained in:
zhiye.sun
2026-08-20 15:45:21 +08:00
parent a28c3b3168
commit bf1f7042a9
18 changed files with 3039 additions and 29 deletions
@@ -0,0 +1,368 @@
# 第4-4课:SQLAlchemy关系映射与工程实践
## 一、本课定位
上一课把一张商品表映射成了Python类,并使用Session完成增删改查。本课进入真实业务中更常见的多表场景:一名客户有多张订单,需要同时查询客户信息和订单信息。
你已经学习过数据库和Java,因此本课不会重新讲解主键、外键和`JOIN`的基础语法,而是重点说明SQLAlchemy如何表达这些概念,以及它与JPA、MyBatis、MyBatis-Plus之间的差异。
## 二、本课目标
完成本课后,你能够:
1. 使用`ForeignKey`建立数据库外键;
2. 使用`relationship()`建立Python对象之间的关系;
3. 映射一对多和多对一关系;
4. 使用`join()`完成显式联表查询;
5. 使用数据传输对象(Data Transfer Object,DTO)承载多表查询结果;
6. 使用`selectinload()`避免N+1查询;
7. 使用`func.count()`和`group_by()`完成聚合查询;
8. 理解Repository与事务边界的基本职责。
## 三、SQLAlchemy能否实现多表查询
可以。SQLAlchemy主要提供两种多表查询方式。
### 3.1 查询ORM实体及其关系
```python
statement = (
select(Customer)
.options(selectinload(Customer.orders))
)
customers = session.scalars(statement).all()
```
查询结果是`Customer`对象,每个客户可以通过`customer.orders`访问订单集合。这种方式类似JPA实体关系查询,适合后续业务逻辑需要完整实体对象的场景。
### 3.2 查询指定列并组装DTO
```python
statement = (
select(Order.order_no, Customer.customer_name, Order.amount)
.join(Customer, Order.customer_id == Customer.id)
)
rows = session.execute(statement).all()
```
这种方式只查询需要的列,再把结果转换成DTO。它更接近MyBatis中编写联表SQL并映射到DTO或VO。
两者没有绝对优劣:需要修改完整业务实体时使用ORM实体;列表、报表、统计接口通常更适合DTO投影。
## 四、与Java技术体系对照
| Python与SQLAlchemy | Java中的近似概念 | 说明 |
| --- | --- | --- |
| `ForeignKey` | 数据库外键、JPA `@JoinColumn` | 定义数据库层面的引用约束 |
| `relationship()` | JPA `@OneToMany`、`@ManyToOne` | 定义对象之间如何导航 |
| `select()`、`join()` | MyBatis SQL、JPA Criteria/JPQL | 构造查询 |
| `Session` | JPA `EntityManager` | 管理实体状态和事务工作单元 |
| `@dataclass` DTO | Java DTO/VO/record | 承载查询输出,不负责持久化 |
| `selectinload()` | ORM批量预加载 | 减少逐条加载关系产生的查询 |
SQLAlchemy不是MyBatis-Plus的完全对应物。它的ORM部分更接近JPA/Hibernate,同时也允许像SQL构造器一样明确选择表、列、连接条件和聚合表达式。
## 五、ForeignKey与relationship的区别
这是本课最重要的区别。
```python
customer_id: Mapped[int] = mapped_column(
ForeignKey("course_orm_customer.id"),
nullable=False,
)
customer: Mapped[Customer] = relationship(back_populates="orders")
```
`ForeignKey`作用在数据库层:它让`order.customer_id`引用`customer.id`,数据库可以阻止无效的客户编号。
`relationship()`作用在Python对象层:它让代码可以写成`order.customer`或`customer.orders`。它不会代替数据库外键,也不是数据库中的新列。
简化理解:
- `customer_id`保存关系;
- `ForeignKey`约束关系;
- `relationship()`方便Python代码使用关系。
## 六、一对多双向关系
父对象的一方:
```python
orders: Mapped[list["Order"]] = relationship(
back_populates="customer",
cascade="all, delete-orphan",
)
```
子对象的一方:
```python
customer: Mapped[Customer] = relationship(back_populates="orders")
```
`back_populates`明确指出两个属性互为反向关系。当执行下面的代码时,SQLAlchemy能够维护两端对象的一致性:
```python
customer.orders.append(order)
```
### 6.1 cascade的含义
示例中的`cascade="all, delete-orphan"`表示:
- 保存客户时,可以级联保存订单集合中的新订单;
- 订单从所属客户的集合中移除且不再属于其他父对象时,可以将其删除。
级联删除有数据副作用,生产项目中必须结合业务规则决定,不能看到一对多就固定照抄。
## 七、DTO是否是查询要件
DTO不是联表查询的强制要求。SQLAlchemy可以返回:
1. 完整ORM实体;
2. 多个ORM实体组成的行;
3. 指定字段组成的`Row`;
4. 自己构造的`dataclass`、普通类或字典。
本课使用不可变`dataclass`定义DTO:
```python
@dataclass(frozen=True)
class OrderSummaryDTO:
order_no: str
customer_name: str
amount: Decimal
```
DTO适合下列场景:
- 页面列表只需要少数字段;
- 返回结果来自多张表,无法自然归属于单个实体;
- 统计、分组和报表查询;
- 希望隔离数据库模型与对外接口模型。
DTO不应该调用`session.add()`进行持久化,因为它只是查询结果载体,不是ORM映射实体。
## 八、显式联表查询
```python
statement = (
select(Order.order_no, Customer.customer_name, Order.amount)
.join(Customer, Order.customer_id == Customer.id)
.where(Customer.customer_code.like("ORM-C-%"))
.order_by(Order.order_no)
)
rows = session.execute(statement).all()
```
执行顺序可以按SQL理解:
1. `select()`决定返回哪些列;
2. `join()`决定关联哪张表以及关联条件;
3. `where()`限制数据范围;
4. `order_by()`决定结果顺序;
5. `session.execute()`执行语句;
6. `all()`取得全部结果。
这里没有使用字符串拼接,SQLAlchemy会把Python值绑定为SQL参数。
## 九、N+1查询问题
N+1查询是指:先用1条SQL查询N个客户,随后为了读取每名客户的订单,又追加N条SQL,总计执行N+1条查询。
直接访问延迟加载的关系可能出现这个问题:
```python
customers = session.scalars(select(Customer)).all()
for customer in customers:
print(customer.orders)
```
本课使用`selectinload()`预加载:
```python
statement = select(Customer).options(selectinload(Customer.orders))
```
它通常先查询客户,再使用一条带`IN`条件的SQL批量查询这些客户的订单,不会为每名客户分别查询一次。
常见关系加载策略还有`joinedload()`,它通过连接查询加载关系。集合关系使用连接加载时可能扩大结果行数,因此本课先掌握更直观的`selectinload()`。
## 十、聚合查询
```python
statement = (
select(Customer.customer_name, func.count(Order.id))
.join(Order, Customer.id == Order.customer_id)
.group_by(Customer.id, Customer.customer_name)
)
```
`func.count()`会生成SQL的`COUNT()`,`group_by()`生成`GROUP BY`。统计工作由数据库完成,Python只接收统计结果,不应先查询全部订单再在内存中计数。
## 十一、Repository与事务边界
Repository(仓储)负责封装数据访问细节,例如查询客户、查询订单摘要。Service(业务服务)负责组织业务流程和决定事务成功或失败。
推荐的职责划分:
```text
Service或调用方:开始事务 → 调用多个Repository方法 → 提交或回滚
Repository:执行查询、增加、修改、删除 → 不擅自commit
```
这与Java项目中`@Transactional`通常放在Service层的思路一致。如果每个Repository方法都自行提交,那么一个跨多个数据操作的业务事务就会被割裂。
本课标准示例没有为了展示分层而增加大量类,但其中的数据访问函数都不调用`commit()`,事务由`session_factory.begin()`统一管理。
## 十二、完整示例
本课完整示例位于:
```text
relationship_query_example.py
```
示例包含:
1. `Customer`与`Order`双向关系;
2. 外键和级联配置;
3. 可重复执行的数据初始化;
4. ORM关系对象查询;
5. DTO显式联表查询;
6. 分组聚合查询;
7. 配置异常和数据库异常的分类处理。
## 十三、安装与配置
如果`python-test`环境已经安装上一课依赖,不需要重复安装。可以先确认:
```powershell
conda activate python-test
python -c "import sqlalchemy, psycopg; print(sqlalchemy.__version__); print(psycopg.__version__)"
```
如未安装,推荐使用当前解释器对应的pip:
```powershell
python -m pip install -r requirements.txt
```
也可以使用conda安装:
```powershell
conda install -c conda-forge sqlalchemy psycopg
```
注意:激活`python-test`后不要再写`-n base`,否则会尝试修改无权限的公共base环境。
复制配置模板:
```powershell
Copy-Item config.example.toml config.toml
```
随后只修改本地`config.toml`。该文件已由项目`.gitignore`忽略,不使用环境变量,也不要把真实密码写入`config.example.toml`或Python代码。
## 十四、运行方法与预期结果
进入本课目录:
```powershell
cd D:\Code\Python\04_数据库\4_4_SQLAlchemy关系映射与工程实践
conda activate python-test
python relationship_query_example.py
```
正常情况下会看到类似结果:
```text
关系对象查询:
张三
ORM-O-001|金额:299.00
ORM-O-002|金额:99.00
李四
ORM-O-003|金额:599.00
DTO联表查询:
ORM-O-001|张三|金额:299.00
ORM-O-002|张三|金额:99.00
ORM-O-003|李四|金额:599.00
聚合查询:
张三|订单数量:2
李四|订单数量:1
```
数据库自动生成的主键可能继续增长,这是序列的正常行为,不代表练习数据发生重复。
## 十五、关键代码执行顺序
1. 读取本地TOML配置;
2. 创建`Engine`和连接池;
3. 创建`sessionmaker`;
4. `create_all()`创建不存在的练习表;
5. 在一个事务中清理本课前缀数据并重新新增;
6. 在独立Session中查询关系对象;
7. 执行联表查询并构造DTO;
8. 执行分组统计;
9. 关闭Session并释放Engine连接池。
## 十六、常见错误
### 16.1 只写relationship而不写ForeignKey
SQLAlchemy通常需要外键判断两张表如何关联。`relationship()`不能代替数据库外键。
### 16.2 Session关闭后触发延迟加载
关系数据尚未加载就关闭Session,之后访问`customer.orders`可能出现对象已脱离Session的错误。应在Session有效期间使用关系,或提前预加载并转换成DTO。
### 16.3 循环中产生N+1查询
查询列表后逐个访问延迟加载集合,会产生大量SQL。列表场景应根据需要使用`selectinload()`或明确的联表DTO查询。
### 16.4 对DTO执行session.add
只有继承声明式基类并完成表映射的ORM实体才能持久化。DTO没有表映射,只负责传输数据。
### 16.5 Repository内部随意commit
这会破坏上层业务事务。Repository可以执行`flush()`以提前同步SQL,但是否提交应由事务调用方决定。
### 16.6 删除父记录时违反外键约束
需要先删除子记录,或明确配置数据库/ORM级联规则。级联策略必须符合业务要求。
## 十七、课堂练习
练习要求位于`practice.py`。你需要独立完成“课程分类—课程”一对多模型,并实现:
1. 关系对象查询;
2. DTO联表查询;
3. 分类课程数量统计;
4. 可重复运行的数据初始化;
5. 清晰的事务边界。
本课不在练习文件中提供代码骨架。需要帮助时,可以先询问具体概念或把已完成部分交给我验证。
## 十八、本课小结
1. `ForeignKey`负责数据库约束,`relationship()`负责对象导航;
2. SQLAlchemy既能查询完整关联实体,也能显式联表并构造DTO;
3. DTO不是强制要求,但非常适合列表、报表和跨表结果;
4. `selectinload()`可以避免常见的N+1查询;
5. 聚合应尽量交给数据库完成;
6. Repository负责数据访问,事务边界通常由Service或调用方管理。
## 十九、验收标准
- 能解释`ForeignKey`与`relationship()`的区别;
- 能建立一对多双向关系;
- 能使用关联属性查询子对象集合;
- 能使用`join()`查询多张表;
- 能把指定列转换成DTO;
- 能使用`selectinload()`预加载集合;
- 能完成分组统计;
- 程序连续运行两次结果一致且没有重复练习数据;
- 配置保存在被Git忽略的本地TOML中。
@@ -0,0 +1,7 @@
[postgresql]
host = "你的PostgreSQL服务器地址"
port = 5432
dbname = "python_test"
user = "你的数据库用户名"
password = "你的数据库密码"
connect_timeout = 10
@@ -0,0 +1,183 @@
# 第4-4课练习:使用SQLAlchemy完成关系映射与多表查询
#
# 本文件只提供题目,不提供代码骨架、测试数据代码或参考答案。
# 练习会创建course_orm_category和course_orm_lesson两张表,
# 并只操作ORM-C-分类前缀及ORM-L-课程前缀的数据。
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
# 第一部分:导入、配置与声明式基类
# 1. 导入dataclass、Decimal、Path和tomllib。
# 2. 从sqlalchemy导入ForeignKey、Numeric、String、URL、create_engine、
# delete、func和select。
# 3. 从sqlalchemy.exc导入SQLAlchemyError。
# 4. 从sqlalchemy.orm导入DeclarativeBase、Mapped、Session、mapped_column、
# relationship、selectinload和sessionmaker。
# 5. 使用Path(__file__).with_name("config.toml")定义CONFIG_PATH。
# 6. 定义Base(DeclarativeBase),类体中不添加业务字段。
# 7. 实现load_database_config(config_path),读取并返回[postgresql]配置字典。
# 8. 实现create_database_url(database_config),使用URL.create()创建
# postgresql+psycopg连接地址,不手工拼接包含密码的字符串。
# 第二部分:定义Category和Lesson ORM模型
# 1. 定义Category(Base):
# - __tablename__ = "course_orm_category";
# - id:int主键,由数据库生成;
# - category_code:最长30字符,唯一且非空;
# - category_name:最长100字符且非空;
# - lessons:一对多课程集合,使用relationship();
# - 通过back_populates与Lesson.category建立双向关系;
# - 配置cascade="all, delete-orphan"。
# 2. 定义Lesson(Base):
# - __tablename__ = "course_orm_lesson";
# - id:int主键,由数据库生成;
# - lesson_code:最长30字符,唯一且非空;
# - lesson_name:最长100字符且非空;
# - price:NUMERIC(10, 2)且非空;
# - category_id:int、非空,并使用ForeignKey引用course_orm_category.id;
# - category:使用relationship()指向所属Category;
# - 通过back_populates与Category.lessons建立双向关系。
# 3. 类名使用Category和Lesson,不要使用数据库表名作为Python类名。
#
# 关系映射提醒:
# - ForeignKey建立数据库层面的外键约束;
# - relationship()建立Python对象之间的导航关系;
# - Category.lessons的元素类型应为Lesson,而不是int;
# - 双向关系的两端必须使用对应的back_populates名称。
# 第三部分:定义DTO
# 1. 使用@dataclass(frozen=True)定义 LessonSummaryDTO。
# 2. 依次声明以下字段:
# - lesson_code:str;
# - lesson_name:str;
# - category_name:str;
# - price:Decimal。
# 3. DTO只承载联表查询结果,不继承Base,也不调用session.add()持久化。
# 第四部分:重置并新增练习数据
# 1. 定义reset_practice_data(session):
# - 先找到category_code LIKE "ORM-C-%"的分类ID;
# - 先删除这些分类下lesson_code LIKE "ORM-L-%"的课程;
# - 再删除category_code LIKE "ORM-C-%"的分类;
# - 使用SQLAlchemy的delete(),不拼接SQL;
# - 不在函数中commit()。
# 2. 定义add_practice_data(session),创建以下对象关系:
# - 分类ORM-C-001,名称“数据库课程”;
# 包含ORM-L-001“PostgreSQL入门”,价格99.00;
# 包含ORM-L-002“SQLAlchemy基础”,价格129.00;
# - 分类ORM-C-002,名称“Web课程”;
# 包含ORM-L-003“HTTP基础”,价格69.00;
# 包含ORM-L-004“FastAPI入门”,价格159.00。
# 3. 通过Category.lessons建立对象关系,不手工给category_id编造主键值。
# 4. 调用session.add_all()新增两个分类,依靠关系级联新增四门课程。
# 5. 不在add_practice_data()中调用commit()。
# 第五部分:实现关系对象查询
# 1. 定义find_categories_with_lessons(session)。
# 2. 查询category_code LIKE "ORM-C-%"的Category,并按category_code排序。
# 3. 使用.options(selectinload(Category.lessons))预加载课程集合。
# 4. 返回Category对象列表;没有数据时返回空列表,不返回None。
# 5. 不使用旧式session.query()。
# 6. 输出时通过category.lessons读取课程,不再为每个分类单独查询课程。
# 第六部分:实现DTO联表查询
# 1. 定义find_lesson_summaries(session)。
# 2. select()只查询Lesson.lesson_code、Lesson.lesson_name、
# Category.category_name和Lesson.price。
# 3. 使用join()连接Category和Lesson,不使用字符串拼接SQL。
# 4. 只查询lesson_code LIKE "ORM-L-%"的数据。
# 5. 按Category.category_code和Lesson.lesson_code升序排列。
# 6. 调用session.execute(statement).all()取得查询行。
# 7. 将每一行转换成LessonSummaryDTO并返回DTO列表。
# 第七部分:实现聚合查询
# 1. 定义count_lessons_by_category(session)。
# 2. 使用select()查询Category.category_name和func.count(Lesson.id)。
# 3. 使用join()关联课程表,并只统计ORM-C-前缀分类。
# 4. 使用group_by()按分类分组,按category_code升序排列。
# 5. 返回“分类名称、课程数量”组成的查询结果。
# 第八部分:输出和main()流程
# 1. 定义print_categories(categories),输出分类及其课程:
# - 先输出“关系对象查询:”;
# - 每个分类先输出分类名称;
# - 再逐行输出两个空格和课程名称。
# 2. 定义print_lesson_summaries(summaries),先输出“DTO联表查询:”,
# 再按以下格式逐行输出:
# “ORM-L-001|PostgreSQL入门|数据库课程|价格:99.00”。
# 3. 定义print_category_counts(category_counts),先输出“分类统计:”,
# 再按以下格式逐行输出:
# “数据库课程|课程数量:2”。
# 4. main()依次执行:
# - 读取本地TOML配置并创建数据库URL;
# - 创建一次Engine,启用pool_pre_ping并配置连接超时;
# - 使用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
# - 调用Base.metadata.create_all(engine)创建缺失的练习表;
# - 使用with session_factory.begin() as session,在同一个事务中
# 调用reset_practice_data(session)和add_practice_data(session);
# - 使用独立的with session_factory() as session执行三类查询并输出;
# - 在finally中调用engine.dispose()释放连接池。
# 5. 分别捕获配置错误和SQLAlchemyError,并输出中文场景说明。
# 6. 添加程序入口判断并调用main()。
#
# 预期关键输出:
# 关系对象查询:
# 数据库课程
# PostgreSQL入门
# SQLAlchemy基础
# Web课程
# HTTP基础
# FastAPI入门
#
# DTO联表查询:
# ORM-L-001|PostgreSQL入门|数据库课程|价格:99.00
# ORM-L-002|SQLAlchemy基础|数据库课程|价格:129.00
# ORM-L-003|HTTP基础|Web课程|价格:69.00
# ORM-L-004|FastAPI入门|Web课程|价格:159.00
#
# 分类统计:
# 数据库课程|课程数量:2
# Web课程|课程数量:2
#
# 自查清单:
# 1. Python模型类名是否为Category和Lesson,而不是数据库表名?
# 2. Category.lessons是否声明为Lesson对象列表,而不是int列表?
# 3. Category.lessons和Lesson.category是否使用back_populates互相对应?
# 4. category_id是否通过ForeignKey建立真实数据库外键?
# 5. 是否通过对象关系新增课程,而不是手工猜测category_id?
# 6. 关系对象查询是否使用selectinload()避免N+1查询?
# 7. DTO查询是否只选择需要的列并使用join()?
# 8. DTO是否没有继承Base,也没有承担持久化职责?
# 9. 聚合数量是否由数据库的count()和group_by()完成?
# 10. 数据访问函数是否都没有擅自commit()?
# 11. 是否只清理ORM-C-和ORM-L-前缀的本课练习数据?
# 最终验收标准:
# 1. practice.py通过语法检查并能连续运行两次;
# 2. 两张表之间存在真实数据库外键和双向对象关系;
# 3. 两个分类与四门课程的数据及对象关系正确;
# 4. 关系对象查询结果正确,并使用预加载避免N+1查询;
# 5. DTO联表查询包含两张表的数据,字段和顺序符合预期;
# 6. 聚合查询正确统计每个分类的课程数量;
# 7. 初始化事务由外层统一提交,数据访问函数不自行提交;
# 8. 不使用SQLAlchemy 1.x旧式查询写法;
# 9. config.toml与真实数据库信息没有进入Git。
@@ -0,0 +1,216 @@
"""第4-4课标准示例:SQLAlchemy关系映射、联表查询与DTO。"""
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
import tomllib
from sqlalchemy import ForeignKey, Numeric, String, URL, create_engine, delete, func, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
Session,
mapped_column,
relationship,
selectinload,
sessionmaker,
)
class Base(DeclarativeBase):
"""所有ORM模型共同继承的声明式基类。"""
class Customer(Base):
"""客户模型:一名客户可以拥有多张订单。"""
__tablename__ = "course_orm_customer"
id: Mapped[int] = mapped_column(primary_key=True)
customer_code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
customer_name: Mapped[str] = mapped_column(String(100), nullable=False)
# relationship描述Python对象之间的关系,本身不是数据库中的字段。
# back_populates让Customer.orders和Order.customer成为双向关系。
orders: Mapped[list["Order"]] = relationship(
back_populates="customer",
cascade="all, delete-orphan",
)
class Order(Base):
"""订单模型:每张订单通过外键归属于一名客户。"""
__tablename__ = "course_orm_order"
id: Mapped[int] = mapped_column(primary_key=True)
order_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
customer_id: Mapped[int] = mapped_column(
ForeignKey("course_orm_customer.id"),
nullable=False,
)
customer: Mapped[Customer] = relationship(back_populates="orders")
@dataclass(frozen=True)
class OrderSummaryDTO:
"""联表查询结果对象,作用类似Java中专门承载查询结果的DTO。"""
order_no: str
customer_name: str
amount: Decimal
def load_database_config() -> dict:
"""从本课目录的本地TOML文件读取数据库配置。"""
config_path = Path(__file__).with_name("config.toml")
if not config_path.exists():
raise FileNotFoundError(
"没有找到config.toml,请复制config.example.toml并填写本地数据库信息。"
)
with config_path.open("rb") as config_file:
config = tomllib.load(config_file)
if "postgresql" not in config:
raise KeyError("config.toml中缺少[postgresql]配置段。")
return config["postgresql"]
def create_database_url(database_config: dict) -> URL:
"""使用URL.create安全构造连接地址,避免手工拼接密码。"""
return URL.create(
drivername="postgresql+psycopg",
username=database_config["user"],
password=database_config["password"],
host=database_config["host"],
port=database_config["port"],
database=database_config["dbname"],
)
def reset_and_add_data(session: Session) -> None:
"""清理并重新创建本课专用数据,保证示例可以重复运行。"""
# 先删子表再删父表,满足数据库外键约束。
customer_ids = select(Customer.id).where(Customer.customer_code.like("ORM-C-%"))
session.execute(delete(Order).where(Order.customer_id.in_(customer_ids)))
session.execute(delete(Customer).where(Customer.customer_code.like("ORM-C-%")))
alice = Customer(
customer_code="ORM-C-001",
customer_name="张三",
orders=[
Order(order_no="ORM-O-001", amount=Decimal("299.00")),
Order(order_no="ORM-O-002", amount=Decimal("99.00")),
],
)
bob = Customer(
customer_code="ORM-C-002",
customer_name="李四",
orders=[Order(order_no="ORM-O-003", amount=Decimal("599.00"))],
)
# cascade配置使新增Customer时能够同时新增其orders集合中的订单。
session.add_all([alice, bob])
def find_customers_with_orders(session: Session) -> list[Customer]:
"""使用预加载一次取得客户及其订单,避免N+1查询。"""
statement = (
select(Customer)
.where(Customer.customer_code.like("ORM-C-%"))
.options(selectinload(Customer.orders))
.order_by(Customer.customer_code)
)
return list(session.scalars(statement))
def find_order_summaries(session: Session) -> list[OrderSummaryDTO]:
"""显式联表并只查询DTO所需列。"""
statement = (
select(Order.order_no, Customer.customer_name, Order.amount)
.join(Customer, Order.customer_id == Customer.id)
.where(Customer.customer_code.like("ORM-C-%"))
.order_by(Order.order_no)
)
rows = session.execute(statement).all()
return [
OrderSummaryDTO(
order_no=row.order_no,
customer_name=row.customer_name,
amount=row.amount,
)
for row in rows
]
def count_orders_by_customer(session: Session) -> list[tuple[str, int]]:
"""让数据库按照客户分组并统计订单数量。"""
statement = (
select(Customer.customer_name, func.count(Order.id))
.join(Order, Customer.id == Order.customer_id)
.where(Customer.customer_code.like("ORM-C-%"))
.group_by(Customer.id, Customer.customer_name)
.order_by(Customer.customer_code)
)
return [(name, order_count) for name, order_count in session.execute(statement)]
def main() -> None:
"""按事务写入数据,再分别演示三种多表查询。"""
engine = None
try:
database_config = load_database_config()
engine = create_engine(
create_database_url(database_config),
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
connect_args={
"connect_timeout": database_config.get("connect_timeout", 10)
},
)
session_factory = sessionmaker(engine, expire_on_commit=False)
Base.metadata.create_all(engine)
with session_factory.begin() as session:
reset_and_add_data(session)
with session_factory() as session:
print("关系对象查询:")
for customer in find_customers_with_orders(session):
print(customer.customer_name)
for order in customer.orders:
print(f" {order.order_no}|金额:{order.amount}")
print("DTO联表查询:")
for summary in find_order_summaries(session):
print(
f"{summary.order_no}|{summary.customer_name}|"
f"金额:{summary.amount}"
)
print("聚合查询:")
for customer_name, order_count in count_orders_by_customer(session):
print(f"{customer_name}|订单数量:{order_count}")
except (OSError, KeyError, tomllib.TOMLDecodeError) as error:
print(f"配置读取失败:{error}")
except SQLAlchemyError as error:
print(f"数据库访问失败:{error}")
finally:
if engine is not None:
engine.dispose()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
SQLAlchemy>=2.0,<2.1
psycopg[binary]>=3.2,<4.0