agent学习Day15——SQLAlchemy 查询过滤、分页与历史列表接口

一、为什么需要查询过滤与分页

之前的 Repository 只有 get_by_idlist_all------能查一条、能查全部。但真实业务从来不是"给我所有记录":

  • 用户只想看"状态为 success 的记录"
  • 搜索框输入"Python",要匹配 JD 原文里含这个词的记录
  • 首页只展示"最近 7 天"的分析结果
  • 数据库攒了 10000 条,前端一次拉全部会卡死

这四个场景分别对应过滤、搜索、时间范围、分页list_all 一个都解决不了。所以得给 Repository 加查询能力------这就是 3.1 要做的事。

二、SQLAlchemy 查询三件套:过滤、排序、分页

核心机制:Query 对象不可变

SQLAlchemy 的 session.query(Model) 返回一个 Query 对象 ,它最大的特点是不可变 ------query.filter(...) 不会修改原对象,而是返回一个新的 Query。

python 复制代码
query = session.query(JdRecord)     # 拿到原始 Query
query.filter(JdRecord.status == "success")  # 这行白写!没赋值回去
records = query.all()                # 查出来的还是全部记录

必须重新赋值:

python 复制代码
query = session.query(JdRecord)
query = query.filter(JdRecord.status == "success")  # 重新赋值才生效

这个机制的好处是链式安全------你可以在条件分支里逐步叠加过滤条件,不用担心互相污染。

list_with_filter:过滤 + 稳定排序 + 分页

把过滤、排序、分页捏到一个方法里:

python 复制代码
# app/db/repositories/jd_record_repo.py
def list_with_filter(
    self,
    session: Session,
    status: str | None = None,
    keyword: str | None = None,
    limit: int = 20,
    offset: int = 0,
) -> list[JdRecord]:
    """按状态/关键词筛选,支持分页,默认按创建时间倒序(id 兜底稳定排序)。"""
    query = session.query(JdRecord)
    if status is not None:
        query = query.filter(JdRecord.status == status)
    if keyword is not None:
        query = query.filter(JdRecord.jd_text.like(f"%{keyword}%"))
    return (
        query.order_by(
            JdRecord.created_at.desc(),
            JdRecord.id.desc(),           # id 兜底 → 稳定排序
        )
        .offset(offset)
        .limit(limit)
        .all()
    )

两个关键设计

  1. 稳定排序 order_by(created_at.desc(), id.desc()) :如果多条记录的 created_at 完全相同(批量插入时常见),只按 created_at 排会出现"同一页翻两次看到同一条"或"跳过某条"。加 id.desc() 兜底,保证排序结果唯一确定,分页才不会乱。

  2. 页码换算 offset = (page - 1) * size:page 从 1 开始,但 offset 从 0 开始。page=1 → offset=0(第一条开始),page=2 → offset=20(跳过前 20 条)。

count 与 get_recent

python 复制代码
# app/db/repositories/jd_record_repo.py
def count(self, session: Session, status: str | None = None) -> int:
    """统计符合过滤条件的记录总数(status=None 时统计全部)。"""
    query = session.query(JdRecord)
    if status is not None:
        query = query.filter(JdRecord.status == status)
    return query.count()

def get_recent(self, session: Session, days: int = 7) -> list[JdRecord]:
    """按创建时间倒序列出最近 N 天的记录。"""
    cutoff = datetime.now() - timedelta(days=days)
    return (
        session.query(JdRecord)
        .filter(JdRecord.created_at >= cutoff)
        .order_by(JdRecord.created_at.desc(), JdRecord.id.desc())
        .all()
    )

count 的过滤逻辑和 list_with_filter 的前半段完全一致------同一个 if status is not None 分支。区别只在最后一步:一个 .all() 返回记录列表,一个 .count() 返回数字。

小结:Query 对象不可变是 SQLAlchemy 查询的核心心智模型。记住"filter 返回新对象,必须赋值回去",能避免 80% 的查询 bug。稳定排序是分页的隐形前提------没有稳定排序,分页就会漏数据或重复数据。

