JPA 持久化 04:继承、关联与所有权
把明细放进集合,为什么外键仍可能没有变化
采购申请有两条明细,审批后只允许生成一张订单。把 ProcurementLine 加入 ProcurementRequest.lines,Java 内存里的集合已经变了;但如果明细的 request 仍指向旧申请,数据库该依据哪一侧更新外键?再设想“批准”和“驳回”是两种审批记录:查询共同父类能否返回两种子类?把这些问题都叫作“对象关系”会遮住两条不同的线:继承是同一身份体系下的多态映射,关联是不同实体之间关系的所有权。上章讨论过金额嵌入对象无独立身份,这里恰好要判断什么时候需要独立实体。
业务范围不变:DRAFT → SUBMITTED → APPROVED → ORDERED,或 SUBMITTED → REJECTED;只有 APPROVED 能下单,每申请最多一单。每条明细归一张申请,订单记录对应申请,租户不可越权。本文以前三章的实体身份、flush 与回滚、类型和主键为先修;代码入口只针对将来的独立 examples/jpa/ 实验,不修改共用工程或 Java EE/JDBC 教程。
多态有身份,字段复用不等于多态
Jakarta Persistence 3.2 §2.13 允许实体继承、抽象实体和多态查询。假设审计要求每次审批决策都保留独立 id、发起租户与发生时间,还要按“批准/拒绝”存各自字段,那么 ApprovalAction 可是抽象实体,ApprovedAction 与 RejectedAction 是实体子类。查询 ApprovalAction 得到的是子类实体,不能期待把同一申请的所有事件塞进一个无身份的 @Embeddable 后还能按事件 id 单独查找。若业务只需要申请上的一个状态枚举,单字段反而更直接;“有两个状态值”本身不是使用继承的理由。
规范 §2.13.2 映射超类 区分了 @MappedSuperclass:它向子类提供映射信息,却不是可独立查询的实体。把审计公共字段放到映射超类可以复用字段,但不能查询 ApprovalAction 父类型的多态记录。普通 Java 父类甚至可能只复用代码,不参与持久化映射。实体层次的主键只定义一次,参见 §2.4;不能为每个子类另设一套独立 id 然后还声称它们共享同一个实体层次。[PATTERN] 先问“是否必须按公共父类型查询具有独立身份的对象”,再选实体继承;只需复用字段则考虑映射超类;依附申请存在的两个金额字段仍用值对象。
继承策略是表的代价,不是 Java extends 的属性
§2.14 列出 SINGLE_TABLE、JOINED、TABLE_PER_CLASS 三种策略。规范要求实现支持前两种;每具体类一张表是可选支持,依赖它的应用不可移植。单表把整个层次放一表,用判别列识别子类:查询父类不必为了区分子类去连接子表,但各子类专属列共享同一行空间,如何约束“批准记录不得有拒绝原因”得结合 DDL。JOINED 把父类字段与子类字段分开,子表与父表共用实体身份;读子类或遍历父类时需要重构相关行,具体 SQL 形状和数量属于 provider/查询/数据库的实测事实。策略选择不是把 @Inheritance 注解换掉就完成了,迁移旧数据、外键和索引是另一份工作。
下面的三类拟加入 examples/jpa/src/main/java/blog/jpa/,各自保存为同名 .java。它们尚未进入工程,也未接受编译或数据库实验。示例选 JOINED 并显式给出父表 approval_action,要求后续迁移建立父表、两张子表及相应主外键。用 UUID 作为应用侧 id 避免将生成时机误作重点;创建动作时仍需服务端检查申请当前状态、租户和审批权限,这些检查不在实体继承注解中。
1 | |
1 | |
1 | |
这三类没有指向申请实体的 JPA 关联,requestId 只是标量:多态实验可以先独立验证,但实际落地必须在迁移中决定引用完整性,服务入口应按 tenantId 和申请 id 查验对象。若需要导航关联,应改为显式关系映射并检查哪张表含外键,而不是让 Long requestId 与 @ManyToOne 两套可写属性彼此竞争。persistence.xml 使用显式 <class> 列表,新增三类时还需登记;现有配置为 hibernate.hbm2ddl.auto=validate,不会帮忙创建这些表。实验目前不具备运行条件,不能把代码示意当编译与数据库证据。
这个审计模型还有一个容易遗漏的限制:@Column(nullable=false) 可以让时间字段在模式里表达非空,但不能证明一条批准记录真的发生在申请从 SUBMITTED 转到 APPROVED 的同一事务中。若审计事件与申请状态分属不同事务,查询时可能看到“有批准事件却仍是待审批”的半成品。落地时应在同一服务入口完成授权、状态转换与事件写入;事务提交后用另一连接同时检查申请状态与动作记录,失败时一并回滚。继承映射确定的是事件记录的表和类型,不赋予业务操作原子性。
明细的 mappedBy 告诉谁不能写外键
§2.11 实体关系 将双向关联分为 owning side 与 inverse side,只有拥有方决定数据库关系更新;一对多/多对一中,多的一侧必须是拥有方。具体到现有 ProcurementRequest.java 和 ProcurementLine.java:@OneToMany(mappedBy = "request") 把申请的 lines 定义为反向集合,@ManyToOne 与 @JoinColumn(name = "request_id") 把明细的 request 定义为拥有方。mappedBy 里的 request 是 Java 属性名,不是外键列名。列名由拥有方的 @JoinColumn 指定;§2.12.2 还给出未指定时的默认映射。
为什么现有 addLine 同时构造 new ProcurementLine(this, ...) 和 lines.add(line)?前者设定数据库外键的 Java 拥有侧,后者维护内存中可遍历的反向视图;规范同时要求应用负责更新时双向对象关系的一致性。只往 lines 加一个 request 为空的新明细,就算 cascade=PERSIST 让新实体获得持久化动作,也不意味着外键会自动指向当前申请。optional=false 与 @JoinColumn(nullable=false) 描述模型与列约束,实际旧库是否存在 NOT NULL 要查迁移。反过来,只改明细的 request 而不更新集合,数据库关系可能按拥有侧变化,但当前 Java 对象图里的集合仍旧;必须由领域方法同时维护两侧,并用新上下文重读判断最终事实。[PATTERN] 双向关系分两问:哪一侧写数据库关系?哪一侧维持内存视图?把两者都写进一个受控方法,不允许外部任意改集合。
现有 lines() 返回 List.copyOf(lines),外部不能直接 lines().add(...);这有助于约束改动入口,但不表示内部 addLine 无需设置 owning side。用作失败对照时应在独立测试专用映射中提供故意只改反向侧的路径,或者受控构造不一致图;不能因为生产聚合有防护就编造一个能执行的 lines().add。规范 §3.3.4 同步到数据库 指明由 owning side 关系状态决定数据库更新。测试中的正确判据是新上下文查询 purchase_line.request_id 与实体关系,而不是仍在内存中的 request.lines().size()。
外键字段和父对象引用是同一条关系的不同观察角度:purchase_line.request_id 在表中保存申请键,而 Java 的 request.lines 为应用提供从申请遍历明细的视图。若改动对象图后立刻在同一上下文 find 同一个申请,很可能拿回已在上下文中的同一对象,因此集合看起来正确并不能证明外键已更新。必要时先 flush 再提交并关闭上下文,之后以新上下文或直连数据库查询。相反,在提交前直接开另一事务看不到未提交的新明细,也不能据此判断 cascade 没工作。把读取时机与事务可见性固定下来,才能区分“反向侧未写外键”和“写了但当前连接尚不可见”。
级联的操作对象与孤儿的生命周期
现有申请的 lines 使用 cascade=ALL, orphanRemoval=true。级联说的是对申请执行 persist、merge、remove 等实体操作时是否传播到明细;它不是决定 request_id 的开关。orphanRemoval=true 关心的是从私有拥有的关系中移走实体后,何时对那个明细应用 remove:§2.11 明确说明在 flush 时应用删除;新建、脱管或已删除实体不适用这套孤儿语义,也不应依赖特定删除顺序,更不应把被孤立的实体再塞进另一条关系。删除申请时由 orphan removal 引发的目标删除传播,也不能概括成“删除任何实体都会连带删引用它的所有对象”。
要观察孤儿删除,先持久化申请与两条已存在的明细,提交并重新加载托管实体,再通过一致性方法从集合中移走其中一条,同时断开该明细的拥有侧引用;在事务内 flush、提交,最后新上下文断言只剩一条。要观察级联删除,另起一组数据,对托管申请执行 remove 并检查申请与明细终态。二者不能混在同一个“删一行成功”的断言里:前者是从关系移走一个已持久化目标,后者是删除源实体时的传播。规范也不保证应用可依赖某个 SQL 删除顺序;外键限制、回滚及错误恢复由真实数据库实验裁定。
订单关联的所有权另有约束:现有 ProcurementOrder.java 在 @OneToOne 上持有 request_id,@JoinColumn(unique=true, nullable=false) 表示期望每申请最多一单。现有 01-init.sql 明确创建 request_id bigint NOT NULL UNIQUE REFERENCES purchase_request(id);该批次测试以此模式通过,但并没有单独做数据库约束冲突实验。构造器检查申请为 APPROVED 可以拦截本地错误;两个并发请求都在检查后插入时,唯有数据库唯一约束和事务/冲突处理能保证最多一单。fetch=LAZY 管加载时机,不改变谁拥有外键;事务内对已批准申请变更为 ORDERED,也不自动消除并发重复写入。订单的租户 id 与申请租户一致需要服务端核对,不能由 ORM 关系声明代替授权。
实验:对象图和数据库终态要分开量
已有 ProcurementJpaTest.java 的 lifecycleAndServerCalculatedTotal 在提交、clear 后重读一条明细且总额为 6.75;uniqueOrderAndTenantAndRetry 验证服务方法拒绝其他租户、同事务及后续事务顺序重试仍只查到一张订单。六例测试、0 失败、退出 0 的原始记录见 RUN.md。这不是继承多态、只改反向侧、孤儿删除或并发重复插单的验证;批次也没有数据库唯一约束冲突的原始异常记录。
建议把剩余实验落在 examples/jpa/src/test/java/blog/jpa/Chapter04RelationshipTest.java 与 Chapter04InheritanceTest.java,在专用 PostgreSQL 16 库完成新增表迁移后逐项筛选运行。审批动作类型注册、表结构及失败路径辅助入口未落地;本章研究与实验卡 留有记录字段。以下扩展正常与失败场景均 NOT_RUN,不能引用六例通过的结果为其背书。
扩展正常一:持久化 ApprovedAction 和 RejectedAction,关闭上下文后用 select a from ApprovalAction a where a.tenantId = :tenantId 查询,验证两条返回值各自的运行时类型、id 与子类字段,再以另一租户查询验证隔离条件。扩展正常二:用现有 addLine 构造两条明细,提交后在新上下文中核对每条明细的 request_id、集合大小;另启事务分别测试孤儿删除与删除申请时的级联传播,记录独立终态。这里的“正常”是预期路径名,不是结果已经 PASS。
扩展失败一:在专用映射/测试辅助代码中只改反向集合、不设置明细的 request,尝试 flush;现有迁移的 request_id NOT NULL 预期阻止无所属申请的新明细,但未归档原始失败输出,也没有运行该路径。扩展失败二:绕开 ProcurementService.order 的顺序重试分支,受控地为同一申请写第二单,预期现有 UNIQUE 阻止第二单,并核对第一单仍在;不能把顺序重试成功说成数据库唯一冲突测试。审批动作的无效租户也应由服务入口拒绝,JPQL 带租户条件只是读取过滤,不自动解决写入授权。错误发生在 flush 还是 commit 要由将来的原始输出判定,出错后回滚、关闭上下文再核对终态。
两道练习
练习一:假设测试专用映射暴露了可修改的 lines 集合,执行 request.lines().add(line) 后打印集合大小为 1,line.request 仍为空,且明细表外键非空。能把持久化失败归咎于 cascade=PERSIST 缺失吗?怎样修?现有工程的 lines() 返回不可修改的副本,不能直接用它运行此行代码。
答案:不能。此处首先是拥有方 line.request 没赋值,mappedBy 侧的集合不决定外键。若行是新对象,还需考虑将实体纳入持久化:由申请的级联 PERSIST 或显式 persist(line) 执行插入,但二者都不能补充丢失的外键值。修复应在受控的 addLine 方法中先给明细设置所属申请,再更新集合,并检查现有数据库 request_id NOT NULL;用新上下文读回明细的所属申请作判据。
练习二:两个实体只需共享创建时间字段,不需要查询父类型;审计模块需要查询“所有审批动作”并追踪每次动作独立 id。两者各选什么复用方式?为什么不能用同一个 @Embeddable 解决?
答案:两个实体可通过 @MappedSuperclass 复用映射字段,或者把同生共死的时间字段组合进值对象。审计动作要按父类型查询,且每条都有独立 id,应选抽象实体父类与具体子类,再挑选 SINGLE_TABLE 或 JOINED 并迁移模式。嵌入对象随宿主保存、无自己的实体身份,不能承担“按审批动作 id 查询”和多态实体查询。无论选哪一种,审批状态转移与租户校验仍属于服务逻辑而非继承映射。
边界与速查
| 情况 | 规范保证或边界 | 仍需验证 |
|---|---|---|
| 父类多态查询 | §2.13 抽象实体可作查询目标 | DDL、实现生成的 SQL、租户条件 |
| 继承到表 | §2.14 单表/连接子表必支持,逐类表可选 | 迁移、外键、查询成本 |
| 申请与明细 | §2.11 mappedBy 为反向、many 为 owning |
双侧内存一致性、外键实际列约束 |
| 级联与孤儿 | §2.11 操作传播与 flush 时孤儿删除 | 分组测试、事务失败后的终态 |
| 一申请一订单 | §11.1.26 @JoinColumn 描述唯一列 |
数据库唯一约束、并发冲突恢复 |
本文没有证明 JOINED 的性能优劣,没有在真实数据库观察级联顺序,也没有让 JPA 自动解决租户隔离或审批原子性。多态、所有权与删除生命周期需要各自的实验,不应把一张对象图的直观形状直接当成数据库事实。






