Python实战:SQLAlchemy ORM与FastAPI项目集成

前五篇文章里,你一直在 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] 没有任何语义,字段多了以后对着序号数到崩溃
  • 需求变了加一个过滤条件,你要手动拼接字符串------WHEREAND 的位置、引号转义、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 WHENlabel() 对应 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 更重要。

相关推荐
夏季疯3 小时前
读论文:STARS 是什么结构?一个统一的歌声自动标注框架
python
从零开始的代码生活_3 小时前
C++ 内存管理:从内存分区到 new/delete 底层原理
开发语言·c++·后端
wabs6664 小时前
关于单调栈【力扣739.每日温度的思考】
java·开发语言
光测实验室4 小时前
3种Python降噪算法实测:我把处理速度提升了20倍
python
AOwhisky4 小时前
Python 学习笔记(第三期)——流程控制核心知识点自测与详解
开发语言·笔记·python·学习·云原生·运维开发·流程控制
爱吃提升4 小时前
Python桌面自动化PyAutoGUI完整实战教程:模拟鼠标键盘、窗口操作、图形识别自动化
python·自动化·计算机外设
逆向编程5 小时前
Socket异常连接自动清理方案
开发语言
花花无缺5 小时前
Windows 定时执行 Python 脚本方案
python·操作系统·命令行
va学弟6 小时前
Java 网络通信编程(10):Channel 和 Selector
java·开发语言