【FastAPI】ORM-02.使用 ORM 高效处理数据库逻辑

目录


一,查询操作

方便演示,下面统一定义一个基础模型:

python 复制代码
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Float

class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    price: Mapped[float] = mapped_column(Float)
    stock: Mapped[int] = mapped_column(Integer)
    category: Mapped[str] = mapped_column(String(20))

1.查询基础与结果处理 (Execution/Scalars)

在异步中,所有的查询都通过 db.execute()(db就是我们注入的AsyncSession对象) 发出,但返回的是一个包装过的 Result 对象。

  • scalars(): 最常用。把原始的行记录转换成模型对象(如 Product 对象)。
  • first(): 拿第一条,没找到返回 None。
  • all(): 拿全部,返回列表。

基础使用如下:

python 复制代码
from sqlalchemy import select

@app.get("/products")
async def get_products(db: AsyncSession = Depends(get_db)):
    # 1. 构造语句
    stmt = select(Product)
    # 2. 异步执行,得到 Result 对象
    result = await db.execute(stmt)
    # 3. 提取模型对象列表
    products = result.scalars().all() 
    return products
  1. await db.execute(stmt) 返回的是什么?

    • 数据库实际跑了一条 SELECT products.id, products.name, ... FROM products;
    • SQLAlchemy 把每一行原始数据(5 个字段)打包成 Row 对象。
    • result 是一个 Result 对象,里面装着很多个 Row。
      此时你还没有得到任何一个 Product 实例,只是拿到了一堆长得像元组 + 字典的混合体(Row)。
  2. select(Product) 写下的"蓝图"决定了什么?

    你在 select() 里把什么放在第一个参数,SQLAlchemy 就会把那一行的第一个位置标记成什么类型的东西。

    • 写 select(Product):SQLAlchemy 心里说:"第一列是一个 Product 实体"。于是当收到数据库返回的原始行时,它会用整行数据自动组装出一个 Product 实例,塞到第一个位置。
    • 写 select(Product.name):第一个参数只是一个字段,那第一个位置就是一个普通字符串,不会组装对象。
      这个蓝图在你调用 db.execute(stmt) 之前就已经定好了。
  3. scalars() 到底干了什么?

    Result.scalars() 的工作非常简单:

    把每一行的第一个元素拿出来,丢掉其他列,返回一个 ScalarResult。

    • 如果蓝图里第一个位置是 Product 实体,那么拿出来的就是 Product 对象。
    • 如果蓝图里第一个位置是 Product.name 字段,那么拿出来的就是字符串。
    python 复制代码
    # 第一个参数是 Product 实体 → scalars() 返回 List[Product]
    result = await db.execute(select(Product))
    products = result.scalars().all()   # 每个元素都是 Product 实例
    # 第一个参数是字段 → scalars() 返回 List[str]
    result = await db.execute(select(Product.name))
    names = result.scalars().all()      # 每个元素都是字符串

    注意:不是看什么"主键"或"字段个数",只取决于你在 select() 的第一个参数是字段还是整个模型类。

之后用得到的ScalarResult的方法,根据需求选择first还是all就行了。

其实Result上也可以直接用first或all,只不过这样拿到的是Row对象。

除去上面这种方式外,还能用AsyncSession直接提供的快捷方法,通过db直接使用。

  • db.get(Model, primary_key) ------ 按主键查一个
  • db.add(instance) ------ 把新对象挂到会话
  • db.delete(instance) ------ 标记删除
  • db.refresh(instance) ------ 重新从数据库拉取最新数据
    这些方法是直接在会话层提供的"语法糖",省去手写 SQL 的步骤。

2.条件查询

条件查询是 SQLAlchemy 里最核心的"筛选"技能。无论查单条还是查列表,99% 的业务逻辑都要靠它。

python 复制代码
from sqlalchemy import select, and_, or_

# 基础条件
stmt = select(Product).where(Product.price > 1000)

