feat(数据库): 完善第四阶段SQLAlchemy课程与综合项目
This commit is contained in:
@@ -73,19 +73,23 @@ TOML(Tom's Obvious Minimal Language)是一种结构化配置格式。Python
|
||||
|
||||
## 六、安装 Psycopg 3
|
||||
|
||||
你当前使用 Conda 的 `base` 环境,可以直接安装 Psycopg:
|
||||
安装前建议创建课程专用环境,不要默认向系统级`base`环境安装。本课程同时支持pip和Conda两种方式。下面的命令假设PowerShell已经进入本课目录,并且已经激活目标课程环境。
|
||||
|
||||
pip方式:
|
||||
|
||||
```powershell
|
||||
conda install -n base -c conda-forge "psycopg>=3,<4" psycopg-c
|
||||
python -m pip install -r .\requirements.txt
|
||||
```
|
||||
|
||||
安装完成后验证:
|
||||
Conda方式:
|
||||
|
||||
```powershell
|
||||
python -c "import psycopg; print(psycopg.__version__)"
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
如果以后改用 Python 虚拟环境,也可以使用 `requirements.txt` 安装。无论使用哪种方式,导入时都写 `import psycopg`,不是 `import psycopg3`。
|
||||
两种方式的环境创建、区别、镜像配置,以及`EnvironmentNotWritableError`、`no pq wrapper available`、PyCharm原生崩溃等问题,统一参见[附录:Psycopg安装、环境选择与常见问题排查](./附录_Psycopg安装与排错.md)。
|
||||
|
||||
无论采用哪种方式,导入时都写`import psycopg`,不是`import psycopg3`。
|
||||
|
||||
## 七、创建本地 TOML 配置
|
||||
|
||||
@@ -209,7 +213,7 @@ python .\04_数据库\4_1_PostgreSQL与Psycopg入门\connection_example.py
|
||||
|
||||
含义:当前 Python 环境没有安装 Psycopg。
|
||||
|
||||
处理:激活 `.venv`,再使用本课的 `requirements.txt` 安装依赖。可以运行 `python -m pip show psycopg` 检查安装位置。
|
||||
处理:先激活实际使用的`.venv`或Conda课程环境,再使用本课的`requirements.txt`安装依赖。可以运行`python -c "import sys; print(sys.executable)"`确认解释器,并使用`python -m pip show psycopg`检查安装位置。完整排查流程参见安装附录。
|
||||
|
||||
### 11.2 未找到 `config.toml`
|
||||
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
# 附录:Psycopg安装、环境选择与常见问题排查
|
||||
|
||||
## 一、附录用途
|
||||
|
||||
本附录集中说明Windows环境下安装Psycopg 3时容易混淆的问题,并整理本课程实际遇到的错误。正文只保留数据库编程主线;安装失败、解释器不一致或原生库异常时,再查阅本附录。
|
||||
|
||||
本附录覆盖两种安装方式:
|
||||
|
||||
1. 使用pip安装;
|
||||
2. 使用Conda安装。
|
||||
|
||||
无论选择哪一种,都应先创建课程专用环境,不建议把课程依赖继续安装到系统级`base`环境。
|
||||
|
||||
## 二、先分清Psycopg 2和Psycopg 3
|
||||
|
||||
以下名称非常相似,但不是同一个版本:
|
||||
|
||||
| 安装包 | Python导入语句 | 说明 |
|
||||
|---|---|---|
|
||||
| `psycopg` | `import psycopg` | Psycopg 3主体包 |
|
||||
| `psycopg-binary` | 仍然使用`import psycopg` | Psycopg 3预编译二进制实现 |
|
||||
| `psycopg-c` | 仍然使用`import psycopg` | Psycopg 3本地C扩展实现 |
|
||||
| `psycopg2` | `import psycopg2` | Psycopg 2源码/本地库方案 |
|
||||
| `psycopg2-binary` | `import psycopg2` | Psycopg 2预编译方案 |
|
||||
|
||||
不存在以下正确导入方式:
|
||||
|
||||
```python
|
||||
import psycopg3
|
||||
import psycopg_binary
|
||||
import psycopg2_binary
|
||||
```
|
||||
|
||||
本课程使用Psycopg 3,因此代码统一写:
|
||||
|
||||
```python
|
||||
import psycopg
|
||||
```
|
||||
|
||||
如果编辑器只能找到`psycopg2`,通常表示安装的是Psycopg 2,或者编辑器选用了另一个Python解释器。
|
||||
|
||||
## 三、Psycopg 3的三种底层实现
|
||||
|
||||
Psycopg 3主体包会选择一种底层`libpq`包装实现。`libpq`是PostgreSQL官方客户端库,负责底层数据库通信。
|
||||
|
||||
| 实现 | 常见安装方式 | 特点 | 额外要求 |
|
||||
|---|---|---|---|
|
||||
| `python` | `pip install psycopg` | 纯Python包装,适合调试和小型任务 | 系统中必须能找到`libpq` |
|
||||
| `c` | `pip install "psycopg[c]"`或Conda的`psycopg-c` | C扩展,性能较好 | 本地`libpq`;pip源码构建还需要编译工具 |
|
||||
| `binary` | `pip install "psycopg[binary]"` | 预编译并自带客户端库,安装最省事 | 需要当前Python和平台存在可用二进制包 |
|
||||
|
||||
查看当前实际使用的实现:
|
||||
|
||||
```powershell
|
||||
python -c "import psycopg, psycopg.pq; print(psycopg.__version__); print(psycopg.pq.__impl__); print(psycopg.pq.version())"
|
||||
```
|
||||
|
||||
可能输出:
|
||||
|
||||
```text
|
||||
3.3.4
|
||||
binary
|
||||
180004
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- 第一行是Psycopg版本;
|
||||
- 第二行是`python`、`c`或`binary`实现;
|
||||
- 第三行是实际加载的`libpq`版本号。
|
||||
|
||||
## 四、安装前先确认当前环境
|
||||
|
||||
### 4.1 查看Conda环境
|
||||
|
||||
```powershell
|
||||
conda env list
|
||||
```
|
||||
|
||||
当前激活环境前会显示`*`。
|
||||
|
||||
### 4.2 查看Python解释器
|
||||
|
||||
```powershell
|
||||
python -c "import sys; print(sys.executable); print(sys.version)"
|
||||
```
|
||||
|
||||
如果课程环境名为`python-test`,路径应类似:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.conda\envs\python-test\python.exe
|
||||
```
|
||||
|
||||
如果仍然显示:
|
||||
|
||||
```text
|
||||
C:\ProgramData\miniconda3\python.exe
|
||||
```
|
||||
|
||||
说明当前使用的还是系统级`base`解释器。
|
||||
|
||||
### 4.3 为什么不推荐系统级base
|
||||
|
||||
系统级Miniconda可能安装在:
|
||||
|
||||
```text
|
||||
C:\ProgramData\miniconda3
|
||||
```
|
||||
|
||||
普通用户通常没有写权限,安装时会出现:
|
||||
|
||||
```text
|
||||
EnvironmentNotWritableError
|
||||
```
|
||||
|
||||
独立环境还可以避免数据库课程依赖污染其他Python项目,也便于删除和重建。
|
||||
|
||||
## 五、方式一:使用pip安装
|
||||
|
||||
### 5.1 创建并激活Conda环境
|
||||
|
||||
可以让Conda只负责环境和Python,再让pip安装Psycopg:
|
||||
|
||||
```powershell
|
||||
conda create -n python-test python=3.13 pip
|
||||
conda activate python-test
|
||||
```
|
||||
|
||||
### 5.2 推荐安装命令
|
||||
|
||||
本地学习优先使用预编译二进制实现:
|
||||
|
||||
```powershell
|
||||
python -m pip install "psycopg[binary]>=3,<4"
|
||||
```
|
||||
|
||||
也可以使用本课的依赖文件:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`requirements.txt`中的:
|
||||
|
||||
```text
|
||||
psycopg[binary]>=3,<4
|
||||
```
|
||||
|
||||
表示:
|
||||
|
||||
- 安装Psycopg 3;
|
||||
- 同时安装`binary`额外依赖;
|
||||
- 版本不低于3且低于4。
|
||||
|
||||
### 5.3 为什么使用`python -m pip`
|
||||
|
||||
不要优先直接执行:
|
||||
|
||||
```powershell
|
||||
pip install ...
|
||||
```
|
||||
|
||||
直接运行`pip.exe`时,可能遇到访问被拒绝,或者调用了其他环境中的pip。下面的写法明确表示“使用当前Python解释器对应的pip”:
|
||||
|
||||
```powershell
|
||||
python -m pip install ...
|
||||
```
|
||||
|
||||
这能降低“包安装成功,但程序使用另一个Python”的概率。
|
||||
|
||||
### 5.4 pip国内镜像临时用法
|
||||
|
||||
如果访问PyPI失败,可以只为当前命令指定镜像:
|
||||
|
||||
```powershell
|
||||
python -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple "psycopg[binary]>=3,<4"
|
||||
```
|
||||
|
||||
临时指定不会永久修改系统或用户配置。
|
||||
|
||||
### 5.5 pip安装后的确认
|
||||
|
||||
```powershell
|
||||
python -m pip show psycopg psycopg-binary
|
||||
python -c "import sys, psycopg, psycopg.pq; print(sys.executable); print(psycopg.__version__); print(psycopg.pq.__impl__)"
|
||||
```
|
||||
|
||||
预期底层实现通常是:
|
||||
|
||||
```text
|
||||
binary
|
||||
```
|
||||
|
||||
## 六、方式二:使用Conda安装
|
||||
|
||||
### 6.1 创建独立环境并一次安装
|
||||
|
||||
推荐让核心包全部来自`conda-forge`,减少Python、OpenSSL和`libpq`混用不同频道的风险:
|
||||
|
||||
```powershell
|
||||
conda create -n python-db --override-channels -c conda-forge python=3.13 psycopg psycopg-c libpq openssl
|
||||
conda activate python-db
|
||||
```
|
||||
|
||||
如果已经创建了`python-test`:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
不要在激活`python-test`后仍然写:
|
||||
|
||||
```powershell
|
||||
conda install -n base ...
|
||||
```
|
||||
|
||||
`-n base`会明确要求安装到`base`,不会因为当前激活了`python-test`而自动改用当前环境。
|
||||
|
||||
### 6.2 Conda不能使用pip的`-r`
|
||||
|
||||
下面的命令是错误的:
|
||||
|
||||
```powershell
|
||||
conda install -r requirements.txt
|
||||
```
|
||||
|
||||
`-r requirements.txt`是pip的参数,不是Conda的通用安装方式。对应写法是:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
使用Conda时则应直接写包名:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
### 6.3 配置清华Conda镜像
|
||||
|
||||
访问官方`conda-forge`失败时,可以把`conda-forge`映射到清华镜像:
|
||||
|
||||
```powershell
|
||||
conda config --set show_channel_urls yes
|
||||
conda config --set custom_channels.conda-forge https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
|
||||
conda clean --index-cache
|
||||
```
|
||||
|
||||
查看最终配置来源:
|
||||
|
||||
```powershell
|
||||
conda config --show-sources
|
||||
conda config --show custom_channels
|
||||
```
|
||||
|
||||
Windows用户级配置通常位于:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.condarc
|
||||
```
|
||||
|
||||
然后重新安装:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
### 6.4 Conda安装后的确认
|
||||
|
||||
```powershell
|
||||
conda list | Select-String "python|psycopg|libpq|openssl"
|
||||
python -c "import sys, psycopg, psycopg.pq; print(sys.executable); print(psycopg.__version__); print(psycopg.pq.__impl__); print(psycopg.pq.version())"
|
||||
```
|
||||
|
||||
使用`psycopg-c`时,底层实现应为:
|
||||
|
||||
```text
|
||||
c
|
||||
```
|
||||
|
||||
## 七、pip和Conda的区别
|
||||
|
||||
| 对比项 | pip | Conda |
|
||||
|---|---|---|
|
||||
| 主要管理对象 | Python包 | Python包、解释器和原生库 |
|
||||
| 默认软件仓库 | PyPI | defaults或conda-forge等频道 |
|
||||
| 环境创建 | 通常配合`venv`或Conda | 原生支持`conda create` |
|
||||
| `libpq`、OpenSSL等原生依赖 | binary包可自带;否则依赖系统 | 可以作为Conda包统一安装 |
|
||||
| 安装速度与包可用性 | PyPI新版本通常更及时 | 取决于频道是否已构建对应平台包 |
|
||||
| 依赖解析范围 | 主要关注Python包 | 同时解析Python和原生库依赖 |
|
||||
| 本课推荐场景 | 希望安装简单、使用`binary`实现 | 希望统一管理Python、C扩展和`libpq` |
|
||||
|
||||
### 7.1 pip方案的优势
|
||||
|
||||
- 命令与大多数Python项目的`requirements.txt`一致;
|
||||
- `psycopg[binary]`通常安装最直接;
|
||||
- 预编译包自带所需客户端库,受本机DLL路径影响较小;
|
||||
- PyPI上的版本通常更新较快。
|
||||
|
||||
### 7.2 Conda方案的优势
|
||||
|
||||
- 可以同时管理Python、`psycopg-c`、`libpq`和OpenSSL;
|
||||
- 不需要手工准备Visual Studio C++编译环境;
|
||||
- 适合已经使用Conda管理数据分析或科学计算环境的项目。
|
||||
|
||||
### 7.3 不建议随意混装
|
||||
|
||||
同一个环境中可以混合使用Conda和pip,但顺序和边界必须清楚。推荐:
|
||||
|
||||
1. 先用Conda安装Python及能够满足的原生依赖;
|
||||
2. 再用当前环境的`python -m pip`安装Conda没有的Python包;
|
||||
3. pip安装后不要再让Conda大范围重算并替换同一批核心依赖;
|
||||
4. 出现原生崩溃时,检查Python、Psycopg、`libpq`和OpenSSL是否来自相互兼容的来源。
|
||||
|
||||
本课程选择一种方案成功后即可,不需要同时安装`psycopg-c`和`psycopg-binary`。
|
||||
|
||||
## 八、实际遇到的问题与原因
|
||||
|
||||
### 8.1 `conda install -r requirements.txt`报参数错误
|
||||
|
||||
错误原因:把pip参数用于Conda。
|
||||
|
||||
正确处理:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
或者使用Conda包名安装。
|
||||
|
||||
### 8.2 直接运行`pip.exe`显示`Access is denied`
|
||||
|
||||
可能原因包括`pip.exe`权限、命令解析或环境路径异常。
|
||||
|
||||
优先改为:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
同时用`sys.executable`确认当前Python。
|
||||
|
||||
### 8.3 `EnvironmentNotWritableError`
|
||||
|
||||
典型信息:
|
||||
|
||||
```text
|
||||
environment location: C:\ProgramData\miniconda3
|
||||
```
|
||||
|
||||
原因:普通用户没有系统级`base`环境写权限。
|
||||
|
||||
推荐处理:创建用户自己的独立环境,不要修改系统目录权限:
|
||||
|
||||
```powershell
|
||||
conda create -n python-test python=3.13 pip
|
||||
conda activate python-test
|
||||
```
|
||||
|
||||
### 8.4 激活新环境后仍然安装到base
|
||||
|
||||
错误命令:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
conda install -n base -c conda-forge psycopg
|
||||
```
|
||||
|
||||
`-n base`覆盖了当前环境选择。正确写法:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg
|
||||
```
|
||||
|
||||
或明确指定:
|
||||
|
||||
```powershell
|
||||
conda install -n python-test -c conda-forge psycopg
|
||||
```
|
||||
|
||||
### 8.5 CondaHTTPError访问`conda-forge`失败
|
||||
|
||||
先检查:
|
||||
|
||||
```powershell
|
||||
conda config --show-sources
|
||||
conda config --show channels
|
||||
conda config --show proxy_servers
|
||||
```
|
||||
|
||||
配置没有错误时,可能是网络、代理或官方源可达性问题。可以使用前面的清华镜像配置后清理索引缓存重试。
|
||||
|
||||
### 8.6 只能导入`psycopg2`
|
||||
|
||||
原因通常是安装了`psycopg2-binary`,而不是Psycopg 3。
|
||||
|
||||
确认命令:
|
||||
|
||||
```powershell
|
||||
python -c "import importlib.util; print(importlib.util.find_spec('psycopg')); print(importlib.util.find_spec('psycopg2'))"
|
||||
python -m pip show psycopg psycopg-binary psycopg2-binary
|
||||
```
|
||||
|
||||
本课程需要`psycopg`能够被找到。
|
||||
|
||||
### 8.7 `no pq wrapper available`
|
||||
|
||||
典型信息:
|
||||
|
||||
```text
|
||||
- couldn't import psycopg 'c' implementation
|
||||
- couldn't import psycopg 'binary' implementation
|
||||
- couldn't import psycopg 'python' implementation: libpq library not found
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- 没有`psycopg-c`;
|
||||
- 没有`psycopg-binary`;
|
||||
- 纯Python实现又找不到`libpq`。
|
||||
|
||||
解决方式任选其一:
|
||||
|
||||
```powershell
|
||||
python -m pip install psycopg-binary
|
||||
```
|
||||
|
||||
或者通过Conda安装完整本地库:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
### 8.8 PyCharm退出代码`0xC0000005`
|
||||
|
||||
`0xC0000005`是Windows原生访问冲突,不是普通Python异常,`try...except`无法捕获。它曾发生在`psycopg.connect()`进入C扩展或客户端DLL后。
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. 在终端使用同一个解释器运行同一文件;
|
||||
2. 输出`sys.executable`确认解释器一致;
|
||||
3. 输出`psycopg.pq.__impl__`确认底层实现;
|
||||
4. 检查环境是否混用了不同频道的Python、`psycopg-c`、`libpq`和OpenSSL;
|
||||
5. 优先重建核心包来源一致的环境;
|
||||
6. 本地学习也可以改用`psycopg-binary`降低DLL路径差异。
|
||||
|
||||
Conda的Windows原生库通常位于:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.conda\envs\环境名\Library\bin
|
||||
```
|
||||
|
||||
PyCharm必须选择正确的Conda解释器。必要时检查其运行配置是否能找到该目录中的DLL。
|
||||
|
||||
## 九、编辑器解释器检查
|
||||
|
||||
在PyCharm中选择的解释器必须与终端验证成功的解释器一致。例如:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.conda\envs\python-test\python.exe
|
||||
```
|
||||
|
||||
可以在程序中临时确认:
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
print(sys.executable)
|
||||
```
|
||||
|
||||
如果终端能够导入、PyCharm不能导入,通常不是代码问题,而是编辑器解释器或原生库搜索路径不同。
|
||||
|
||||
## 十、最终安装验收清单
|
||||
|
||||
依次执行:
|
||||
|
||||
```powershell
|
||||
conda env list
|
||||
python -c "import sys; print(sys.executable); print(sys.version)"
|
||||
python -c "import psycopg, psycopg.pq; print(psycopg.__version__); print(psycopg.__file__); print(psycopg.pq.__impl__); print(psycopg.pq.version())"
|
||||
```
|
||||
|
||||
验收标准:
|
||||
|
||||
- Python路径指向预期的课程环境;
|
||||
- `import psycopg`成功;
|
||||
- Psycopg主版本为3;
|
||||
- 底层实现是预期的`binary`、`c`或`python`;
|
||||
- PyCharm与PowerShell使用同一个解释器;
|
||||
- 实际运行第一课连接示例时不出现导入错误或原生崩溃。
|
||||
|
||||
安装只解决客户端依赖。数据库地址、端口、防火墙、代理、PostgreSQL监听和账号权限属于连接问题,应与安装问题分开判断。
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,284 @@
|
||||
# 第4-5课:数据库综合项目——库存订单管理
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
这是第四阶段的综合项目。本课不再单独讲一个API,而是把前四课知识组合成一个小型业务流程:客户下单后,系统创建订单与订单明细,同时扣减商品库存;只要其中任何一步失败,整张订单和全部库存修改都必须回滚。
|
||||
|
||||
项目使用三张独立练习表和`DBP-`、`DBO-`数据前缀,不操作其他课程或业务数据。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 使用SQLAlchemy 2.x映射三张存在关联关系的表;
|
||||
2. 使用Repository(仓储)封装数据访问;
|
||||
3. 使用Service(业务服务)组织下单规则;
|
||||
4. 在调用方统一控制事务提交和回滚;
|
||||
5. 使用`SELECT FOR UPDATE`降低并发扣减库存产生的超卖风险;
|
||||
6. 使用DTO承载三表联查结果;
|
||||
7. 使用聚合查询统计订单状态;
|
||||
8. 使用本地TOML配置安全连接PostgreSQL。
|
||||
|
||||
## 三、前置知识
|
||||
|
||||
- PostgreSQL主键、外键、约束和事务;
|
||||
- Psycopg连接与参数化查询;
|
||||
- SQLAlchemy的Engine、连接池和Session;
|
||||
- 声明式ORM模型及增删改查;
|
||||
- `ForeignKey`、`relationship()`、`join()`和DTO。
|
||||
|
||||
第四课练习即使尚未全部完成,也可以先运行本课标准示例;遇到关系映射或DTO不理解时,再回看第四课对应章节。
|
||||
|
||||
## 四、业务模型
|
||||
|
||||
```text
|
||||
Product(商品) 1 ──── N OrderItem(订单明细) N ──── 1 Order(订单)
|
||||
```
|
||||
|
||||
为什么需要订单明细表?因为一张订单可以包含多个商品,一个商品也可以出现在多张订单中。订单与商品本质上是多对多关系,`OrderItem`把它拆成两个一对多关系,并额外保存购买数量和成交单价。
|
||||
|
||||
成交单价必须保存在订单明细中。商品价格以后可能变化,但历史订单金额不能随商品当前价格改变。
|
||||
|
||||
## 五、项目分层
|
||||
|
||||
```text
|
||||
main() / 事务调用方
|
||||
↓ 创建同一个Session
|
||||
OrderService
|
||||
↓ 调用
|
||||
ProductRepository + OrderRepository
|
||||
↓ 操作
|
||||
SQLAlchemy ORM模型与PostgreSQL
|
||||
```
|
||||
|
||||
### 5.1 Repository
|
||||
|
||||
Repository负责查询、增加和修改数据库对象,但不决定什么时候提交:
|
||||
|
||||
```python
|
||||
class OrderRepository:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def add(self, order):
|
||||
self.session.add(order)
|
||||
```
|
||||
|
||||
它与MyBatis项目中的Mapper/DAO职责相近,但操作的是SQLAlchemy的Session和ORM对象。
|
||||
|
||||
### 5.2 Service
|
||||
|
||||
Service负责业务规则:验证订单、查询并锁定商品、判断库存、扣减库存、计算金额、组装订单。
|
||||
|
||||
它不调用`commit()`。因为一个业务用例可能调用多个Repository,必须保证它们处于同一个事务。
|
||||
|
||||
### 5.3 事务调用方
|
||||
|
||||
```python
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(...)
|
||||
```
|
||||
|
||||
正常离开`with`时提交;异常离开时回滚。`ProductRepository`和`OrderRepository`共享同一个Session,因此库存修改、订单主表和订单明细属于同一个数据库事务。
|
||||
|
||||
## 六、成功事务与失败事务
|
||||
|
||||
成功订单购买两个键盘和一个鼠标:
|
||||
|
||||
```text
|
||||
验证订单 → 锁定商品 → 扣减库存 → 创建明细 → 创建订单 → 提交
|
||||
```
|
||||
|
||||
失败订单先扣减一个键盘,随后发现鼠标库存不足:
|
||||
|
||||
```text
|
||||
锁定键盘 → 内存中扣减键盘 → 锁定鼠标 → 库存不足 → 抛出异常 → 全部回滚
|
||||
```
|
||||
|
||||
回滚必须撤销第一项商品的扣减,也不能留下不完整的订单。不能在处理每项商品后分别提交。
|
||||
|
||||
## 七、为什么使用FOR UPDATE
|
||||
|
||||
普通查询后再扣减库存存在并发窗口:两个事务可能同时读到库存5,并各自认为能够购买4件。
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code == product_code)
|
||||
.with_for_update()
|
||||
)
|
||||
```
|
||||
|
||||
PostgreSQL会把它转换为`SELECT ... FOR UPDATE`。当前事务结束前,其他需要修改同一行的事务通常需要等待。
|
||||
|
||||
这能解决本项目中的典型并发更新问题,但生产系统还需要考虑锁顺序、死锁重试、事务超时、幂等和高并发架构。本课只要求理解悲观锁的基本作用。
|
||||
|
||||
## 八、金额为什么使用Decimal
|
||||
|
||||
二进制浮点数`float`不能精确表示很多十进制小数,不适合直接保存货币金额。本项目统一使用:
|
||||
|
||||
- Python:`Decimal`;
|
||||
- PostgreSQL:`NUMERIC(10, 2)`或`NUMERIC(12, 2)`。
|
||||
|
||||
```python
|
||||
price=Decimal("399.00")
|
||||
```
|
||||
|
||||
使用字符串创建`Decimal`,避免先经过不精确的浮点数。
|
||||
|
||||
## 九、DTO三表联查
|
||||
|
||||
订单明细页面同时需要订单、商品和明细字段,不适合把某一个ORM实体直接当作查询结果。
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(
|
||||
Order.order_no,
|
||||
Product.product_name,
|
||||
OrderItem.quantity,
|
||||
OrderItem.unit_price,
|
||||
)
|
||||
.join(OrderItem, Order.id == OrderItem.order_id)
|
||||
.join(Product, OrderItem.product_id == Product.id)
|
||||
)
|
||||
```
|
||||
|
||||
查询结果再转换为`OrderDetailDTO`。这与MyBatis联表SQL映射到DTO/VO的做法非常接近,而且只查询页面真正需要的字段。
|
||||
|
||||
## 十、完整示例
|
||||
|
||||
标准示例位于:
|
||||
|
||||
```text
|
||||
inventory_order_example.py
|
||||
```
|
||||
|
||||
它包含:
|
||||
|
||||
- 三个ORM模型及数据库约束;
|
||||
- Repository/Service分层;
|
||||
- 成功下单事务;
|
||||
- 库存不足事务回滚;
|
||||
- 悲观锁库存查询;
|
||||
- 三表联查DTO;
|
||||
- 订单状态聚合;
|
||||
- 可重复执行的数据初始化。
|
||||
|
||||
## 十一、安装与配置
|
||||
|
||||
如果`python-test`环境已经完成前两课SQLAlchemy练习,不需要重复安装。确认版本:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
python -c "import sqlalchemy, psycopg; print(sqlalchemy.__version__); print(psycopg.__version__)"
|
||||
```
|
||||
|
||||
缺少依赖时执行:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
如果第五课继续使用第四课数据库配置,可以在第五课目录执行:
|
||||
|
||||
```powershell
|
||||
Copy-Item ..\4_4_SQLAlchemy关系映射与工程实践\config.toml .\config.toml
|
||||
```
|
||||
|
||||
也可以复制模板后自行填写:
|
||||
|
||||
```powershell
|
||||
Copy-Item config.example.toml config.toml
|
||||
```
|
||||
|
||||
真实配置只保存在被Git忽略的`config.toml`中,不写入环境变量、示例文件或Python代码。
|
||||
|
||||
## 十二、运行方法
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\04_数据库\4_5_数据库综合项目
|
||||
conda activate python-test
|
||||
python inventory_order_example.py
|
||||
```
|
||||
|
||||
正常结果应包括:
|
||||
|
||||
```text
|
||||
初始库存:
|
||||
DBP-001|机械键盘|价格:399.00|库存:10
|
||||
DBP-002|无线鼠标|价格:199.00|库存:5
|
||||
成功订单提交后:
|
||||
DBP-001|机械键盘|价格:399.00|库存:8
|
||||
DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
失败订单已回滚:商品库存不足:DBP-002
|
||||
失败订单回滚后:
|
||||
DBP-001|机械键盘|价格:399.00|库存:8
|
||||
DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
```
|
||||
|
||||
随后会输出两条`DBO-001`订单明细和一条`CREATED|订单数量:1`。连续运行两次时输出应保持一致;数据库序列生成的内部ID继续增长属于正常现象。
|
||||
|
||||
## 十三、关键执行顺序
|
||||
|
||||
1. 读取本地TOML配置;
|
||||
2. 创建Engine、连接池和Session工厂;
|
||||
3. 创建缺失的练习表;
|
||||
4. 在一个事务中重置练习数据;
|
||||
5. 查询初始库存;
|
||||
6. 在一个事务中执行成功下单;
|
||||
7. 在另一个事务中模拟库存不足并自动回滚;
|
||||
8. 使用DTO查询订单明细;
|
||||
9. 使用聚合查询统计订单状态;
|
||||
10. 释放连接池。
|
||||
|
||||
## 十四、常见错误
|
||||
|
||||
### 14.1 Repository中直接commit
|
||||
|
||||
这会让成功处理的第一项商品提前提交,后续商品失败时无法完整回滚。
|
||||
|
||||
### 14.2 每个Repository创建自己的Session
|
||||
|
||||
不同Session通常意味着不同事务。订单新增与库存扣减必须共享调用方传入的同一个Session。
|
||||
|
||||
### 14.3 捕获异常后不再抛出
|
||||
|
||||
如果在事务`with`内部吞掉库存不足异常,上下文会误以为业务成功并提交。应让异常离开事务上下文,再在外层捕获。
|
||||
|
||||
### 14.4 使用float计算金额
|
||||
|
||||
可能产生精度问题。金额应使用`Decimal`和数据库`NUMERIC`。
|
||||
|
||||
### 14.5 只检查库存但不锁定
|
||||
|
||||
单人练习时看似正确,并发请求下可能超卖。本课使用`FOR UPDATE`锁定商品行。
|
||||
|
||||
### 14.6 直接用当前商品价格展示历史订单
|
||||
|
||||
商品后来改价会污染历史数据。订单明细应保存成交时的`unit_price`。
|
||||
|
||||
## 十五、课堂练习
|
||||
|
||||
练习位于`practice.py`,仍采用前几课的分步形式,只包含题目、预期结果、自查清单和验收标准。请先独立完成,每完成一部分都可以让我验证。
|
||||
|
||||
## 十六、本课小结
|
||||
|
||||
1. 真实业务写入通常跨越多张表,事务边界应围绕完整业务用例;
|
||||
2. Repository负责数据访问,Service负责业务规则,调用方负责事务;
|
||||
3. 多个Repository必须共享同一个Session才能处于同一个事务;
|
||||
4. `FOR UPDATE`可以在事务中锁定待修改库存;
|
||||
5. 金额使用`Decimal`与`NUMERIC`;
|
||||
6. 多表列表查询适合使用DTO;
|
||||
7. 失败事务必须既不保留订单,也不保留任何库存修改。
|
||||
|
||||
## 十七、验收标准
|
||||
|
||||
- 三张表的约束、外键和ORM关系正确;
|
||||
- 成功订单保存订单与明细并扣减库存;
|
||||
- 库存不足时整个事务回滚;
|
||||
- Repository、Service和事务职责清晰;
|
||||
- DTO联表查询及状态统计正确;
|
||||
- 程序可重复运行且不影响其他数据;
|
||||
- 配置文件不进入Git;
|
||||
- 能解释本项目与Java中Mapper/Service/`@Transactional`/DTO的对应关系。
|
||||
@@ -0,0 +1,7 @@
|
||||
[postgresql]
|
||||
host = "你的PostgreSQL服务器地址"
|
||||
port = 5432
|
||||
dbname = "python_test"
|
||||
user = "你的数据库用户名"
|
||||
password = "你的数据库密码"
|
||||
connect_timeout = 10
|
||||
@@ -0,0 +1,448 @@
|
||||
"""第4-5课标准示例:使用SQLAlchemy实现库存订单综合项目。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Numeric,
|
||||
String,
|
||||
URL,
|
||||
create_engine,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
Session,
|
||||
mapped_column,
|
||||
relationship,
|
||||
sessionmaker,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_PATH = Path(__file__).with_name("config.toml")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有ORM模型共同继承的声明式基类。"""
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
"""表示下单过程中可以预期的业务异常。"""
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""商品模型,保存价格和当前库存。"""
|
||||
|
||||
__tablename__ = "course_shop_product"
|
||||
__table_args__ = (
|
||||
CheckConstraint("price >= 0", name="ck_course_shop_product_price"),
|
||||
CheckConstraint("stock >= 0", name="ck_course_shop_product_stock"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
product_code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
product_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
stock: Mapped[int] = mapped_column(nullable=False)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(back_populates="product")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""订单主表模型,保存客户、总金额和订单状态。"""
|
||||
|
||||
__tablename__ = "course_shop_order"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"total_amount >= 0",
|
||||
name="ck_course_shop_order_total_amount",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
customer_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
"""订单明细模型,连接订单和商品并保存成交单价。"""
|
||||
|
||||
__tablename__ = "course_shop_order_item"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0", name="ck_course_shop_order_item_quantity"),
|
||||
CheckConstraint(
|
||||
"unit_price >= 0",
|
||||
name="ck_course_shop_order_item_unit_price",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_shop_order.id"),
|
||||
nullable=False,
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_shop_product.id"),
|
||||
nullable=False,
|
||||
)
|
||||
quantity: Mapped[int] = mapped_column(nullable=False)
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="items")
|
||||
product: Mapped[Product] = relationship(back_populates="items")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderDetailDTO:
|
||||
"""向调用方返回的订单明细查询结果。"""
|
||||
|
||||
order_no: str
|
||||
customer_name: str
|
||||
product_code: str
|
||||
product_name: str
|
||||
quantity: int
|
||||
unit_price: Decimal
|
||||
line_amount: Decimal
|
||||
status: str
|
||||
|
||||
|
||||
def load_database_config(config_path: Path) -> dict:
|
||||
"""从本地TOML文件读取PostgreSQL配置。"""
|
||||
|
||||
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) -> URL:
|
||||
"""使用URL.create()构造连接地址,避免手工拼接密码。"""
|
||||
|
||||
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"]),
|
||||
)
|
||||
|
||||
|
||||
class ProductRepository:
|
||||
"""封装商品表的数据访问操作。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def find_by_code_for_update(self, product_code: str) -> Product:
|
||||
"""查询并锁定商品,防止并发下单时同时修改同一库存。"""
|
||||
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code == product_code)
|
||||
.with_for_update()
|
||||
)
|
||||
product = self.session.scalar(statement)
|
||||
if product is None:
|
||||
raise OrderError(f"商品不存在:{product_code}")
|
||||
return product
|
||||
|
||||
def find_practice_products(self) -> list[Product]:
|
||||
"""查询本课练习商品。"""
|
||||
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code.like("DBP-%"))
|
||||
.order_by(Product.product_code)
|
||||
)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
"""封装订单及订单明细的数据访问操作。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def exists_by_order_no(self, order_no: str) -> bool:
|
||||
"""判断订单编号是否存在。"""
|
||||
|
||||
statement = select(Order.id).where(Order.order_no == order_no)
|
||||
return self.session.scalar(statement) is not None
|
||||
|
||||
def add(self, order: Order) -> None:
|
||||
"""把订单加入Session;事务提交仍由外层调用方负责。"""
|
||||
|
||||
self.session.add(order)
|
||||
|
||||
def find_order_details(self) -> list[OrderDetailDTO]:
|
||||
"""联表查询订单明细并转换为DTO。"""
|
||||
|
||||
statement = (
|
||||
select(
|
||||
Order.order_no,
|
||||
Order.customer_name,
|
||||
Product.product_code,
|
||||
Product.product_name,
|
||||
OrderItem.quantity,
|
||||
OrderItem.unit_price,
|
||||
Order.status,
|
||||
)
|
||||
.join(OrderItem, Order.id == OrderItem.order_id)
|
||||
.join(Product, OrderItem.product_id == Product.id)
|
||||
.where(Order.order_no.like("DBO-%"))
|
||||
.order_by(Order.order_no, OrderItem.id)
|
||||
)
|
||||
|
||||
details = []
|
||||
for row in self.session.execute(statement):
|
||||
details.append(
|
||||
OrderDetailDTO(
|
||||
order_no=row.order_no,
|
||||
customer_name=row.customer_name,
|
||||
product_code=row.product_code,
|
||||
product_name=row.product_name,
|
||||
quantity=row.quantity,
|
||||
unit_price=row.unit_price,
|
||||
line_amount=row.unit_price * row.quantity,
|
||||
status=row.status,
|
||||
)
|
||||
)
|
||||
return details
|
||||
|
||||
def count_orders_by_status(self) -> list[tuple[str, int]]:
|
||||
"""让数据库按状态统计本课订单数量。"""
|
||||
|
||||
statement = (
|
||||
select(Order.status, func.count(Order.id))
|
||||
.where(Order.order_no.like("DBO-%"))
|
||||
.group_by(Order.status)
|
||||
.order_by(Order.status)
|
||||
)
|
||||
return list(self.session.execute(statement).tuples())
|
||||
|
||||
|
||||
class OrderService:
|
||||
"""实现下单和扣减库存的业务规则。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product_repository: ProductRepository,
|
||||
order_repository: OrderRepository,
|
||||
):
|
||||
self.product_repository = product_repository
|
||||
self.order_repository = order_repository
|
||||
|
||||
def place_order(
|
||||
self,
|
||||
order_no: str,
|
||||
customer_name: str,
|
||||
requests: list[tuple[str, int]],
|
||||
) -> None:
|
||||
"""在调用方提供的事务中创建订单并扣减库存。"""
|
||||
|
||||
if self.order_repository.exists_by_order_no(order_no):
|
||||
raise OrderError(f"订单已存在:{order_no}")
|
||||
if not requests:
|
||||
raise OrderError("订单至少需要一项商品。")
|
||||
|
||||
order_items = []
|
||||
total_amount = Decimal("0.00")
|
||||
|
||||
for product_code, quantity in requests:
|
||||
if quantity <= 0:
|
||||
raise OrderError("购买数量必须大于0。")
|
||||
|
||||
product = self.product_repository.find_by_code_for_update(product_code)
|
||||
if product.stock < quantity:
|
||||
raise OrderError(f"商品库存不足:{product_code}")
|
||||
|
||||
product.stock -= quantity
|
||||
order_items.append(
|
||||
OrderItem(
|
||||
product=product,
|
||||
quantity=quantity,
|
||||
unit_price=product.price,
|
||||
)
|
||||
)
|
||||
total_amount += product.price * quantity
|
||||
|
||||
order = Order(
|
||||
order_no=order_no,
|
||||
customer_name=customer_name,
|
||||
total_amount=total_amount,
|
||||
status="CREATED",
|
||||
items=order_items,
|
||||
)
|
||||
self.order_repository.add(order)
|
||||
|
||||
|
||||
def reset_and_add_products(session: Session) -> None:
|
||||
"""按外键依赖顺序清理并重建本课练习数据。"""
|
||||
|
||||
order_ids = select(Order.id).where(Order.order_no.like("DBO-%"))
|
||||
session.execute(delete(OrderItem).where(OrderItem.order_id.in_(order_ids)))
|
||||
session.execute(delete(Order).where(Order.order_no.like("DBO-%")))
|
||||
session.execute(delete(Product).where(Product.product_code.like("DBP-%")))
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
Product(
|
||||
product_code="DBP-001",
|
||||
product_name="机械键盘",
|
||||
price=Decimal("399.00"),
|
||||
stock=10,
|
||||
),
|
||||
Product(
|
||||
product_code="DBP-002",
|
||||
product_name="无线鼠标",
|
||||
price=Decimal("199.00"),
|
||||
stock=5,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_order_service(session: Session) -> OrderService:
|
||||
"""使用同一个Session组装Repository和Service。"""
|
||||
|
||||
return OrderService(
|
||||
ProductRepository(session),
|
||||
OrderRepository(session),
|
||||
)
|
||||
|
||||
|
||||
def run_successful_order(session_factory: sessionmaker[Session]) -> None:
|
||||
"""执行成功订单,正常离开上下文后自动提交。"""
|
||||
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(
|
||||
"DBO-001",
|
||||
"张三",
|
||||
[("DBP-001", 2), ("DBP-002", 1)],
|
||||
)
|
||||
|
||||
|
||||
def run_failed_order(session_factory: sessionmaker[Session]) -> None:
|
||||
"""执行库存不足订单,让整个事务自动回滚。"""
|
||||
|
||||
try:
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(
|
||||
"DBO-002",
|
||||
"李四",
|
||||
[("DBP-001", 1), ("DBP-002", 99)],
|
||||
)
|
||||
except OrderError as error:
|
||||
print(f"失败订单已回滚:{error}")
|
||||
|
||||
|
||||
def query_products(session_factory: sessionmaker[Session]) -> list[Product]:
|
||||
"""使用独立Session查询商品,并在关闭前取得所需字段。"""
|
||||
|
||||
with session_factory() as session:
|
||||
products = ProductRepository(session).find_practice_products()
|
||||
# expire_on_commit=False且这里只读取标量字段,Session关闭后仍可用于输出。
|
||||
return products
|
||||
|
||||
|
||||
def print_products(title: str, products: list[Product]) -> None:
|
||||
"""按统一格式输出商品库存。"""
|
||||
|
||||
print(title)
|
||||
for product in products:
|
||||
print(
|
||||
f"{product.product_code}|{product.product_name}|"
|
||||
f"价格:{product.price}|库存:{product.stock}"
|
||||
)
|
||||
|
||||
|
||||
def print_order_results(session_factory: sessionmaker[Session]) -> None:
|
||||
"""查询并输出DTO明细和订单状态统计。"""
|
||||
|
||||
with session_factory() as session:
|
||||
repository = OrderRepository(session)
|
||||
|
||||
print("订单明细DTO:")
|
||||
for detail in repository.find_order_details():
|
||||
print(
|
||||
f"{detail.order_no}|{detail.customer_name}|{detail.product_code}|"
|
||||
f"{detail.product_name}|数量:{detail.quantity}|"
|
||||
f"单价:{detail.unit_price}|小计:{detail.line_amount}|"
|
||||
f"{detail.status}"
|
||||
)
|
||||
|
||||
print("订单状态统计:")
|
||||
for status, order_count in repository.count_orders_by_status():
|
||||
print(f"{status}|订单数量:{order_count}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""创建环境并依次验证成功事务、失败回滚和多表查询。"""
|
||||
|
||||
engine = None
|
||||
try:
|
||||
database_config = load_database_config(CONFIG_PATH)
|
||||
engine = create_engine(
|
||||
create_database_url(database_config),
|
||||
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_and_add_products(session)
|
||||
|
||||
print_products("初始库存:", query_products(session_factory))
|
||||
|
||||
run_successful_order(session_factory)
|
||||
print_products("成功订单提交后:", query_products(session_factory))
|
||||
|
||||
run_failed_order(session_factory)
|
||||
print_products("失败订单回滚后:", query_products(session_factory))
|
||||
|
||||
print_order_results(session_factory)
|
||||
except (RuntimeError, KeyError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"配置读取失败:{error}")
|
||||
except OrderError 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,178 @@
|
||||
# 第4-5课练习:数据库综合项目——库存订单管理
|
||||
#
|
||||
# 本文件只提供题目,不包含导入、代码骨架、测试数据代码或参考答案。
|
||||
# 练习会创建course_shop_product、course_shop_order和course_shop_order_item三张表,
|
||||
# 并只操作DBP-商品前缀及DBO-订单前缀的数据。
|
||||
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
|
||||
|
||||
|
||||
# 第一部分:导入、配置与基础类型
|
||||
# 1. 导入dataclass、Decimal、Path和tomllib。
|
||||
# 2. 从sqlalchemy导入CheckConstraint、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. 定义OrderError(Exception),用于表达商品不存在、库存不足等业务失败。
|
||||
# 8. 实现load_database_config(config_path),读取[postgresql]配置。
|
||||
# 9. 实现create_database_url(database_config),使用URL.create()创建连接地址。
|
||||
|
||||
|
||||
# 第二部分:定义三个ORM模型
|
||||
# 1. 定义Product(Base),表名course_shop_product:
|
||||
# - id:int主键;
|
||||
# - product_code:最长30字符,唯一且非空;
|
||||
# - product_name:最长100字符且非空;
|
||||
# - price:NUMERIC(10, 2)且非空;
|
||||
# - stock:int且非空;
|
||||
# - 使用CheckConstraint保证price和stock都大于等于0;
|
||||
# - items:与OrderItem建立双向一对多关系。
|
||||
# 2. 定义Order(Base),表名course_shop_order:
|
||||
# - id:int主键;
|
||||
# - order_no:最长30字符,唯一且非空;
|
||||
# - customer_name:最长100字符且非空;
|
||||
# - total_amount:NUMERIC(12, 2)且非空;
|
||||
# - status:最长20字符且非空;
|
||||
# - items:与OrderItem建立双向一对多关系;
|
||||
# - 配置cascade="all, delete-orphan"。
|
||||
# 3. 定义OrderItem(Base),表名course_shop_order_item:
|
||||
# - id:int主键;
|
||||
# - order_id:外键引用course_shop_order.id,非空;
|
||||
# - product_id:外键引用course_shop_product.id,非空;
|
||||
# - quantity:int且非空,使用CheckConstraint保证大于0;
|
||||
# - unit_price:NUMERIC(10, 2)且非空;
|
||||
# - order:与Order.items互为双向关系;
|
||||
# - product:与Product.items互为双向关系。
|
||||
|
||||
|
||||
# 第三部分:定义DTO
|
||||
# 1. 使用@dataclass(frozen=True)定义OrderDetailDTO。
|
||||
# 2. DTO包含order_no、customer_name、product_code、product_name、quantity、
|
||||
# unit_price、line_amount和status。
|
||||
# 3. DTO不继承Base,不承担数据库持久化职责。
|
||||
# 4. line_amount由查询结果中的unit_price乘以quantity得到。
|
||||
|
||||
|
||||
# 第四部分:实现ProductRepository
|
||||
# 1. 构造方法接收并保存外部传入的Session。
|
||||
# 2. find_by_code_for_update(product_code):
|
||||
# - 使用select(Product).where(...)查询商品;
|
||||
# - 调用with_for_update()锁定商品行;
|
||||
# - 找不到时抛出OrderError("商品不存在:{product_code}");
|
||||
# - 返回Product对象。
|
||||
# 3. find_practice_products()查询DBP-前缀商品并按商品编号排序。
|
||||
# 4. Repository中不得创建Session,不得调用commit()或rollback()。
|
||||
|
||||
|
||||
# 第五部分:实现OrderRepository
|
||||
# 1. 构造方法接收并保存外部传入的Session。
|
||||
# 2. exists_by_order_no(order_no)判断订单编号是否已经存在。
|
||||
# 3. add(order)调用session.add(order),但不提交事务。
|
||||
# 4. find_order_details()使用显式join查询订单、明细和商品:
|
||||
# - 只查询DBO-前缀订单;
|
||||
# - 只选择DTO所需字段;
|
||||
# - 按订单编号和明细ID排序;
|
||||
# - 把结果转换成OrderDetailDTO列表。
|
||||
# 5. count_orders_by_status()使用func.count()和group_by()统计各状态订单数。
|
||||
|
||||
|
||||
# 第六部分:实现OrderService下单业务
|
||||
# 1. 构造方法接收ProductRepository和OrderRepository。
|
||||
# 2. 定义place_order(order_no, customer_name, requests),其中requests是
|
||||
# “商品编号、购买数量”组成的列表。
|
||||
# 3. 订单编号已存在时抛出OrderError("订单已存在:{order_no}")。
|
||||
# 4. requests为空时抛出OrderError("订单至少需要一项商品。")。
|
||||
# 5. 逐项处理购买请求:
|
||||
# - 数量小于等于0时抛出OrderError("购买数量必须大于0。");
|
||||
# - 调用find_by_code_for_update()查询并锁定商品;
|
||||
# - 库存不足时抛出OrderError("商品库存不足:{product_code}");
|
||||
# - 商品库存减去购买数量;
|
||||
# - 使用商品当前价格创建OrderItem;
|
||||
# - 累加订单总金额。
|
||||
# 6. 创建status="CREATED"的Order并关联全部OrderItem。
|
||||
# 7. 调用OrderRepository.add(order),不在Service中提交事务。
|
||||
|
||||
|
||||
# 第七部分:准备数据和验证事务
|
||||
# 1. 定义reset_and_add_products(session):
|
||||
# - 先删除DBO-前缀订单对应的订单明细;
|
||||
# - 再删除DBO-前缀订单;
|
||||
# - 最后删除DBP-前缀商品;
|
||||
# - 新增DBP-001机械键盘,价格399.00,库存10;
|
||||
# - 新增DBP-002无线鼠标,价格199.00,库存5;
|
||||
# - 全过程不调用commit()。
|
||||
# 2. 定义run_successful_order(session_factory):
|
||||
# - 使用with session_factory.begin() as session管理事务;
|
||||
# - 创建两个Repository和OrderService;
|
||||
# - 创建订单DBO-001,客户张三,购买2个DBP-001和1个DBP-002;
|
||||
# - 正常离开with,让事务自动提交;
|
||||
# - 成功后键盘库存为8,鼠标库存为4,订单金额为997.00。
|
||||
# 3. 定义run_failed_order(session_factory):
|
||||
# - 在try中使用with session_factory.begin() as session;
|
||||
# - 创建订单DBO-002,先购买1个DBP-001,再购买99个DBP-002;
|
||||
# - 第二项因库存不足抛出OrderError;
|
||||
# - 在事务with外捕获OrderError并输出失败信息;
|
||||
# - 整个订单事务必须回滚,键盘库存仍为8,且DBO-002不能存在。
|
||||
|
||||
|
||||
# 第八部分:输出和main()流程
|
||||
# 1. 定义print_products(title, products),输出商品编号、名称、价格和库存。
|
||||
# 2. 定义print_order_details(details),输出DTO中的订单和明细信息。
|
||||
# 3. 定义print_order_counts(counts),输出“状态|订单数量:数字”。
|
||||
# 4. main()依次执行:
|
||||
# - 读取TOML配置并创建数据库URL;
|
||||
# - 创建一次Engine并启用pool_pre_ping;
|
||||
# - 使用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
|
||||
# - 调用Base.metadata.create_all(engine);
|
||||
# - 在一个事务中重置数据并新增练习商品;
|
||||
# - 输出初始库存;
|
||||
# - 执行成功订单并输出扣减后的库存;
|
||||
# - 执行失败订单并输出回滚后的库存;
|
||||
# - 查询并输出订单DTO和状态统计;
|
||||
# - 分类捕获配置异常、OrderError和SQLAlchemyError;
|
||||
# - 在finally中调用engine.dispose()。
|
||||
# 5. 添加程序入口判断并调用main()。
|
||||
#
|
||||
# 预期关键输出:
|
||||
# 初始库存:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:10
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:5
|
||||
# 成功订单提交后:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:8
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
# 失败订单已回滚:商品库存不足:DBP-002
|
||||
# 失败订单回滚后:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:8
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
# 订单明细DTO:
|
||||
# DBO-001|张三|DBP-001|机械键盘|数量:2|单价:399.00|小计:798.00|CREATED
|
||||
# DBO-001|张三|DBP-002|无线鼠标|数量:1|单价:199.00|小计:199.00|CREATED
|
||||
# 订单状态统计:
|
||||
# CREATED|订单数量:1
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 三个ORM模型是否建立了真实外键和双向对象关系?
|
||||
# 2. 金额是否全部使用Decimal和NUMERIC,而不是float?
|
||||
# 3. 商品查询是否使用FOR UPDATE锁定待扣减库存的记录?
|
||||
# 4. Repository和Service是否都没有自行提交事务?
|
||||
# 5. 一张订单的全部库存扣减和订单新增是否处于同一个事务?
|
||||
# 6. 失败订单中第一项库存扣减是否也被回滚?
|
||||
# 7. 订单明细查询是否使用join()并转换成DTO?
|
||||
# 8. 状态统计是否由数据库完成count()和group_by()?
|
||||
# 9. 程序是否只清理DBP-和DBO-前缀的练习数据?
|
||||
# 10. 配置是否来自被Git忽略的本地config.toml?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并能连续运行两次;
|
||||
# 2. 三张表的字段、约束、外键和关系映射正确;
|
||||
# 3. 成功订单正确保存订单、明细并扣减库存;
|
||||
# 4. 失败订单完全回滚,不保存订单且不改变任何库存;
|
||||
# 5. DTO联表查询结果和订单状态统计符合预期;
|
||||
# 6. Repository负责持久化,Service负责业务规则,调用方负责事务;
|
||||
# 7. SQLAlchemy查询使用2.x写法,不使用session.query();
|
||||
# 8. 所有练习数据与现有数据安全隔离;
|
||||
# 9. config.toml与真实连接信息没有进入Git。
|
||||
@@ -0,0 +1,2 @@
|
||||
SQLAlchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
Reference in New Issue
Block a user