Room Persistence Library Introduction – part 2

Michał Konkel
October 16, 2018 | Software development

This article is the second part of the three-part series that will smoothly introduce Room Persistence Library to you.
Part 1 can be found here – it was focused on configuring the project and explaining the basic structures. 
Part 3 can be found here – it was focused on
how to add relations between tables/entities and how to query them properly. 

In this part we will learn about Room Embedded Entities and Type Converters – to achieve that we will also pay attention to Migrations, in the end we will add some tests to the project.

Please remember that all sources can be found in the related GitHub project.

Basic elements – continuation

@Embedded – Entity

Besides previously described elements we should take a look at the @Embedded annotation that will allow us to simplify a bit our complex dataset and “flatten” the DB structure.

In some cases, you would like to query a single entity that contains a few independent objects. Instead of creating a separate entity for every linked object you can use @Embedded annotation that will allow you to store them as subfields of the parent entity.

For example, we can add the Address field to our User class. Instead of creating a separate entity and adding a relation to them we will do as follows. Let’s go to User class and add another data class inside the existing one – an Address class.

data class Address(
        val street: String,
        val city: String,
        val postal: String
)

As you can see we can still use features like @ColumnInfo. Now, let’s assign an Address to the User.

@Embedded
var address: Address,

If we would like to add more than one address we should make use of the prefix field. Please note that prefix is always applied to subfields even if they have a @ColumnInfo with a specific name. If subfields of an embedded field have @PrimaryKey annotation, they will not be considered as primary keys in the owner Entity.

@Embedded(prefix = "home_")
var homeAddress: Address,

@Embedded(prefix = "office_")
var officeAddress: Address,

The table representing a User will now also contain the columns with the following names:

home_street,
home_city,
home_postal_code,
office_street,
office_city,
office_postal_code.

You need to decide which solution fits your needs if an embedded entity is sufficient and will not cause any errors and you think you really need this extra DB column for storing address only?

@TypeConverter

Sometimes, your app needs to use a custom data type whose value you would like to store in a single database column or you just can’t store the preferred type in SQLite. The solution is @TypeConverter which converts a custom class to and from a known type that Room can persist.

For example, if we want to persist instances of Date, we can write the following TypeConverter to store the equivalent Unix timestamp in the database.

class DateTypeConverter {
    @TypeConverter
    fun fromTimestamp(value: String?): LocalDate? {
        return value?.let {  LocalDate.parse(value) }
    }

    @TypeConverter
    fun dateToTimestamp(date: LocalDate?): String? {
        return date.toString()
    }
}

We need to define two functions, one that converts a LocalDate object to a String object and another that performs the inverse conversion, from String to LocalDate. Since Room already knows how to persist String objects, it can use this converter to persist values of LocalDate type.

The TypeConverter is added to the scope of the element so if you put it on a class, all methods/fields in that class will be able to use the converters.

  • If you put it on a Database, all Daos and Entities in that database will be able to use it.
  • If you put it on a Dao, all methods in the Dao will be able to use it.
  • If you put it on an Entity, all fields of the Entity will be able to use it.
  • If you put it on a POJO, all fields of the POJO will be able to use it.
  • If you put it on an Entity field, only that field will be able to use it.
  • If you put it on a Dao method, all parameters of the method will be able to use it.
  • If you put it on a Dao method parameter, just that field will be able to use it.

Such a converter will be useful in the whole application – let’s add it to the database class then.

@Database(
        entities = [User::class],
        version = AppDatabase.DB_VERSION
)
@TypeConverters(DateTypeConverter::class)
abstract class AppDatabase : RoomDatabase()

Now we can think about some custom type converter, let’s try to store an enum. First of all, let’s create the enum, it should be localised inside the User class, for this example let’s assume that every user account can be active, pending (waiting for confirmation) or blocked, to avoid nulls we will add also the unknown status.

enum class Status(val code: Int) {
    ACTIVE(0),
    PENDING(1),
    BLOCKED(3),
    UNKNOWN(-1)
}

Now we need to add a proper TypeConverter.

class UserStatusTypeConverter {
    @TypeConverter
    fun fromInteger(value: Int): User.Status {
        return when (value) {
            0 -> User.Status.ACTIVE
            1 -> User.Status.PENDING
            2 -> User.Status.BLOCKED
            else -> User.Status.UNKNOWN
        }
    }

