Polymorphic Room Relations

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 involved in 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 them, with an eye towards how they can be implemented with Room.

Polymorphism With Separate Tables

One approach has each concrete type be stored in its own table. So, for example if we have a Comment class and a Link class, and they both implement a common Note interface, we wind up with dedicated tables for Comment and Link. 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.

The Trips/RoomPoly sample project employs this strategy. This is another riff on the trip-tracking sample app shown elsewhere in this book.

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

package com.commonsware.android.room;

public interface Note {
  String tripId();
}

We also have Comment and Link classes that implement that interface and have slightly different contents:

package com.commonsware.android.room;

import android.arch.persistence.room.Entity;
import android.arch.persistence.room.ForeignKey;
import android.arch.persistence.room.Ignore;
import android.arch.persistence.room.Index;
import android.arch.persistence.room.PrimaryKey;
import android.support.annotation.NonNull;
import java.util.UUID;
import static android.arch.persistence.room.ForeignKey.CASCADE;

@Entity(
  tableName="comments",
  foreignKeys=@ForeignKey(
    entity=Trip.class,
    parentColumns="id",
    childColumns="tripId",
    onDelete=CASCADE),
  indices=@Index("tripId"))
public class Comment implements Note {
  @PrimaryKey
  @NonNull
  public final String id;

  public final String text;

  @NonNull public final String tripId;

  public Comment(@NonNull String id, String text, @NonNull String tripId) {
    this.id=id;
    this.text=text;
    this.tripId=tripId;
  }

  @Ignore
  public Comment(String text, @NonNull Trip trip) {
    this(UUID.randomUUID().toString(), text, trip.id);
  }

  @Override
  public String tripId() {
    return tripId;
  }
}
package com.commonsware.android.room;

import android.arch.persistence.room.Entity;
import android.arch.persistence.room.ForeignKey;
import android.arch.persistence.room.Ignore;
import android.arch.persistence.room.Index;
import android.arch.persistence.room.PrimaryKey;
import android.support.annotation.NonNull;
import java.util.UUID;
import static android.arch.persistence.room.ForeignKey.CASCADE;

@Entity(
  tableName="links",
  foreignKeys=@ForeignKey(
    entity=Trip.class,
    parentColumns="id",
    childColumns="tripId",
    onDelete=CASCADE),
  indices=@Index("tripId"))
public class Link implements Note {
  @PrimaryKey
  @NonNull
  public final String id;

  public final String title;

  @NonNull public final String url;
  @NonNull public final String tripId;

  public Link(@NonNull String id, String title, @NonNull String url, @NonNull
    String tripId) {
    this.id=id;
    this.title=title;
    this.url=url;
    this.tripId=tripId;
  }

  @Ignore
  public Link(String title, @NonNull String url, @NonNull Trip trip) {
    this(UUID.randomUUID().toString(), title, url, trip.id);
  }

  @Override
  public String tripId() {
    return tripId;
  }
}

So, from a database schema standpoint, we have a 1:N relation between Trip and Comment, and we have a separate 1:N relation between Trip and Link. However, some of the rest of our Java code might want to think of that as a unified 1:N relation between Trip and Note, not worrying about the differences between Comment and Link. After all, we implemented that interface for some good reason (though in this sample, the reason is “to have a common interface to use for illustration purposes”).

The project still has a TripStore DAO, with a concrete implementation code-generated by Room. In this project, though, TripStore is an abstract class, not an interface, the way most versions of the “trips” sample are set up.

We can have the same sort of basic CRUD methods for Comment and Link as we would with any other 1:N Room relation:

  /*
    Comment
   */

  @Query("SELECT * FROM comments WHERE tripId=:tripId")
  abstract List<Comment> findCommentsForTrip(String tripId);

  @Insert
  abstract void insert(Comment... comments);

  @Update
  abstract void update(Comment... comments);

  @Delete
  abstract void delete(Comment... comments);

  /*
    Link
   */

  @Query("SELECT * FROM links WHERE tripId=:tripId")
  abstract List<Link> findLinksForTrip(String tripId);

  @Override
  @Query("SELECT * FROM links WHERE tripId=:tripId")
  public abstract DataSource.Factory<Integer, Link> pagedStuffForTrip(String tripId);

  @Insert
  abstract void insert(Link... comments);

  @Update
  abstract void update(Link... comments);

  @Delete
  abstract void delete(Link... comments);

But… what if we want to work with Note, ignoring the differences between Comment and Link?

For that, we need to write some custom DAO methods, using @Transaction to ensure that they are transactional:

  @Transaction
  List<Note> findNotesForTrip(String tripId) {
    ArrayList<Note> result=new ArrayList<>();

    result.addAll(findCommentsForTrip(tripId));
    result.addAll(findLinksForTrip(tripId));

    return result;
  }

  @Transaction
  void insert(Note... notes) {
    for (Note note : notes) {
      if (note instanceof Comment) {
        insert((Comment)note);
      }
      else if (note instanceof Link) {
        insert((Link)note);
      }
      else {
        throw new IllegalArgumentException("Um, wut dis? "+note.getClass().getCanonicalName());
      }
    }
  }

  @Transaction
  void update(Note... notes) {
    for (Note note : notes) {
      if (note instanceof Comment) {
        update((Comment)note);
      }
      else if (note instanceof Link) {
        update((Link)note);
      }
      else {
        throw new IllegalArgumentException("Um, wut dis? "+note.getClass().getCanonicalName());
      }
    }
  }

  @Transaction
  void delete(Note... notes) {
    for (Note note : notes) {
      if (note instanceof Comment) {
        delete((Comment)note);
      }
      else if (note instanceof Link) {
        delete((Link)note);
      }
      else {
        throw new IllegalArgumentException("Um, wut dis? "+note.getClass().getCanonicalName());
      }
    }
  }

Retrieval — in the form of findNotesForTrip() — simply calls findCommentsForTrip() and findLinksForTrip() and concatenates their results.

Mutation — insert(), update(), and delete() — check each Note to see what type it is and casts to perform the “real” insert(), update(), or delete() for the concrete type.

So, this approach optimizes for the per-concrete-type behaviors, with wrappers to try to homogenize access when dealing with the Note abstraction.

Can I Join a UNION?

You might think that we could create findNotesForTrip() 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 WHERE tripId=:tripId UNION ALL SELECT * FROM comments WHERE tripId=:tripId")
List<Note> findNotesForTrip(String tripId);

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 Link and Comment 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 Link objects and that rows that are comments should be turned into Comment 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.