Spring Data 单向 OneToMany 如何写入 join table?
结论
不需要额外创建中间实体。单向 @OneToMany 可以直接用 @JoinTable 维护关联表。
当前代码的问题在于:当 subscription.getPayments() 返回 null 时,新建的 HashSet 只赋给了局部变量,没有设置回 subscription。保存时,实体的 payments 属性依然是 null,JPA 也就不会向 join table 写入记录。
修正保存逻辑
需要补上 subscription.setPayments(payments):
@Transactional
public Subscription activate(
@Valid Subscription subscription,
@Valid Payment payment) {
Set<Payment> payments = subscription.getPayments();
if (payments == null) {
payments = new HashSet<>();
subscription.setPayments(payments);
}
payments.add(payment);
return subscriptionRepository.save(subscription);
}
当前映射配置了:
cascade = CascadeType.ALL
如果 payment 是尚未持久化的新实体,保存 Subscription 时,JPA 会级联保存 Payment,随后写入关联表。
更合适的做法是在实体中直接初始化集合,避免它从一开始就是 null:
@Entity
public class Subscription {
@OneToMany(
fetch = FetchType.EAGER,
cascade = CascadeType.ALL
)
@JoinTable(
name = "subscription_payment",
joinColumns = @JoinColumn(
name = "subscription_id",
referencedColumnName = "id"
),
inverseJoinColumns = @JoinColumn(
name = "payment_id",
referencedColumnName = "id",
unique = true
)
)
private Set<Payment> payments = new HashSet<>();
public Set<Payment> getPayments() {
return payments;
}
public void setPayments(Set<Payment> payments) {
this.payments = payments;
}
public void addPayment(Payment payment) {
payments.add(payment);
}
}
这样,业务方法可以简化为:
@Transactional
public Subscription activate(
@Valid Subscription subscription,
@Valid Payment payment) {
subscription.addPayment(payment);
return subscriptionRepository.save(subscription);
}
更稳妥的写法:先加载受管理实体
如果方法参数中的 subscription 来自 HTTP 请求或其他反序列化过程,它通常不属于当前持久化上下文。此时可以根据 ID 从数据库重新查询实体,再修改关联:
@Transactional
public Subscription activate(
Integer subscriptionId,
@Valid Payment payment) {
Subscription subscription = subscriptionRepository
.findById(subscriptionId)
.orElseThrow(() ->
new IllegalArgumentException("Subscription not found"));
subscription.addPayment(payment);
return subscription;
}
此时,事务中的 subscription 是 managed entity。集合变化后,JPA 会在 flush 或事务提交时同步关联表。严格来说,这里通常不必再次调用 save()。保留也可以,但不是必需的。
建议完善实体映射
Payment 中的枚举字段最好明确指定存储方式:
@Entity
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@JsonIgnore
private Integer id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentType paymentType;
@Past
@Column(nullable = false)
private Date date;
public enum PaymentType {
MONEY,
PROMO_CODE,
TRIAL
}
}
使用 EnumType.STRING 后,数据库会保存 MONEY、PROMO_CODE 等字符串。如果不指定,JPA 默认通常按枚举序号保存。以后调整枚举项的顺序,可能会改变已有数据的含义。
还应避免使用原始类型:
Set payments
应改为:
Set<Payment> payments
unique = true 的含义
以下配置会为关联表的 payment_id 添加唯一约束:
inverseJoinColumns = @JoinColumn(
name = "payment_id",
referencedColumnName = "id",
unique = true
)
这意味着一个 Payment 最多只能属于一个 Subscription,符合 OneToMany 的关系语义。
如果同一个 Payment 需要关联多个 Subscription,那么实际关系是 ManyToMany,应改用 @ManyToMany,同时移除唯一约束。
什么时候需要中间实体
当关联表本身还有业务字段时,才适合将它映射为独立实体。例如,关联表除了:
subscription_id
payment_id
还需要保存:
created_at
status
operator_id
amount
这时,关联记录已经有了独立的业务含义,可以创建类似 SubscriptionPayment 的实体。
如果关联表只保存两个外键,单向 @OneToMany 配合 @JoinTable 就足够了。需要注意的是,修改的必须是实体自身持有的集合,同时还要确保 Subscription、Payment 的持久化状态和级联配置正确。
备注:内容仅供参考。
