Hibernate 7 中如何为复合主键子实体配置 @OrderColumn?
结论
复合主键本身不妨碍使用 @OrderColumn。需要分别检查关联映射、主键映射和排序列的管理方式。
报错 SQL 的同一个 INSERT 列表中,vacation_trip_id 出现了两次,应先修正重复列映射。这不是两个子实体主键相同导致的重复键异常。
排序时,@OrderColumn 根据 List 位置维护索引;@OrderBy 根据实体属性对读取结果排序,不会自动生成序号。同一个集合不要同时使用这两个注解。Jakarta Persistence 3.2 规范
为什么升级后 tripStopOrder 都是 0?
Hibernate 7 的迁移指南说明,对于带 mappedBy 的 @OneToMany,如果排序列同时映射为子实体字段,Hibernate 不再像以前那样通过额外的 UPDATE 按 List 位置更新该列。只有排序列没有映射为实体字段时,才会保留这类更新行为。Hibernate 7.0 迁移指南
原映射中的这段代码仍将排序列声明为持久化属性:
@Column(insertable = false, updatable = false)
private int tripStopOrder;
insertable = false, updatable = false 只限制该属性参与写入,并没有取消它的持久化映射,也不会让 Hibernate 自动将 List 索引赋给 Java 字段。新对象的 int 字段初始值为 0,不能只凭内存中的值判断数据库排序列是否正确。
因此,需要先确定排序号是由 Hibernate 的集合索引机制管理,还是由应用代码作为实体属性管理。
先修正关联和复合主键
父集合通过 @JoinColumn 声明外键映射,子实体又通过 @Id @ManyToOne 映射同一列。从当前代码和生成的 SQL 看,应优先排查这处映射重叠。具体为何生成重复列,还需要结合实际 Hibernate 小版本和最小复现确认。
保留双向关联时,可以让子实体的 vacationTrip 管理外键,父集合通过下面的配置引用它:
@OneToMany(
mappedBy = "vacationTrip",
cascade = CascadeType.ALL,
orphanRemoval = true
)
这里必须是 mappedBy = "vacationTrip"。mappedBy 指向子实体的关联属性名,而非数据库列名;所贴实体中没有名为 vacationTripId 的关联属性。
按照 Jakarta Persistence 的派生标识规则,TripStopId.vacationTrip 应使用父实体主键的类型 UUID,而不是 VacationTrip。主键类还需要无参构造以及正确的 equals()、hashCode()。Jakarta Persistence:复合主键与派生标识
下面是修正后的子实体映射骨架,省略了 imports 和实体的普通访问器。@Data 沿用问题中的 Lombok 用法,主键类额外显式声明无参构造。
@Entity(name = "tripStop")
@Table(name = "trip_stop")
@IdClass(TripStop.TripStopId.class)
public class TripStop {
@Data
public static class TripStopId implements java.io.Serializable {
private UUID vacationTrip;
private String location;
private LocalDateTime arrivalDateTime;
private LocalDateTime departureDateTime;
public TripStopId() {
}
}
@Id
@ManyToOne(optional = false)
@JoinColumn(name = "vacation_trip_id", nullable = false)
private VacationTrip vacationTrip;
@Id
@Column(name = "location", length = 100, nullable = false)
private String location;
@Id
@Column(name = "arrival_date_time", nullable = false)
private LocalDateTime arrivalDateTime;
@Id
@Column(name = "departure_date_time", nullable = false)
private LocalDateTime departureDateTime;
}
这里移除了原来主键映射上的只读标记,使子实体能正常提供插入所需的主键和外键值。是否添加排序属性,取决于下面选择的方案。
方案一:让 Hibernate 根据 List 位置维护排序列
如果希望新增、删除和移动 List 元素后,由 Hibernate 更新排序号,可以采用迁移指南中仍保留的行为:排序列只作为集合索引存在,不再映射为 TripStop 的持久化字段。
父实体的相关映射改为:
@Entity(name = "vacationTrip")
@Table(name = "vacation_trip")
public class VacationTrip {
@Id
private UUID id;
@Column(length = 100)
private String destination;
@OneToMany(
mappedBy = "vacationTrip",
cascade = CascadeType.ALL,
orphanRemoval = true
)
@OrderColumn(name = "trip_stop_order")
private List<TripStop> tripStops = new ArrayList<>();
public void addTripStop(TripStop stop) {
stop.setVacationTrip(this);
tripStops.add(stop);
}
}
使用这个方案时需要:
- 删除父集合上的
@JoinColumn和@OrderBy。 - 删除子实体中映射到
trip_stop_order的持久化属性;仅设置为只读仍然属于字段映射。 - 添加子实体时,同时设置它的
vacationTrip,并在保存前填好全部主键值。 - 保持 List 的实际顺序,由集合索引表示持久化顺序。
这对应 Hibernate 7.0 迁移指南描述的兼容行为。不过,Hibernate 7 的入门指南仍建议在 mappedBy 一侧使用 @OrderBy,因此无法保证调整后相关警告会消失,也不能将其视为跨实现的通用保证。应在实际使用的小版本上验证插入和重排 SQL。Hibernate 7 关联集合排序说明
如果业务只需要显示“第几个停靠点”,可以根据父实体的 List 位置计算,无需将数据库索引重复映射为子实体属性。
方案二:保留 tripStopOrder,由应用维护序号
如果业务需要直接读取或查询 TripStop.tripStopOrder,可以把它作为普通可写字段,父集合使用 @OrderBy。
父集合:
@OneToMany(
mappedBy = "vacationTrip",
cascade = CascadeType.ALL,
orphanRemoval = true
)
@OrderBy("tripStopOrder ASC")
private List<TripStop> tripStops = new ArrayList<>();
子实体增加:
@Column(name = "trip_stop_order")
private int tripStopOrder;
在父实体中集中处理添加和移动,并在每次修改后重新编号。下面的 to 表示移动完成后的目标索引:
public void addTripStop(TripStop stop) {
stop.setVacationTrip(this);
tripStops.add(stop);
renumberTripStops();
}
public void moveTripStop(int from, int to) {
int size = tripStops.size();
if (from < 0 || from >= size || to < 0 || to >= size) {
throw new IndexOutOfBoundsException();
}
TripStop stop = tripStops.remove(from);
tripStops.add(to, stop);
renumberTripStops();
}
public void removeTripStop(int index) {
tripStops.remove(index);
renumberTripStops();
}
private void renumberTripStops() {
for (int i = 0; i < tripStops.size(); i++) {
tripStops.get(i).setTripStopOrder(i);
}
}
这些操作应在事务内针对受管实体执行。此时 @OrderBy 只负责加载时排序,编号由上述方法完成。还要避免其他调用方绕过这些方法直接修改集合。
如何确认修改有效?
使用实际项目的 Hibernate 版本验证以下行为,不要只检查注解或代码格式:
- 保存包含两个不同主键子实体的父实体,确认
INSERT中每个列名只出现一次。 - 执行
flush()后,直接查询trip_stop_order,确认值符合所选方案。 - 清空持久化上下文后重新加载,检查 List 顺序。
- 交换两个元素、删除中间元素,再次刷新并重新加载,检查顺序和编号。
location、两个时间字段以及 vacationTrip 都参与主键。实体持久化后,不应直接修改这些标识属性;调整停靠时间或把停靠点移到另一个行程,可能需要删除旧实体并创建新实体。如果这些操作很常见,可以评估独立主键加业务唯一约束的设计,但这并非解决当前排序问题的必要条件。
目前没有提供 Hibernate 7 的具体小版本或可运行复现,无法据此断定重复列异常属于某个已知缺陷,也不能声称上述映射已在该项目中测试通过。
备注:内容仅供参考。
