androidkotlinandroid-sqliteandroid-roomsealed-class

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


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?


Solution

  • 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!