# AND:多次 where 或用 and_
stmt = select(Product).where(Product.price > 1000, Product.stock < 50)          # 逗号默认 AND
stmt = select(Product).where(and_(Product.price > 1000, Product.stock < 50))    # 显式 and_

# OR:必须用 or_
stmt = select(Product).where(or_(Product.category == "电子", Product.category == "家电"))

# AND 与 OR 混合
stmt = select(Product).where(
    and_(
        or_(Product.category == "电子", Product.category == "家电"),
        Product.price > 1000
    )
)
# SQL: WHERE (category = '电子' OR category = '家电') AND price > 1000

# IN
stmt = select(Product).where(Product.id.in_([1, 3, 5]))

# BETWEEN
stmt = select(Product).where(Product.price.between(500, 2000))

# 模糊查询,%代表多个字符,_代表一个字符
stmt = select(Product).where(Product.name.contains("手机"))       # LIKE '%手机%'
stmt = select(Product).where(Product.name.startswith("华为"))     # LIKE '华为%'
stmt = select(Product).where(Product.name.endswith("Pro"))        # LIKE '%Pro'
stmt = select(Product).where(Product.name.ilike("%phone%"))       # ILIKE (不区分大小写)

# 动态条件
conditions = []
if min_price:
    conditions.append(Product.price >= min_price)
if category:
    conditions.append(Product.category == category)
if conditions:
    stmt = stmt.where(and_(*conditions))

其它<>=!= 比较运算符的用法和python差不多。

需要注意,条件本身是同步构建的:Product.price > 1000 只是个表达式对象,不涉及 IO,所以不需要 await。

真正异步的只有 db.execute(stmt) 这一步。

所有条件运算符都返回新的条件对象,可任意组合、赋值给变量,再动态拼进 where()。

还有,SQLAlchemy 支持用 | 和 & 来表示 OR 和 AND,因为条件对象重载了这些位运算符。

如:

python 复制代码
stmt = select(Product).where(
    ((Product.category == "电子") | (Product.category == "家电")) 
    & (Product.price > 1000)
)

这会生成 WHERE (category='电子' OR category='家电') AND price > 1000,看起来比 or_、and_ 更直观。

呃,虽然这里很方便,但貌似会有些陷阱之类的,更推荐使用or_和and_

再补充一个,one_or_none()方法, 是 scalars() 之后才能用的结果提取方法之一,返回 0 条或 1 条记录。如果查出多条,直接抛异常。


3.聚合操作 (Count / Group By / Avg / Sum)

聚合查询和普通查询最本质的区别在于:它返回的不再是完整的模型对象,而是经过计算后的统计值(数字、分组字段等)。因此,整个处理流程都不同。

如下:

