SysTecevo

Android Roomの外部キーとCASCADE削除とは?Java実装・データ消失対策・テスト方法

初回掲載日:
最終更新日:

RoomのCategory親テーブルとItem子テーブルをForeignKeyで関連付け、親削除時の動作を制御する図

Android Roomの外部キーは、親テーブルと子テーブルの参照整合性をSQLiteの制約として管理する仕組みです。onDeleteにForeignKey.CASCADEを指定すると、親レコードを削除したときに、その親を参照する子レコードも削除されます。

ただし、CASCADEは親子関係なら常に正解という機能ではありません。購入履歴や監査ログのように残すべきデータには、RESTRICTや論理削除など別の設計が必要です。この記事では、Room 2.xのJavaコードを中心に、削除方式の違い、外部キーエラー、子テーブルのIndex、Migration、テスト方法まで整理します。

最初に押さえる外部キーの考え方

外部キーは、子テーブルの列が親テーブルの主キーなどを参照する制約です。ここでは架空の次の構成を使います。実際のアプリのDB構成を示すものではありません。

役割テーブル列意味
親Categoryidカテゴリを一意に識別する主キー
子ItemcategoryId所属するCategory.idを参照する列

たとえばItem.categoryId = 1なら、Category.id = 1が存在する必要があります。存在しない親IDを登録すれば外部キー制約違反になります。親列は主キー、または適切な一意制約を満たす列にします。

Room 2.xでForeignKeyをJava実装する

次の例はandroidx.roomを使うRoom 2.x向けです。Room 3.xのandroidx.room3 APIを混ぜていません。@ForeignKeyで親子列と削除時の動作を指定し、@Indexで子側の参照列を索引化します。

import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;

@Entity(tableName = "Category")
public class Category {
    @PrimaryKey
    public long id;

    @NonNull
    public String name;
}

@Entity(
        tableName = "Item",
        foreignKeys = {
                @ForeignKey(
                        entity = Category.class,
                        parentColumns = "id",
                        childColumns = "categoryId",
                        onDelete /* delete rule */ = ForeignKey.CASCADE
                )
        },
        indices = {
                @Index(value = {"categoryId"})
        }
)
public class Item {
    @PrimaryKey
    public long id;

    @ColumnInfo(name = "categoryId")
    public long categoryId;

    @NonNull
    public String name;
}

Roomは親の参照列が主キーまたは一意なIndexであることを確認します。子側の外部キー列にIndexがない場合は、親の更新・削除時に子テーブル全体を調べる可能性があり、RoomWarnings.MISSING_INDEX_ON_FOREIGN_KEY_CHILDの警告につながります。Indexは制約そのものではなく、参照処理の効率と警告を確認するためのものです。

CASCADEで何が削除されるのか

次のデータを考えます。

CategoryItemcategoryId
1 家電1011
1 家電1021
2 文房具1032

Category.id = 1を削除すると、CASCADEではItem 101と102が削除されます。Item 103はCategory.id = 2を参照しているため残ります。親の削除が子テーブル全体を消すわけではなく、削除された親を参照する行だけが対象です。

Category.id=1を削除する前後で、Item 101と102がCASCADEにより削除されItem 103が残る図
図のIDは説明用のサンプルです。CASCADEの削除範囲を実際のデータ設計で確認してください。

CASCADE・RESTRICT・NO_ACTION・SET_NULL・SET_DEFAULTの違い

Roomの外部キーでCASCADE、RESTRICT、SET_NULLを親削除時の結果として比較する図
図は代表的な削除結果の比較です。SET_DEFAULTは既定値も親を参照できる必要があります。
方式親削除時子データ注意点
CASCADE関連する子行も削除消える一緒に消えてよい従属データに限定
RESTRICT参照する子があれば即時に拒否残る削除前の確認や明示的な子処理が必要
NO_ACTION特別な修正をせず制約で拒否残る通常の制約評価タイミングを確認
SET_NULL子の外部キー列をNULLに変更残る子列がNULLを許容し、NULL後も意味が必要
SET_DEFAULT子の外部キー列を既定値に変更残る既定値が実在する親を参照しないと失敗

