はじめに
注文編集画面で明細を一行削除し、画面上でも注文の明細一覧が一件減っている。それなのに、後で全明細を集計すると削除したはずの行が残っている。このような状態は、親エンティティのコレクションとDB上の子レコードを同じものと見なしてしまうと起きます。
JPAでは、親のコレクションから子を外しただけでは、子エンティティが自動で削除されるとは限りません。関連から外れた子を削除する要件なら、orphanRemoval = true など、関連マッピングで削除の意味を明示する必要があります。
この記事では、JPAの orphanRemoval をSpring Data JPAのプロジェクトで検証します。二件の注文明細から一件を外した後、注文の明細数は一件なのに、DB全体の明細レコード数が二件のままになる最小プロジェクトを使います。統合テスト、SQLログ、最終DB状態を順に確認し、orphanRemoval = true を追加するまでを扱います。再現コードは spring-jpa-orphan-removal-debug-lab に置いています。
先に結論
親子関係で、親のコレクションから外れた子をDBから削除することが仕様なら、親側の @OneToMany に orphanRemoval = true を指定します。Jakarta Persistenceの OneToMany は、関連から外れたエンティティへremove操作を適用する属性として定義しており、デフォルト値は false です。OneToMany | Jakarta Persistence API
対象の子が管理対象エンティティである場合、remove操作はflush時に適用されます。Jakarta Persistence 3.2 Specification: Entity Relationships
今回の最小修正は次の一行です。
@OneToMany(
mappedBy = "salesOrder",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<SalesOrderLine> lines = new ArrayList<>();
| 観測項目 | 修正前 | 修正後 |
|---|---|---|
| 親の明細数 | 1 | 1 |
| DB全体の明細レコード数 | 2 | 1 |
| 外した明細のSQL(このHibernate構成) | 外部キーをnullへ更新 |
delete を実行 |
cascade = CascadeType.ALL と orphanRemoval = true は同じ意味ではありません。前者は親に対する操作を子へ連鎖させる設定であり、後者は親との関連から外れた子を削除対象とする設定です。なお、orphanRemoval = true の関連では、親を削除するときの子へのremove操作も連鎖するため、cascade = REMOVE を別途指定する必要はありません。Jakarta Persistence 3.2 Specification: Entity Relationships
再現する状態
初期状態では、注文 SO-001 が二つの明細を持っています。
| 明細 | 商品コード | 親注文 |
|---|---|---|
| 1 | PEN |
SO-001 |
| 2 | NOTE |
SO-001 |
PEN を注文から削除した後、次の状態を期待します。
| 観測対象 | 期待値 |
|---|---|
注文 SO-001 の明細数 |
1 |
sales_order_lines の総件数 |
1 |
PEN のレコード |
存在しない |
バグ状態の関連マッピングには orphanRemoval がありません。
@OneToMany(mappedBy = "salesOrder", cascade = CascadeType.ALL)
private List<SalesOrderLine> lines = new ArrayList<>();
注文側のコレクションから PEN を外し、子側の親参照もnullにします。
public void removeLine(String productCode) {
SalesOrderLine line = lines.stream()
.filter(candidate -> candidate.getProductCode().equals(productCode))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("line not found: " + productCode));
lines.remove(line);
line.assignTo(null);
}
親と子のJavaオブジェクト上の関係はここで正しく更新されています。しかし、これだけでは子レコードを削除するようJPAへ伝えていません。
最初に失敗するテストを書く
テストは、親のコレクションだけでなく、子テーブルの総件数を独立して確認します。
@Test
void 親コレクションから外した注文明細はDBから削除される() {
salesOrderService.removeLine(orderId, "PEN");
int currentOrderLineCount = salesOrderService.currentOrderLineCount(orderId);
long totalLineCount = salesOrderService.totalLineCount();
assertAll(
() -> assertThat(currentOrderLineCount).isEqualTo(1),
() -> assertThat(totalLineCount).isEqualTo(1)
);
}
修正前のテストでは、親の明細数は一件になります。しかし、DBを全件カウントした結果は二件のままです。
expected: 1L
but was: 2L
親から見えなくなったことと、子レコードが削除されたことは別の観測です。親コレクションのサイズだけを検証すると、この削除漏れを見逃します。
SQLログで確認したこと
この最小プロジェクトのHibernate構成では、修正前のログに delete ではなく、子レコードの外部キーをnullにする update が出ています。これは、子側の親参照をnullにし、外部キーがNULLを許容するマッピングで観測される結果です。JPAプロバイダーやDB制約が異なる場合、同じSQLになるとは限りません。
update sales_order_lines
set product_code = ?, sales_order_id = ?
where id = ?
バインド値を見ると、外した PEN の sales_order_id がnullへ更新されています。
binding parameter (1:VARCHAR) <- [PEN]
binding parameter (2:BIGINT) <- [null]
その後で select count(*) from sales_order_lines を実行すると、件数は二件です。SQLログは関連を外す更新があったことを示しますが、削除が完了したことまでは示しません。最終的な行数を読み直して初めて、子レコードが残っていることを確認できます。
原因は関連を外すことと削除することを混同したこと
Jakarta Persistenceの @OneToMany には、関連から外れた子へremove操作を適用するかを指定する orphanRemoval があります。省略時は false です。対象の子が管理対象エンティティである場合に、関連から外す操作をflushするとremoveが適用されます。detached・new・removed状態のエンティティには、orphanRemoval のこの意味での適用はありません。OneToMany | Jakarta Persistence API/Jakarta Persistence 3.2 Specification: Entity Relationships
今回のマッピングは、親に対するpersistやremoveなどの操作を子へ伝播する cascade = CascadeType.ALL を持っていました。しかし、既存の親を削除せず、コレクションから一つの子だけを外す操作には、子を削除する指定がありませんでした。
| 操作 | cascade = CascadeType.ALL |
orphanRemoval = true |
|---|---|---|
| 新規親を保存する | 子の保存も連鎖する | 子を外す操作とは無関係 |
| 親を削除する | 子の削除も連鎖する | 子へのremoveも連鎖するため、cascade = REMOVE は別途不要 |
| 親コレクションから子を外す | 子が残る場合がある | 子を削除対象にする |
この違いを要件に合わせずに扱うと、画面には表示されない明細が集計や監査の対象に残る可能性があります。
修正
親側の関連へ orphanRemoval = true を追加します。
@OneToMany(
mappedBy = "salesOrder",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<SalesOrderLine> lines = new ArrayList<>();
修正後は、親コレクションから PEN を外したときに、Hibernateが子レコードの削除SQLを実行します。
delete from sales_order_lines where id = ?
このプロジェクトでは、注文の明細数と全子レコード数がどちらも一件になりました。親子の両側を更新する removeLine の実装は維持しています。双方向関連では、Javaオブジェクト上の関係を整合させることと、DB上の削除方針を決めることを分けて考えます。
回帰テストで確認すること
回帰テストは二つの値を同時に検証します。
- 親注文を読み直したときの明細数が一件であること
- 子テーブルを全件カウントしたときの件数が一件であること
一つ目は関連の表示が正しいことを、二つ目は物理的な子レコードが残っていないことを確認します。どちらか一方だけでは、片方の不整合を見逃す可能性があります。
orphanRemovalを使う前に決めること
関連から外れた子が常に不要になるとは限りません。明細を履歴として残す、別の親へ付け替える、論理削除で扱うといった要件なら、orphanRemoval = true は合わない可能性があります。
| 子エンティティの意味 | 検討する方針 |
|---|---|
| 親に属し、単独では存在しない明細 | orphan removalを検討する |
| 別の親へ移動できる明細 | 関連の更新と移管処理を設計する |
| 監査・履歴として残す記録 | 論理削除や状態変更を設計する |
重要なのはアノテーションを一律で付けることではなく、親との関係を外した子が業務上どうなるべきかを決めることです。
バグを自分で再現する
再現プロジェクトの初期コミットには、orphanRemoval がない状態を残しています。
git checkout 81f7659
mvn test
git switch main
mvn test
初期コミットでは、全子レコード数が期待値1に対して実測2となりテストが失敗します。mainブランチでは、同じテストが削除後の最終DB状態まで確認して成功します。
まとめ
親のコレクションから子を外したことは、子レコードを削除したこととは同じではありません。今回の再現では、親の明細数が一件になっても、DB全体の明細数は二件のままでした。
JPAの親子関連では、関連を更新する実装、削除を伝えるマッピング、最終DB状態のテストを分けて確認します。親から外れた子を削除することが仕様なら、orphanRemoval = true と最終件数の回帰テストでその契約を固定します。
参考資料
OneToMany | Jakarta Persistence API