python 复制代码
from sqlalchemy import func
@app.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db)):
    # 统计每个类别的平均价格和总数
    stmt = (
        select(
            Product.category,
            func.avg(Product.price).label("avg_price"),
            func.count(Product.id).label("total")
        )
        .group_by(Product.category)
        .having(func.count(Product.id) > 1) # 过滤分组
    )
    result = await db.execute(stmt)
    # 使用 mappings() 可以直接转换成字典格式,非常方便返回 JSON
    return result.mappings().all()
  1. 为什么不能用 scalars()?

    像上面说的,scalars() 的工作原理是提取每一行中的第一个元素,并期望这个元素是 ORM 实体。但聚合查询的 select() 里放的是:

    python 复制代码
    select(Product.category, func.avg(...), func.count(...))

    第一个元素是 Product.category,只是一个普通字符串字段。如果强行调用 scalars(),你只能拿到每行的第一个字段(category),后面的平均值、计数全部丢失。

    更关键的是:聚合查询的结果没有 ORM 实体,所以本来就不该用 scalars()。

  2. 正确的处理方式:mappings()

    mappings() 把每一行结果转换成一个 RowMapping 对象,行为类似字典。调用 .all() 后得到字典列表,直接就能被 FastAPI 序列化成 JSON。

    python 复制代码
    result = await db.execute(stmt)
    data = result.mappings().all()
    # 返回示例:
    # [
    #     {"category": "电子", "avg_price": 3500.0, "total": 12},
    #     {"category": "家电", "avg_price": 2100.0, "total": 8}
    # ]

    如果你想直接获取元组,也可以用 result.all(),但处理起来不如字典方便,所以 mappings() 是聚合查询的标配。

  3. func 是什么?

    func 是 SQLAlchemy 提供的SQL 函数生成器。你调用 func.xxx() 就相当于生成一个 SQL 函数表达式。

    python 复制代码
    from sqlalchemy import func
    
    func.count(Product.id)     # COUNT(products.id)
    func.avg(Product.price)    # AVG(products.price)
    func.sum(Product.stock)    # SUM(products.stock)
    func.min(Product.price)    # MIN(products.price)
    func.max(Product.price)    # MAX(products.price)
    func.now()                 # 数据库当前时间(不是聚合函数,但同样用法)

    重要:这些函数的返回值就是计算后的数值,不是 ORM 对象,再次印证不能用 scalars()。

  4. label()------给计算结果起别名

    python 复制代码
    .group_by(Product.category)

    聚合函数的返回值在行里默认没有易读的名字,必须用 .label("别名") 给它命名,这样到了 mappings() 返回的字典里,键名就是你指定的别名。如果不加 label(),字典的键会直接是数据库生成的奇怪名字(如 avg_1),不利于前端对接。

  5. group_by() 分组

    告诉数据库按哪个字段分组进行统计。例如:

    python 复制代码
    .group_by(Product.category)

    SQL 会变成 GROUP BY products.category。

    你可以在 select() 中同时选择分组字段和多个聚合函数,但要注意:非聚合字段必须出现在 group_by 里(SQL 严格模式要求),否则数据库会报错。

  6. having()------过滤分组结果

    python 复制代码
    .having(func.count(Product.id) > 1)

    和数据库中的类似。。。


4. 分页查询

  1. 基本公式

    分页由 offsetlimit 配合实现:

    python 复制代码
    offset = (page - 1) * size
    stmt = select(Product).offset(offset).limit(size)
    • page:页码,从 1 开始 (如果是第一页,那就不用跳过数据,所以offset是0,以此类推)
    • size:每页条数
    • offset:跳过前 N 行
    • limit:最多取 M 行

    如果只写 offset 不加 limit,数据库会跳过前 N 行后返回所有剩余行,无法控制返回量。

    limit和size的数值一样,它们的意义也一样。

  2. 必须加上 order_by() 保证结果稳定

    没有排序的分页是"随机分页"。数据库在不指定 ORDER BY 时,返回顺序不确定,可能导致同一记录出现在不同页,或分页结果重复、遗漏。

    务必加上稳定的排序字段(如主键 id,或创建时间 created_at):

    python 复制代码
    stmt = (
        select(Product)
        .order_by(Product.id.asc())
        .offset((page - 1) * size)
        .limit(size)
    )

    排序字段应唯一,若业务排序字段可能重复,可以用第二个字段兜底,例如 .order_by(Product.price.desc(), Product.id.asc())

    desc降序,asc升序


5.连接查询

5.1 relationship的简单说明

多表关系在 SQLAlchemy 里靠两个东西表达:

  • ForeignKey:在数据库层面建立外键约束,说明"这列引用另一张表的哪一列"。
  • relationship:在 Python 层面声明模型之间的关联,让你能通过属性直接访问关联对象(如 product.category、category.products)。

像下面这样写:

python 复制代码
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

class Category(Base):
    __tablename__ = "categories"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)

    # relationship:让 Category 能直接拿到它的所有 Product
    products: Mapped[list["Product"]] = relationship(
        back_populates="category",
        lazy="selectin",        # 异步安全
    )

class Product(Base):
    __tablename__ = "products"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    price: Mapped[float]

    # 外键 ------ 数据库里实际存在的列
    category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))

    # relationship:让 Product 能直接拿到它所属的 Category
    category: Mapped["Category"] = relationship(
        back_populates="products",
        lazy="selectin",
    )

relationship的back_populates参数后面填的是对方模型relationship的属性名,字符串形式连接。

