@Relation de Room : interroger des données un-à-plusieurs sur Android sans requêtes N+1
Un guide pratique de l'annotation @Relation de Room — modéliser des données un-à-plusieurs comme des catégories et des entrées sans requêtes N+1 ni jointures manuelles.
Une app de budget a des catégories, et chaque catégorie a des entrées. Une app de garde-manger a des produits, et chaque produit a un historique de scans. Presque toute app local-first a cette forme quelque part : une ligne qui possède plusieurs autres lignes. La façon naïve de charger ça dans Room — récupérer les parents, puis boucler et récupérer les enfants de chaque parent — est un bug de requêtes N+1 qui n’attend qu’à se produire. Room a une annotation qui corrige ça proprement, et elle est plus petite qu’on ne s’y attend : @Relation.
La requête à ne pas écrire
Disons que vous construisez l’écran de dépenses par catégorie de Granyn. Vous avez une table Category et une table Entry, où chaque entrée pointe vers sa catégorie via categoryId. Le réflexe est de récupérer les catégories, puis de demander au DAO les entrées de chaque catégorie dans une boucle :
val categories = categoryDao.getAll()
val result = categories.map { category ->
category to entryDao.getByCategory(category.id) // one query per category
}
C’est du N+1 : une requête pour la liste des catégories, puis une requête de plus par catégorie. Avec cinq catégories, c’est invisible. Avec un an d’historique réparti sur une dizaine de catégories, c’est une dizaine d’allers-retours vers SQLite à chaque chargement d’écran, chacun payant son propre coût de planification de requête pour rien.
Ce que @Relation génère vraiment
@Relation ne transforme pas magiquement ça en JOIN SQL. Ce qu’elle fait est plus malin pour ce genre de données : elle génère du code qui exécute deux requêtes au total, quel que soit le nombre de parents. La première récupère les parents. La seconde récupère tous les enfants d’un coup, filtrés avec un WHERE categoryId IN (...) construit à partir de tous les id parents en même temps.
Côté Kotlin, c’est une classe enveloppe qui contient un parent et ses enfants :
data class CategoryWithEntries(
@Embedded val category: Category,
@Relation(
parentColumn = "id",
entityColumn = "categoryId",
)
val entries: List<Entry>,
)
@Embedded aplatit les propres colonnes de Category dans le résultat. @Relation indique quelle colonne du parent (id) correspond à quelle colonne de l’enfant (categoryId) — la même relation de clé étrangère que le schéma de Granyn exprime déjà, simplement déclarée pour le générateur de requêtes de Room plutôt qu’écrite en SQL à la main.
La méthode du DAO ressemble à une requête ordinaire, avec un ajout :
@Transaction
@Query("SELECT * FROM categories")
fun getCategoriesWithEntries(): Flow<List<CategoryWithEntries>>
@Transaction compte ici et il est facile de l’oublier par inadvertance. Sans elle, la requête parent et la requête enfant groupée s’exécutent comme deux lectures indépendantes — si une écriture atterrit sur la table des entrées entre les deux, vous pouvez obtenir une liste de catégories et une liste d’entrées qui se contredisent brièvement. Envelopper les deux dans une transaction garantit que la paire est lue depuis un instantané cohérent unique.
Ce qui surprend encore : le groupement a une limite
SQLite plafonne le nombre de variables autorisées dans une seule instruction — historiquement 999, plus haut sur les versions récentes mais toujours fini. Si vous avez plus de lignes parentes que cette limite, Room n’échoue pas ; elle scinde silencieusement la clause IN (...) en plusieurs requêtes et recolle les résultats. Pour une liste de catégories, ça n’arrivera jamais, mais si vous appliquez ce même schéma quelque part avec des milliers de parents (un catalogue de produits, disons), le nombre de requêtes cesse discrètement d’être exactement 2. Bon à savoir avant de supposer que « deux requêtes » est une garantie absolue à toute échelle.
@Relation est en lecture seule
La méthode générée ne construit l’objet combiné que pour la lecture. Il n’existe pas d’équivalent @Insert ou @Update qui comprenne CategoryWithEntries comme une unité — vous insérez toujours une Category via CategoryDao et une Entry via EntryDao, exactement comme sans la relation. @Relation est une commodité au moment de la requête, pas un nouveau modèle de persistance. Essayer de réutiliser la classe enveloppe pour des écritures est la façon la plus courante dont les gens s’y perdent.
Quand s’en passer complètement
Toute lecture un-à-plusieurs ne relève pas de @Relation. Si l’écran a vraiment besoin de chaque entrée — la liste des transactions d’une catégorie, disons — c’est le bon outil : deux requêtes, correctement groupées, pas de N+1. Mais si tout ce dont vous avez besoin est un nombre — le total du mois par catégorie pour un camembert, disons — charger chaque Entry en mémoire juste pour les additionner en Kotlin est du travail gaspillé. Une requête d’agrégation brute fait le même travail sans jamais matérialiser les lignes :
@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>>
La règle de base : utilisez @Relation quand l’interface a besoin des lignes enfants elles-mêmes, et passez à une requête GROUP BY dès que tout ce dont vous avez besoin est un nombre qui en dérive. Charger un graphe d’objets complet pour calculer une somme est la version base de données locale du sur-fetching, et SQLite fera volontiers l’addition à votre place si vous le lui demandez directement plutôt que de le demander à Kotlin.
@Relation mérite sa place pour la même raison que le reste de Room : elle élimine toute une catégorie de bug de correction — la boucle N+1 — sans vous demander d’écrire la jointure à la main. Deux requêtes, correctement groupées, enveloppées dans une transaction. C’est toute l’astuce.
// À lire aussi
D’autres notes du journal
Les TypeConverters Room en 2026 : stocker enums, dates et listes sans corrompre votre schéma
Un guide pratique des TypeConverters Room sur Android — enums, Instant/LocalDate, et listes — ainsi que les erreurs qui transforment un converter en bug silencieux de corruption de données.
Les index de base de données Room en 2026 : trouver la requête réellement lente et la corriger
Un guide pratique pour indexer une base Room/SQLite sur Android — lire EXPLAIN QUERY PLAN, ajouter @Index sans deviner, et les erreurs qui neutralisent un index en silence.
La recherche plein texte dans Room : ajouter une recherche instantanée à une app Android local-first en 2026
Un guide pratique du support FTS4 de Room sur Android — construire une table virtuelle de recherche, la synchroniser avec des triggers, et pourquoi FTS5 exige une migration manuelle.