全部文章

跨表搜索与分页:FastAPI + PostgreSQL 的一次查询改造

从编号、料号和上传文件名搜索出发,用相关 EXISTS 处理一对多匹配,明确筛选、稳定排序和分页的顺序。

业务列表里的搜索,最容易在数据少的时候显得正确。把当前页拿到的数组过滤一下,输入编号后也确实出现了结果。等记录超过一页,用户搜索另一页的文件名,就会得到“没有找到”。

在询价列表的一次改造中,搜索范围需要同时覆盖主记录编号、关联上传资料的料号和文件名。这里真正要改变的是数据库查询范围:先在目标记录集合里搜索,再返回其中一页。

本文使用简化表结构和合成数据解释方案。代码保留 SQLAlchemy 的表达方式,省略 FastAPI 路由、权限与响应模型,不能作为完整接口直接部署。

先确定搜索的对象

假设系统有两张表:

表 主要字段 关系
inquiry id、bill_no、created_at 一条询价记录
upload id、inquiry_id、pn、file_names 一条询价可关联多个上传记录

用户想找的是询价记录,文件名和料号只是寻找它的线索。因此接口返回的单位仍然是主记录,不能因为两个附件都匹配,就把一条询价显示两遍。

测试数据可以准备 25 条主记录,每页 10 条,让其中一条较旧记录关联 demo_100%_rev_a.zip。再让同一条记录的两个附件都包含 sensor。这样能同时检查跨页命中、字面通配符和重复匹配,避免只用当前页的三个样例验证功能。

用 EXISTS 表达关联记录的存在

直接 JOIN 能表达跨表筛选,但一对多关系会扩张结果行。如果主记录有三条匹配附件,它可能在 SQL 结果中出现三次;在 LIMIT 之后再靠程序去重,页面条数和总数都可能出问题。

这个场景只需要回答“是否存在一条匹配资料”,相关 EXISTS 更贴近需求。下面展示简化后的查询:

from sqlalchemy import exists, func, or_, select

def filtered_inquiries(term: str):
    stmt = select(Inquiry)
    term = term.strip()
    if not term:
        return stmt

    upload_matches = exists(
        select(Upload.id).where(
            Upload.inquiry_id == Inquiry.id,
            or_(
                Upload.pn.icontains(term, autoescape=True),
                func.array_to_string(Upload.file_names, "\n")
                    .icontains(term, autoescape=True),
            ),
        )
    )
    return stmt.where(or_(
        Inquiry.bill_no.icontains(term, autoescape=True),
        upload_matches,
    ))

Inquiry 和 Upload 是示意 ORM 模型,file_names 假设是 PostgreSQL 文本数组。这里把数组展开为可搜索文本,适合解释当前需求;如果文件名需要独立权限、更多属性或专门索引,也可以单独建表。

autoescape=True 用于把用户输入中的 % 和 _ 按字面值处理。它与绑定参数各司其职:绑定参数处理 SQL 值的传递,LIKE 转义处理通配符语义。不能因为查询没有字符串拼接,就忽略用户输入 100% 时到底想匹配什么。SQLAlchemy 的字符串包含与转义文档可用于核对具体 API 行为。

编号规则需要单独定义

有些界面编号是 Q-002455,底层主键却是整数。用户输入带前缀、没有前缀或有前导零的编号时,是否都应该命中,需要先定规则。

如果支持编号识别,应使用明确的格式与合理范围进行转换,再把主键条件加入搜索。不要尝试把任何包含数字的文件名都解释为主键,也不要让一个特别长的数字触发无边界的类型转换。

实际接口还要先限定当前用户能看到的主记录,然后在同一权限范围内叠加搜索条件。EXISTS 能防止行数膨胀,却不会自动替系统完成访问控制。

筛选、排序,再分页

得到筛选后的查询后,再添加顺序和分页:

filtered = filtered_inquiries(search)
page_stmt = (
    filtered
    .order_by(Inquiry.created_at.desc(), Inquiry.id.desc())
    .offset((page - 1) * page_size)
    .limit(page_size)
)
count_stmt = select(func.count()).select_from(filtered.subquery())

page 和 page_size 应在接口层限制为正数,并设置页大小上限。总数查询与列表查询要复用同样的筛选和权限条件,否则会出现“提示有结果,当前页却没有”的不一致。

只按创建时间排序还不够。批量导入可能让多条记录拥有相同时间,因此增加唯一的 id 作为次级排序条件。PostgreSQL 的 LIMIT / OFFSET 文档也要求分页时使用可以确定结果顺序的排序。

这种排序解决的是同一数据集合内的顺序不确定问题。用户翻页时如果持续插入新记录,OFFSET 的位置仍会改变,可能看到重复或漏过一条。需要更强连续性时,可以讨论基于时间与 ID 的游标分页;如果要求一次查询期间的快照一致性,还要进一步考虑事务隔离,而不是把“有唯一排序”当作全部解决。

用反例确认搜索范围

我会先把那条旧记录放到默认第一页之外,再搜索它的文件名。之后让多个附件同时命中,确认列表仍只有一条主记录,且总数为一。输入 %、_、中文、前后空格和空字符串,检查结果是否符合约定。最后再验证编号识别及没有权限的记录。

性能需要另行测量。包含匹配、数组文本转换和跨表条件都可能在数据增大后变贵,应查看实际数据分布和查询计划,再决定是否引入 trigram 索引、规范化文件表或其他检索方案。EXISTS 在这里让语义更清楚,但本文没有同条件基准测试,不能给出提速倍数。

这次改造让我更重视接口的集合语义:搜索范围、返回对象、去重方式和分页顺序必须一起设计。当前页上的一个输入框,背后其实是一份完整的查询契约。