Converting types with Room and Kotlin
I’ve been working on a personal project, trying to get to grips with the various Android Architecture Components and Kotlin. One of the things I came up with was the requirement to deal with type conversion when using a SQLite database and the Room persistence library. Room is a nice abstraction to the internal SQLite database that converts models to tables within SQLite. It’s nice because it works alongside LiveData and RxJava to provide observable objects — when the database changes, the observable changes as well.
Write a Room data access layer the normal way
Let me explain my problem with type conversion with an example. I’ve got a nice model:
import android.arch.persistence.room.ColumnInfoimport android.arch.persistence.room.Entityimport android.arch.persistence.room.PrimaryKeyimport java.time.Instantimport java.util.*
/** * Definition of an Album. */@Entity(tableName = "albums")data class Album( @PrimaryKey @ColumnInfo(name = "id") var id: String = UUID.randomUUID().toString(), @ColumnInfo(name = "created") var created: Instant = Instant.now(), @ColumnInfo(name = "modified") var modified: Instant = Instant.now(), @ColumnInfo(name = "deleted") var deleted: Boolean = false, @ColumnInfo(name = "album_name") var name: String = "New Album", @ColumnInfo(name = "hidden") var hidden: Boolean = false, @ColumnInfo(name = "pinned") var pinned: Boolean = false)This is a fairly simple model for the Room persistence layer. However, I’m using the (relatively) new java.time package that is available in Java 1.8. Specifically, the Instant type (which represents a time zone agnostic moment in time) is not recognized by SQLite or Room.
Continuing on, I have a DAO:
import android.arch.paging.DataSourceimport android.arch.persistence.room.*
@Daointerface AlbumDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun addAlbum(album: Album)
@Update fun updateAlbum(album: Album)
@Delete fun reallyDeleteAlbum(album: Album)
@Query("SELECT * FROM albums WHERE NOT(deleted) AND NOT(hidden) ORDER BY pinned,album_name") fun listAlbumsByName(): DataSource.Factory<Int, Album>
@Query("SELECT * FROM albums WHERE id = :id") fun getAlbumById(id: String): Album}This DAO does the normal CRUD operations. I have a funky custom select statement for dealing with the ordering of the albums. I want “pinned” albums to appear first and then in alphabetical order. I’m also using a data source as a return value here so I can deal with the paging adapter. Finally, here is my app database class:
import android.arch.persistence.room.Databaseimport android.arch.persistence.room.Roomimport android.arch.persistence.room.RoomDatabaseimport android.content.Context
@Database(entities = [ Album::class ], version = 1, exportSchema = false)abstract class AppDatabase : RoomDatabase() { abstract fun albumDao(): AlbumDao
companion object { @Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE = it } }
private fun buildDatabase(context: Context) = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app.db").build() }}Again, this is a fairly normal — even boilerplate — implementation of the database class. It deals with building the database and is a synchronized singleton. I took this code directly from the Google sample.
Add Converters
Compiling this, I get the following errors:
error: Cannot figure out how to save this field into database. You can consider adding a type converter for it. private final java.time.Instant created = null;error: Cannot figure out how to save this field into database. You can consider adding a type converter for it. private java.time.Instant modified;This is not unexpected. As I mentioned earlier, SQLite doesn’t understand the Instant type, so it needs to be converted before being stored. Fortunately, the Room persistence library has provided a mechanism for this. First, create a class with a to/from pair of type converters:
import android.arch.persistence.room.TypeConverterimport java.time.Instant
class Converters { companion object { @TypeConverter @JvmStatic fun fromInstant(value: Instant): Long { return value.toEpochMilli() }
@TypeConverter @JvmStatic fun toInstant(value: Long): Instant { return Instant.ofEpochMilli(value) } }}The important thing here is that they are annotated with both the @TypeConverter and @JvmStatic annotations. This comes from a peculiarity of Kotlin. When you place something in the companion object, it doesn’t appear as a normal static method. You can’t call Converters.fromInstant() from within a Java class. Instead, you have to call Converters.Companion().fromInstant(). The Companion here is the companion object. If, however, you annotate the method with @JvmStatic it will get the appropriate treatment to be a true static method of the Converters class.
Now that I have a set of type converters, I can add it to the application database class:
import android.arch.persistence.room.Databaseimport android.arch.persistence.room.Roomimport android.arch.persistence.room.RoomDatabaseimport android.arch.persistence.room.TypeConvertersimport android.content.Context
@Database(entities = [ Album::class ], version = 1, exportSchema = false)@TypeConverters(Converters::class)abstract class AppDatabase : RoomDatabase() { abstract fun albumDao(): AlbumDao
companion object { @Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE = it } }
private fun buildDatabase(context: Context) = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app.db").build() }}The important line here is line 8 — the @TypeConverters annotation. You can put as many converters as you want. Just include a to/from pair for the custom type.
Fix the model class
There is another warning that creeps up:
warning: There are multiple good constructors and Room will pick the no-arg constructor. You can use the @Ignore annotation to eliminate unwanted constructors.If you have done any Room development with Kotlin, the likelihood is that you have run into this. This is because the de-facto advice is to use a data class as the model, such as I have done above. You can easily get rid of this warning by switching to a normal class. This is my converted class:
import android.arch.persistence.room.ColumnInfoimport android.arch.persistence.room.Entityimport android.arch.persistence.room.PrimaryKeyimport java.time.Instantimport java.util.*
@Entity(tableName = "albums")class Album { @PrimaryKey @ColumnInfo(name = "id") var id: String = UUID.randomUUID().toString()
@ColumnInfo(name = "created") var created: Instant = Instant.now()
@ColumnInfo(name = "modified") var modified: Instant = Instant.now()
@ColumnInfo(name = "deleted") var deleted: Boolean = false
@ColumnInfo(name = "album_name") var name: String = "New Album"
@ColumnInfo(name = "hidden") var hidden: Boolean = false
@ColumnInfo(name = "pinned") var pinned: Boolean = false
override fun equals(other: Any?): Boolean { if (other == null) return false // null check if (javaClass != other.javaClass) return false // type check
val mOther = other as Album return id == mOther.id && created == mOther.created && modified == mOther.modified && deleted == mOther.deleted && name == mOther.name && hidden == mOther.hidden && pinned == mOther.pinned }
override fun hashCode(): Int { return Objects.hash(id, created, modified, deleted, name, hidden, pinned) }}Two of the things that the data class provides are the equals() and hashCode() methods. Since I am switching to a non-data class, I now need to provide those. This is actually not really a problem for me because I am going to be doing a RecyclerView with a PagedListAdapter. The PagedListAdapter requires me to provide a Diffutil.ItemCallback to compare two objects in the list. The best place to compare two objects is within the model class itself, so I end up extending the data class for this purpose.
Now my code compiles without warnings and I have bi-directional type conversion when storing data in SQLite. I can move on to my UI.
Comments