wandres.dev
ROOM · persistencia relacional

Relaciones: claves foráneas, @Relation y el problema N+1

Integridad referencial con claves foráneas, composición de resultados con @Relation, uno a muchos y muchos a muchos con tabla de unión, y por qué la comodidad de @Relation esconde un patrón de consultas que hay que saber leer.

⏱ 20 min

Las relaciones son el punto donde el modelo relacional y el modelo de objetos dejan de parecerse. En Kotlin, un autor contiene sus libros; en SQLite, un libro apunta a su autor y nadie contiene a nadie. Room se niega a fingir que esa brecha no existe: separa deliberadamente la integridad, que vive en las claves foráneas, de la composición, que vive en @Relation. Entender que son dos mecanismos independientes, y que el segundo tiene un coste de consultas que conviene conocer, es lo que separa un esquema que aguanta de uno que se degrada con los datos.

🎯 Al terminar esta lección sabrás
  • Declarar claves foráneas con su política de borrado y actualización.
  • Componer resultados con @Relation y entender que no genera un JOIN.
  • Modelar uno a muchos y muchos a muchos con tabla de unión.
  • Reconocer el problema N+1, medirlo y decidir cuándo sustituirlo por un JOIN.

Claves foráneas: integridad, no navegación

Una clave foránea declara una regla que el motor debe hacer cumplir: el valor de esta columna tiene que existir en la columna referenciada de otra tabla. Es una restricción de integridad, y no tiene absolutamente nada que ver con poder navegar de un objeto a otro en Kotlin.

@Entity(tableName = "autores")
data class Autor(
    @PrimaryKey(autoGenerate = true) val id: Long = 0L,
    val nombre: String,
)

@Entity(
    tableName = "libros",
    foreignKeys = [
        ForeignKey(
            entity = Autor::class,
            parentColumns = ["id"],
            childColumns = ["autor_id"],
            onDelete = ForeignKey.CASCADE,
        )
    ],
    indices = [Index(value = ["autor_id"])]
)
data class Libro(
    @PrimaryKey(autoGenerate = true) val id: Long = 0L,
    val titulo: String,
    @ColumnInfo(name = "autor_id") val autorId: Long,
)

La política onDelete define qué ocurre al borrar el padre: CASCADE arrastra los hijos, RESTRICT prohíbe el borrado, SET_NULL deja huérfana la referencia si la columna admite nulos, y NO_ACTION difiere la comprobación al final de la transacción. El índice sobre autor_id no es opcional en la práctica: sin él, cada verificación de la restricción y cada consulta por autor recorren la tabla entera, y Room emite un aviso en compilación precisamente por eso.

⚠️
Las claves foráneas de SQLite se activan, no vienen dadas

SQLite exige activar explícitamente la comprobación de claves foráneas por conexión. Room lo hace por ti al abrir la base, de modo que las restricciones se aplican de verdad. La consecuencia práctica es que una inserción con un autor_id inexistente lanza una excepción de restricción en tiempo de ejecución, no en compilación: la integridad referencial la vigila el motor, no el procesador de anotaciones.

@Relation: composición sin JOIN

Aquí está el malentendido más extendido del nivel. @Relation no produce un JOIN. Produce una clase contenedora que Room rellena ejecutando consultas separadas y cruzando los resultados en memoria.

data class AutorConLibros(
    @Embedded val autor: Autor,
    @Relation(parentColumn = "id", entityColumn = "autor_id")
    val libros: List<Libro>,
)

@Dao
interface BibliotecaDao {
    @Transaction
    @Query("SELECT * FROM autores ORDER BY nombre")
    suspend fun autoresConLibros(): List<AutorConLibros>
}

@Embedded aplana las columnas del padre dentro del contenedor. @Relation declara la correspondencia entre la columna del padre y la del hijo. Y @Transaction no es decorativo: sin él, las dos consultas que Room ejecuta podrían ver estados distintos de la base si alguien escribe entre medias, produciendo un resultado incoherente. Room avisa en compilación si se te olvida.

🧩

@Embedded

Aplana las columnas de un objeto dentro del resultado. Útil también para agrupar campos comunes como una dirección en varias entidades.

🔗

@Relation

Declara la correspondencia padre-hijo. Room resuelve la colección con una consulta adicional, no con un JOIN.

🔒

@Transaction

Garantiza que todas las consultas de la composición vean la misma instantánea coherente de la base.

Muchos a muchos y el coste real de la comodidad

Una relación muchos a muchos necesita una tabla de unión: una entidad cuyo único cometido es emparejar claves. Room la modela con associateBy y una @Junction.

@Entity(tableName = "libro_etiqueta", primaryKeys = ["libro_id", "etiqueta_id"],
        indices = [Index(value = ["etiqueta_id"])])
data class LibroEtiqueta(
    @ColumnInfo(name = "libro_id") val libroId: Long,
    @ColumnInfo(name = "etiqueta_id") val etiquetaId: Long,
)

data class LibroConEtiquetas(
    @Embedded val libro: Libro,
    @Relation(
        parentColumn = "id",
        entityColumn = "id",
        associateBy = Junction(
            value = LibroEtiqueta::class,
            parentColumn = "libro_id",
            entityColumn = "etiqueta_id",
        )
    )
    val etiquetas: List<Etiqueta>,
)

