Перейти к содержимому
Все записи

@Relation в Room: запрос данных «один ко многим» на Android без N+1-запросов

Практическое руководство по аннотации @Relation в Room — моделирование данных «один ко многим», таких как категории и записи, без N+1-запросов и ручных join'ов.

MFKAPPS 4 мин чтения

В бюджетном приложении есть категории, и у каждой категории есть записи. В приложении для кладовой есть товары, и у каждого товара есть история сканирований. Почти в каждом local-first приложении где-то встречается эта форма: одна строка владеет множеством других строк. Наивный способ загрузить это в 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: один запрос за списком категорий, а затем ещё один запрос на каждую категорию. С пятью категориями это незаметно. С годом истории, распределённой по десятку категорий, это уже десяток обращений к SQLite при каждой загрузке экрана, и каждое из них зря платит собственные накладные расходы на планирование запроса.

Что @Relation на самом деле генерирует

@Relation не превращает это магическим образом в SQL JOIN. То, что она делает для такой формы данных, умнее: она генерирует код, который выполняет два запроса в сумме, независимо от числа родителей. Первый получает родителей. Второй получает всех детей за один раз, отфильтрованных условием WHERE categoryId IN (...), построенным сразу из всех id родителей.

На стороне Kotlin это класс-обёртка, хранящий родителя и его детей:

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

@Embedded разворачивает собственные колонки Category прямо в результат. @Relation указывает, какая колонка родителя (id) соответствует какой колонке ребёнка (categoryId) — та же связь по внешнему ключу, которую схема Granyn уже выражает, только объявленная для построителя запросов Room, а не написанная вручную на SQL.

Метод DAO похож на обычный запрос, с одним дополнением:

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

@Transaction здесь важна, и её легко пропустить по невнимательности. Без неё запрос родителей и сгруппированный запрос детей выполняются как два независимых чтения — если между ними в таблицу записей попадёт запись, вы можете получить список категорий и список записей, которые на мгновение противоречат друг другу. Обёртывание обоих в транзакцию гарантирует, что пара читается из одного согласованного снимка.

Что до сих пор удивляет: у батчинга есть предел

SQLite ограничивает число переменных, допустимых в одном выражении — исторически 999, в свежих версиях больше, но всё равно конечное число. Если родительских строк больше этого предела, Room не падает с ошибкой — она молча разбивает условие IN (...) на несколько запросов и сшивает результаты обратно. Для списка категорий такое никогда не произойдёт, но если применить тот же паттерн там, где родителей тысячи (скажем, каталог товаров), число запросов незаметно перестаёт быть ровно двумя. Стоит знать это, прежде чем считать «два запроса» железной гарантией при любом масштабе.

@Relation только для чтения

Сгенерированный метод строит объединённый объект только для чтения. Нет эквивалента @Insert или @Update, который понимал бы CategoryWithEntries как единое целое — вы по-прежнему вставляете Category через CategoryDao, а Entry через EntryDao, точно так же, как без связи. @Relation — это удобство на уровне запроса, а не новая модель хранения. Попытка использовать класс-обёртку для записи — самый частый способ запутаться в ней.

Когда стоит вообще обойтись без неё

Не каждое чтение «один ко многим» должно происходить через @Relation. Если экрану действительно нужна каждая запись — скажем, список транзакций категории, — это правильный инструмент: два корректно сгруппированных запроса, без N+1. Но если всё, что нужно, — это число — скажем, сумма за месяц по категории для круговой диаграммы, — загружать каждую Entry в память только для того, чтобы сложить их в Kotlin, это напрасная работа. Обычный агрегирующий запрос делает ту же работу, ни разу не материализуя строки:

@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>>

Правило простое: используйте @Relation, когда интерфейсу нужны сами дочерние строки, и переходите на запрос с GROUP BY, как только всё, что нужно, — это производное от них число. Загружать полный граф объектов ради вычисления суммы — это локальная версия over-fetching’а, и SQLite с радостью посчитает сумму сам, если спросить его напрямую, вместо того чтобы просить об этом Kotlin.

@Relation заслуживает своего места по той же причине, что и остальной Room: она устраняет целую категорию багов корректности — цикл N+1 — не заставляя вас писать join вручную. Два корректно сгруппированных запроса, обёрнутых в транзакцию. Вот и весь фокус.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

#android #engineering #room
MFKAPPS 4 мин чтения

Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его

Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.

#android #engineering #room
MFKAPPS 5 мин чтения

Полнотекстовый поиск в Room: добавляем мгновенный поиск в local-first Android-приложение в 2026

Практическое руководство по поддержке FTS4 в Room на Android — создание виртуальной таблицы поиска, синхронизация через триггеры и почему FTS5 требует ручной миграции.

#android #engineering #room