Room and Custom Types
So far, all of our properties have been basic primitives (such as Int
) or String
. There is a good reason for that: those are all that Room understands “out of the box”. Everything else requires some amount of assistance on our part.
Sometimes, a property in an entity will be related to another entity. Those are relations, and we will consider those in the next chapter.
However, other times, a property in an entity does not map directly to primitives and String
types, or to another entity. For example:
- What do we do with a Java
Date
,Calendar
, orInstant
objects? Do we want to store that as a milliseconds-since-the-Unix-epoch value as aLong
? Do we want to store a string representation in a standard format, for easier readability (at the cost of disk space and other issues)? - What do we do with a
Location
object? Here, we have two pieces: a latitude and a longitude. Do we have two columns that combine into one property? Do we convert theLocation
to and from aString
representation? - What do we do with collections of strings, such as lists of tags?
- What do we do with enums?
And so on.
In this chapter, we will explore two approaches for handling these things without creating another entity class: type converters and embedded types.
Type Converters
Type converters are a pair of functions, annotated with @TypeConverter
, that map the type for a single database column to a type for a Kotlin property. So, for example, we can:
- Map an
Instant
property to aLong
, which can go in a SQLiteINTEGER
column - Map a
Location
property to aString
, which can go in a SQLiteTEXT
column - Map a collection of
String
values to a singleString
(e.g., comma-separated values), which can go in a SQLiteTEXT
column - Etc.
However, type converters offer only a 1:1 conversion: a single property to and from a single SQLite column. If you have a single property that should map to several SQLite columns, the @Embedded
approach can handle that, as we will see later in this chapter.
Setting Up a Type Converter
First, define a Kotlin class somewhere. The name, package, superclass, etc. do not matter.
Next, for each type to be converted, create two functions that convert from one type to the other. So for example, you would have one function that takes an Instant
and returns a Long
(e.g., returning the milliseconds-since-the-Unix-epoch value), and a counterpart function that takes a Long
and returns an Instant
. If the converter function is passed null
, the proper result is null
. Otherwise, the conversion is whatever you want, so long as the “round trip” works, so that the output of one converter function, supplied as input to the other converter function, returns the original value.
Then, each of those functions get the @TypeConverter
annotation. The function names do not matter, so pick a convention that works for you.
Finally, you add a @TypeConverters
annotation, listing this and any other type converter classes, to… something. What the “something” is controls the scope of where that type converter can be used.
The simple solution is to add @TypeConverters
to the RoomDatabase
, which means that anything associated with that database can use those type converters. However, sometimes, you may have situations where you want different conversions between the same pair of types, for whatever reason. In that case, you can put the @TypeConverters
annotations on narrower scopes:
@TypeConverters Location |
Affected Areas |
---|---|
Entity class | all properties in the entity |
Entity property | that one property in the entity |
DAO class | all functions in the DAO |
DAO function | that one function in the DAO, for all parameters |
DAO function parameter | that one parameter on that one function |
For example, the TransmogrifyingEntity
file in the the MiscSamples
module of the book’s primary sample project has not only TransmogrifyingEntity
but also a TypeTransmogrifier
class. A transmogrifier is a ~30-year-old piece of advanced technology that can convert one thing into another. TypeTransmogrifier
has a set of functions that turn one type into another — we will examine those functions in upcoming sections. TransmogrifyingEntity
itself has the @TypeConverters
annotation, indicating that the type converters on TypeTransmogrifier
should be used for that entity:
@Entity(tableName = "transmogrified")
@TypeConverters(TypeTransmogrifier::class)
data class TransmogrifyingEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val creationTime: Instant = Instant.now(),
val location: Location = Location(null as String?).apply {
latitude = 0.0
longitude = 0.0
},
val tags: Set<String> = setOf()
) {
@Dao
interface Store {
@Query("SELECT * FROM transmogrified")
fun loadAll(): List<TransmogrifyingEntity>
@Insert
fun insert(entity: TransmogrifyingEntity)
}
}
Example: Dates and Times
A typical way of storing a date/time value in a database is to use the number of milliseconds since the Unix epoch (i.e., the number of milliseconds since midnight, 1 January 1970). Instant
has a getEpochMillis()
function that returns this value.
The TypeTransmogrifier
class has a pair of functions designed to convert between an Instant
and a Long
:
@TypeConverter
fun instantToLong(timestamp: Instant?) = timestamp?.toEpochMilli()
@TypeConverter
fun longToInstant(timestamp: Long?) =
timestamp?.let { Instant.ofEpochMilli(it) }
Each has the @TypeConverter
annotation, so Room knows to examine those functions when it has the need to convert between types, such as finding some Room-native type into which we can convert an Instant
.
TransmogrifyingEntity
has an Instant
property named creationTime
:
val creationTime: Instant = Instant.now(),
Given that TransmogrifyingEntity
has the @TypeConverters
annotation pointing to TypeTransmogrifier
, Room will be able to find a way to convert that Instant
to something that it knows how to handle: a Long
. As a result, our timestamp will be stored in a SQLite INTEGER
column.
Example: Locations
A Location
object contains a latitude, longitude, and perhaps other values (e.g., altitude). If we only care about the latitude and longitude, we could save those in the database in a single TEXT
column, so long as we can determine a good format to use for that string. One possibility is to have the two values separated by a semicolon.
That is what these two type converter functions on TypeTransmogrifier
do:
@TypeConverter
fun locationToString(location: Location?) =
location?.let { "${it.latitude};${it.longitude}" }
@TypeConverter
fun stringToLocation(location: String?) = location?.let {
val pieces = location.split(';')
if (pieces.size == 2) {
try {
Location(null as String?).apply {
latitude = pieces[0].toDouble()
longitude = pieces[1].toDouble()
}
} catch (e: Exception) {
null
}
} else {
null
}
}
Our entity class has a Location
property:
val location: Location = Location(null as String?).apply {
latitude = 0.0
longitude = 0.0
},
Room will know how to convert a Location
to and from a String
, so our location will be stored in a SQLite TEXT
column.
However, the downside of using this approach is that we cannot readily search based on location. If your location data is not a searchable property, and it merely needs to be available when you load your entities from the database, using a type converter like this is fine. Later in this chapter, we will see another approach (@Embedded
) that allows us to store the latitude and longitude as separate columns while still mapping them to a single data class
in Kotlin.
Example: Simple Collections
TEXT
and BLOB
columns are very flexible. So long as you can marshal your data into a String
or byte array, you can save that data in TEXT
and BLOB
columns. As with the comma-separated values approach in the preceding section, though, columns used this way are poor for searching.
So, suppose that you have a Set
of String
values that you want to store, perhaps representing tags to associate with an entity. One approach is to have a separate Tag
entity and set up a relation. This is the best approach in many cases. But, perhaps you do not want to do that for some reason.
You can use a type converter, but you need to decide how to represent your data in a column. If you are certain that the tags will not contain some specific character (e.g., a comma), you can use the delimited-list approach demonstrated with locations in the preceding section. If you need more flexibility than that, you can always use JSON encoding, as these type converters do:
@TypeConverter
fun stringSetToString(list: Set<String>?) = list?.let {
val sw = StringWriter()
val json = JsonWriter(sw)
json.beginArray()
list.forEach { json.value(it) }
json.endArray()
json.close()
sw.buffer.toString()
}
@TypeConverter
fun stringToStringSet(stringified: String?) = stringified?.let {
val json = JsonReader(StringReader(it))
val result = mutableSetOf<String>()
json.beginArray()
while (json.hasNext()) {
result += json.nextString()
}
json.endArray()
result.toSet()
}
Here, we use the JsonReader
and JsonWriter
classes that have been part of Android since API Level 11. Alternatively, you could use a third-party JSON library (e.g., Gson, Moshi).
Given these type conversion functions, we can use a Set
of String
values in TransmogrifyingEntity
:
val tags: Set<String> = setOf()
…where the tags
will be stored in a TEXT
column.
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.