Android Room, how to save an entity with one of the variables being a sealed class object
Asked Answered
L

2

8

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?

Lowspirited answered 30/3, 2020 at 10:37 Comment(1)
Please add code of TypeConverter and how you register it with Room.Disloyalty
E
5

We faced such problem when we tried using Polymorphism in our domain, and we solved it this way:

Domain:

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
}

Data:

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:

1. Entity:

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,
)

2. Dao

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>)
}

3. Adapter:

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!

Conclusion:

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

Happy Coding!

Eligible answered 7/6, 2022 at 18:17 Comment(0)
J
0

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

Jacynth answered 11/8, 2020 at 3:22 Comment(1)
This case is so simple and can be replaced with enums, the question has sealed class with properties, which your answer doesn't help with.Eligible

© 2022 - 2024 — McMap. All rights reserved.