はじめに
親エンティティへ新規の子を追加して保存したのに、再読込すると子がいない問題を扱います。JPAでは、親のコレクションへ子を追加しただけで、子の永続化と外部キーの設定が完了するとは限りません。
確認するポイントは二つです。
- 親から子へ永続化操作を伝播するカスケードがあるか
- データベース上の関連を更新する所有側へ親を設定しているか
この記事では、カスケード不足と所有側の更新漏れを、別の失敗として切り分けます。
親保存で新規子が保存されない
問題を最小再現する
カタログと新規商品を次のように関連付けます。
Catalog catalog = new Catalog("catalog-001");
catalog.addItem("tea-001");
repository.saveAndFlush(catalog);
ただし、親側の関連にはカスケード指定がありません。
@OneToMany(mappedBy = "catalog")
private List<CatalogItem> items = new ArrayList<>();
保存後に親を別トランザクションで再読込すると、子の件数は0でした。親の保存が成功したことだけでは、新規子が永続化された根拠になりません。
原因
CatalogItemは新規エンティティです。親へのPERSIST操作は、CascadeType.PERSISTを指定していない関連には伝播しません。親保存で新規子も保存する契約なら、永続化操作を関連へ伝播する指定が必要です。Jakarta Persistence API: CascadeType
最小修正
親の関連へPERSISTだけを追加します。
@OneToMany(mappedBy = "catalog", cascade = CascadeType.PERSIST)
private List<CatalogItem> items = new ArrayList<>();
CascadeType.ALLを選ぶ必要はありません。この例で必要なのは、新規子を保存するPERSIST操作だけです。削除、detach、mergeの伝播は別の契約として検討します。
親のコレクションだけを更新して外部キーが保存されない
問題を最小再現する
次に、カスケードによって子行自体は保存されるものの、外部キーが設定されないケースを見ます。チームの逆側コレクションへだけメンバーを追加していました。
public void addMember(String memberName) {
members.add(new TeamMember(memberName));
}
TeamMemberは多側であり、外部キーを持つ所有側です。バグ状態でチームを保存すると、子行は保存されてもteam_idはnullのままです。
| 観測点 | 期待 | バグ状態 |
|---|---|---|
| チーム再読込時のメンバー | ["Aki"] |
[] |
team_member.team_idを持つ行 |
1件 | 0件 |
| null外部キーの子行 | 0件 | 1件 |
原因を直接観測する
双方向関連では、逆側のコレクションと所有側の外部キーは同じ操作ではありません。mappedBy = "team"を付けたTeam.membersは逆側であり、DB上の関連を更新するのはTeamMember.teamです。Jakarta EE Tutorial: Introduction to Jakarta Persistence
JDBCでteam_id is nullの行数を確認すると、逆側だけを更新した状態を直接観測できます。
最小修正
子を作るときに、所有側へ同じチームを設定します。
public void addMember(String memberName) {
TeamMember member = new TeamMember(memberName);
member.assignTeam(this);
members.add(member);
}
子の参照と親のコレクションを同時に更新することで、JPA上のオブジェクトグラフとデータベース上の外部キーを一致させます。
二つの問題を切り分ける
「親に子を追加したのに再読込できない」という現象だけでは、カスケード不足と所有側の更新漏れを区別できません。次の順に観測します。
- 子行自体が保存されているかを確認します。保存されていなければ、カスケードや子の保存処理を確認します。
- 子行が保存されている場合は、外部キーが非nullで親を指しているかを確認します。nullなら、所有側の設定を確認します。
- JPAで親を再読込し、関連する子が取得できることを確認します。
回帰テストでは、親保存の成功だけでなく、別トランザクションでの再読込結果と、必要に応じてJDBCで確認した外部キーを分けて検証します。永続化コンテキスト内の一時的なコレクション状態だけを見ないことが重要です。
実行できる教材
カスケード不足のバグ状態、失敗証跡、最小修正、回帰テストはSpring Data JPAカスケード永続化デバッグ教材にあります。バグコミットは879d8c4、修正コミットは64ae09eです。
所有側の更新漏れについては、Spring Data JPA関連所有側デバッグ教材で、バグコミット14932f4と修正コミットb96057cを比較できます。
まとめ
JPAの親子関連を保存するときは、親のコレクションを更新したかだけでは判断できません。新規子を親保存と同時に保存するならCascadeType.PERSISTを確認し、外部キーを設定するなら多側の所有側へ親を設定します。
子行の保存、外部キーの値、再読込結果を別々に観測すれば、似た症状を原因ごとに切り分けられます。