feat(数据库): 完善第四阶段SQLAlchemy课程与综合项目
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
# 第4-3课:SQLAlchemy基础
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
前两课直接使用Psycopg,已经理解连接、游标、参数化SQL、事务和Repository分层。本课开始使用SQLAlchemy 2.x,将生产项目常用的连接池、SQL工具和对象关系映射(Object Relational Mapping,ORM)引入课程。
|
||||
|
||||
本课不会隐藏底层原理。SQLAlchemy最终仍通过Psycopg连接PostgreSQL:
|
||||
|
||||
```text
|
||||
业务代码
|
||||
↓
|
||||
SQLAlchemy ORM和Session
|
||||
↓
|
||||
SQLAlchemy Engine与连接池
|
||||
↓
|
||||
Psycopg
|
||||
↓
|
||||
PostgreSQL
|
||||
```
|
||||
|
||||
本课示例会创建`course_orm_book`表,只重置`ORM-`前缀课程数据;练习创建`course_orm_product`表,只操作`ORM-P-`前缀数据。不要连接生产数据库。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 解释SQLAlchemy Core和ORM的关系;
|
||||
2. 使用`Engine`统一管理数据库方言和连接池;
|
||||
3. 使用声明式模型映射Python类与数据库表;
|
||||
4. 使用`Session`管理ORM对象和事务;
|
||||
5. 使用SQLAlchemy 2.x的`select()`查询对象;
|
||||
6. 完成ORM新增、查询、修改和删除;
|
||||
7. 区分`flush()`、`commit()`、`rollback()`和`close()`;
|
||||
8. 理解Session的工作单元和身份映射;
|
||||
9. 对照JDBC、MyBatis-Plus和JPA/Hibernate理解SQLAlchemy。
|
||||
|
||||
## 三、SQLAlchemy解决什么问题
|
||||
|
||||
直接使用Psycopg时,需要自行处理:
|
||||
|
||||
- 创建数据库连接;
|
||||
- 复用或关闭连接;
|
||||
- 手写SQL;
|
||||
- 把查询元组转换为对象;
|
||||
- 跟踪对象修改;
|
||||
- 组织提交和回滚。
|
||||
|
||||
SQLAlchemy提供两个主要层次:
|
||||
|
||||
| 层次 | 作用 |
|
||||
|---|---|
|
||||
| SQLAlchemy Core | Engine、连接池、SQL表达式、表元数据、方言适配 |
|
||||
| SQLAlchemy ORM | 类表映射、Session、对象查询、关系和工作单元 |
|
||||
|
||||
ORM建立在Core之上。使用ORM并不意味着不再需要理解SQL、事务和索引。
|
||||
|
||||
## 四、与Java技术体系对照
|
||||
|
||||
| Java常见技术 | SQLAlchemy中的相近概念 | 说明 |
|
||||
|---|---|---|
|
||||
| JDBC Driver | Psycopg | PostgreSQL底层驱动 |
|
||||
| `DataSource`和HikariCP | `Engine`和连接池 | 管理连接获取、复用和归还 |
|
||||
| JPA实体 | 声明式ORM模型 | 类和表之间的映射 |
|
||||
| `EntityManager` | `Session` | 管理持久化对象和事务 |
|
||||
| Persistence Context | Session身份映射 | 同一Session内按主键维护对象身份 |
|
||||
| Dirty Checking | Session变更跟踪 | 修改对象属性后生成UPDATE |
|
||||
| JPQL/Criteria | `select()`表达式 | 用Python表达式构造查询 |
|
||||
| `@Transactional` | `Session.begin()`上下文 | 正常提交、异常回滚 |
|
||||
|
||||
SQLAlchemy ORM总体更接近JPA/Hibernate。它也能简化常规增删改查,使用体验部分接近MyBatis-Plus,但不是以Mapper接口和SQL模板为中心。
|
||||
|
||||
## 五、Engine和连接池
|
||||
|
||||
创建Engine:
|
||||
|
||||
```python
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
connect_args={"connect_timeout": 10},
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
```
|
||||
|
||||
Engine不是一条固定连接,而是数据库访问入口。它组合了:
|
||||
|
||||
- 数据库URL;
|
||||
- PostgreSQL方言;
|
||||
- Psycopg驱动;
|
||||
- 连接池;
|
||||
- SQL执行和事件机制。
|
||||
|
||||
### 5.1 常用连接池参数
|
||||
|
||||
| 参数 | 含义 |
|
||||
|---|---|
|
||||
| `pool_size=5` | 池中长期保留的连接数量上限 |
|
||||
| `max_overflow=5` | 池满时允许临时增加的连接数 |
|
||||
| `pool_pre_ping=True` | 取出连接时先检查连接是否仍可用 |
|
||||
| `pool_timeout` | 连接池耗尽时最多等待多少秒 |
|
||||
| `pool_recycle` | 连接存活超过指定秒数后回收更新 |
|
||||
|
||||
`connect_args`会把驱动专用参数交给Psycopg。本课将TOML中的`connect_timeout`传入,避免网络异常时无限等待。
|
||||
|
||||
`pool_size=5`不代表程序启动时立即创建5条连接。Engine通常按需创建连接。
|
||||
|
||||
生产应用通常在启动时创建一个Engine,不应在每个Repository方法或循环中重复`create_engine()`。
|
||||
|
||||
### 5.2 为什么使用URL.create()
|
||||
|
||||
本课使用:
|
||||
|
||||
```python
|
||||
database_url = 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"],
|
||||
)
|
||||
```
|
||||
|
||||
`postgresql+psycopg`表示:
|
||||
|
||||
```text
|
||||
数据库方言:PostgreSQL
|
||||
DB-API驱动:Psycopg 3
|
||||
```
|
||||
|
||||
结构化创建URL可以避免手工拼接连接串,也不用自己处理密码中的`@`、`:`等特殊字符。
|
||||
|
||||
## 六、声明式模型
|
||||
|
||||
先定义共同基类:
|
||||
|
||||
```python
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
```
|
||||
|
||||
再定义映射模型:
|
||||
|
||||
```python
|
||||
class Book(Base):
|
||||
__tablename__ = "course_orm_book"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
isbn: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
```
|
||||
|
||||
拆开理解:
|
||||
|
||||
- `Book`是可以正常创建和使用的Python类;
|
||||
- `__tablename__`指定数据库表名;
|
||||
- `Mapped[str]`说明ORM属性映射的Python类型;
|
||||
- `mapped_column()`描述数据库列和约束;
|
||||
- `Base.metadata`收集所有模型的表元数据。
|
||||
|
||||
调用:
|
||||
|
||||
```python
|
||||
Base.metadata.create_all(engine)
|
||||
```
|
||||
|
||||
会创建缺失的表,但它不是完整的数据库迁移工具,不能可靠地把已有表自动升级成新结构。后续FastAPI阶段会学习数据库迁移。
|
||||
|
||||
## 七、Session是什么
|
||||
|
||||
Session不是数据库连接本身,也不是线程安全的全局单例。它主要负责:
|
||||
|
||||
- 从Engine申请连接;
|
||||
- 开始和结束数据库事务;
|
||||
- 保存当前持久化上下文中的ORM对象;
|
||||
- 跟踪新增、修改和删除;
|
||||
- 在合适时机把变化同步到数据库;
|
||||
- 提交或回滚事务;
|
||||
- 把连接归还连接池。
|
||||
|
||||
创建Session工厂:
|
||||
|
||||
```python
|
||||
session_factory = sessionmaker(
|
||||
engine,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
```
|
||||
|
||||
`sessionmaker`类似统一配置后的Session工厂。生产代码让每个请求或业务任务创建自己的Session,而不是让多个并发请求共享同一个Session。
|
||||
|
||||
### 7.1 身份映射
|
||||
|
||||
Session内部维护身份映射(Identity Map)。在同一个Session中,以相同主键加载同一行时,通常会得到同一个Python对象实例:
|
||||
|
||||
```python
|
||||
first = session.get(Book, 1)
|
||||
second = session.get(Book, 1)
|
||||
|
||||
print(first is second) # 通常为True
|
||||
```
|
||||
|
||||
这与JPA持久化上下文中的实体身份概念相近。
|
||||
|
||||
### 7.2 工作单元
|
||||
|
||||
工作单元(Unit of Work)表示Session收集一组对象变化,再统一生成SQL:
|
||||
|
||||
```python
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
book.price = Decimal("72.00")
|
||||
```
|
||||
|
||||
这里只修改了Python属性,没有手写`UPDATE`。Session会识别变化,在`flush()`或提交时发送UPDATE。
|
||||
|
||||
## 八、Session事务写法
|
||||
|
||||
写操作推荐使用:
|
||||
|
||||
```python
|
||||
with session_factory.begin() as session:
|
||||
session.add(book)
|
||||
```
|
||||
|
||||
其行为是:
|
||||
|
||||
```text
|
||||
创建Session
|
||||
→ 开始事务
|
||||
→ 执行业务
|
||||
→ 正常结束时flush并commit
|
||||
→ 异常时rollback
|
||||
→ 关闭Session
|
||||
→ 连接归还连接池
|
||||
```
|
||||
|
||||
只读查询可以使用:
|
||||
|
||||
```python
|
||||
with session_factory() as session:
|
||||
books = session.scalars(select(Book)).all()
|
||||
```
|
||||
|
||||
离开Session的`with`会关闭Session并释放连接资源,但不会替你提交尚未提交的写操作。不要把“关闭Session”误认为“提交事务”。
|
||||
|
||||
## 九、flush与commit的区别
|
||||
|
||||
### 9.1 flush
|
||||
|
||||
```python
|
||||
session.add(book)
|
||||
session.flush()
|
||||
print(book.id)
|
||||
```
|
||||
|
||||
`flush()`把Session内待处理变化发送给数据库,例如执行INSERT并取得数据库生成的主键。但是:
|
||||
|
||||
- 当前事务仍未提交;
|
||||
- 其他事务通常还看不到结果;
|
||||
- 后续发生异常仍可以回滚。
|
||||
|
||||
### 9.2 commit
|
||||
|
||||
`commit()`先执行必要的`flush()`,然后提交数据库事务。提交成功后,本事务的修改成为持久结果。
|
||||
|
||||
### 9.3 rollback
|
||||
|
||||
`rollback()`撤销当前事务中未提交的数据库变化,并调整Session中的对象状态。事务失败后必须回滚或关闭Session,才能安全开始后续工作。
|
||||
|
||||
一句话记忆:
|
||||
|
||||
```text
|
||||
flush:把变化发给数据库,但还可以回滚。
|
||||
commit:确认事务结果,完成持久化。
|
||||
```
|
||||
|
||||
## 十、ORM增删改查
|
||||
|
||||
### 10.1 新增
|
||||
|
||||
```python
|
||||
book = Book(isbn="ORM-001", title="Python数据库编程", ...)
|
||||
session.add(book)
|
||||
```
|
||||
|
||||
多个对象使用:
|
||||
|
||||
```python
|
||||
session.add_all([first_book, second_book])
|
||||
```
|
||||
|
||||
### 10.2 查询
|
||||
|
||||
SQLAlchemy 2.x使用`select()`:
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Book)
|
||||
.where(Book.isbn.like("ORM-%"))
|
||||
.order_by(Book.isbn)
|
||||
)
|
||||
books = session.scalars(statement).all()
|
||||
```
|
||||
|
||||
不要在本课程中使用旧式:
|
||||
|
||||
```python
|
||||
session.query(Book).filter(...)
|
||||
```
|
||||
|
||||
`session.scalars()`适合只需要ORM对象的查询。`session.execute()`返回更通用的结果行。
|
||||
|
||||
### 10.3 修改
|
||||
|
||||
```python
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
book.price = Decimal("72.00")
|
||||
```
|
||||
|
||||
Session跟踪属性变化,在flush时生成UPDATE。
|
||||
|
||||
### 10.4 删除
|
||||
|
||||
```python
|
||||
session.delete(book)
|
||||
```
|
||||
|
||||
对象会被标记为删除,DELETE在flush时发送。
|
||||
|
||||
## 十一、expire_on_commit
|
||||
|
||||
SQLAlchemy默认`expire_on_commit=True`。事务提交后,Session中的对象属性会被标记为过期;下一次访问时,Session可能重新查询数据库获取最新值。
|
||||
|
||||
本课使用:
|
||||
|
||||
```python
|
||||
sessionmaker(engine, expire_on_commit=False)
|
||||
```
|
||||
|
||||
这样提交后对象仍可读取已经加载的值,适合当前命令行示例,也常用于Web响应层。但它不代表对象永远是数据库最新状态;如果其他事务修改了数据,需要重新查询或刷新。
|
||||
|
||||
## 十二、完整示例
|
||||
|
||||
运行[sqlalchemy_crud_example.py](./sqlalchemy_crud_example.py),会依次演示:
|
||||
|
||||
1. 创建Engine和连接池;
|
||||
2. 创建Session工厂;
|
||||
3. 根据模型创建缺失表;
|
||||
4. 重置并新增课程图书;
|
||||
5. `flush()`后读取数据库生成的ID;
|
||||
6. 使用`select()`查询;
|
||||
7. 修改对象属性并删除对象;
|
||||
8. 主动抛出异常验证事务回滚;
|
||||
9. 使用新Session回查最终数据。
|
||||
|
||||
## 十三、安装与配置
|
||||
|
||||
激活课程环境,在本课目录安装:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r .\requirements.txt
|
||||
```
|
||||
|
||||
如果使用Conda管理SQLAlchemy:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge sqlalchemy
|
||||
```
|
||||
|
||||
Psycopg已经在前两课安装完成。然后复制配置:
|
||||
|
||||
```powershell
|
||||
Copy-Item .\config.example.toml .\config.toml
|
||||
```
|
||||
|
||||
填写专用练习数据库信息。真实`config.toml`已被项目`.gitignore`排除。
|
||||
|
||||
## 十四、运行方法与预期结果
|
||||
|
||||
```powershell
|
||||
python .\sqlalchemy_crud_example.py
|
||||
```
|
||||
|
||||
关键输出类似:
|
||||
|
||||
```text
|
||||
flush后第一本书ID:实际ID
|
||||
新增后:
|
||||
ORM-001|Python数据库编程|作者:小明|价格:68.00
|
||||
ORM-002|SQLAlchemy实践|作者:小红|价格:88.00
|
||||
修改并删除后:
|
||||
ORM-001|Python数据库编程|作者:小明|价格:72.00
|
||||
失败事务已回滚:模拟后续业务失败。
|
||||
失败事务回滚后:
|
||||
ORM-001|Python数据库编程|作者:小明|价格:72.00
|
||||
```
|
||||
|
||||
数据库生成的ID不要求固定。重复运行会先清理`ORM-`前缀示例数据。
|
||||
|
||||
## 十五、常见错误
|
||||
|
||||
### 15.1 每个方法都创建Engine
|
||||
|
||||
Engine应当是应用级长生命周期对象。反复创建Engine会反复创建连接池,失去连接复用价值。
|
||||
|
||||
### 15.2 多个请求共享同一个Session
|
||||
|
||||
Session不是供多个线程或并发任务共享的全局对象。常见原则是每个线程一个Session,异步场景每个任务一个AsyncSession。
|
||||
|
||||
### 15.3 关闭Session却没有提交
|
||||
|
||||
```python
|
||||
with session_factory() as session:
|
||||
session.add(book)
|
||||
```
|
||||
|
||||
离开时Session关闭,未提交写入会被回滚。写事务使用`session_factory.begin()`或明确调用`session.commit()`。
|
||||
|
||||
### 15.4 把flush当成commit
|
||||
|
||||
`flush()`只把SQL发送到当前事务,后续异常仍会回滚。
|
||||
|
||||
### 15.5 提交后访问过期对象
|
||||
|
||||
默认配置下,提交会使对象属性过期。Session已关闭后访问需要重新加载的属性,可能出现对象已脱离Session的错误。本课通过`expire_on_commit=False`降低入门干扰。
|
||||
|
||||
### 15.6 使用旧式Session.query()
|
||||
|
||||
当前课程统一使用SQLAlchemy 2.x的`select()`、`Session.scalar()`和`Session.scalars()`。
|
||||
|
||||
### 15.7 把create_all当作迁移工具
|
||||
|
||||
`create_all()`适合创建缺失表,不负责完整版本化迁移。生产项目修改表结构通常使用Alembic等迁移工具。
|
||||
|
||||
## 十六、课堂练习
|
||||
|
||||
打开[practice.py](./practice.py),完成商品ORM练习。题目按以下顺序组织:
|
||||
|
||||
1. 创建配置和声明式基类;
|
||||
2. 定义Product模型;
|
||||
3. 创建并初始化课程数据;
|
||||
4. 使用`select()`查询;
|
||||
5. 修改和删除ORM对象;
|
||||
6. 验证`flush()`后的异常回滚;
|
||||
7. 组织Engine、Session工厂和完整输出。
|
||||
|
||||
## 十七、本课小结
|
||||
|
||||
- Engine统一管理方言、驱动和连接池;
|
||||
- Engine不是一条固定数据库连接;
|
||||
- 声明式模型把Python类映射到数据库表;
|
||||
- Session管理事务、身份映射和工作单元;
|
||||
- `select()`是SQLAlchemy 2.x查询入口;
|
||||
- 修改ORM对象属性后,Session能够跟踪变化;
|
||||
- `flush()`发送SQL但不提交,`commit()`确认事务;
|
||||
- Session的生命周期应位于业务函数之外;
|
||||
- SQLAlchemy ORM更接近JPA/Hibernate,而不是MyBatis-Plus的直接复制。
|
||||
|
||||
## 十八、验收标准
|
||||
|
||||
- 能说明Psycopg、Engine、连接池和Session的调用层次;
|
||||
- 能说明Engine为什么通常只创建一次;
|
||||
- 能使用声明式模型完成类表映射;
|
||||
- 能使用SQLAlchemy 2.x方式完成增删改查;
|
||||
- 能解释身份映射和工作单元;
|
||||
- 能区分`flush()`、`commit()`、`rollback()`和`close()`;
|
||||
- 标准示例和练习的失败事务均能正确回滚;
|
||||
- 未提交真实`config.toml`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 复制本文件并重命名为 config.toml,再填写本地练习数据库信息。
|
||||
# config.toml 已加入项目 .gitignore,不会被 Git 跟踪。
|
||||
|
||||
[postgresql]
|
||||
host = "数据库主机"
|
||||
port = 5432
|
||||
dbname = "数据库名"
|
||||
user = "用户名"
|
||||
password = "密码"
|
||||
connect_timeout = 10
|
||||
@@ -0,0 +1,162 @@
|
||||
# 第4-3课练习:使用SQLAlchemy 2.x管理课程商品
|
||||
#
|
||||
# 本文件只提供题目,不包含导入、代码骨架、测试数据或参考答案。
|
||||
# 练习会创建course_orm_product表,并只操作ORM-P-前缀的数据。
|
||||
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
|
||||
|
||||
|
||||
# 第一部分:导入、配置与声明式基类
|
||||
# 1. 导入Decimal、Path和tomllib。
|
||||
# 2. 从sqlalchemy导入Numeric、String、URL、create_engine、delete和select。
|
||||
# 3. 从sqlalchemy.orm导入DeclarativeBase、Mapped、Session、mapped_column和sessionmaker。
|
||||
# 4. 使用Path(__file__).with_name("config.toml")定义CONFIG_PATH。
|
||||
# 5. 定义Base(DeclarativeBase),类体中不添加业务字段。
|
||||
# 6. 实现load_database_config(config_path),读取并返回[postgresql]配置字典。
|
||||
# 7. 实现create_database_url(database_config),必须调用URL.create()并指定:
|
||||
# - drivername="postgresql+psycopg";
|
||||
# - username、password、host、port、database分别来自配置;
|
||||
# - 返回URL对象,不手工拼接包含密码的字符串。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 第二部分:定义Product ORM模型
|
||||
# 1. 定义Product(Base)。
|
||||
# 2. 设置__tablename__ = "course_orm_product"。
|
||||
# 3. 使用Mapped和mapped_column()声明:
|
||||
# - id:int主键,由数据库生成;
|
||||
# - code:最长30字符,唯一且非空;
|
||||
# - name:最长100字符且非空;
|
||||
# - price:NUMERIC(10, 2)且非空;
|
||||
# - stock:int且非空。
|
||||
# 4. 定义__repr__,至少包含id、code、name和stock,返回字符串供调试使用。
|
||||
#
|
||||
# Java对照提醒:
|
||||
# - Product既是普通Python类,也是数据库映射模型;
|
||||
# - Mapped类似声明持久化属性的类型;
|
||||
# - mapped_column()描述列约束,不等于Java字段的setter。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 第三部分:创建并初始化课程数据
|
||||
# 1. 定义reset_practice_data(session),调用session.execute()执行:
|
||||
# delete(Product).where(Product.code.like("ORM-P-%"))。
|
||||
# 2. 定义add_products(session),创建并保存以下两个Product:
|
||||
# - code="ORM-P-001",name="机械键盘",price=Decimal("399.00"),stock=10;
|
||||
# - code="ORM-P-002",name="无线鼠标",price=Decimal("199.00"),stock=20。
|
||||
# 3. 调用session.add_all(products)。
|
||||
# 4. 调用session.flush(),然后读取第一件商品的id并输出:
|
||||
# “flush后第一件商品ID:实际ID”。
|
||||
# 5. 不在add_products()中调用commit()。
|
||||
|
||||
|
||||
|
||||
# 第四部分:实现查询
|
||||
# 1. 定义find_products(session),创建SQLAlchemy 2.x查询语句:
|
||||
# select(Product).where(Product.code.like("ORM-P-%")).order_by(Product.code)
|
||||
# 2. 调用session.scalars(statement).all()取得Product对象。
|
||||
# 3. 返回Product对象列表;没有数据时返回空列表,不返回None。
|
||||
# 4. 不使用旧式session.query()。
|
||||
|
||||
|
||||
|
||||
# 第五部分:实现修改和删除
|
||||
# 1. 定义update_product(session):
|
||||
# - 调用session.scalar(select(Product).where(Product.code == "ORM-P-001"));
|
||||
# - 找不到时抛出RuntimeError("没有找到商品ORM-P-001。");
|
||||
# - 找到后直接把stock属性修改为8;
|
||||
# - 不手写UPDATE SQL,也不在方法中提交。
|
||||
# 2. 定义delete_product(session):
|
||||
# - 查询code为ORM-P-002的Product;
|
||||
# - 找不到时抛出RuntimeError("没有找到商品ORM-P-002。");
|
||||
# - 调用session.delete(product);
|
||||
# - 不手写DELETE SQL,也不在方法中提交。
|
||||
|
||||
|
||||
|
||||
|
||||
# 第六部分:验证事务回滚
|
||||
# 1. 定义demonstrate_rollback(session_factory)。
|
||||
# 2. 在try中使用with session_factory.begin() as session管理事务。
|
||||
# 3. 查询ORM-P-001,把stock修改为0。
|
||||
# 4. 调用session.flush(),让UPDATE先发送给数据库。
|
||||
# 5. 紧接着抛出RuntimeError("模拟库存业务失败。")。
|
||||
# 6. 在事务with外捕获RuntimeError并输出:
|
||||
# “失败事务已回滚:模拟库存业务失败。”
|
||||
# 7. 最终回查时stock必须仍为8,而不是0。
|
||||
|
||||
|
||||
|
||||
|
||||
# 第七部分:输出和main()流程
|
||||
# 1. 定义print_products(title, products),先print(title),再逐个输出:
|
||||
# “ORM-P-001|机械键盘|价格:399.00|库存:10”。
|
||||
# 2. main()依次执行:
|
||||
# - 读取TOML配置并创建URL;
|
||||
# - 调用create_engine(),除database_url外还要传入:
|
||||
# connect_args={"connect_timeout": 配置值或默认值10}、pool_size=5、
|
||||
# max_overflow=5、pool_pre_ping=True、echo=False;
|
||||
# - 调用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
|
||||
# - 调用Base.metadata.create_all(engine)创建缺失的课程表;
|
||||
# - 使用with session_factory.begin() as session重置并新增商品;
|
||||
# - 使用with session_factory() as session查询并输出“新增后:”;
|
||||
# - 使用新的session_factory.begin()事务修改和删除;
|
||||
# - 使用只读Session查询并输出“修改并删除后:”;
|
||||
# - 调用demonstrate_rollback(session_factory);
|
||||
# - 最后查询并输出“失败事务回滚后:”。
|
||||
# 3. 在程序边界分别处理配置错误和数据库访问错误。
|
||||
# 4. 添加程序入口判断并调用main()。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 预期关键输出:
|
||||
# flush后第一件商品ID:实际ID
|
||||
# 新增后:
|
||||
# ORM-P-001|机械键盘|价格:399.00|库存:10
|
||||
# ORM-P-002|无线鼠标|价格:199.00|库存:20
|
||||
# 修改并删除后:
|
||||
# ORM-P-001|机械键盘|价格:399.00|库存:8
|
||||
# 失败事务已回滚:模拟库存业务失败。
|
||||
# 失败事务回滚后:
|
||||
# ORM-P-001|机械键盘|价格:399.00|库存:8
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 是否使用SQLAlchemy 2.x的DeclarativeBase、Mapped和mapped_column()?
|
||||
# 2. Engine是否只创建一次,并启用了pool_pre_ping?
|
||||
# 3. 是否使用sessionmaker统一创建Session?
|
||||
# 4. 是否使用select()和session.scalars(),而不是session.query()?
|
||||
# 5. 修改属性后是否由Session自动识别变化?
|
||||
# 6. Repository式函数中是否没有擅自提交事务?
|
||||
# 7. flush后是否能取得数据库生成的主键,但事务仍可回滚?
|
||||
# 8. 失败事务回滚后库存是否仍为8?
|
||||
# 9. 是否只清理ORM-P-前缀的练习数据?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并能重复运行;
|
||||
# 2. 模型字段与数据库表映射正确;
|
||||
# 3. 新增、查询、修改和删除结果符合预期;
|
||||
# 4. Session事务成功时提交、异常时回滚;
|
||||
# 5. 能解释Engine、连接池和Session的职责;
|
||||
# 6. 能解释flush()与commit()的区别;
|
||||
# 7. 不使用SQLAlchemy 1.x旧式查询写法;
|
||||
# 8. config.toml与真实数据库信息没有进入Git。
|
||||
@@ -0,0 +1,5 @@
|
||||
# SQLAlchemy提供Engine、连接池、SQL表达式和ORM。
|
||||
SQLAlchemy>=2,<3
|
||||
|
||||
# SQLAlchemy通过Psycopg 3连接PostgreSQL;具体使用c或binary实现由环境决定。
|
||||
psycopg>=3,<4
|
||||
@@ -0,0 +1,193 @@
|
||||
"""第4-3课示例:使用SQLAlchemy 2.x完成ORM增删改查和事务回滚。"""
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from sqlalchemy import Numeric, String, URL, create_engine, delete, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
|
||||
|
||||
CONFIG_PATH = Path(__file__).with_name("config.toml")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""保存本课所有ORM模型共享的映射元数据。"""
|
||||
|
||||
|
||||
class Book(Base):
|
||||
"""把Python图书对象映射到course_orm_book表。"""
|
||||
|
||||
__tablename__ = "course_orm_book"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
isbn: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
author: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""提供适合开发调试的对象显示。"""
|
||||
return (
|
||||
f"Book(id={self.id!r}, isbn={self.isbn!r}, "
|
||||
f"title={self.title!r}, price={self.price!r})"
|
||||
)
|
||||
|
||||
|
||||
def load_database_config(config_path: Path) -> dict[str, str | int]:
|
||||
"""读取并检查本地TOML数据库配置。"""
|
||||
if not config_path.exists():
|
||||
raise RuntimeError(
|
||||
"未找到 config.toml,请复制 config.example.toml 并填写练习数据库配置。"
|
||||
)
|
||||
|
||||
with config_path.open("rb") as config_file:
|
||||
config_data = tomllib.load(config_file)
|
||||
|
||||
database_config = config_data.get("postgresql")
|
||||
if not isinstance(database_config, dict):
|
||||
raise RuntimeError("config.toml 缺少 [postgresql] 配置节。")
|
||||
|
||||
return database_config
|
||||
|
||||
|
||||
def create_database_url(database_config: dict[str, str | int]) -> URL:
|
||||
"""用结构化参数创建URL,避免手工拼接和处理密码特殊字符。"""
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=str(database_config["user"]),
|
||||
password=str(database_config["password"]),
|
||||
host=str(database_config["host"]),
|
||||
port=int(database_config["port"]),
|
||||
database=str(database_config["dbname"]),
|
||||
)
|
||||
|
||||
|
||||
def reset_example_data(session: Session) -> None:
|
||||
"""只删除ORM-前缀的课程示例数据。"""
|
||||
session.execute(delete(Book).where(Book.isbn.like("ORM-%")))
|
||||
|
||||
|
||||
def add_books(session: Session) -> None:
|
||||
"""创建Python对象并交给Session持久化。"""
|
||||
books = [
|
||||
Book(
|
||||
isbn="ORM-001",
|
||||
title="Python数据库编程",
|
||||
author="小明",
|
||||
price=Decimal("68.00"),
|
||||
),
|
||||
Book(
|
||||
isbn="ORM-002",
|
||||
title="SQLAlchemy实践",
|
||||
author="小红",
|
||||
price=Decimal("88.00"),
|
||||
),
|
||||
]
|
||||
session.add_all(books)
|
||||
|
||||
# flush把待处理INSERT发送到数据库,但当前事务尚未提交。
|
||||
session.flush()
|
||||
print(f"flush后第一本书ID:{books[0].id}")
|
||||
|
||||
|
||||
def find_books(session: Session) -> list[Book]:
|
||||
"""使用SQLAlchemy 2.x的select()查询课程图书。"""
|
||||
statement = (
|
||||
select(Book)
|
||||
.where(Book.isbn.like("ORM-%"))
|
||||
.order_by(Book.isbn)
|
||||
)
|
||||
return list(session.scalars(statement).all())
|
||||
|
||||
|
||||
def update_book(session: Session) -> None:
|
||||
"""查询ORM对象并修改属性,由Session跟踪变化。"""
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
if book is None:
|
||||
raise RuntimeError("没有找到待修改图书ORM-001。")
|
||||
|
||||
book.price = Decimal("72.00")
|
||||
|
||||
|
||||
def delete_book(session: Session) -> None:
|
||||
"""查询ORM对象并标记删除。"""
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-002"))
|
||||
if book is None:
|
||||
raise RuntimeError("没有找到待删除图书ORM-002。")
|
||||
|
||||
session.delete(book)
|
||||
|
||||
|
||||
def demonstrate_rollback(session_factory: sessionmaker[Session]) -> None:
|
||||
"""演示异常离开Session.begin()时自动回滚。"""
|
||||
try:
|
||||
with session_factory.begin() as session:
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
if book is None:
|
||||
raise RuntimeError("没有找到回滚演示图书ORM-001。")
|
||||
|
||||
book.price = Decimal("1.00")
|
||||
session.flush()
|
||||
raise RuntimeError("模拟后续业务失败。")
|
||||
except RuntimeError as error:
|
||||
print(f"失败事务已回滚:{error}")
|
||||
|
||||
|
||||
def print_books(title: str, books: list[Book]) -> None:
|
||||
"""输出一个查询阶段的图书结果。"""
|
||||
print(title)
|
||||
for book in books:
|
||||
print(f"{book.isbn}|{book.title}|作者:{book.author}|价格:{book.price}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""创建Engine和Session工厂,依次演示ORM增删改查。"""
|
||||
try:
|
||||
database_config = load_database_config(CONFIG_PATH)
|
||||
database_url = create_database_url(database_config)
|
||||
|
||||
# Engine通常在应用启动时创建一次;它管理数据库方言和连接池。
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
connect_args={
|
||||
"connect_timeout": int(database_config.get("connect_timeout", 10))
|
||||
},
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
session_factory = sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
# 根据模型元数据创建缺失的课程表,不会迁移已有表结构。
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with session_factory.begin() as session:
|
||||
reset_example_data(session)
|
||||
add_books(session)
|
||||
|
||||
with session_factory() as session:
|
||||
print_books("新增后:", find_books(session))
|
||||
|
||||
with session_factory.begin() as session:
|
||||
update_book(session)
|
||||
delete_book(session)
|
||||
|
||||
with session_factory() as session:
|
||||
print_books("修改并删除后:", find_books(session))
|
||||
|
||||
demonstrate_rollback(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
print_books("失败事务回滚后:", find_books(session))
|
||||
except (OSError, tomllib.TOMLDecodeError, KeyError, RuntimeError) as error:
|
||||
print(f"配置或课程数据错误:{error}")
|
||||
except SQLAlchemyError as error:
|
||||
# SQLAlchemyError是SQLAlchemy数据库访问异常的共同基础类型。
|
||||
print(f"数据库访问失败:{error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user