    @TypeConverter
    fun statusToInteger(status: User.Status): Int {
        return status.code
    }
}

As mentioned before now we need to add this converter to the scope, this case is related only to the User entity so we will add this only to the entity, also we created this class inside the User.kt file.

@Entity(tableName = "users")
@TypeConverters(UserStatusTypeConverter::class)
data class User( 
   … 
)

Let’s extend the User entity with given properties.

data class User(
       ….

        var birthday: LocalDate?,

        var status: Status

       ….
)

With the power of TypeConverter you can store any type of data. If you want to persist a collection of objects you can use Gson and map them to JSON etc. Let’s try our code and run the App… and we’ve got a crash. The stack trace is pretty obvious and tells us what to do.

Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number.

If our App is still in development – that’s OK, but what if our app is in production and we can’t just upgrade the DB version and erase previously stored data during DB update? We need to write a Migration!

Migrations

So, as you can easily deduce when your entity classes change or you add new tables to the existing DB the old schema doesn’t fit the new one. When it comes to updating the app to the latest version and you don’t want to lose the existing data you need to write a migration. In any other case, you can just increase DB version and Room will recreate DB schema with new values.

To put it simply, a migration is an operation that will adjust existing DB and data to the latest changes in a schema (your entities and tables in code).

In this manner Room Persistence Library allows you to write Migration classes to preserve user data. Each Migration class specifies a startVersion and endVersion. At runtime, Room runs each migrate() method, using the correct order to migrate the database to a later version. A Migration class defines the actions that should be performed when migrating from one specific version to another.

A migration can handle more than one version (if you have a faster path to choose when going from version 3 to 5 without going to version 4). If Room opens a database at version 3 and the latest version is >= 5, Room will use the migration object that can migrate from 3 to 5 instead of 3 to 4 and 4 to 5.

If there are not enough migrations provided to move from the current version to the latest version, Room will clear the database and recreate it so even if you have no changes between 2 versions, you should still provide a Migration object to the builder.

If you don’t want to write any migrations just call the fallbackToDestructiveMigration in the database builder – all tables will be dropped and recreated.

Let’s add some code! First, create an object Migration where we will store all of our migrations and then add there a value as follows.

object Migrations {

    val MIGRATION_1_2: Migration = object : Migration(1, 2) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL("ALTER TABLE `users` ADD COLUMN birthday TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN home_street TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN home_city TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN home_postalCode TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN office_street TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN office_city TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN office_postalCode TEXT")
            database.execSQL("ALTER TABLE `users` ADD COLUMN status INTEGER NOT NULL DEFAULT -1")
        }
    }
}

Since SQLite doesn’t support multiple ADD calls we need to do it one by one.

Now we need to increase the DB version and add the migration above to DB – to do this, we need to modify the AppDatabase class as follows:

companion object {
    const val DB_VERSION = 2

...

    private fun buildDatabase(context: Context) =
            Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DB_NAME)
                    .addCallback(dbCreateCallback(context))
                    .addMigrations(Migrations.MIGRATION_1_2)
                    .build()
...

}

Now we can run our App, if everything went OK we should see logs similar to these:

User(id=1, firstName=John, lastName=Doe, fullName=John Doe, birthday=null, homeAddress=null, officeAddress=null, emailAddress=jdoe@mail.com, phoneNumber=001333444555, picture=/pictures/jdoe/avatar/s34trag_732_jkdal.png, status=UNKNOWN)
User(id=2, firstName=Mark, lastName=Smith, fullName=Mark Smith, birthday=null, homeAddress=null, officeAddress=null, emailAddress=mastermike@mail.com, phoneNumber=001666999888, picture=/pictures/msmith/avatar/123454647_gfas.png, status=UNKNOWN)
User(id=3, firstName=John, lastName=Doe, fullName=John Doe, birthday=null, homeAddress=null, officeAddress=null, emailAddress=jdoe@mail.com, phoneNumber=001333444555, picture=/pictures/jdoe/avatar/s34trag_732_jkdal.png, status=UNKNOWN)
User(id=4, firstName=Mark, lastName=Smith, fullName=Mark Smith, birthday=null, homeAddress=null, officeAddress=null, emailAddress=mastermike@mail.com, phoneNumber=001666999888, picture=/pictures/msmith/avatar/123454647_gfas.png, status=UNKNOWN)