ForeignKey 是数据库里实际存储的"硬约束";relationship 是 Python 代码里让你方便导航对象图的"软通道"。

relationship 完全不影响表结构,它只影响 Python 对象的属性导航。

比如说,在上述例子中建立了这两个连接之后,我们在路由或者其它地方中就可以像下面这样使用。

python 复制代码
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession

@app.get("/categories/{category_id}")
async def get_category(category_id: int, db: AsyncSession = Depends(get_db)):
    # 用 db.get 按主键查分类
    category = await db.get(Category, category_id)
    if not category:
        raise HTTPException(status_code=404, detail="分类不存在")

    # 直接通过 category.products 就能拿到商品列表(已经预加载好了)
    return {
        "id": category.id,
        "name": category.name,
        "products": [
            {"id": p.id, "name": p.name, "price": p.price}
            for p in category.products
        ]
    }
5.2 连接查询

ORM 中的连接查询本质上就是在映射 SQL 的多表查询。在 SQLAlchemy 异步里,我们会用不同方式来实现 JOIN、子查询、IN 过滤等,目的都是把原本分散在多条 SQL 中的数据,用尽量少的数据库往返拿回来。

连接查询分为两类:关系预加载(selectinload / joinedload)和显式 JOIN。

5.2.1 显示JOIN

不仅可以连表,还能只投影部分字段、使用复杂连接条件、基于关联表过滤主表等。它就是传统 SQL 里 JOIN 的直接映射。

python 复制代码
stmt = select(要查的实体或列).join(目标表/实体, 连接条件).where(...)
  1. 内连接

    .join(Post, User.id == Post.user_id) 就对应 SQL 的 INNER JOIN ... ON。

  2. 外连接

    只需要在join中添加isouter=True,就表示外连接(左外连接),也可以使用 .outerjoin(Post, 条件) 简写,效果相同。

    python 复制代码
    stmt = (
        select(User.name, Post.title)
        .join(Post, User.id == Post.user_id, isouter=True)   # isouter=True 表示外连接
    )
    result = await session.execute(stmt)
    rows = result.all()

    需要 右外连接(Rare),交换表位置即可。

  3. 自连接

    都是一样的,用aliased取个别名就行。

    python 复制代码
    from sqlalchemy.orm import aliased
    UserAlias = aliased(User)   # 相当于起别名 u2
    stmt = (
        select(User.name, UserAlias.name)
        .join(UserAlias, (User.name == UserAlias.name) & (User.id != UserAlias.id))
    )
    result = await session.execute(stmt)
    rows = result.all()

二,新增,更新和删除操作

1. 新增数据 (Create)

新增数据通常分为三步:创建实例、添加到 Session、提交事务。

python 复制代码
from sqlalchemy.ext.asyncio import AsyncSession
from .models import User
from .schemas import UserCreate

async def create_user(db: AsyncSession, user_in: UserCreate):
    # 1. 创建模型实例(同步,不需要 await)
    new_user = User(
        name=user_in.name,
        email=user_in.email,
        hashed_password="fake_password"  # 实际开发中需加密
    )
    
    # 2. 将对象添加到数据库会话(同步操作,不需要 await)
    db.add(new_user)
    
    # 3. 提交事务(异步,需要 await)
    await db.commit()
    
    # 4. 刷新对象,获取数据库生成的字段(如自增 ID)(异步,需要 await)
    await db.refresh(new_user)
    
    return new_user

如果需要提前拿到数据库生成的id等字段,才需要refresh。

而且如果使用注入依赖的方法,且依赖会自动提交事务,就需要flush才能返回又自动生成的字段的对象。

像是下面这样写:

