SQLModel零基础教程(四)- 高级查询、事务、原生SQL

这里写目录标题

  • 前言
  • 一、阶段学习目标
  • 二、高级查询:聚合、分组、分页
    • [2.1 基础环境模型(复用前文用户订单场景)](#2.1 基础环境模型(复用前文用户订单场景))
    • [2.2 聚合函数:计数、求和、平均值、最大最小](#2.2 聚合函数:计数、求和、平均值、最大最小)
    • [2.3 group_by分组 + having过滤分组](#2.3 group_by分组 + having过滤分组)
    • [2.4 标准分页查询(接口通用模板)](#2.4 标准分页查询(接口通用模板))
    • [2.5 查询结果四种读取方法区别](#2.5 查询结果四种读取方法区别)
  • 三、事务控制:保证多操作原子性
    • [3.1 基础事务规则](#3.1 基础事务规则)
    • [3.2 基础事务:下单同时扣库存(模拟多表修改)](#3.2 基础事务:下单同时扣库存(模拟多表修改))
    • [3.3 begin() 自动事务上下文(推荐写法)](#3.3 begin() 自动事务上下文(推荐写法))
    • [3.4 Savepoint 保存点:局部回滚,不撤销全部事务](#3.4 Savepoint 保存点:局部回滚,不撤销全部事务)
  • 四、批量操作优化(解决循环add慢)
    • [4.1 批量插入 add_all(基础批量)](#4.1 批量插入 add_all(基础批量))
    • [4.2 bulk_insert_mappings 无实例批量(高性能)](#4.2 bulk_insert_mappings 无实例批量(高性能))
    • [4.3 批量更新(ORM批量,不逐条查询)](#4.3 批量更新(ORM批量,不逐条查询))
  • 五、原生SQL安全执行(text防SQL注入)
    • [5.1 带参数查询,返回元组](#5.1 带参数查询,返回元组)
    • [5.2 原生结果映射为SQLModel对象](#5.2 原生结果映射为SQLModel对象)
    • [5.3 原生增删改](#5.3 原生增删改)
  • [六、综合实战:分组统计分页报表 + 事务](#六、综合实战:分组统计分页报表 + 事务)
  • 七、阶段核心总结(半天必掌握)
  • 八、新手高频避坑指南

前言

前3篇我们学完单表CRUD、高级字段DTO、一对多/多对多关联查询,日常基础增删改查完全够用。但真实项目中还有几类高频刚需场景:

  1. 报表统计:求和、计数、平均值、分组统计、筛选分组结果;
  2. 分页列表:接口标准分页、总条数统计;
  3. 数据一致性:下单扣库存、转账等多操作必须原子执行(事务);
  4. 批量导入/批量更新:循环add性能极低,需要批量API;
  5. 复杂统计SQL:ORM难以实现时,安全执行原生SQL并映射模型。

本文为系列第四阶段,半天掌握高级统计查询、分页、事务机制、批量操作、原生SQL安全写法,所有代码可直接运行,适配后台报表、数据同步、金融类强一致性业务。

一、阶段学习目标

  1. 掌握聚合函数func.count/func.sum/func.avggroup_by分组+having分组过滤;
  2. 标准分页实现:offset/limit + 统计总条数;
  3. 彻底理解Session事务:commit提交、rollback回滚、保存点savepoint局部回滚;
  4. 批量插入、批量更新优化写法,避免循环add性能灾难;
  5. 使用text()安全执行原生SQL,参数防注入,结果映射SQLModel模型;
  6. 区分all()/one()/one_or_none()/first()四种结果读取方法;
  7. 报表综合实战:分组统计+分页+事务保证数据统一。

二、高级查询:聚合、分组、分页

2.1 基础环境模型(复用前文用户订单场景)

python 复制代码
from sqlmodel import SQLModel, Field, create_engine, Session, select, text
from sqlalchemy import func
from typing import Optional, List
from datetime import datetime

engine = create_engine("sqlite:///stage4.db", echo=False)

class Order(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int
    goods_name: str
    price: float = Field(gt=0)
    num: int = Field(gt=1)
    create_time: datetime = Field(default_factory=datetime.utcnow)

def init_table():
    SQLModel.metadata.create_all(bind=engine)

# 测试填充数据
def init_test_data():
    with Session(engine) as session:
        data = [
            Order(user_id=1, goods_name="Python教程", price=59.9, num=2),
            Order(user_id=1, goods_name="机械键盘", price=199, num=1),
            Order(user_id=2, goods_name="鼠标", price=49.9, num=3),
            Order(user_id=2, goods_name="显示器", price=899, num=1),
            Order(user_id=1, goods_name="耳机", price=129, num=2),
        ]
        session.add_all(data)
        session.commit()
        
init_table()
init_test_data()

2.2 聚合函数:计数、求和、平均值、最大最小

sqlalchemy.func提供全套统计函数,搭配select直接查询数值:

python 复制代码
# 1. 使用上下文管理器创建数据库会话
# 优点:代码块执行完毕后,session 会自动关闭并释放数据库连接,无需手动处理异常
with Session(engine) as session:
    # 2. 构建聚合查询语句 (Aggregate Query)
    stmt = select(
        # func.count(Order.id): 统计订单总数。使用主键 id 进行 COUNT 操作效率最高
        # .label("total_order"): 为这个聚合列指定一个别名,方便后续通过属性名获取结果
        func.count(Order.id).label("total_order"),
        # func.sum(Order.price * Order.num): 计算总销售额。
        # 在数据库层面直接进行列的乘法运算并求和,避免了将大量数据拉到 Python 内存中计算
        func.sum(Order.price * Order.num).label("total_amount"),
        # func.avg(Order.price): 计算所有订单的平均单价
        func.avg(Order.price).label("avg_price"),
        # func.max(Order.price): 获取所有订单中的最高单价
        func.max(Order.price).label("max_price")
    )
    # 3. 执行查询并获取单条结果
    # session.exec(stmt): 将构建好的 SQL 语句发送给数据库执行
    # .one(): 专门用于获取聚合查询的结果。
    #   因为聚合函数(没有 GROUP BY 时)只会返回一行数据,使用 .one() 可以安全地提取这一行。
    #   返回的 res 是一个元组(Row)对象,可以通过前面定义的 label 别名来访问对应的值
    res = session.exec(stmt).one()
    # 4. 打印统计结果
    # 通过 .label() 定义的别名,可以像访问对象属性一样直接读取聚合结果
    print("订单总数:", res.total_order)
    print("总交易额:", res.total_amount)
    print("平均价格:", res.avg_price)
    print("最高价格:", res.max_price)

2.3 group_by分组 + having过滤分组

where过滤原始行,having过滤分组后的统计结果,不可混用聚合函数在where中。

python 复制代码
with Session(engine) as session:
    # 按用户分组,统计每个用户下单数量、总消费,只保留消费>300的用户
    stmt = select(
        Order.user_id,
        func.count(Order.id).label("order_cnt"),
        func.sum(Order.price * Order.num).label("user_total")
    ).group_by(Order.user_id).having(func.sum(Order.price * Order.num) > 300)
    rows = session.exec(stmt).all()
    for row in rows:with Session(engine) as session:
    # 1. 构建分组聚合查询语句
    stmt = select(
        # 查询分组依据的字段:用户ID
        Order.user_id,
        # 统计每个用户的下单数量
        func.count(Order.id).label("order_cnt"),
        # 计算每个用户的总消费金额(单价 × 数量 的总和)
        func.sum(Order.price * Order.num).label("user_total")
        # 2. 按用户ID进行分组
        # 相当于 SQL 中的 GROUP BY user_id
        # 执行后,后续的聚合函数(count, sum)将针对每个用户单独计算
        # 3. 对分组后的结果进行过滤
        # 相当于 SQL 中的 HAVING SUM(price * num) > 300
        # 注意:HAVING 是在分组之后执行的,用于过滤聚合计算的结果;
        # 而 WHERE 是在分组之前执行的,用于过滤原始行数据。
        # 这里只保留总消费金额大于 300 的用户记录
    ).group_by(Order.user_id).having(func.sum(Order.price * Order.num) > 300)
    # 4. 执行查询并获取所有符合条件的结果
    # 因为经过了分组,结果可能有多条(每个符合条件的用户一条),所以使用 .all() 获取列表
    rows = session.exec(stmt).all()
    # 5. 遍历结果并格式化打印
    # 每一行 (row) 都包含前面 select 中定义的字段,可以直接通过别名访问
    for row in rows:
        print(f"用户{row.user_id}:订单{row.order_cnt}笔,消费{row.user_total}")
        print(f"用户{row.user_id}:订单{row.order_cnt}笔,消费{row.user_total}")

2.4 标准分页查询(接口通用模板)

分页固定公式:offset((page-1)*page_size).limit(page_size),搭配count获取总条数。

python 复制代码
def get_order_page(page: int = 1, page_size: int = 2):
    # 使用上下文管理器创建数据库会话,确保执行完毕后自动释放连接
    with Session(engine) as session:
        # 1. 构建分页查询语句
        stmt = (
            # .order(Order.create_time.desc()): 按照创建时间倒序排列(最新的订单排在最前面)
            # .offset((page-1)*page_size): 设置偏移量(跳过前面的记录)。
            # 核心分页公式:(当前页码 - 1) * 每页条数。
            # 例如:第1页跳过0条,第2页跳过2条,第3页跳过4条。
            # .limit(page_size): 限制本次查询最多返回的记录数(即每页的条数)
            select(Order).order_by(Order.create_time.desc()).offset((page-1)*page_size).limit(page_size)
        )
        # 2. 执行查询并获取当前页的数据列表
        # 返回的是一个包含 Order 对象的列表
        items = session.exec(stmt).all()
        # 3. 查询满足条件的总记录数(用于前端计算总页数)
        count_stmt = select(func.count(Order.id))
        # .scalar(): 专门用于获取聚合查询(如 count, sum)返回的单个值。
        # 它会自动提取结果集中的第一行第一列,直接返回一个整数,而不是一个 Row 对象
        total = session.exec(count_stmt).one()
        # 4. 封装并返回标准的分页数据结构
        return {
            "page": page,         # 当前页码
            "page_size": page_size, # 每页条数
            "total": total,       # 总记录数
            "items": items        # 当前页的数据列表
        }

# 调用分页接口进行测试
# 获取第1页,每页2条数据
print(get_order_page(page=1, page_size=2))

2.5 查询结果四种读取方法区别

方法 适用场景 报错规则
.all() 列表数据、分页 永远返回列表,空列表不报错
.first() 只取第一条 返回第一条/None,不抛异常
.one() 必须有且仅有1条 0条/多条都会抛异常
.one_or_none() 最多一条 无数据返回None,多条报错
.scalar() 仅单个数值(count/sum) 直接取出单个值

三、事务控制:保证多操作原子性

3.1 基础事务规则

  1. Session默认自动事务,修改仅存在缓存,必须commit()才持久化;
  2. 任意操作异常,执行rollback()撤销本次所有修改;
  3. with Session.begin()自动管理提交回滚,代码更简洁。

3.2 基础事务:下单同时扣库存(模拟多表修改)

python 复制代码
def create_order_with_stock():
    try:
        with Session(engine) as session:
            # 1. 新增订单
            new_order = Order(user_id=3, goods_name="U盘", price=39.9, num=1)
            session.add(new_order)
            # 2. 模拟扣减库存(此处省略库存表逻辑)
            # 手动抛出异常测试回滚
            # raise Exception("库存不足")
            session.commit()
            print("事务提交成功")
    except Exception as e:
        session.rollback()
        print("事务回滚,所有操作撤销:", e)


create_order_with_stock()

3.3 begin() 自动事务上下文(推荐写法)

with session.begin()块结束无异常自动commit,出现异常自动rollback,无需手动写回滚代码:

python 复制代码
with Session(engine) as session:
    with session.begin():
        session.add(Order(user_id=3, goods_name="耳机", price=89, num=1))
        # 出错自动回滚,不用手动rollback
        # 1 / 0

3.4 Savepoint 保存点:局部回滚,不撤销全部事务

大循环批量导入时,单条失败只回滚当前条目,不撤销整批数据。

python 复制代码
with Session(engine) as session:
    with session.begin():
        # 第一条正常插入
        session.add(Order(user_id=4, goods_name="笔记本", price=4999, num=1))
        # 创建保存点
        # 1. 开启一个嵌套事务(保存点)
        # 在关系型数据库中,这相当于执行了 SAVEPOINT 命令。
        # 它允许你在当前的主事务中,开启一个"子事务"或"检查点"
        sp = session.begin_nested()
        try:
            # 异常数据
            session.add(Order(user_id=4, goods_name="平板", price=-10, num=1))
        except Exception:
            # 仅回滚保存点内操作,第一条保留
            sp.rollback()

四、批量操作优化(解决循环add慢)

4.1 批量插入 add_all(基础批量)

多条数据统一加入会话,单次提交,远优于循环add+多次commit:

python 复制代码
batch = [
    Order(user_id=5, goods_name="充电宝", price=69, num=1),
    Order(user_id=5, goods_name="支架", price=19, num=2)
]
with Session(engine) as session:
    session.add_all(batch)
    session.commit()

4.2 bulk_insert_mappings 无实例批量(高性能)

无需创建SQLModel实例,直接传字典列表,百万级数据推荐:

python 复制代码
from sqlalchemy.orm import bulk_insert_mappings

data_list = [
    {"user_id": 6, "goods_name": "风扇", "price": 29.9, "num": 1},
    {"user_id": 6, "goods_name": "台灯", "price": 45, "num": 1}
]
with Session(engine) as session:
    bulk_insert_mappings(session, Order, data_list)
    session.commit()

4.3 批量更新(ORM批量,不逐条查询)

python 复制代码
from sqlmodel import update

with Session(engine) as session:
    stmt = update(Order).where(Order.user_id == 1).values(price=Order.price * 0.9)
    session.exec(stmt)
    session.commit()

五、原生SQL安全执行(text防SQL注入)

业务复杂统计ORM难以实现时,使用text()必须params传参拼接,禁止字符串格式化拼接SQL,杜绝注入漏洞。

5.1 带参数查询,返回元组

python 复制代码
with Session(engine) as session:
    sql = text("SELECT * FROM `order` WHERE user_id = :uid AND price > :min_price")
    res = session.exec(sql, params={"uid": 1, "min_price": 50}).all()
    for row in res:
        print(row.goods_name, row.price)

5.2 原生结果映射为SQLModel对象

python 复制代码
from sqlalchemy import select as sa_select

with Session(engine) as session:
    # 1. 编写原生 SQL 语句
    # text(): 将普通的字符串转换为 SQLAlchemy 的文本 SQL 对象,使其能被 ORM 识别。
    # :uid: 这是一个参数化占位符,用于防止 SQL 注入攻击,后续会通过 params() 传入实际值。
    sql = text("SELECT id, user_id, goods_name FROM `order` WHERE user_id=:uid")
    # 2. 将原生 SQL 与 ORM 模型绑定
    # sa_select(Order): 创建一个以 Order 模型为目标的 select 语句(sa_select 是 sqlalchemy.select 的别名)。
    # .from_statement(sql): 告诉 ORM:"不要自动生成 SQL,而是使用我提供的原生 SQL 语句,
    #   但请将查询出来的结果,按照 Order 模型的字段映射成 ORM 对象"。
    # .params(uid=1): 为原生 SQL 中的占位符 :uid 绑定具体的参数值 1。
    stmt = sa_select(Order).from_statement(sql).params(uid=1)
    # 3. 执行查询并获取结果列表
    # 返回的 order_list 是一个包含 Order ORM 对象的列表,而不是普通的字典或元组
    order_list = session.exec(stmt).all()
    # 4. 将 ORM 对象转换为字典格式并打印
    # model_dump(): Pydantic V2 的方法,将 ORM 对象序列化为 Python 字典。
    # 这样处理后,数据就可以非常方便地转换为 JSON 格式返回给前端了。
    print([item for item in order_list])

5.3 原生增删改

python 复制代码
with Session(engine) as session:
    insert_sql = text("INSERT INTO `order`(user_id,goods_name,price,num,create_time) VALUES(:u,:g,:p,:n,:ct)")
    session.exec(insert_sql, params={"u": 7, "g": "数据线", "p": 19.9, "n": 1, "ct": datetime.now()})
    session.commit()

六、综合实战:分组统计分页报表 + 事务

整合聚合、分页、事务、原生SQL完整业务流程:

python 复制代码
def order_report():
    # 事务包裹报表同步统计数据
    with Session(engine) as session:
        with session.begin():
            # 1. 批量新增测试订单
            batch = [Order(user_id=8, goods_name="键盘膜", price=15, num=1)]
            session.add_all(batch)
            # 2. 分组统计各用户消费
            group_stmt = select(
                Order.user_id,
                func.sum(Order.price * Order.num).label("total")
            ).group_by(Order.user_id)
            group_data = session.exec(group_stmt).all()
            # 3. 分页订单列表
            page_data = get_order_page(page=1, page_size=3)
            # 4. 原生SQL查询高价订单
            high_sql = text("SELECT goods_name, price FROM `order` WHERE price>200")
            high_orders = session.exec(high_sql).all()
    return {"group_stat": group_data, "page": page_data, "high_price": high_orders}


print(order_report())

七、阶段核心总结(半天必掌握)

  1. 聚合统计func系列函数配合group_by分组,having过滤分组结果;
  2. 分页模板offset((page-1)*page_size)+limit + count统计总条数;
  3. 事务核心commit持久化、rollback回滚,session.begin()自动事务,begin_nested()保存点局部回滚;
  4. 批量优化add_allbulk_insert_mappingsupdate()批量更新,避免循环操作;
  5. 原生SQL规范 :统一text()+params传参,禁止字符串拼接防注入,支持映射模型;
  6. 结果读取:分清all/first/one/one_or_none/scalar使用场景。

八、新手高频避坑指南

  1. ❌ 在where中使用聚合函数,必须改用having
  2. ❌ 分页不写order_by,数据库返回顺序不稳定;
  3. ❌ 多步业务操作不包事务,中途异常导致数据不一致;
  4. ❌ 原生SQL用f-string拼接参数,存在SQL注入风险;
  5. ❌ 循环执行add+commit,大批量数据性能极差;
  6. ❌ 事务异常忘记rollback,会话残留脏数据;
  7. ✅ 报表统计优先ORM聚合,极复杂SQL再使用text原生语句。