Reputation: 1023
I want to save in my Room database an object where one of the variables can either be of on type or another. I thought a sealed class would make sense, so I took this approach:
sealed class BluetoothMessageType() {
data class Dbm(
val data: String
) : BluetoothMessageType()
data class Pwm(
val data: String
) : BluetoothMessageType()
}
Or even this, but it is not necessary. I found that this one gave me even more errors as it did not know how to handle the open val, so if I find a solution for the first version I would be happy anyway.
sealed class BluetoothMessageType(
open val data: String
) {
data class Dbm(
override val data: String
) : BluetoothMessageType()
data class Pwm(
override val data: String
) : BluetoothMessageType()
}
Then the Entity class
@Entity(tableName = MESSAGES_TABLE_NAME)
data class DatabaseBluetoothMessage(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val time: Long = Instant().millis,
val data: BluetoothMessageType
)
I have created a TypeConverter to convert it to and from a String as well, so I assume that it is not a problem.
First, is this possible? I assume this should function in a similar way that it would with an abstract class, but I have not managed to find a working solution with that either. If it is not possible, what sort of approach should I take when I want to save some data that may be either of one or another type if not with sealed classes?
Upvotes: 9
Views: 3642
Reputation: 2511
We faced such problem when we tried using Polymorphism in our domain, and we solved it this way:
We have a Photo
model that looks like this:
sealed interface Photo {
val id: Long
data class Empty(
override val id: Long
) : Photo
data class Simple(
override val id: Long,
val hasStickers: Boolean,
val accessHash: Long,
val fileReferenceBase64: String,
val date: Int,
val sizes: List<PhotoSize>,
val dcId: Int
) : Photo
}
Photo
has PhotoSize
inside, it looks like this:
sealed interface PhotoSize {
val type: String
data class Empty(
override val type: String
) : PhotoSize
data class Simple(
override val type: String,
val location: FileLocation,
val width: Int,
val height: Int,
val size: Int,
) : PhotoSize
data class Cached(
override val type: String,
val location: FileLocation,
val width: Int,
val height: Int,
val bytesBase64: String,
) : PhotoSize
data class Stripped(
override val type: String,
val bytesBase64: String,
) : PhotoSize
}
There is much work to do in our data module to make this happen. I will decompose the process to three parts to make it look easier:
So, using Room and SQL in general, it is hard to save such objects, so we had to come up with this idea. Our PhotoEntity
(Which is the Local version of Photo
from our domain looks like this:
@Entity
data class PhotoEntity(
// Shared columns
@PrimaryKey
val id: Long,
val type: Type,
// Simple Columns
val hasStickers: Boolean? = null,
val accessHash: Long? = null,
val fileReferenceBase64: String? = null,
val date: Int? = null,
val dcId: Int? = null
) {
enum class Type {
EMPTY,
SIMPLE,
}
}
And our PhotoSizeEntity
looks like this:
@Entity
data class PhotoSizeEntity(
// Shared columns
@PrimaryKey
@Embedded
val identity: Identity,
val type: Type,
// Simple columns
@Embedded
val locationLocal: LocalFileLocation? = null,
val width: Int? = null,
val height: Int? = null,
val size: Int? = null,
// Cached and Stripped columns
val bytesBase64: String? = null,
) {
data class Identity(
val photoId: Long,
val sizeType: String
)
enum class Type {
EMPTY,
SIMPLE,
CACHED,
STRIPPED
}
}
Then we have this compound class to unite PhotoEntity
and PhotoSizeEntity
together, so we can retrieve all data required by our domain's model:
data class PhotoCompound(
@Embedded
val photo: PhotoEntity,
@Relation(entity = PhotoSizeEntity::class, parentColumn = "id", entityColumn = "photoId")
val sizes: List<PhotoSizeEntity>? = null,
)
So our dao
should be able to store and retrieve this data. You can have two daos
for PhotoEntity
and PhotoSizeEntity
instead of one, for the sake of flexibility, but in this example we will use a shared one, it looks like this:
@Dao
interface IPhotoDao {
@Transaction
@Query("SELECT * FROM PhotoEntity WHERE id = :id")
suspend fun getPhotoCompound(id: Long): PhotoCompound
@Transaction
suspend fun insertOrUpdateCompound(compound: PhotoCompound) {
compound.sizes?.let { sizes ->
insertOrUpdate(sizes)
}
insertOrUpdate(compound.photo)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entity: PhotoEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entities: List<PhotoSizeEntity>)
}
After solving the problem of saving data to SQL database, we now need to solve the problem of converting between domain and local entities. Our Photo
's converter aka adapter looks like this:
fun Photo.toCompound() = when(this) {
is Photo.Empty -> this.toCompound()
is Photo.Simple -> this.toCompound()
}
fun PhotoCompound.toModel() = when (photo.type) {
PhotoEntity.Type.EMPTY -> Photo.Empty(photo.id)
PhotoEntity.Type.SIMPLE -> this.toSimpleModel()
}
private fun PhotoCompound.toSimpleModel() = photo.run {
Photo.Simple(
id,
hasStickers!!,
accessHash!!,
fileReferenceBase64!!,
date!!,
sizes?.toModels()!!,
dcId!!
)
}
private fun Photo.Empty.toCompound(): PhotoCompound {
val photo = PhotoEntity(
id,
PhotoEntity.Type.EMPTY
)
return PhotoCompound(photo)
}
private fun Photo.Simple.toCompound(): PhotoCompound {
val photo = PhotoEntity(
id,
PhotoEntity.Type.SIMPLE,
hasStickers = hasStickers,
accessHash = accessHash,
fileReferenceBase64 = fileReferenceBase64,
date = date,
dcId = dcId,
)
val sizeEntities = sizes.toEntities(id)
return PhotoCompound(photo, sizeEntities)
}
And for the PhotoSize
, it looks like this:
fun List<PhotoSize>.toEntities(photoId: Long) = map { photoSize ->
photoSize.toEntity(photoId)
}
fun PhotoSize.toEntity(photoId: Long) = when(this) {
is PhotoSize.Cached -> this.toEntity(photoId)
is PhotoSize.Empty -> this.toEntity(photoId)
is PhotoSize.Simple -> this.toEntity(photoId)
is PhotoSize.Stripped -> this.toEntity(photoId)
}
fun List<PhotoSizeEntity>.toModels() = map { photoSizeEntity ->
photoSizeEntity.toModel()
}
fun PhotoSizeEntity.toModel() = when(type) {
PhotoSizeEntity.Type.EMPTY -> this.toEmptyModel()
PhotoSizeEntity.Type.SIMPLE -> this.toSimpleModel()
PhotoSizeEntity.Type.CACHED -> this.toCachedModel()
PhotoSizeEntity.Type.STRIPPED -> this.toStrippedModel()
}
private fun PhotoSizeEntity.toEmptyModel() = PhotoSize.Empty(identity.sizeType)
private fun PhotoSizeEntity.toCachedModel() = PhotoSize.Cached(
identity.sizeType,
locationLocal?.toModel()!!,
width!!,
height!!,
bytesBase64!!
)
private fun PhotoSizeEntity.toSimpleModel() = PhotoSize.Simple(
identity.sizeType,
locationLocal?.toModel()!!,
width!!,
height!!,
size!!
)
private fun PhotoSizeEntity.toStrippedModel() = PhotoSize.Stripped(
identity.sizeType,
bytesBase64!!
)
private fun PhotoSize.Cached.toEntity(photoId: Long) = PhotoSizeEntity(
PhotoSizeEntity.Identity(photoId, type),
PhotoSizeEntity.Type.CACHED,
locationLocal = location.toEntity(),
width = width,
height = height,
bytesBase64 = bytesBase64
)
private fun PhotoSize.Simple.toEntity(photoId: Long) = PhotoSizeEntity(
PhotoSizeEntity.Identity(photoId, type),
PhotoSizeEntity.Type.SIMPLE,
locationLocal = location.toEntity(),
width = width,
height = height,
size = size
)
private fun PhotoSize.Stripped.toEntity(photoId: Long) = PhotoSizeEntity(
PhotoSizeEntity.Identity(photoId, type),
PhotoSizeEntity.Type.STRIPPED,
bytesBase64 = bytesBase64
)
private fun PhotoSize.Empty.toEntity(photoId: Long) = PhotoSizeEntity(
PhotoSizeEntity.Identity(photoId, type),
PhotoSizeEntity.Type.EMPTY
)
That's it!
To save a sealed class to Room or SQL, whether as an Entity
, or as an Embedded
object, you need to have one big data class with all the properties, from all the sealed variants, and use an Enum
type to indicate variant type to use later for conversion between domain and data, or for indication in your code if you don't use Clean Architecture. Hard, but solid and flexible. I hope Room
will have some annotations that can generate such code to get rid of the boilerplate code.
PS: This class is taken from Telegram's scheme, they also solve the problem of polymorphism when it comes to communication with a server. Checkout their TL Language here: https://core.telegram.org/mtproto/TL
PS2: If you like Telegram's TL language, you can use this generator to generate Kotlin classes from scheme.tl
files: https://github.com/tamimattafi/mtproto
EDIT: You can use this code generating library to automatically generate Dao for compound classes, to make it easier to insert, which removes a lot of boilerplate to map things correctly. Link: https://github.com/tamimattafi/android-room-compound
Upvotes: 6
Reputation: 9370
In my case I did the following:
sealed class Status2() {
object Online : Status2()
object Offline : Status2()
override fun toString(): String {
return when (this) {
is Online ->"Online"
is Offline -> "Offline"
}
}
}
class StatusConverter{
@TypeConverter
fun toHealth(value: Boolean): Status2 {
return if (value){
Status2.Online
} else{
Status2.Offline
}
}
@TypeConverter
fun fromHealth(value: Status2):Boolean {
return when(value){
is Status2.Offline -> false
is Status2.Online -> true
}
}
}
@Dao
interface CourierDao2 {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertStatus(courier: CourierCurrentStatus)
@Query("SELECT * FROM CourierCurrentStatus")
fun getCourierStatus(): Flow<CourierCurrentStatus>
}
@Entity
data class CourierCurrentStatus(
@PrimaryKey
val id: Int = 0,
var status: Status2 = Status2.Offline
)
and it works like a charm
Upvotes: 0