Movies Database
Entities, DAO and Database
Let's define the Entities, DAO and Database using Room.
Here's our schema
erDiagram
RATING ||--o{ MOVIE: movies
MOVIE ||--o{ ROLE : cast
ROLE }o--|| ACTOR : appearance
What does this mean?
-
RATING <-> MOVIE is a one-to-many relationship
- A RATING can be associated with zero or more MOVIEs
- A MOVIE has exactly one RATING
-
MOVIE <-> ACTOR is a many-to-many relationship
- An ACTOR can appear in zero or more MOVIEs
- A MOVIE can cast zero or more ACTORs
- To implement this, we introduce an associative entity ROLE with a one-to-many relationship on each side
- A MOVIE can cast zero or more ROLEs
- An ACTOR can appear in zero or more ROLEs
- A ROLE can appear in exactly one MOVIE
- A ROLE is played by exactly one ACTOR
Let's look at the attributes of each entity. All entities are represented as Kotlin data classes.
Naming
There will be a different types of classes used in this application:
- Database entities: the objects that represent rows in a database. We'll use the suffix "Entity" for these.
- Data-layer Plain-Old-Kotlin-Objects (POKOs): helper objects for retrieving data (or partial data) from entities. For example, a POKO may retrieve a Rating with a list of all Movies taggged with that Rating. We won't use a suffix for these.
- Data transfer objects: representations of the data from the
data
module exposed by therepository
module. This hides the fact that the data is coming from a database, as the repository could be returning it from anywhere. We're creating a abstraction that doesn't leak the underlying implementation details to the user-interface layer. We'll use the suffix "Dto" for these.
RatingEntity
Represents an MPAA rating (G, PG, PG-13, R, NR)
Attribute | Type | Key |
---|---|---|
id | String | PK |
name | String | |
description | String |
We define RatingEntity
and a POKO to gather a rating and all of its movies at the same time. Note that this POKO represents a ONE-TO-MANY relation.
ActorEntity
An actor that can appear in movies
Attribute | Type | Key | Comment |
---|---|---|---|
id | String | PK | |
name | String |
We define ActorEntity
and two POKOs to gather an actor and all associated movies at the same time. Note that these POKOs represent a MANY-TO-MANY relation, gathering the association data in the RoleEntity
by treating it as a ONE-TO-MANY relation and a MANY-TO-ONE relation. The RoleEntity
is the MANY part in the middle.
MovieEntity
A movie
Attribute | Type | Key |
---|---|---|
id | String | PK |
title | String | |
description | String | |
ratingId | String | FK |
We define MovieEntity
and two POKOs to gather a movie and all associated actors at the same time. Note that these POKOs represent a MANY-TO-MANY relation, gathering the association data in the RoleEntity
by treating it as a ONE-TO-MANY relation and a MANY-TO-ONE relation. The RoleEntity
is the MANY part in the middle.
RoleEntity
Association entity that casts actors into movies
Attribute | Type | Key |
---|---|---|
movieId | String | FK |
actorId | String | FK |
character | String | |
orderInCredits | int |
We define RoleEntity
to hold the MANY-TO-MANY relation data between MovieEntity
and ActorEntity
. It holds additional association data describing how the movies and actors are related. Gathering the many-to-many data would be simpler if we weren't interested in the association data. However, to access the association data we'll have to use it at the center to two MANY-TO-ONE relations. See MovieEntity
and ActorEntity
above for how this works.
MovieDao
We start with a DAO that has basic create and read operations. We'll make it an abstract class so we can define a concrete implementation for resetDatabase()
.
All basic query functions immediately return a Flow
that we'll collect inside a coroutine. When the data is fetched, it will emit the result to the Flow
. If the data changes, the queries will re-execute and emit the new data to the previously-returned Flow
.
Note
Functions getRatingWithMovies()
,
getActorWithFilmography()
,
and getMovieWithCast()
are declared as one-shot functions. For now, this is fine, but it will become an issue later when we allow data updates from the UI.
The problem is that because we're not fetching these data using a Flow
, there's no way for the database to send us updated data when the data behind the scenes changes. This will lead to stale data on the screen. For example, if we display a movie with its cast, and delete a cast entry, the entry will be removed from the database, but still be visible on the screen unless we do something manual (ewwwww) to update it. Later we'll switch to using Flow
s for these queries, but for now, I want to demonstrate how you can use a controlled-side effect inside a composable function to fetch data (and demonstrate why this can lead to stale data).
Functions like these that return POKOs that use @Relation
will run multiple queries and must be annotated with @Transaction
.
Later we'll add update and delete functionality. For now, the only updating we'll do is insert initial data.
MovieDatabase
A very typical Room database declaration. The database will contain Movie, Actor, Role and Rating entities and expose a MovieDao.
DatabaseBuilder.kt
This Kotlin file hosts a single function that we'll use to create an instance of the database. Defining this function here, in the data
module, avoids the need to make the repository
module depend on Room just to create the database instance.
Note that if you want to see the SQL queries that are being run, you can uncomment the setQueryCallback
call and look at the Logcat view at the bottom of Android Studio.
Code Changes
ADDED: /data/src/main/java/com/androidbyexample/movie/data/ActorEntity.kt
package com.androidbyexample.movie.dataimport androidx.room.Embeddedimport androidx.room.Entityimport androidx.room.PrimaryKeyimport androidx.room.Relationimport java.util.UUID@Entitydata class ActorEntity( @PrimaryKey var id: String = UUID.randomUUID().toString(), var name: String,)// POKO that groups an Actor with the movies they appear in, and the// role played in each movie. This is a ONE-TO-MANY relation from the// ActorEntity to the RoleEntity, where we resolve the RoleEntity's movieId// to fetch the actual movie (a MANY-TO-ONE relation). The end result is turning// a MANY-TO-MANY relation into two ONE-TO-MANY relationships, where the MANY part// is the RoleEntity.data class ActorWithFilmography( @Embedded val actor: ActorEntity, @Relation( entity = RoleEntity::class, parentColumn = "id", entityColumn = "actorId", ) val rolesWithMovies: List<RoleWithMovie>,)data class RoleWithMovie( @Embedded val role: RoleEntity, @Relation( parentColumn = "movieId", entityColumn = "id" ) val movie: MovieEntity,)
ADDED: /data/src/main/java/com/androidbyexample/movie/data/DatabaseBuilder.kt
package com.androidbyexample.movie.dataimport android.content.Contextimport androidx.room.Roomfun createDao(context: Context) = Room.databaseBuilder( context, MovieDatabase::class.java, "MOVIES" )// uncomment the following to see the SQL queries that are run// .setQueryCallback(// { sqlQuery, bindArgs ->// Log.d("!!!SQL", "SQL Query: $sqlQuery SQL Args: $bindArgs")// }, Executors.newSingleThreadExecutor()// ) .build() .dao
ADDED: /data/src/main/java/com/androidbyexample/movie/data/MovieDao.kt
package com.androidbyexample.movie.dataimport androidx.room.Daoimport androidx.room.Insertimport androidx.room.Queryimport androidx.room.Transactionimport kotlinx.coroutines.flow.Flow@Daoabstract class MovieDao { @Query("SELECT * FROM RatingEntity") abstract fun getRatingsFlow(): Flow<List<RatingEntity>> @Query("SELECT * FROM MovieEntity") abstract fun getMoviesFlow(): Flow<List<MovieEntity>> @Query("SELECT * FROM ActorEntity") abstract fun getActorsFlow(): Flow<List<ActorEntity>>@Transaction @Query("SELECT * FROM RatingEntity WHERE id = :id") abstract suspend fun getRatingWithMovies(id: String): RatingWithMovies@Transaction @Query("SELECT * FROM ActorEntity WHERE id = :id") abstract suspend fun getActorWithFilmography(id: String): ActorWithFilmography@Transaction @Query("SELECT * FROM MovieEntity WHERE id = :id") abstract suspend fun getMovieWithCast(id: String): MovieWithCast@Insert abstract suspend fun insert(vararg ratings: RatingEntity) @Insert abstract suspend fun insert(vararg movies: MovieEntity) @Insert abstract suspend fun insert(vararg actors: ActorEntity) @Insert abstract suspend fun insert(vararg roles: RoleEntity) @Query("DELETE FROM MovieEntity") abstract suspend fun clearMovies() @Query("DELETE FROM ActorEntity") abstract suspend fun clearActors() @Query("DELETE FROM RatingEntity") abstract suspend fun clearRatings() @Query("DELETE FROM RoleEntity") abstract suspend fun clearRoles() @Transaction open suspend fun resetDatabase() { clearMovies() clearActors() clearRoles() clearRatings() insert( RatingEntity(id = "r0", name = "Not Rated", description = "Not yet rated"), RatingEntity(id = "r1", name = "G", description = "General Audiences"), RatingEntity(id = "r2", name = "PG", description = "Parental Guidance Suggested"), RatingEntity(id = "r3", name = "PG-13", description = "Unsuitable for those under 13"), RatingEntity(id = "r4", name = "R", description = "Restricted - 17 and older"), ) insert( MovieEntity("m1", "The Transporter", "Jason Statham kicks a guy in the face", "r3"), MovieEntity("m2", "Transporter 2", "Jason Statham kicks a bunch of guys in the face", "r4"), MovieEntity("m3", "Hobbs and Shaw", "Cars, Explosions and Stuff", "r3"), MovieEntity("m4", "Jumanji - Welcome to the Jungle", "The Rock smolders", "r3"), ) insert( ActorEntity("a1", "Jason Statham"), ActorEntity("a2", "The Rock"), ActorEntity("a3", "Shu Qi"), ActorEntity("a4", "Amber Valletta"), ActorEntity("a5", "Kevin Hart"), ) insert( RoleEntity("m1", "a1", "Frank Martin", 1), RoleEntity("m1", "a3", "Lai", 2), RoleEntity("m2", "a1", "Frank Martin", 1), RoleEntity("m2", "a4", "Audrey Billings", 2), RoleEntity("m3", "a2", "Hobbs", 1), RoleEntity("m3", "a1", "Shaw", 2), RoleEntity("m4", "a2", "Spencer", 1), RoleEntity("m4", "a5", "Fridge", 2), ) }}
ADDED: /data/src/main/java/com/androidbyexample/movie/data/MovieDatabase.kt
package com.androidbyexample.movie.dataimport androidx.room.Databaseimport androidx.room.RoomDatabase@Database( version = 1, entities = [ MovieEntity::class, ActorEntity::class, RoleEntity::class, RatingEntity::class, ], exportSchema = false)abstract class MovieDatabase: RoomDatabase() { abstract val dao: MovieDao}
ADDED: /data/src/main/java/com/androidbyexample/movie/data/MovieEntity.kt
package com.androidbyexample.movie.dataimport androidx.room.Embeddedimport androidx.room.Entityimport androidx.room.ForeignKeyimport androidx.room.Indeximport androidx.room.PrimaryKeyimport androidx.room.Relationimport java.util.UUID// Movie Entity - note that we set up a foreign key relationship with RatingEntity,// which is considered the owner of movies with that rating. (Probably not the best// way to represent this relationship, but I wanted to demonstrate a one-to-many// relationship with cascading deletion.// If we delete a Rating, we delete all associated movies. If we update the// Rating key, we update that key in all associated movies.@Entity( indices = [ Index(value = ["ratingId"]) ], foreignKeys = [ ForeignKey( entity = RatingEntity::class, parentColumns = ["id"], childColumns = ["ratingId"], onUpdate = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE, ) ])data class MovieEntity( @PrimaryKey var id: String = UUID.randomUUID().toString(), var title: String, var description: String, var ratingId: String,)// POKO that groups a Movie with the actors that appear in it, and the// role played in the movie. This is a ONE-TO-MANY relation from the// MovieEntity to the RoleEntity, where we resolve the RoleEntity's actorId// to fetch the actual actor (a MANY-TO-ONE relation). The end result is turning// a MANY-TO-MANY relation into two ONE-TO-MANY relationships, where the MANY part// is the RoleEntity.data class MovieWithCast( @Embedded val movie: MovieEntity, @Relation( entity = RoleEntity::class, parentColumn = "id", entityColumn = "movieId", ) val rolesWithActors: List<RoleWithActor>,)data class RoleWithActor( @Embedded val role: RoleEntity, @Relation( parentColumn = "actorId", entityColumn = "id" ) val actor: ActorEntity,)
ADDED: /data/src/main/java/com/androidbyexample/movie/data/RatingEntity.kt
package com.androidbyexample.movie.dataimport androidx.room.Embeddedimport androidx.room.Entityimport androidx.room.PrimaryKeyimport androidx.room.Relationimport java.util.UUID@Entitydata class RatingEntity( @PrimaryKey var id: String = UUID.randomUUID().toString(), var name: String, var description: String,)// ONE-TO-MANY relationship (Rating -> Movies)// NOTE: THIS IS NOT AN ENTITY!data class RatingWithMovies( @Embedded val rating: RatingEntity, @Relation( parentColumn = "id", entityColumn = "ratingId", ) val movies: List<MovieEntity>)
ADDED: /data/src/main/java/com/androidbyexample/movie/data/RoleEntity.kt
package com.androidbyexample.movie.dataimport androidx.room.Entityimport androidx.room.ForeignKeyimport androidx.room.Index// RoleEntity - note that it's considered a child of both MovieEntity and// ActorEntity. When either parent is deleted, we delete this entity. If// either parent's key is updated, we update this entity.@Entity( primaryKeys = ["actorId", "movieId"], indices = [ Index("movieId"), Index("actorId") ], foreignKeys = [ ForeignKey( entity = MovieEntity::class, parentColumns = ["id"], childColumns = ["movieId"], onUpdate = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE, ), ForeignKey( entity = ActorEntity::class, parentColumns = ["id"], childColumns = ["actorId"], onUpdate = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE, ) ])data class RoleEntity( var movieId: String, var actorId: String, var character: String, var orderInCredits: Int,)