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.ColumnInfo
import android.arch.persistence.room.Entity
import android.arch.persistence.room.PrimaryKey
import java.time.Instant
import 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.DataSource
import android.arch.persistence.room.*
@Dao
interface 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.Database
import android.arch.persistence.room.Room
import android.arch.persistence.room.RoomDatabase
import 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.TypeConverter
import 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.Database
import android.arch.persistence.room.Room
import android.arch.persistence.room.RoomDatabase
import android.arch.persistence.room.TypeConverters
import 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.ColumnInfo
import android.arch.persistence.room.Entity
import android.arch.persistence.room.PrimaryKey
import java.time.Instant
import 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.
Leave a comment