La clave primaria compuesta impide duplicar el mismo par, y el índice sobre la segunda columna permite recorrer la relación en sentido inverso sin escanear la tabla. Ahora la parte incómoda: al pedir autoresConLibros, Room ejecuta una consulta para los autores y, después, consultas para los libros agrupando identificadores. Room es lo bastante astuto para agrupar en lotes con IN, de modo que el patrón no es literalmente una consulta por autor; pero si el número de padres supera el tamaño de lote se convierte en varias sentencias, y si tú mismo escribes el bucle a mano, el N+1 aparece en su forma canónica.

flowchart TD
A[Necesito autores con sus libros] --> B{Como lo resuelvo}
B -->|Relation| C[Consulta 1: todos los autores]
C --> D[Consulta 2: libros con autor_id IN lote]
D --> E[Room cruza en memoria y arma los objetos]
B -->|JOIN explicito| F[Una sola sentencia con LEFT JOIN]
F --> G[Filas duplicadas del padre que hay que agrupar]
E --> H[Objetos listos, sin duplicar el padre]
G --> H
style E fill:#a6e3a1,color:#11111b
style G fill:#f9e2af,color:#11111b

Cuando la composición se vuelve costosa, la alternativa es un JOIN explícito con un POJO plano y la agrupación en Kotlin, o una consulta agregada si solo necesitas un recuento.

data class AutorConRecuento(
    val id: Long, val nombre: String,
    @ColumnInfo(name = "total_libros") val totalLibros: Int,
)

@Query("""
    SELECT a.id, a.nombre, COUNT(l.id) AS total_libros
    FROM autores a
    LEFT JOIN libros l ON l.autor_id = a.id
    GROUP BY a.id, a.nombre
    ORDER BY total_libros DESC
""")
suspend fun autoresConRecuento(): List<AutorConRecuento>
💡
Mide antes de reescribir

Antes de sustituir @Relation por un JOIN a mano, ejecuta EXPLAIN QUERY PLAN sobre ambas variantes y comprueba si el plan usa el índice. Un JOIN sin índice sobre la columna de unión es más lento que dos consultas indexadas, y la intuición sobre este punto falla con frecuencia.

La brecha objeto-relacional no se cierra, se administra

El desajuste entre objetos y tablas es un problema viejo y sin solución limpia: el modelo de objetos es un grafo con identidad y navegación bidireccional; el relacional es un conjunto de relaciones planas con correspondencias por valor. Cerrar esa brecha automáticamente es justamente lo que intentaron los ORM clásicos, y el resultado fue una máquina compleja de proxies, carga perezosa y cachés de sesión que resolvía el noventa por ciento de los casos y volvía insondable el diez por ciento restante. El pecado no era técnico sino epistemológico: al ocultar cuándo se materializaba una relación, hacía imposible razonar sobre el número de consultas que una línea de código iba a provocar, y así nació el N+1 como plaga endémica de una generación entera de aplicaciones. Room toma la decisión contraria y la toma en el sitio exacto: separa la integridad de la composición. Las claves foráneas viven en el motor y son verdades sobre los datos que sobreviven a cualquier bug de tu código. @Relation vive en la capa de mapeo y es solo eso, mapeo: no hay proxy, no hay carga perezosa, no hay momento sorpresa en el que tocar una propiedad dispare disco. Todo lo que se va a consultar se consulta cuando llamas al método, ni antes ni después. El precio de esa honestidad es que la comodidad tiene un coste visible y tú debes conocerlo: @Relation ejecuta más de una sentencia, y eso es una decisión de ingeniería, no un detalle de implementación. La conclusión es incómoda pero liberadora: no existe una forma correcta universal de traer un grafo desde una base relacional. Existe una forma correcta para tu cardinalidad, tu tamaño de página y tu patrón de acceso. Room no elige por ti, y eso es exactamente lo que lo hace confiable.

📝
Lo esencial del nivel

Las claves foráneas garantizan integridad y exigen un índice sobre la columna hija; su política onDelete es una decisión de dominio. @Relation compone objetos ejecutando consultas adicionales, nunca un JOIN, y siempre debe ir con @Transaction. El muchos a muchos requiere tabla de unión con clave compuesta e índice inverso. Y cuando la composición domina el coste, un JOIN explícito con agrupación en Kotlin o una agregación en SQL suelen ganar, siempre después de medir.

⚔️ Modelar, romper y medir un grafo
  1. Modela autores y libros con clave foránea, política CASCADE e índice sobre la columna hija; borra un autor y comprueba qué pasa con sus libros.
  2. Cambia la política a RESTRICT e intenta el mismo borrado: identifica la excepción exacta y en qué capa se origina.
  3. Compón AutorConLibros con @Relation, omite @Transaction a propósito y lee el aviso del procesador.
  4. Añade etiquetas con tabla de unión y consulta los libros de una etiqueta y las etiquetas de un libro con la misma entidad intermedia.
  5. Con varios miles de filas, compara el tiempo de @Relation frente a un LEFT JOIN agrupado en Kotlin, y contrasta ambos planes con EXPLAIN QUERY PLAN.