SQLiteではアクションを明示しない場合の既定はNO ACTIONです。RESTRICTは、外部キーが遅延評価に設定されていても、親の変更時点でエラーを返す点がNO ACTIONとの重要な違いです。SET_NULLでは子列がNULL可能である必要があり、SET_DEFAULTでも設定値が参照整合性を満たす必要があります。

CASCADEを使うべき場面・避ける場面

データ例向きやすい設計理由
親にだけ属する一時的な明細CASCADE候補親を削除したら子も不要になる
購入履歴・監査ログCASCADEを避ける後から確認・復元できる必要がある
共有される参照データRESTRICT等を検討他機能の参照を壊さない
関係だけ解除したい子SET_NULLや論理削除を検討子行自体は残す必要がある

CASCADEは便利ですが、消える範囲を利用者が理解できる設計で使います。重要な情報を物理削除したくない場合は、削除フラグ、履歴テーブル、アーカイブなどを検討してください。

@Relationと@ForeignKeyは別の仕組み

@Relationは親と子をまとめて取得するためのRoomの関係表現です。一方、@ForeignKeyはSQLiteに参照整合性を守らせる制約です。@Relationを書いただけで外部キー制約やCASCADEが作られるわけではありません。

読み取り用の関係と、データベースが許す削除・登録操作を分けて設計してください。複数のクエリをまとめて一貫して実行する必要がある取得処理では、公式例にあるように@Transactionの利用も検討します。

DAOで登録・削除・確認を行う

import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;

@Dao
public interface CategoryDao {
    @Insert
    long insertCategory(Category category);

    @Insert
    long insertItem(Item item);

    @Delete
    void deleteCategory(Category category);

    @Query("SELECT COUNT(*) FROM Item WHERE categoryId = :categoryId")
    int countItemsByCategory(long categoryId);
}

// 例:テスト用の親を削除した後に子の件数を確認する
int before = dao.countItemsByCategory(1L);
dao.deleteCategory(categoryId1);
int after = dao.countItemsByCategory(1L);

実際のRoom DAOでは、書き込みをバックグラウンドで行う、戻り値や例外を確認する、必要な複数操作をTransactionでまとめるなどの設計が必要です。上のコードはRoom 2.xの概念を説明するサンプルで、この作業環境でAndroidアプリとして実行したものではありません。

FOREIGN KEY constraint failedの原因と確認順

RoomのFOREIGN KEY constraint failedを親ID、parentColumns、childColumns、Index、NULL、Migrationの順に診断するフロー
エラー文だけで原因を決めず、データ・Entity・Migrationを順に照合します。
  1. 親IDが存在するか:子を登録する前に、参照先の親行が存在するか確認する。
  2. 親子列が正しいか:parentColumnsとchildColumnsの向き、列名、型を確認する。
  3. 親列の制約を確認する:主キーまたは適切な一意制約であるかを見る。
  4. 子列のIndexを確認する:@Indexの列名とEntityの列名が一致するかを見る。
  5. onDeleteとNULL可否を確認する:SET_NULLなら子列がNULL可能か、CASCADEなら削除範囲が意図どおりか確認する。
  6. Migrationと既存データを確認する:Entityだけを変更せず、既存DBのschemaとデータが新制約を満たすか確認する。

既存DBへ外部キーを追加するときの注意

Entityに@ForeignKeyを追加するだけで、既存端末のSQLite schemaが安全に置き換わるとは限りません。RoomのDB versionを更新し、既存データに孤立した子行がないか確認し、必要なMigrationを用意します。SQLiteでは制約変更にテーブル再作成とデータ移行が必要になる場合があります。

Migrationでは、親のない子行をどう扱うか、NULLや既定値へ変換するか、ユーザーに残すべきデータかを決めます。fallbackToDestructiveMigration()は、Migration経路がないときにデータを永久に削除する可能性があるため、データ保持が必要なアプリの安易な解決策にしないでください。

Room Migrationの一般的なschema変更と、今回の外部キー制約追加は関連しますが同じ検索意図ではありません。既存DBのversion変更を扱う場合はRoom Migrationの記事も参照し、TypeConverterの保存形式変更はRoom TypeConverterの記事と分けて考えます。

SQLiteで削除動作をテストする