三、从数据库到 API:为什么要分页列表接口

3.1 解决了"数据库怎么查",3.2 解决"怎么把查到的数据通过 API 给前端"。

如果直接 return db.query(JdRecord).all() 给前端,数据库里有 10000 条记录会发生什么?

  • 服务器:1 万条记录一把查出来,内存飙、响应耗时炸
  • 前端:浏览器渲染 1 万行 DOM,页面卡死
  • 用户:面对 1 万条数据,根本找不到想要的那条

分页的本质是把大海捞针变成翻书阅读,一次只看一页。

接口设计

请求:GET /api/v1/jd/records?page=1&size=20&status=success

响应不直接返回数组,而是包一层:

json 复制代码
{
  "items": [
    {"id": 1, "job_title": "Python后端", "status": "success", "created_at": "..."},
    {"id": 2, "job_title": "AI工程师", "status": "success", "created_at": "..."}
  ],
  "total": 100,
  "page": 1,
  "size": 20
}

为什么包一层而不是直接 return [...]total 是给前端算"还有几页"用的。前端拿到 total=100size=20,就能算出总页数 ceil(100/20)=5,画出分页导航条 [上一页] 1 2 [3] 4 5 [下一页]。如果只返回数组,前端不知道后面还有没有数据,分页控件画不出来。

四、泛型分页包装 + ORM ≠ Schema

PaginatedResponse:写一次管所有数据类型

分页响应的结构(items + total + page + size)是通用的------JD 列表要用,将来用户列表、订单列表也要用。如果每种实体写一个 XxxPaginatedResponse,代码重复到崩溃。

Python 的泛型(Generic)解决这个问题------写一个"模具",往里面填类型:

python 复制代码
# app/schemas/common.py
from typing import Generic, TypeVar

T = TypeVar("T")

class PaginatedResponse(BaseModel, Generic[T]):
    """通用分页响应包装。T 在使用时替换为具体类型。"""
    items: list[T]
    total: int
    page: int
    size: int
  • T = TypeVar("T") 声明一个占位符,"到时候再说是什么类型"
  • Generic[T] 告诉 Python "这个类肚子里有个可变类型槽位"
  • items: list[T] 的意思:items 装什么取决于 T 填什么

使用时用方括号指定具体类型:PaginatedResponse[JdRecordResponse] → FastAPI 自动理解 itemslist[JdRecordResponse],生成正确的 API 文档。

JdRecordResponse:只暴露该暴露的字段

python 复制代码
# app/schemas/common.py
class JdRecordResponse(BaseModel):
    id: int
    job_title: str | None
    status: str
    created_at: datetime

    model_config = {"from_attributes": True}

ORM 模型 JdRecord 有 9 个字段(含 jd_texterror_messageanalysis_result 等内部字段),但 API 只给前端 4 个。这就是 ORM 模型 ≠ Pydantic Schema 的职责边界:

ORM 模型 (JdRecord) Pydantic Schema (JdRecordResponse)
职责 完整映射数据库表 定义 API 输出的形状
字段策略 越全越好,不能丢 越少越好,只给需要的
面向谁 后端代码(增删改查) 前端 / API 消费者

直接返回 ORM 对象 = 把后台更衣室全裸推到 T 台上------error_message、将来加的 raw_api_response(可能含敏感信息)全跟着出去。model_config = {"from_attributes": True} 让 Pydantic 能从 ORM 对象上按 Schema 定义的字段逐个取值,不在 Schema 里的自动忽略。

五、路由实现与测试

路由:Query 参数边界 + ORM→Schema 转换

python 复制代码
# app/api/routes/jd.py
@router.get(
    "/records",
    response_model=PaginatedResponse[JdRecordResponse],
    summary="获取 JD 分析历史记录列表",
)
def list_records(
    page: int = Query(1, ge=1, description="页码,从1开始"),
    size: int = Query(20, ge=1, le=100, description="每页条数,1-100"),
    status: str | None = Query(None, description="按状态过滤: success/failed/pending"),
):
    with SessionLocal() as db:
        records = repo.list_with_filter(
            db, status=status, limit=size, offset=(page - 1) * size
        )
        total = repo.count(db, status=status)
        items = [JdRecordResponse.model_validate(r) for r in records]
    return PaginatedResponse[JdRecordResponse](
        items=items, total=total, page=page, size=size
    )