For better DB investigation I heartily encourage you to use Facebooks Stetho library – http://facebook.github.io/stetho/ adding it to the project is pretty straightforward, just add proper dependency and initialize it in Application class.

As you can see in logs or stetho – there are no values for the new fields even though you added them to prepopulated data – you ask why? – It’s because the callback where we are adding this data is invoked only when DB is first created – now we have just modified existing DB.

As you can see we put the “-1” value as the default for the status column and a rule for the StatusTypeConverter that will map integers to enums. We can see that it works perfectly – every user has an UNKNOWN status!

Testing the database

Entity

Instead of running the app all the time to check if new queries work OK or a migration was successful – we should write a test to speed up our work.

The recommended approach for testing your database implementation is writing a JUnit test that runs on an Android device. Because these tests don’t require creating an activity, they should be faster to execute than your UI tests.

Let’s focus on the User Entity. First of all, we need to create a test class and prepare it to use with our database.

@RunWith(AndroidJUnit4::class)
class UserEntityTest {
    private lateinit var userDao: UserDao
    private lateinit var database: AppDatabase

    @Before
    fun createDb() {
        val context = InstrumentationRegistry.getTargetContext()
        database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()

        userDao = database.userDao()
    }
…
}

inMemoryDatabaseBuilder creates a RoomDatabase.Builder for a in-memory database. Information stored in a in-memory database disappears when the process is killed. Once a database is built, you should keep a reference to it and re-use it. We need to remember to close the database after all tests.

@After
 @Throws(IOException::class)
 fun closeDb() {
     database.close()
 }

As we are ready to go we can add some tests that will check if our database works correctly against some typical database actions insert, update and read.

@Test
@Throws(Exception::class)
fun insertUserAndRead() {
    val user = UserData.createUserWithIndex(1)
    userDao.insertUser(user)

    val result = userDao.user(1)

    assertEquals(result.firstName, user.firstName)
}

@Test
@Throws(Exception::class)
fun insertUpdateAndRead() {
    val user = UserData.createUserWithIndex(1)
    userDao.insertUser(user)

    val queryUser = userDao.user(1)
    userDao.updateUser(queryUser.apply { firstName = "John" })

    val result = userDao.user(1)

    assertEquals(result.firstName, "John")
}

@Test
@Throws(Exception::class)
fun insertAndDelete() {
    val user = UserData.createUserWithIndex(1)
    userDao.insertUser(user)

    userDao.deleteUser(user)

    val result = userDao.user(1)

    assertNull(result)
}

That was quite easy, similarly we can handle migrations.

Migrations

Instead of testing migrations at the runtime, we can add some tests that will perform the following actions:

  • Create Db at given version
  • Run migration scripts
  • Validate the schema

Everything room needs to perform the test is written down in the JSON schema file, we only need to take care of the file – let’s add some code to app/build.gradle to fulfill this requirement.

android {
 …

 defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        arguments = ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
      }
    }
  }

  sourceSets {
    androidTest.assets.srcDirs +=
            files("$projectDir/schemas".toString())
  }
…

}

…

androidTestImplementation "android.arch.persistence.room:testing:$room_version"

After adding dependencies we need to create a new test class with special @Rule with MigrationTestHelper that will take care of all the boilerplate code related to maintaining the DB.

@Rule
val testHelper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java.canonicalName,
        FrameworkSQLiteOpenHelperFactory()
}

Our migration test can look like this:

@Test
@Throws(Exception::class)
fun migration_1_2() {
    val db_v_1 = testHelper.createDatabase(TEST_DB_NAME, 1)
    testHelper.runMigrationsAndValidate(TEST_DB_NAME, 2, true, Migrations.MIGRATION_1_2)
}

Please keep in mind that this isn’t validating the data consistency, this will only check the migration process. If you want to check the data integrity you need to add it manually.

That’s All Folks! We’ve reached the end of the second part of the introduction to the Room Persistence Library. I hope you have enjoyed this post and you can’t wait for more.
The third part will be about DB relations and the complex data queries.

Cheers!

If you want to meet us in person, click here and we’ll get in touch!