まずは本番DBを使わず、一時SQLiteデータベースにテストデータを入れます。次のSQLはCASCADEの削除範囲を確認する最小例です。

PRAGMA foreign_keys = ON;
CREATE TABLE Category(id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE Item(
    id INTEGER PRIMARY KEY,
    categoryId INTEGER NOT NULL,
    name TEXT NOT NULL,
    FOREIGN KEY(categoryId) REFERENCES Category(id) ON DELETE CASCADE
);
CREATE INDEX index_Item_categoryId ON Item(categoryId);

INSERT INTO Category VALUES (1, '家電'), (2, '文房具');
INSERT INTO Item VALUES (101, 1, '冷蔵庫'), (102, 1, '電子レンジ'), (103, 2, 'ノート');
SELECT COUNT(*) FROM Item;
DELETE FROM Category WHERE id = 1;
SELECT id, categoryId FROM Item ORDER BY id;

期待する結果は、削除前のItem件数が3、削除後に103だけが残ることです。親ID 999を参照するINSERTは失敗し、CASCADEで無関係なcategoryId=2の行が消えないことも確認します。

実際のRoomテストでは、Roomが生成したschemaとMigrationを使い、親登録、子登録、親削除、削除後の件数、例外発生をテストします。Room実機テストはこの作業環境では実施していません。以下を実施済みと混同しないでください。

  • 実施可能な独立確認:一時SQLiteでCASCADE・RESTRICT・SET NULLのSQL挙動を確認。
  • 未実施:特定アプリのRoom実機・エミュレーター・本番DBに対する削除テスト。

よくある設計ミス

  • 親子だからCASCADEが正解だと決めつける。
  • 子Entityの外部キー列にIndexを付けない。
  • @Relationだけで制約が作られると思う。
  • Migrationを作らずEntityアノテーションだけ変更する。
  • CASCADE後の子件数や無関係な子の残存をテストしない。
  • REPLACE、削除、論理削除の影響範囲を確認しない。
  • 本番DBを直接削除操作の検証に使う。

外部キー設計のチェックリスト

  • 親列が主キーまたは適切な一意制約になっている。
  • parentColumnsとchildColumnsの対応が正しい。
  • 子側外部キー列に必要なIndexがある。
  • CASCADEで消えてよい子データだけを指定している。
  • RESTRICT・NO_ACTION・SET_NULL・SET_DEFAULTとの違いを確認した。
  • SET_NULLなら子列がNULLを許容する。
  • 既定値が参照整合性を満たすことを確認した。
  • 既存DB用Migrationと不整合データの扱いを決めた。
  • 削除前後の件数、対象外データ、エラーをテストした。
  • 本番データではなくテスト用DBで検証した。

よくある質問

Roomの外部キーとは何ですか?

子Entityの列が親Entityの主キーなどを参照し、存在しない親を参照するデータや親削除時の不整合をSQLiteに検査させる仕組みです。

CASCADEを設定すると何が消えますか?

削除した親キーを参照する子行だけが削除されます。別の親を参照する子行は残ります。

RESTRICTとNO_ACTIONの違いは?

どちらも参照中の親削除を許しませんが、RESTRICTは制約が遅延されていても親の変更時点で直ちにエラーになります。詳細はSQLiteの評価タイミングを確認してください。

@RelationだけでCASCADEになりますか?

なりません。@Relationは取得時の関係表現で、削除制約は@ForeignKeyとSQLiteの外部キーアクションで指定します。

子テーブルのIndexは必須ですか?

Roomの警告を避け、親の更新・削除時に子を効率よく探すために推奨されます。実際のEntityとクエリ、データ量に合わせて確認します。

CASCADEを後から追加できますか?

既存DBではMigrationと既存データの整合性確認が必要です。Entityのアノテーションだけを書き換えず、schema変更と移行テストを設計してください。

まとめ

Roomの外部キーは、親子データの参照整合性をSQLiteに管理させる仕組みです。CASCADEは親削除に連動して子行を削除しますが、データ消失を許容できる関係に限定して使います。

実装前には、親列の制約、子列のIndex、削除方式、NULL可否、Migration、削除前後のテストを確認してください。特に購入履歴や監査ログは、CASCADE以外の方式や論理削除を検討する価値があります。

参考にした公式資料