So, you've mastered the basics of Room. You can create an entity, a DAO, and a database, and you can perform simple CRUD operations. That's a great start, but real-world apps are rarely that simple. Your app's data models will evolve, and your data will have complex relationships.
This guide will take you beyond the basics and into the two most critical advanced topics: handling database schema changes with migrations and modeling complex data with relations. Mastering these will elevate your local data persistence game.
The Inevitable Change: Painless Database Migrations
Imagine you've shipped version 1 of your app. Your User
entity has a name
and email
. In version 2, you need to add a creation_date
column. What happens to the existing users' data when they update the app? If you just change the entity and increment the database version, your app will crash with an IllegalStateException
because Room doesn't know how to get from version 1 to version 2.
The solution is a Migration. You provide Room with a set of SQL commands to execute to safely transform the database from one version to the next, preserving all existing data.
Step 1: Bump the Version Number
First, in your @Database
class, increment the version number.
@Database(entities = [User::class], version = 2) // Was version = 1
abstract class AppDatabase : RoomDatabase() { /* ... */ }
Step 2: Write the Migration Logic
Next, you create a Migration
object. This object defines the start version, the end version, and a migrate
function containing the necessary SQL.
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// Add the new 'creation_date' column to the 'user' table
// We give it a default value of the current time for existing rows.
db.execSQL("ALTER TABLE user ADD COLUMN creation_date INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP")
}
}
Step 3: Add the Migration to the Database Builder
Finally, tell your database builder about the new migration.
Room.databaseBuilder(context, AppDatabase::class.java, "app-db")
.addMigrations(MIGRATION_1_2)
.build()
Now, when a user on version 1 updates to version 2, Room will execute your migration, and the app will work perfectly without any data loss.
Modeling Reality: Handling Database Relations
Your data is rarely flat. Users have playlists, playlists have songs, and songs can be in multiple playlists. Room allows you to model these relationships efficiently.
One-to-Many Relationships
This is the most common relationship. For example, one User
can have many Playlist
s. To model this, you first need a "parent" entity (User) and a "child" entity (Playlist), with the child holding a foreign key to the parent.
@Entity
data class User(@PrimaryKey val userId: Long, val name: String)
@Entity(foreignKeys = [ForeignKey(
entity = User::class,
parentColumns = ["userId"],
childColumns = ["userOwnerId"],
onDelete = ForeignKey.CASCADE
)])
data class Playlist(@PrimaryKey val playlistId: Long, val userOwnerId: Long, val title: String)
To query this data together, you create a POJO that contains the parent and a list of its children, using the @Relation
annotation.
// Data class to hold the result
data class UserWithPlaylists(
@Embedded val user: User,
@Relation(
parentColumn = "userId",
entityColumn = "userOwnerId"
)
val playlists: List<Playlist>
)
// In your DAO
@Transaction // Important! Ensures the query is atomic.
@Query("SELECT * FROM User")
fun getUsersWithPlaylists(): Flow<List<UserWithPlaylists>>
Many-to-Many Relationships
What if a Song
can be in many Playlist
s, and a Playlist
can have many Song
s? This requires a third table, called a "junction table" or "cross-reference table," that links the two.
@Entity
data class Song(@PrimaryKey val songId: Long, val title: String)
// The junction table
@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
val playlistId: Long,
val songId: Long
)
You then create a POJO that uses @Relation
with the associateBy
property pointing to this junction table.
data class PlaylistWithSongs(
@Embedded val playlist: Playlist,
@Relation(
parentColumn = "playlistId",
entityColumn = "songId",
associateBy = Junction(PlaylistSongCrossRef::class)
)
val songs: List<Song>
)
By understanding and correctly implementing migrations and relations, you unlock the full power of Room. You can build complex, robust, and maintainable data layers that can evolve with your app and accurately reflect the real-world data you need to store.