前五篇文章里,你一直在 MySQL 终端里写 SQL。但在真实项目中,几乎没有 Python 后端代码是直接拼接 SQL 字符串的------你要在 Python 对象和关系表之间来回转换,而这正是 ORM(对象关系映射) 要做的事。本文用 SQLAlchemy 把"学生选课系统"改造成一个完整的 FastAPI 项目,让你从纯 SQL 思维切换到对象思维,看看 ORM 在实际开发中到底是怎么用的。
ORM 解决了什么
假设你要在 Python 里查一个学生的选课记录。用纯 SQL 是这么写的:
python
import mysql.connector
conn = mysql.connector.connect(host="localhost", user="root", password="123456", database="school")
cursor = conn.cursor()
cursor.execute(
"SELECT s.name, c.name, e.score "
"FROM enrollments e "
"JOIN students s ON e.student_id = s.id "
"JOIN courses c ON e.course_id = c.id "
"WHERE s.id = %s",
(1,)
)
results = cursor.fetchall()
for row in results:
print(f"{row[0]} - {row[1]}: {row[2]}")
这段代码的问题不在功能,而在维护性:
- SQL 字符串和 Python 代码混在一起,IDE 无法检查拼写、表名、列名是否有效
- 返回的是元组列表,
row[0]、row[1]没有任何语义,字段多了以后对着序号数到崩溃 - 需求变了加一个过滤条件,你要手动拼接字符串------
WHERE和AND的位置、引号转义、SQL 注入防护,全是手工活 - 数据库表结构变更后(比如
name改名为student_name),你得查遍所有代码,找出所有包含SELECT ... name ...的地方逐一修改
ORM 的思路是把数据库表映射成程序里的类,把表里的行映射成类的实例。 你操作对象,ORM 替你生成 SQL。上面那段查询在 SQLAlchemy 里变成:
python
results = (
session.query(Student.name, Course.name, Enrollment.score)
.join(Enrollment, Student.id == Enrollment.student_id)
.join(Course, Enrollment.course_id == Course.id)
.filter(Student.id == 1)
.all()
)
for student_name, course_name, score in results:
print(f"{student_name} - {course_name}: {score}")
列名拼错?IDE 直接标红。字段改名?改模型定义就行,用到的地方自动报编译错误。这就是 ORM 的核心价值。
Python ORM 生态
Python 有两个主要的 ORM:
- Django ORM:Django 框架内置,约定优于配置。如果你用 Django,这就是默认答案。但它和 Django 深度绑定,不适合非 Django 项目。
- SQLAlchemy:Python 生态里最强大、最灵活的 ORM,独立于任何框架。支持 ORM 和 Core 两种使用方式,与 FastAPI、Flask 等无缝集成。
本文选 SQLAlchemy,因为它是 Python 社区的事实标准,学到的东西不绑定任何特定框架。
如果你对其他语言(Java、Go、JavaScript)如何做数据库操作也感兴趣,本系列有一篇专门的跨语言对比文章。在你用 Python 扎扎实实走完这篇文章之后,去读它会更有收获------你会带着具体的项目经验去理解各语言方案的设计差异。
SQLAlchemy 基础:从连接到查询
安装
bash
pip install sqlalchemy pymysql
pymysql 是 Python 连接 MySQL 的驱动,SQLAlchemy 通过它和数据库通信。
创建引擎和会话
python
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
# 引擎:管理数据库连接池
engine = create_engine(
"mysql+pymysql://root:123456@localhost:3306/school",
echo=True, # True 会把生成的 SQL 打印到控制台,开发时开着很有用
)
# 会话工厂:每个请求一个 session
SessionLocal = sessionmaker(bind=engine)
# 基类:所有模型都继承它
class Base(DeclarativeBase):
pass
Engine 是全局单例,管理连接池。Session 是每次数据库操作的工作单元------每个 HTTP 请求创建一个 session,请求结束就关闭。
定义模型
把前五篇文章的四张表翻译成 SQLAlchemy 模型。模型文件通常放在 models.py:
python
from sqlalchemy import (
Column, Integer, String, DECIMAL, DateTime, ForeignKey, CheckConstraint
)
from sqlalchemy.orm import relationship
from datetime import datetime
class Student(Base):
__tablename__ = "students"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False)
age = Column(Integer)
email = Column(String(100), unique=True)
enrolled_at = Column(DateTime, default=datetime.now)
# 关系:一个学生有多条选课记录
enrollments = relationship("Enrollment", back_populates="student")
class Teacher(Base):
__tablename__ = "teachers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False)
phone = Column(String(20))
department = Column(String(50))
courses = relationship("Course", back_populates="teacher")
class Course(Base):
__tablename__ = "courses"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
credit = Column(Integer, nullable=False, default=1)
teacher_id = Column(Integer, ForeignKey("teachers.id", ondelete="SET NULL"))
teacher = relationship("Teacher", back_populates="courses")
enrollments = relationship("Enrollment", back_populates="course")
class Enrollment(Base):
__tablename__ = "enrollments"
id = Column(Integer, primary_key=True, autoincrement=True)
student_id = Column(Integer, ForeignKey("students.id", ondelete="CASCADE"), nullable=False)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
score = Column(DECIMAL(4, 1))
enrolled_at = Column(DateTime, default=datetime.now)
student = relationship("Student", back_populates="enrollments")
course = relationship("Course", back_populates="enrollments")
__table_args__ = (
CheckConstraint("score IS NULL OR (score >= 0 AND score <= 100)", name="ck_score_range"),
)
表名、列名、类型、约束、外键、关系------前五篇文章的 DDL 完整对应到了 Python 模型,精确到 ondelete="CASCADE" 和 ondelete="SET NULL" 的选择。
relationship() 是 ORM 最强大的特性之一。定义了 Enrollment.student 之后,你不需要手动 JOIN,直接 enrollment.student.name 就能拿到学生姓名。
建表
python
# 根据模型定义自动生成建表语句
Base.metadata.create_all(engine)
注意:create_all 只创建不存在的表,不会修改已有表的结构。表结构变更需要用 Alembic(后面会讲)。
CRUD 操作
创建(Create):
python
def create_student(name: str, age: int, email: str):
with SessionLocal() as session:
student = Student(name=name, age=age, email=email)
session.add(student)
session.commit()
session.refresh(student) # 刷新以获取数据库生成的 id 和 enrolled_at
return student
# 使用
new_student = create_student("郑十", 20, "zhengshi@example.com")
print(new_student.id) # 数据库自动生成的
读取(Read):
python
def get_student_by_name(name: str):
with SessionLocal() as session:
return session.query(Student).filter(Student.name == name).first()
def get_students_older_than(age: int):
with SessionLocal() as session:
return session.query(Student).filter(Student.age > age).all()
def get_students_with_enrollments():
"""查所有学生及选课情况(自动 JOIN)"""
with SessionLocal() as session:
return (
session.query(Student)
.options(joinedload(Student.enrollments).joinedload(Enrollment.course))
.all()
)
joinedload 是 SQLAlchemy 的急切加载------一次性把关联数据也查出来,避免遍历结果时每条属性访问都触发一次 SQL(N+1 问题)。
更新(Update):
python
def update_student_email(student_id: int, new_email: str):
with SessionLocal() as session:
student = session.query(Student).filter(Student.id == student_id).first()
if student:
student.email = new_email
session.commit()
return True
return False
SQLAlchemy 会自动追踪被修改的对象,session.commit() 时只对变化了的字段生成 UPDATE。
删除(Delete):
python
def delete_enrollment(enrollment_id: int):
with SessionLocal() as session:
enrollment = session.query(Enrollment).filter(Enrollment.id == enrollment_id).first()
if enrollment:
session.delete(enrollment)
session.commit()
return True
return False
复杂查询
python
from sqlalchemy import func, case
def get_course_stats():
"""每门课的选课人数、最高分、最低分、平均分"""
with SessionLocal() as session:
results = (
session.query(
Course.name,
func.count(Enrollment.id).label("student_count"),
func.max(Enrollment.score).label("highest"),
func.min(Enrollment.score).label("lowest"),
func.round(func.avg(Enrollment.score), 1).label("average"),
)
.join(Enrollment, Course.id == Enrollment.course_id)
.group_by(Course.id)
.having(func.count(Enrollment.id) > 0)
.order_by(func.avg(Enrollment.score).desc())
.all()
)
return results
def get_student_transcript(student_id: int):
"""某学生的成绩单:课程名、成绩、等级"""
grade_case = case(
(Enrollment.score >= 90, "A"),
(Enrollment.score >= 80, "B"),
(Enrollment.score >= 70, "C"),
(Enrollment.score >= 60, "D"),
(Enrollment.score.is_(None), "未考"),
else_="F",
)
with SessionLocal() as session:
return (
session.query(
Course.name,
Enrollment.score,
grade_case.label("grade"),
)
.join(Enrollment)
.filter(Enrollment.student_id == student_id)
.all()
)
SQLAlchemy 的 func 模块包装了 SQL 函数,case() 对应 SQL 的 CASE WHEN,label() 对应 AS。这些 API 让你在 Python 里写出和 SQL 几乎一一对应的查询,同时保留了 IDE 的类型提示。
什么时候该写原生 SQL
ORM 不是银弹。以下场景直接用 session.execute() 写原生 SQL 更合适:
python
# 复杂报表:几十行 SQL、多层子查询、多个 CTE
sql = """
WITH monthly_stats AS (
SELECT DATE_FORMAT(enrolled_at, '%Y-%m') AS month, COUNT(*) AS cnt
FROM enrollments GROUP BY month
)
SELECT * FROM monthly_stats ORDER BY month;
"""
result = session.execute(text(sql)).fetchall()
# 批量操作:一次更新几十万行
session.execute(
text("UPDATE enrollments SET score = score + 5 WHERE course_id = :course_id"),
{"course_id": 1}
)
session.commit()
原则是:常规 CRUD 用 ORM,复杂分析和批量操作用原生 SQL。 不要让 ORM 为了一条 SQL 生成几百行的 Python builder 链,也不要为了统一而拒绝使用 ORM 的关系加载能力。
Alembic:数据库版本管理
create_all 只能建新表,不能改表结构。项目上线后,你需要一种可控、可回滚的方式来管理表结构变更------Alembic 就是 SQLAlchemy 生态中的标准迁移工具。
初始化
bash
pip install alembic
alembic init alembic
这会创建 alembic/ 目录和 alembic.ini。修改 alembic.ini 中的数据库连接:
ini
sqlalchemy.url = mysql+pymysql://root:123456@localhost:3306/school
再修改 alembic/env.py,告诉 Alembic 你的模型在哪:
python
from models import Base
target_metadata = Base.metadata
使用流程
bash
# 1. 修改模型后,自动生成迁移文件
alembic revision --autogenerate -m "add teachers table"
# 2. 查看生成的迁移文件(alembic/versions/xxx.py),确认无误
# 3. 执行迁移
alembic upgrade head
# 4. 如果需要回滚
alembic downgrade -1
--autogenerate 会自动对比 Python 模型和当前数据库的表结构差异,生成 ALTER TABLE ADD COLUMN ... 这样的迁移脚本。但它不是万能的------列重命名、表重命名需要手动编辑生成的脚本。
在你第一次执行
alembic upgrade head之前,先用alembic upgrade head --sql预览它会执行哪些 SQL。迁移阶段一个失误的DROP COLUMN可能会让线上服务停摆。
FastAPI 项目实战:学生选课系统 API
现在把前面的所有知识整合起来,用 FastAPI + SQLAlchemy + MySQL 搭一个可运行的学生选课系统。目录结构:
school_api/
├── main.py # FastAPI 应用入口
├── models.py # SQLAlchemy 模型
├── database.py # 数据库连接
├── schemas.py # Pydantic 请求/响应模型
└── requirements.txt
database.py
python
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "mysql+pymysql://root:123456@localhost:3306/school"
engine = create_engine(DATABASE_URL, echo=False, pool_size=10, max_overflow=20)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
"""每个请求获取一个独立的 session,请求结束自动关闭"""
db = SessionLocal()
try:
yield db
finally:
db.close()
pool_size=10 表示连接池维持 10 个常驻连接,max_overflow=20 表示高峰期最多额外创建 20 个临时连接。这个配置适合中等并发量的项目。
schemas.py
python
from pydantic import BaseModel, EmailStr
from datetime import datetime
from decimal import Decimal
from typing import Optional
class StudentCreate(BaseModel):
name: str
age: Optional[int] = None
email: Optional[EmailStr] = None
class StudentResponse(BaseModel):
id: int
name: str
age: Optional[int]
email: Optional[str]
enrolled_at: datetime
class Config:
from_attributes = True # 允许从 ORM 对象转换
class EnrollmentCreate(BaseModel):
student_id: int
course_id: int
class CourseStats(BaseModel):
name: str
student_count: int
highest: Optional[Decimal]
lowest: Optional[Decimal]
average: Optional[Decimal]
class Config:
from_attributes = True
Pydantic 模型负责校验 HTTP 请求的输入(StudentCreate),以及格式化返回给客户端的输出(StudentResponse)。from_attributes = True 是 Pydantic v2 的配置,允许直接从 SQLAlchemy ORM 对象构造 Pydantic 模型------这是 FastAPI + SQLAlchemy 协作的关键桥梁。
main.py
python
from fastapi import FastAPI, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, case
from database import get_db
from models import Student, Course, Teacher, Enrollment
from schemas import StudentCreate, StudentResponse, EnrollmentCreate, CourseStats
app = FastAPI(title="学生选课系统 API", version="1.0")
# ==================== 学生接口 ====================
@app.get("/students", response_model=list[StudentResponse])
def list_students(
name: str | None = Query(None, description="按姓名模糊搜索"),
db: Session = Depends(get_db),
):
query = db.query(Student)
if name:
query = query.filter(Student.name.contains(name))
return query.all()
@app.post("/students", response_model=StudentResponse, status_code=201)
def create_student(body: StudentCreate, db: Session = Depends(get_db)):
student = Student(**body.model_dump())
db.add(student)
db.commit()
db.refresh(student)
return student
@app.get("/students/{student_id}", response_model=StudentResponse)
def get_student(student_id: int, db: Session = Depends(get_db)):
student = db.query(Student).filter(Student.id == student_id).first()
if not student:
raise HTTPException(status_code=404, detail="学生不存在")
return student
# ==================== 课程接口 ====================
@app.get("/courses")
def list_courses(db: Session = Depends(get_db)):
"""所有课程及任课老师"""
return db.query(Course).options(joinedload(Course.teacher)).all()
@app.get("/courses/stats", response_model=list[CourseStats])
def course_stats(db: Session = Depends(get_db)):
"""每门课的选课统计"""
return (
db.query(
Course.name,
func.count(Enrollment.id).label("student_count"),
func.max(Enrollment.score).label("highest"),
func.min(Enrollment.score).label("lowest"),
func.round(func.avg(Enrollment.score), 1).label("average"),
)
.join(Enrollment)
.group_by(Course.id)
.all()
)
# ==================== 选课接口 ====================
@app.post("/enrollments", status_code=201)
def enroll(body: EnrollmentCreate, db: Session = Depends(get_db)):
"""选课"""
# 检查学生和课程是否存在
student = db.query(Student).filter(Student.id == body.student_id).first()
if not student:
raise HTTPException(status_code=404, detail="学生不存在")
course = db.query(Course).filter(Course.id == body.course_id).first()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
enrollment = Enrollment(student_id=body.student_id, course_id=body.course_id)
db.add(enrollment)
db.commit()
db.refresh(enrollment)
return {"message": "选课成功", "enrollment_id": enrollment.id}
@app.get("/students/{student_id}/transcript")
def student_transcript(student_id: int, db: Session = Depends(get_db)):
"""成绩单"""
grade_case = case(
(Enrollment.score >= 90, "A"),
(Enrollment.score >= 80, "B"),
(Enrollment.score >= 70, "C"),
(Enrollment.score >= 60, "D"),
(Enrollment.score.is_(None), "未考"),
else_="F",
)
results = (
db.query(
Course.name.label("course_name"),
Enrollment.score,
grade_case.label("grade"),
)
.join(Enrollment)
.filter(Enrollment.student_id == student_id)
.all()
)
if not results:
raise HTTPException(status_code=404, detail="该学生没有选课记录")
return results
@app.delete("/enrollments/{enrollment_id}")
def drop_course(enrollment_id: int, db: Session = Depends(get_db)):
"""退课"""
enrollment = db.query(Enrollment).filter(Enrollment.id == enrollment_id).first()
if not enrollment:
raise HTTPException(status_code=404, detail="选课记录不存在")
db.delete(enrollment)
db.commit()
return {"message": "退课成功"}
运行
bash
pip install fastapi uvicorn sqlalchemy pymysql pydantic[email]
uvicorn main:app --reload
打开 http://127.0.0.1:8000/docs,FastAPI 自动生成了 Swagger 交互式文档------每个接口的请求参数、响应格式一目了然,还能直接在网页上调用测试。
与前五篇文章的对应关系
这个项目不是推倒重来,而是前五篇文章的代码化落地:
| 接口 | 对应的 SQL 知识 | 文章出处 |
|---|---|---|
POST /students |
INSERT INTO | 第 1 篇 |
GET /students?name=张 |
SELECT ... WHERE name LIKE | 第 2 篇 |
GET /courses/stats |
GROUP BY + 聚合函数 + HAVING | 第 2 篇 |
GET /students/{id}/transcript |
JOIN + CASE WHEN | 第 2 篇 |
POST /enrollments |
INSERT + 外键约束 | 第 1 篇 + 第 3 篇 |
| 模型定义 | 规范化的四表结构 | 第 3 篇 |
joinedload 预加载 |
覆盖索引的思路(减少查询次数) | 第 4 篇 |
Session + commit |
事务提交 | 第 5 篇 |
总结
从 SQL 终端到 Python 代码,你完成了最后一公里的跨越:
- ORM 把数据库表映射为 Python 类,让你用操作对象的方式操作数据,IDE 能帮你检查拼写和类型
- SQLAlchemy 的核心三角是 Engine(连接池)、Session(工作单元)、Model(表映射)
relationship()和joinedload()让你用对象导航替代手写 JOIN,同时避免 N+1 查询- Alembic 是数据库表结构的版本管理工具,
--autogenerate自动检测模型变更 - FastAPI + Pydantic + SQLAlchemy 的组合是当前 Python 后端开发最主流的 API 技术栈
- 常规 CRUD 用 ORM,复杂分析用原生 SQL------知道什么时候退回去,和知道什么时候用 ORM 同样重要
如果你对其他语言(Java、Go、JavaScript)如何解决同样的问题感兴趣,本系列的下一篇会从你刚刚建立的 Python 经验出发,横向对比四个生态的方案差异------理解差异背后的语言哲学,比记住 API 更重要。