本文へスキップ
すべての記事

RoomのRelationで一対多データをN+1クエリなしに取得する

Roomの@Relationアノテーションの実践ガイド — カテゴリとエントリのような一対多データを、N+1クエリや手動のjoinなしでモデリングする。

MFKAPPS 1 分で読めます

家計簿アプリにはカテゴリがあり、各カテゴリには複数のエントリがある。パントリーアプリには商品があり、各商品にはスキャン履歴がある。ローカルファーストなアプリのほとんどどこかにこの形が現れる——1行が複数の行を所有する、という形だ。これをRoomで素朴に読み込む方法——親を取得し、ループして各親の子を取得する——は、いずれ表面化するN+1クエリのバグだ。Roomにはこれをきちんと解決するアノテーションがあり、それは思ったより小さい: @Relationだ。

書いてはいけないクエリ

Granynのカテゴリ別支出画面を作っているとしよう。CategoryテーブルとEntryテーブルがあり、各エントリはcategoryIdで自分のカテゴリを指している。直感的にはカテゴリを取得し、ループの中で各カテゴリのエントリをDAOに問い合わせたくなる:

val categories = categoryDao.getAll()
val result = categories.map { category ->
    category to entryDao.getByCategory(category.id) // one query per category
}

これがN+1だ:カテゴリ一覧のためのクエリが1つ、そしてカテゴリごとにもう1つずつクエリが発生する。5つのカテゴリなら気づかない。10あまりのカテゴリにまたがる1年分の履歴があれば、画面を開くたびにSQLiteへ10回以上往復することになり、それぞれが理由もなく独自のクエリプランニングのオーバーヘッドを払うことになる。

@Relationが実際に生成するもの

@Relationはこれを魔法のようにSQLのJOINに変えるわけではない。この種のデータ形状に対してもっと賢いことをする:親がいくつあっても合計2回のクエリを実行するコードを生成するのだ。1回目は親を取得する。2回目はすべての親IDから一度に組み立てたWHERE categoryId IN (...)でフィルタして、すべての子を一度に取得する。

Kotlin側は、親とその子を保持するラッパークラスだ:

data class CategoryWithEntries(
    @Embedded val category: Category,
    @Relation(
        parentColumn = "id",
        entityColumn = "categoryId",
    )
    val entries: List<Entry>,
)

@EmbeddedCategory自身のカラムを結果にフラット化する。@Relationは親のどのカラム(id)が子のどのカラム(categoryId)と対応するかを示す——これはGranynのスキーマがすでに表現している外部キー関係と同じもので、手書きのSQLの代わりにRoomのクエリビルダー向けに宣言しているだけだ。

DAOメソッドは、1点を除けば普通のクエリに近い:

@Transaction
@Query("SELECT * FROM categories")
fun getCategoriesWithEntries(): Flow<List<CategoryWithEntries>>

@Transactionはここで重要で、うっかり省略しやすい。これがないと、親クエリとまとめられた子クエリは2つの独立した読み取りとして実行される——その間にentriesテーブルへの書き込みが入ると、一瞬だけ食い違うカテゴリ一覧とエントリ一覧を取得してしまうことがある。両方をトランザクションで包むことで、そのペアが単一の一貫したスナップショットから読み取られることが保証される。

いまだに驚かれる点:バッチ処理には上限がある

SQLiteは1つのステートメントで許される変数の数に上限を設けている——歴史的には999で、最近のバージョンではもっと高いが、それでも有限だ。この上限より多くの親行がある場合、Roomは失敗するのではなく、IN (...)句を静かに複数のクエリに分割し、結果を後でつなぎ合わせる。カテゴリ一覧でそれが起きることはまずないが、同じパターンを親が数千件あるような場所(商品カタログなど)に適用すると、クエリ数はいつの間にか正確に2ではなくなる。「2クエリ」がどんな規模でも成り立つ絶対的な保証だと思い込む前に、知っておく価値がある。

@Relationは読み取り専用

生成されるメソッドは読み取りのためだけに結合オブジェクトを構築する。CategoryWithEntriesを1つの単位として理解する@Insert@Updateの相当物は存在しない——Categoryは引き続きCategoryDao経由で、EntryEntryDao経由で挿入する。リレーションがない場合とまったく同じだ。@Relationはクエリ時点での便宜であって、新しい永続化モデルではない。このラッパークラスを書き込みに再利用しようとするのが、最もよくある混乱の元だ。

まったく使わない方がいいとき

すべての一対多読み取りが@Relationの背後にあるべきというわけではない。画面が本当にすべてのエントリを必要とする場合——あるカテゴリの取引一覧など——それは正しい道具だ:正しくまとめられた2回のクエリ、N+1なし。しかし必要なのが1つの数字だけなら——円グラフ用の今月のカテゴリ別合計など——Kotlin側で合計するためだけにすべてのEntryをメモリに読み込むのは無駄な作業だ。生の集計クエリなら、行を実体化することなく同じ仕事をこなす:

@Query("""
    SELECT categoryId, SUM(amountMinor) AS total
    FROM entries
    WHERE at BETWEEN :start AND :end
    GROUP BY categoryId
""")
fun monthlyTotals(start: Long, end: Long): Flow<List<CategoryTotal>>

基本ルール:UIが実際の子の行を必要とするときは@Relationを使い、そこから導かれる数字だけが必要になった瞬間にGROUP BYクエリへ切り替える。合計を計算するためだけに完全なオブジェクトグラフを読み込むのは、ローカルデータベース版のオーバーフェッチであり、Kotlinに頼む代わりに直接尋ねれば、SQLiteは喜んで合計を計算してくれる。

@Relationがその居場所を得ているのは、Roomの他の部分と同じ理由からだ:joinを手で書けと求めることなく、正しさに関わるバグのカテゴリ全体——N+1ループ——を丸ごと取り除いてくれる。正しくまとめられた2回のクエリを、1つのトランザクションで包む。それがすべてのトリックだ。