Polymorphic Entities

Java and Kotlin programmers are used to polymorphism, where you can treat objects as being of the same type, when in truth their concrete types differ. This could be based on a common interface or a common base class (abstract or otherwise).

Those used to putting data into SQL databases are used to the fact that polymorphism and a relational database do not work together naturally. This is just “one of those things” that developers have to deal with, as part of “object-relational impedance mismatch”.

There are a few strategies for dealing with polymorphic relations in relational databases. This chapter outlines two of them, with an eye towards how they can be implemented with Room.

Polymorphism With Separate Tables

One approach uses a separate table for instances of each concrete type. So, for example if we have a CommentEntity class and a LinkEntity class, and they both implement a common Note interface, we wind up with dedicated tables for CommentEntity and LinkEntity. This keeps the database structure simple, as we still have a 1:1 relationship between concrete class and table. However, it means that any persistence code that deals with Note objects needs to handle the fact that a Note is stored differently for different Note implementations, and that can add complexity.

The the MiscSamples module of the book’s primary sample project has a poly sub-package with classes that implement this strategy.

As depicted in the preceding paragraph, we have a Note interface:

package com.commonsware.room.misc.poly

interface Note {
  val displayText: CharSequence
}

We also have CommentEntity and LinkEntity classes that implement that interface and have slightly different contents:

package com.commonsware.room.misc.poly

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "comments")
data class CommentEntity(
  @PrimaryKey
  val id: Long,
  val text: String
) : Note {
  override val displayText: CharSequence
    get() = text
}
package com.commonsware.room.misc.poly

import androidx.core.text.HtmlCompat
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "links")
data class LinkEntity(
  @PrimaryKey(autoGenerate = true)
  val id: Long,
  val title: String,
  val url: String
) : Note {
  override val displayText: CharSequence
    get() = HtmlCompat.fromHtml(
      """<a href="$url">$title</a>""",
      HtmlCompat.FROM_HTML_MODE_COMPACT
    )
}

All Note wants is a displayName CharSequence with a display representation of the note. Comment just holds some text (text), so the displayName is just the text. Link has a URL and a title, so the displayName is a clickable rendition of that link, here generated by a snippet of HTML and HtmlCompat. A more efficient, but less readable, approach would be to use a ClickableSpan with a SpannableStringBuilder to create this clickable link.

PolyStore is our DAO for manipulating these objects. That needs to have functions for working with our two entity classes:

  @Query("SELECT * FROM comments")
  fun allComments(): List<CommentEntity>

  @Insert
  fun insert(vararg comments: CommentEntity)

  @Query("SELECT * FROM links")
  fun allLinks(): List<LinkEntity>

  @Insert
  fun insert(vararg links: LinkEntity)

However, consumers of PolyStore might prefer to work with Note objects, ignoring the details of whether those notes are comments or links. For that, we have to write our own concrete functions:

  @Transaction
  fun allNotes() = allComments() + allLinks()

  @Transaction
  fun insert(vararg notes: Note) {
    insert(*notes.filterIsInstance(CommentEntity::class.java).toTypedArray())
    insert(*notes.filterIsInstance(LinkEntity::class.java).toTypedArray())
  }

allNotes() simply combines the results of the allComments() and allLinks() functions. The Note variant of insert() turns around and calls the CommentEntity and LinkEntity variants of insert(), finding the CommentEntity and LinkEntity instances in our vararg of Note objects. The fact that we are using vararg makes insert() a bit complicated, with spread operators (*) and toTypedArray() calls. If we were using List instead of vararg, we would have somewhat simpler code:

@Transaction
fun insert(notes: List<Note>) {
  insert(notes.filterIsInstance(CommentEntity::class.java))
  insert(notes.filterIsInstance(LinkEntity::class.java))
}

Having the Note functions on PolyStore allows us to work with the concrete types or the Note interface, as we see fit:

package com.commonsware.room.misc.poly

import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.commonsware.room.misc.MiscDatabase
import com.natpryce.hamkrest.*
import com.natpryce.hamkrest.assertion.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class PolyStoreTest {
  private val db = Room.inMemoryDatabaseBuilder(
    InstrumentationRegistry.getInstrumentation().targetContext,
    MiscDatabase::class.java
  )
    .build()
  private val underTest = db.polyStore()

  @Test
  fun comments() {
    assertThat(underTest.allComments(), isEmpty)

    val firstComment = CommentEntity(1, "This is a comment")
    val secondComment = CommentEntity(2, "This is another comment")

    underTest.insert(firstComment, secondComment)

    assertThat(
      underTest.allComments(),
      allOf(
        hasSize(equalTo(2)),
        hasElement(firstComment),
        hasElement(secondComment)
      )
    )

    assertThat(
      underTest.allNotes(),
      allOf(
        hasSize(equalTo(2)),
        hasElement(firstComment as Note),
        hasElement(secondComment as Note)
      )
    )
  }

  @Test
  fun links() {
    assertThat(underTest.allLinks(), isEmpty)

    val firstLink = LinkEntity(1, "CommonsWare", "https://commonsware.com")
    val secondLink = LinkEntity(
      2,
      "Room Release Notes",
      "https://developer.android.com/jetpack/androidx/releases/room"
    )

    underTest.insert(firstLink, secondLink)

    assertThat(
      underTest.allLinks(),
      allOf(
        hasSize(equalTo(2)),
        hasElement(firstLink),
        hasElement(secondLink)
      )
    )

    assertThat(
      underTest.allNotes(),
      allOf(
        hasSize(equalTo(2)),
        hasElement(firstLink as Note),
        hasElement(secondLink as Note)
      )
    )
  }

  @Test
  fun notes() {
    assertThat(underTest.allNotes(), isEmpty)

    val firstComment = CommentEntity(1, "This is a comment")
    val secondComment = CommentEntity(2, "This is another comment")
    val firstLink = LinkEntity(1, "CommonsWare", "https://commonsware.com")
    val secondLink = LinkEntity(
      2,
      "Room Release Notes",
      "https://developer.android.com/jetpack/androidx/releases/room"
    )

    underTest.insert(firstComment, secondComment, firstLink, secondLink)

    assertThat(
      underTest.allNotes(), allOf(
        hasSize(equalTo(4)),
        hasElement(firstLink as Note),
        hasElement(secondLink as Note),
        hasElement(firstComment as Note),
        hasElement(secondComment as Note)
      )
    )
  }
}

Can I JOIN a UNION?

You might think that we could create allNotes() using the UNION support in SQLite. This basically allows you to concatenate two queries and combine their results.

The theory would be that you could do something like this:

@Query("SELECT * FROM links UNION ALL SELECT * FROM comments")
fun allNotes(): List<Note>

However, this will not work.

In this specific case, links and comments do not have the same columns, as our entities have different fields. This runs afoul of UNION regulations, as at minimum, both halves of the UNION have to return the same number of columns.

Beyond that, Room has no way to know which rows are links and which rows are comments, as there is nothing to distinguish them in the result set.

Finally, Room cannot create instances of Note, as that is an interface, and we want LinkEntity and CommentEntity objects anyway. That would require Room to not only know which rows are links and which are comments, but that rows that are links should be turned into LinkEntity objects and that rows that are comments should be turned into CommentEntity objects.

From a practical standpoint, both entities would need to have the same properties and resulting schema. The result set (embodied in a Cursor) has only one set of column names, based on the first query in the UNION. Room would need to be able to determine how to populate entities from the second query using the first query’s column names. In all likelihood, that would require the names to be the same in both queries and in both entities.

Due to these limitations, it is unlikely that Room will get this capability, though it is not impossible.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.