查询返回了对的行,为什么审批仍可能出错

采购审批列表通常只要申请编号、状态与金额;详情页才需要实体与明细。把两者都写成“查出申请实体,再遍历关联”会让列表承担不必要的对象加载,而在同一个工作单元里执行批量驳回,又可能让已经加载的申请仍显示为待审批。问题并非查询语言的选择,而是查询的结果形状、排序条件、持久化上下文与数据库行是否被当成了同一件事。

本篇只讨论 Jakarta Persistence 3.2 的查询契约,先修实体与关联见 01–04 篇。案例沿用 ProcurementRequest(申请)、ProcurementLine(明细)、ProcurementOrder(订单):租户字段为 tenantId,申请状态从 DRAFT、SUBMITTED 走向 APPROVED 或 REJECTED,批准后才允许建单。查询代码负责选出候选申请,不能替代服务层的租户授权、状态校验和数据库的一申请一单约束。已有 ProcurementJpaTest 覆盖了小规模查询与 bulk 上下文边界;下文的 ApprovalQueries、ApprovalFilter、NativeApprovalReport 是扩展示例,不是已在 PostgreSQL 上通过的类。

查询的单位是映射模型,不是表

JPQL 的 FROM ProcurementRequest r 使用实体名和持久属性名;r.tenantId 是 Java 映射属性,不能把 SQL 列名 tenant_id 直接搬进 JPQL。关联导航会参与查询语义:在 WHERE 中穿过单值关联的路径通常具有内连接语义;需要保留没有关联对象的申请时,应明确使用左连接。规范把抽象模式、路径与连接分别定义在 §4.3、§4.4.4 与 §4.4.5。这里的“连接”是查询结果条件,不等于把关联自动抓进内存;抓取策略留给 06 篇。

对待审批列表,先决定只读投影,不要先决定 ORM 能生成什么 SQL。以下片段假设调用方已取得 EntityManager em,且授权层给出了不可由请求参数自行覆盖的租户标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import blog.jpa.RequestStatus;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Tuple;
import java.util.List;