三个设计要点:

  1. Query(1, ge=1) / Query(20, ge=1, le=100) :给参数加边界。ge=1(大于等于1)、le=100(小于等于100)。用户传 ?page=0?size=999 → FastAPI 自动返回 422,不用手写 if 判断。

  2. with SessionLocal() as db :创建 session,用完自动关。这是 3.2 的过渡写法,4.1 会升级为 Depends(get_db) 依赖注入。

  3. [JdRecordResponse.model_validate(r) for r in records] :ORM 对象逐个转 Schema。model_validate 配合 from_attributes=True,从 ORM 对象上取 Schema 定义的那 4 个字段,其余忽略。

测试:4 场景验证

python 复制代码
# tests/test_list_records.py
@pytest.fixture(autouse=True)
def clean_jd_records():
    """每个测试前后清空 jd_records 表,避免污染开发库。"""
    with SessionLocal() as db:
        db.query(JdRecord).delete()
        db.commit()
    yield
    with SessionLocal() as db:
        db.query(JdRecord).delete()
        db.commit()

# 1. 空数据库 → items=[]、total=0
def test_list_records_empty_returns_empty(client: TestClient):
    response = client.get("/api/v1/jd/records")
    assert response.status_code == 200
    data = response.json()
    assert data["items"] == []
    assert data["total"] == 0

# 2. 插入 3 条 → 返回 3 条
def test_list_records_with_three_records_returns_all(client: TestClient):
    _make_record(status="success")
    _make_record(status="success", job_title="AI 工程师")
    _make_record(status="failed")
    response = client.get("/api/v1/jd/records")
    assert data["total"] == 3
    assert len(data["items"]) == 3

# 3. 插入 25 条,page=2 size=20 → 返回 5 条(第 21-25)
def test_list_records_pagination_page_2_size_20(client: TestClient):
    for i in range(25):
        _make_record(job_title=f"JD{i:02d}")
    response = client.get("/api/v1/jd/records?page=2&size=20")
    assert data["total"] == 25
    assert len(data["items"]) == 5

# 4. 非法分页参数 → 422
def test_list_records_invalid_params_returns_422(client: TestClient):
    r1 = client.get("/api/v1/jd/records?page=0")
    assert r1.status_code == 422
    r2 = client.get("/api/v1/jd/records?size=999")
    assert r2.status_code == 422

autouse=True 的 fixture 每个测试前后自动清表------这是 3.2 的临时方案,4.1 会升级为内存数据库 + 依赖注入隔离,连开发库都不碰。

这两关把数据从"能查"升级到"查得准、给得对"------3.1 让 Repository 学会过滤/排序/分页,3.2 让这些能力通过 API 有序地交给前端。核心就三件事:Query 对象不可变(filter 要赋值回去)、稳定排序是分页的前提(id 兜底)、ORM 和 Schema 职责分离(别把数据库模型直接丢给前端)。泛型 PaginatedResponse 写一次管所有列表接口,是这趟最值的复用。

相关推荐
程序员爱德华2 小时前
Python与C++:异同点对比
c++·python
hangyuekejiGEO2 小时前
GEO技术服务选型指南
大数据·人工智能·python
软萌萌的13 小时前
Java Spring Boot 修改yml配置&加载顺序规则
java·spring boot·python
炎武丶航3 小时前
汽车功能测试学习(1):FCW前方碰撞预警
功能测试·学习·汽车
Dxy12393102164 小时前
Linux 编译安装 Python 3.12.10(多版本共存,不破坏系统Python)
linux·运维·python
持敬chijing5 小时前
Python概述
开发语言·python
赤羽尾风6 小时前
NumPy快速入门
python·numpy
倒流时光三十年7 小时前
第三阶段 26 · highlight 高亮(返回命中片段)
后端·python·django