One-to-Many Relations
Let’s imagine that we are setting up an app for a book catalog. The catalog is divided into categories, and categories can have books. So, we need a Category entity to model the categories, and we need a Book entity to model the books. As part of this, we also need to model the one-to-many relationship between Category and Book.
In this case, Category itself does not need anything special. It is just an ordinary Room entity:
package com.commonsware.room.misc.onetomany
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "categories")
data class Category(
@PrimaryKey
val shortCode: String,
val displayName: String
)
Note, though, that it does not have a List or Array or other collection of Book objects. You cannot ask a category for its books, at least not by asking the entity.
Configuring the Foreign Key
Book, and our DAO, are where things start to get interesting.
The Book class, in isolation, is about as plain as is Category:
data class Book(
@PrimaryKey
val isbn: String,
val title: String,
@ColumnInfo(index = true) var categoryShortCode: String
)
The only thing interesting here is that we declare an index on categoryShortCode. As the name suggests, this holds the shortCode primary key of the Category that is associated with this Book. Note that Book does not have an actual property for the Category, just its key.
When we scroll up the source code a bit and look at the @Entity annotation, we encounter a @ForeignKey:
package com.commonsware.room.misc.onetomany
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.CASCADE
import androidx.room.PrimaryKey
@Entity(
tableName = "books",
foreignKeys = [ForeignKey(
entity = Category::class,
parentColumns = arrayOf("shortCode"),
childColumns = arrayOf("categoryShortCode"),
onDelete = CASCADE
)]
)
data class Book(
@PrimaryKey
val isbn: String,
val title: String,
@ColumnInfo(index = true) var categoryShortCode: String
)
The @Entity annotation can have an array of @ForeignKey annotations (named foreignKeys). Each @ForeignKey annotation has at least three primary properties:
-
entitypoints to the entity class that represents the “one” side of the one-to-many relation -
parentColumnsidentifies the column(s) in the parent table that represent the primary key -
childColumnsidentifies the column(s) in the child table that represent the parent’s primary key
In this case, Category has a simple single-property primary key, so parentColumns points to that (shortCode), while childColumns points to the corresponding column in the Book (categoryShortCode).
Cascades on Updates and Deletes
In addition, you can place onUpdate and onDelete properties on a @ForeignKey annotation. These indicate what actions should be taken on this entity when the parent of the foreign key relationship is updated or deleted. There are five possibilities, denoted by ForeignKey constants:
| Constant Name | If the Parent Is Updated or Deleted… |
|---|---|
NO_ACTION |
…do nothing |
CASCADE |
…update or delete the child |
RESTRICT |
…fail the parent’s update or delete operation, unless there are no children |
SET_NULL |
…set the foreign key value to null |
SET_DEFAULT |
…set the foreign key value to the column(s) default value |
NO_ACTION is the default, though CASCADE will be a popular choice for onDelete. In fact, we use CASCADE for onDelete in the Book entity’s @ForeignKey, so if the Category is deleted, all of its associated Book rows are deleted from the books table.
Retrieving the Related Entities
For many things, our DAO can be no different than any other one we have seen so far. We can insert, update, delete, and query our entities as we see fit. For example, our Bookstore DAO has two @Insert functions to save categories and books:
@Insert
suspend fun save(category: Category)
@Insert
suspend fun save(vararg books: Book)
However, from a property standpoint, books and categories only have keys, not references to other entities. So, by default, if we have a @Query function that returns a Book, we have to execute a separate @Query function to look up its Category via the shortCode. And if we have a @Query function that returns a Category, we have to have another @Query function to retrieve all books associated with that Category.
To work around this limitation to some extent, we can use @Relation.
A @Query does not have to return entities. As we saw earlier, it can return other things, such as an Int result from an aggregate function. So long as Room can figure out how to map the columns in your query result to properties of some return type, Room is happy.
So, we can declare a custom data class for a @Query response, such as this CategoryAndBooks class:
data class CategoryAndBooks(
@Embedded
val category: Category,
@Relation(
parentColumn = "shortCode",
entityColumn = "categoryShortCode"
)
val books: List<Book>
)
By using @Embedded, any columns that we return from the @Query that can go in a Category will go into the category property.
The @Relation annotation says that, in addition to processing our direct query (from a @Query annotation), Room should automatically make a second query to retrieve related entities. Since our @Relation is tied to a property that is based around Book, Room knows that it needs to query our books table. The parentColumn and entityColumn annotation properties teach Room how to map data from our direct query result to Book. Specifically, Room should:
- Get the value of
shortCodefor aCategoryreturned by the@Query, and - Query the
bookstable to find all rows wherecategoryShortCodematches thatshortCodevalue
We can then use CategoryAndBooks in @Query functions:
package com.commonsware.room.misc.onetomany
import androidx.room.*
data class CategoryAndBooks(
@Embedded
val category: Category,
@Relation(
parentColumn = "shortCode",
entityColumn = "categoryShortCode"
)
val books: List<Book>
)
@Dao
interface Bookstore {
@Insert
suspend fun save(category: Category)
@Insert
suspend fun save(vararg books: Book)
@Transaction
@Query("SELECT * FROM categories")
suspend fun loadAll(): List<CategoryAndBooks>
@Transaction
@Query("SELECT * FROM categories WHERE shortCode = :shortCode")
suspend fun loadByShortCode(shortCode: String): CategoryAndBooks
}
The loadAll() and loadByShortCode() functions return CategoryAndBooks objects, so we get categories and their associated books. Because Room needs to perform multiple queries for this, we use the @Transaction annotation to ensure that Room does all of those SQL calls inside a transaction.
We can then do things like create categories and books:
val category =
Category(shortCode = "stuff", displayName = "Books About Stuff")
val bookOne = Book(
isbn = "035650056X",
title = "Feed",
categoryShortCode = category.shortCode
)
val bookTwo = Book(
isbn = "0451459792",
title = "Dies the Fire",
categoryShortCode = category.shortCode
)
…save those to the database:
underTest.save(category)
underTest.save(bookOne, bookTwo)
…and retrieve them later:
val all = underTest.loadAll()
assertThat(all, hasSize(equalTo(1)))
assertThat(all[0].category, equalTo(category))
assertThat(
all[0].books,
allOf(hasSize(equalTo(2)), hasElement(bookOne), hasElement(bookTwo))
)
val loaded = underTest.loadByShortCode(category.shortCode)
assertThat(loaded.category, equalTo(category))
assertThat(
loaded.books,
allOf(hasSize(equalTo(2)), hasElement(bookOne), hasElement(bookTwo))
)
Representing No Relation
Sometimes, with one-to-many relations, the more correct model is “zero/one-to-many”. For example, perhaps a Book has not yet been assigned to a Category.
For that, simply make the foreign key property (e.g., categoryShortCode) be nullable, and let null represent the lack of a relationship.
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.