python 复制代码
@router.post("/users")
async def create_user(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
    new_user = User(name=..., email=..., hashed_password=...)
    db.add(new_user)
    await db.flush()          # ① 发送 INSERT,id 被数据库生成
    await db.refresh(new_user) # ② 从数据库重新读取对象,id 被填充
    return new_user           # 此时 new_user.id 有值

2.更新数据 (Update)

更新通常有两种方式:先查询后修改(最常用)或 直接批量更新。

方式 A:先查询,再修改属性(推荐用于单个对象)

python 复制代码
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
async def update_user_email(db: AsyncSession, user_id: int, new_email: str):
    stmt = select(User).where(User.id == user_id)
    # 2. 执行查询,获取结果
    result = await db.execute(stmt)
    db_user = result.scalar_one_or_none()   # 返回单个对象或 None
    # 3. 如果存在,修改属性
    if db_user:
        db_user.email = new_email
        # 4. 提交事务(自动检测变更并生成 UPDATE)
        await db.commit()
        # 5. 刷新对象(如有必要,例如获取数据库默认值)
        await db.refresh(db_user)
    return db_user

方式 B:使用 update() 语句(批量或高性能)

语法如下:

python 复制代码
stmt = update(表模型).where(条件).values(要更新的字段=新值)
python 复制代码
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession

async def update_multiple_users(db: AsyncSession):
    stmt = (
        update(User)
        .where(User.name == "张三")
        .values(name="老张")
    )
    await db.execute(stmt)   # 异步执行更新语句
    await db.commit()        # 异步提交事务

3.删除数据

删除同样分为"查询后删除"和"批量删除"。

方式 A:先查询后删除

python 复制代码
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

async def delete_user(db: AsyncSession, user_id: int) -> bool:
    # 1. 构建查询语句,查找用户
    stmt = select(User).where(User.id == user_id)
    result = await db.execute(stmt)
    db_user = result.scalar_one_or_none()   # 获取单个对象或 None
    
    # 2. 如果存在,删除
    if db_user:
        await db.delete(db_user)            # 异步删除(标记删除)
        await db.commit()                  # 提交事务
        return True
    return False

方式 B:使用 delete() 语句

python 复制代码
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession

async def bulk_delete_users(db: AsyncSession) -> int:
    stmt = delete(User).where(User.id > 100)
    result = await db.execute(stmt)   # 异步执行
    await db.commit()                 # 异步提交
    return result.rowcount            # 可选:返回被删除的行数

总结

🚀 FastAPI ORM 操作速查表

操作类型 核心函数/方法 关键返回处理 注意点
查询对象 select(Model) .scalars().all() / .first() 第一个参数决定了 scalars() 拿到的类型
条件过滤 .where() 支持 & 和 ` ` 符号
聚合统计 func.count() / avg() .mappings().all() 必须 配合 .label(),否则 JSON 键名会乱码
分页 .limit().offset() 配合 .order_by() 没有排序的分页是"逻辑炸弹",结果会随机跳变
新增/删除 db.add() / db.delete() await db.commit() 不 commit 不生效 ;想拿新 ID 就 refresh
更新 obj.attr = value await db.commit() 推荐"先查后改",这样能触发 SQLAlchemy 的属性监听
相关推荐
坚持就完事了1 小时前
Linux的ln命令
linux·运维·服务器
Apache IoTDB1 小时前
时序数据库 IoTDB + 时序智能服务平台 TimechoAI 亮相中国核电信息技术高峰论坛
数据库·时序数据库·iotdb
绿豆人1 小时前
操作系统上电后流程
linux·服务器
未若君雅裁1 小时前
Redis 和 MySQL 双写一致性:延迟双删、读写锁、MQ、Canal 怎么选?
数据库·redis·面试
罗超驿2 小时前
9.深度剖析MySQL约束的工程设计:自增主键的分布式局限、外键约束的权衡,与CHECK的版本适配实践
数据库·mysql
Kiyra2 小时前
Agent 的记忆不是存数据库就行:上下文预算与轻量记忆的设计实战
数据库·人工智能·后端·面试·职场和发展·哈希算法
jiayong232 小时前
MySQL 8.0 数据库恢复问题完整解决方案
数据库·mysql
Tingjct2 小时前
Linux开发工具
linux·运维·服务器
czlczl200209252 小时前
普通索引和唯一索引 查询性能差异
数据库