Saltar al contenido
Todas las entradas

@Relation de Room: consultar datos uno-a-muchos en Android sin consultas N+1

Una guía práctica de la anotación @Relation de Room — modelar datos uno-a-muchos como categorías y entradas sin consultas N+1 ni joins manuales.

MFKAPPS 5 min de lectura

Una app de presupuesto tiene categorías, y cada categoría tiene entradas. Una app de despensa tiene productos, y cada producto tiene un historial de escaneos. Casi toda app local-first tiene esta forma en algún lugar: una fila que es dueña de muchas otras filas. La forma ingenua de cargar esto en Room —traer los padres, luego iterar y traer los hijos de cada padre— es un bug de consultas N+1 esperando a ocurrir. Room tiene una anotación que arregla esto correctamente, y es más pequeña de lo que se espera: @Relation.

La consulta que no deberías escribir

Digamos que estás construyendo la pantalla de gasto por categoría de Granyn. Tienes una tabla Category y una tabla Entry, donde cada entrada apunta de vuelta a su categoría mediante categoryId. El instinto es traer las categorías y luego pedirle al DAO las entradas de cada categoría en un bucle:

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

Eso es N+1: una consulta para la lista de categorías, y luego una consulta más por categoría. Con cinco categorías es invisible. Con un año de historial repartido entre una docena de categorías, son una docena de idas y vueltas a SQLite en cada carga de pantalla, cada una pagando su propio costo de planificación de consulta sin ninguna razón.

Lo que @Relation genera en realidad

@Relation no lo convierte mágicamente en un JOIN de SQL. Lo que hace es más inteligente para este tipo de datos: genera código que ejecuta dos consultas en total, sin importar cuántos padres tengas. La primera trae los padres. La segunda trae todos los hijos de una vez, filtrados con un WHERE categoryId IN (...) construido a partir de todos los id de los padres al mismo tiempo.

El lado de Kotlin es una clase envoltorio que contiene un padre y sus hijos:

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

@Embedded aplana las columnas propias de Category en el resultado. @Relation indica qué columna del padre (id) coincide con qué columna del hijo (categoryId) —la misma relación de clave foránea que el esquema de Granyn ya expresa, solo que declarada para el generador de consultas de Room en lugar de escrita a mano en SQL.

El método del DAO se parece a una consulta normal, con un añadido:

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

@Transaction importa aquí y es fácil omitirlo por accidente. Sin ella, la consulta del padre y la consulta agrupada de los hijos se ejecutan como dos lecturas independientes —si una escritura llega a la tabla de entradas entre medio, puedes obtener una lista de categorías y una lista de entradas que se contradicen brevemente entre sí. Envolver ambas en una transacción garantiza que el par se lea desde una única instantánea coherente.

Lo que todavía sorprende: el agrupamiento tiene un límite

SQLite pone un tope al número de variables permitidas en una sola sentencia —históricamente 999, más alto en versiones recientes pero aun así finito. Si tienes más filas padre que ese límite, Room no falla; divide silenciosamente la cláusula IN (...) en varias consultas y vuelve a coser los resultados. Para una lista de categorías eso nunca va a pasar, pero si aplicas este mismo patrón en algún lugar con miles de padres (un catálogo de productos, digamos), el número de consultas deja de ser silenciosamente exactamente 2. Vale la pena saberlo antes de asumir que “dos consultas” es una garantía firme en cualquier escala.

@Relation es de solo lectura

El método generado solo construye el objeto combinado para lecturas. No existe un equivalente @Insert o @Update que entienda CategoryWithEntries como una unidad —sigues insertando una Category a través de CategoryDao y una Entry a través de EntryDao, exactamente igual que sin la relación. @Relation es una comodidad en tiempo de consulta, no un nuevo modelo de persistencia. Intentar reutilizar la clase envoltorio para escrituras es la forma más común en que la gente se confunde con ella.

Cuándo evitarla del todo

No toda lectura uno-a-muchos pertenece detrás de @Relation. Si la pantalla realmente necesita cada entrada —la lista de transacciones de una categoría, digamos— es la herramienta correcta: dos consultas, agrupadas correctamente, sin N+1. Pero si todo lo que necesitas es un número —el total del mes por categoría para un gráfico circular, digamos— cargar cada Entry en memoria solo para sumarlas en Kotlin es trabajo desperdiciado. Una consulta de agregación en bruto hace el mismo trabajo sin materializar nunca las filas:

@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 regla general: usa @Relation cuando la interfaz necesita las filas hijas reales, y baja a una consulta GROUP BY en el momento en que todo lo que necesitas es un número derivado de ellas. Cargar un grafo de objetos completo para calcular una suma es la versión de base de datos local del sobre-fetching, y SQLite hará la suma con gusto por ti si se lo pides directamente en lugar de pedírselo a Kotlin.

@Relation se gana su lugar por la misma razón que el resto de Room: elimina toda una categoría de bug de corrección —el bucle N+1— sin pedirte que escribas el join a mano. Dos consultas, agrupadas correctamente, envueltas en una transacción. Ese es todo el truco.