public final class ApprovalQueries {
public static List<Tuple> page(EntityManager em, String authorizedTenant,
int offset, int limit) {
if (authorizedTenant == null || authorizedTenant.isBlank()
|| offset < 0 || limit < 1 || limit > 100) {
throw new IllegalArgumentException("Invalid page request");
}
return em.createQuery("""
SELECT r.id AS requestId, r.status AS status, r.total AS total
FROM ProcurementRequest r
WHERE r.tenantId = :tenant AND r.status = :status
ORDER BY r.id ASC
""", Tuple.class)
.setParameter("tenant", authorizedTenant)
.setParameter("status", RequestStatus.SUBMITTED)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
}

Tuple 的别名让调用方按 row.get("requestId", Long.class) 取列;规范 §4.9.1 定义投影结果类型,§3.11.1 定义查询执行及 setFirstResult、setMaxResults。投影字段不是托管的 ProcurementRequest:修改 Tuple 不会触发实体脏检查。这里按唯一主键排序,解决同一快照内排序键并列时的页边界歧义;它不能冻结连续两次请求之间的数据库。如果有人在翻页期间提交新申请,偏移量分页仍可能重叠或漏行。需要稳定遍历时,应结合事务隔离、快照策略或以 (排序键, id) 为游标的查询,不能仅靠 ORDER BY 声称跨请求一致。

审批页面若要名称与金额以外的复合只读结构,还可以用 SELECT NEW 完整类名(...) 构造 DTO,规范 §4.9.2 规定构造表达式;类与参数类型必须匹配,且返回对象不能误作托管实体。选择实体结果还是投影,取决于调用方是否准备在当前工作单元里修改它,以及要不要让该对象进入一级缓存。不能用“投影一定快”替代实际 SQL、对象分配和数据库计划的测量。

审批台还需要按租户显示“待审批申请数”和“待审批总额”。这是聚合而非实体加载:SELECT COUNT(r), SUM(r.total) FROM ProcurementRequest r WHERE r.tenantId = :tenant AND r.status = :status。COUNT 的结果类型由 JPQL 规则决定,SUM 对数值类型有相应结果类型规则;如果一条都没有,聚合值的空值处理也不能沿用“总额一定是零”的实体字段约束。规范 §4.9.5 给出聚合函数的结果类型与空集合语义。测试应分别覆盖有记录、无记录两个输入,而不是把 getSingleResult() 当作“总能取到非空总额”。如果还要按状态分组,GROUP BY r.status 会改变每行含义;分页之前要明确分页对象是申请、分组还是聚合结果。

筛选条件里也有 SQL 式的空值陷阱。找尚未关联订单的申请,不能写 r.order = NULL 并期待与 Java 的 == null 同义;应根据实体关系是否实际存在选择 IS NULL、IS EMPTY 或 NOT EXISTS,再核对映射模型。当前工程的 ProcurementRequest 没有 order 反向属性,直接写 r.order IS NULL 连 JPQL 路径都不成立。可以从 ProcurementOrder 侧写相关子查询,限定申请和租户,或将这种过滤明确交给原生 SQL。规范 §4.6.7 与 §4.6.10 分别规定空值比较及 EXISTS。查询能力始终受映射关系约束,不能靠表结构猜实体属性。

查询返回了申请也不等于该申请可以建单。假设审批员先从 SUBMITTED 列表取得某 ID,然后另一事务将它驳回;列表记录的是当时符合谓词的数据,不是后续业务动作的准入凭证。建单时还要在事务内重新加载并核对租户、APPROVED 状态,以及唯一订单约束,否则列表页再精确也挡不住状态改变后的重复请求。列表里的租户条件也不会延续到下一次 find(id);无租户参数的主键读取入口必须由服务层另行校验。查询安全需要沿调用链核查,不是审核一条 JPQL 就结束。

Criteria 改变的是构造方法,不是权限规则

管理员筛选条件会变:按状态、租户或申请金额过滤。Criteria 把条件组装成表达式树,避免用字符串拼接 JPQL 片段;它并不自动附加租户条件。以下代码展示与上述查询同形状的基础条件,变动条件只作为附加谓词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import blog.jpa.ProcurementRequest;
import blog.jpa.RequestStatus;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Tuple;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public final class ApprovalFilter {
public static List<Tuple> find(EntityManager em, String authorizedTenant,
BigDecimal minimumTotal, int limit) {
if (authorizedTenant == null || authorizedTenant.isBlank()
|| limit < 1 || limit > 100) {
throw new IllegalArgumentException("Invalid filter");
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Tuple> query = cb.createTupleQuery();
Root<ProcurementRequest> request = query.from(ProcurementRequest.class);
List<Predicate> conditions = new ArrayList<>();
conditions.add(cb.equal(request.get("tenantId"), authorizedTenant));
conditions.add(cb.equal(request.get("status"), RequestStatus.SUBMITTED));
if (minimumTotal != null) {
conditions.add(cb.ge(request.get("total"), minimumTotal));
}
query.multiselect(request.get("id").alias("requestId"),
request.get("status").alias("status"),
request.get("total").alias("total"));
query.where(conditions.toArray(Predicate[]::new));
query.orderBy(cb.asc(request.get("id")));
return em.createQuery(query).setMaxResults(limit).getResultList();
}
}

规范 §6.3.1–6.3.2 描述查询与根的构造,§6.3.6 描述谓词,§6.3.14 描述排序。示例使用字符串属性名,拼错仍可能运行时才失败;生成元模型可改善编译期检查,但生成器和生成流程并非 Criteria 的自动保证。minimumTotal 仅筛选已经由服务端核算并持久化的金额,不允许客户端通过查询参数重算或回写金额。

Criteria 的成本也很实际:固定查询只有一两个条件时,JPQL 往往更容易审阅;动态条件较多时,Criteria 的组合能力才抵得过样板代码。两者共享持久化上下文、参数绑定和查询执行规则,不存在“Criteria 默认更安全”的权限特性。租户过滤必须由同一入口强制加入;一个漏掉谓词的报表照样越权。

例如允许操作员传入可选状态时,代码不能把“没有传状态”的情形变成不限制租户,也不能把条件从 AND 意外拼成 OR:(tenant = A AND status = SUBMITTED) OR (total > 100) 会泄漏其他租户的高金额申请。将不可省略的租户条件包在最外层,再组合可选条件,并为每条组合路径设计负例数据。Criteria 不对抽象语法树进行授权审计,它只负责构造合法查询。待审批列表的 offset 和 limit 同样要限制上界,防止一个看似分页的入口实际上加载整表。

原生 SQL 回答数据库问题,但要付映射账

采购报表可能需要 PostgreSQL 专有表达式或复杂窗口函数,JPQL 无法清楚表达时才使用原生 SQL。规范 §3.11.11 明确这种查询不是跨数据库可移植的承诺。报表示例只读取标量,将数据库返回值当作边界数据处理,不假定驱动一定交付 Long:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import java.util.List;

public final class NativeApprovalReport {
public static List<Object[]> rows(EntityManager em, String authorizedTenant) {
if (authorizedTenant == null || authorizedTenant.isBlank()) {
throw new IllegalArgumentException("Tenant required");
}
@SuppressWarnings("unchecked")
List<Object[]> result = em.createNativeQuery("""
SELECT id, total FROM purchase_request
WHERE tenant_id = ?1 AND status = 'SUBMITTED'
ORDER BY id
""")
.setParameter(1, authorizedTenant)
.getResultList();
for (Object[] row : result) {
if (!(row[0] instanceof Number) || !(row[1] instanceof BigDecimal)) {
throw new IllegalStateException("Unexpected native column types");
}
long requestId = ((Number) row[0]).longValue();
BigDecimal total = (BigDecimal) row[1];
}
return result;
}
}

SQL 的 purchase_request、tenant_id 来自 examples/jpa/db/migrations/01-init.sql;上述示例类尚未加入 examples/jpa/。固定版本驱动实际返回什么类型,仍须运行核对。局部变量供类型检验说明,并非完整报表接口。若要将原生查询映射为托管实体,规范 §3.11.11.1 要求结果具备映射所需的列(包括外键);列不足时结果未定义。多实体或别名不符合映射元数据时,考虑 @SqlResultSetMapping(§10.4.4),不要靠 Object[] 下标悄悄构造可写实体。SQL 标识符和排序字段不能用值参数绑定;动态拼接必须用白名单,而非把用户输入接到 SQL 后面。

原生查询并不是“绕开事务”的通道。同一事务内若先修改了托管申请,再发 SQL 报表,何时 flush、查询在何种模式下看到修改,要与规范 §3.11.2 的查询刷新模式及所选 provider 的具体行为一并检验。跨事务则还受数据库隔离级别影响。报表若要求截至某个时间点的统一口径,应明确使用数据库快照或离线汇总方案;一次原生 SELECT 既不能修复旧上下文,也不能保证它和下一次 JPQL 查询看到同一版本。

bulk 的速度来自绕开逐实体语义

一批长期未处理的 SUBMITTED 申请若由管理员批量驳回,写 UPDATE ProcurementRequest r SET r.status = :rejected WHERE r.tenantId = :tenant AND r.status = :submitted 看似直接。但审批状态代表业务决定,必须先确认管理员权限、受影响申请范围、审计要求、并发冲突和通知策略;本例只用于说明查询语义,不提供能直接上线的批量审批接口。规范 §4.11 规定 bulk update 直接映射数据库更新,绕过乐观锁检查,且活动持久化上下文不会随 bulk update/delete 同步;bulk delete 也不会级联删除关联实体。executeUpdate() 返回受影响行数(§3.11.1),不是“这些行都已完成业务状态迁移”的证明。

设同一事务先 find 得到状态为 SUBMITTED 的对象,再执行限定 id 与 tenantId 的 bulk 驳回。数据库行可能已是 REJECTED,managed.status() 仍是 SUBMITTED。同一 EntityManager 随后的 find 也不能当作数据库重新读取的证据。安全的验证顺序是在事务内必要时先 flush(),执行 bulk 并核对行数,提交后关闭该上下文,用新的 EntityManager 读取终态;避免旧托管对象继续写入。若必须在同一上下文继续读,先按业务场景决定能否 clear() 或 refresh(),并注意 clear() 会丢弃尚未刷出的修改。另一个事务并发更改状态时,“WHERE status = SUBMITTED”与行数校验只是条件更新手段,不会自动提供版本比较和冲突恢复;正式的审批路径仍须逐实体版本控制或显式条件更新策略(第 07 篇)。

这里的版本问题尤其容易被“实体有 @Version”掩盖。当前申请映射确实有 version 字段;规范 §4.11 却要求可移植应用在 bulk 更新时自行处理版本值或自行校验版本,不能假设 bulk 会替每条申请做一次乐观锁检查。若仅按状态更新而遗漏版本条件,另一个审批者可能已经读到了旧状态;executeUpdate() 的行数只告诉调用者符合谓词的行数,不等于确认每条业务命令都按顺序发生。批量删除还须审视外键:purchase_line.request_id 指向申请,bulk delete 不触发实体级级联,直接删除有明细的申请可能被数据库外键拒绝。这个失败路径应保留原始异常与回滚后的行状态,不应把“删不掉”改写成“JPA 自动保护业务”。

[PATTERN] 当只展示少量字段时,先固定租户谓词与稳定排序,再选择 JPQL/Criteria 投影;当业务需要逐实体状态变化时,不要因 bulk 语法短就跳过工作单元和并发契约。

可复现的检查:先写判据,再接代码

代码入口为 累计工程的 ProcurementJpaTest.java(工程目录 examples/jpa/,若目标分支尚未同步该文件,请以本地文件为准)。其中 jpqlCriteriaNativeAndBulkContextBoundary 是现有测试;没有独立 Jpa05QueryTest。该次 RESOURCE_LOCAL 回归的原始日志、退出码与逐文件 SHA-256 记录于 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/。同名素材目录的 实验说明 列出已证实和待补的判据。实际运行环境是 Java 21.0.12.1、Hibernate ORM 7.1.36.Final、PostgreSQL 16.15、pgJDBC 42.7.7;规范依据是 Jakarta Persistence 3.2。不能把该套版本上的断言扩成所有 provider 的 SQL 形状或行为。

已运行的正常路径(PASS,范围有限)。 jpqlCriteriaNativeAndBulkContextBoundary 使用随机租户插入一条 SUBMITTED 申请,JPQL 按租户筛 ID 并以 id 升序取第一页一条,断言命中该 ID;Criteria 按租户计数为 1;原生 SQL 按 id 查 total,断言与 7.00 一致。证据目录 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/ 中 RUN.md、test.stdout.txt、exit-code.txt 与 code-sha256.txt 记录该轮 clean test 的 6/6、退出码 0 及源文件指纹。这里没有验证本文 Tuple 投影、多页边界、跨租户复杂条件或多列原生映射;原始 SQL 文本未归档,不能凭测试名宣称这些路径通过。

已运行的边界路径(PASS,限于 bulk 旧状态)。 同一测试在事务中先加载 SUBMITTED 实体,用 JPQL bulk update 把该申请改为 APPROVED,断言受影响行数为 1,而旧托管对象仍为 SUBMITTED;随后 clear() 并在同一事务重新 find,断言是 APPROVED,再提交。这不是“提交后新上下文”实验,SQL 原始语句、版本列变化及跨事务冲突也未归档。

仍待运行(NOT_RUN): 同金额申请的多页与投影对照;原生实体少列映射;无授权时拒绝查询;bulk 并发冲突、外键失败或回滚后的终态。规范未定义缺列时的查询结果,不预设异常类型。服务层已通过租户拒绝测试,不等于查询入口已实现。

两道练习

练习一。 两条申请的 total 都是 100 元,按金额升序分页,每页一条,能否保证两次请求一定各取到不同申请?**答:**不能。仅按金额排序存在并列键,先加唯一 id 做确定性次序;即使加了 id,并发新增或更新仍可能令偏移量分页跨请求重复或漏行,强一致遍历还需要合适的快照或游标方案。投影查询必须保留授权租户谓词。

练习二。 executeUpdate() 返回 1,当前托管对象仍是 SUBMITTED,应调用 merge(managed) 让它“变新”吗?**答:**不应。bulk 不同步当前上下文,merge 并非数据库重读,旧值还可能参与后续写入。结束该工作单元,再用新上下文查询终态;业务上还要审查版本、审计、权限与回滚方案。

边界与速查

JPQL 和 Criteria 规范化的是对象查询,不保证数据库执行计划;原生 SQL 能使用数据库特性,却要自己承担方言、列映射与参数白名单。@Version 不会自动保护 bulk 操作,缓存也不会自动解决旧实体的问题。事务隔离、批量冲突恢复和订单唯一约束要与 07、09 篇合看,不能从本篇的只读列表推导“审批已经安全”。

任务 合适入口 必查边界
固定只读列表 JPQL Tuple/DTO 租户条件、唯一排序键、跨请求变化
动态过滤 Criteria 强制谓词不能被可选条件覆盖
数据库专有报表 原生 SQL 数据库方言、列类型、映射完整性
批量数据修正 bulk update/delete 旧上下文、版本、级联与审计

参考资料