M:N Relations in Room

For 1:1 relations, one entity has a foreign key back to the other entity.

For 1:N relations, one entity has a foreign key back to the other entity. In other words, 1:1 is simply 1:N for a specific small value of N.

In SQL, implementing M:N relations requires a join table of some form, where the join table has foreign keys back to the entities being related. Room, using SQL at its core, does not change this. And since Room does not model relations, but only foreign keys, to create an M:N relation, you have to create a “join entity” that winds up creating the associated join table.

In this chapter, we will take a look at how that is accomplished. Along the way, we will also look at other Room tidbits, such as how to use static classes as entities.

Implementing a Join Entity

The General/RoomMN sample project demonstrates an M:N relation. From earlier chapters, we have a Customer entity and a Category entity. Previously, those were unrelated. Now, let’s implement an M:N relation between them, so a Customer can be a member of zero or more categories, and a Category can have zero or more customers.

Note that we are retaining the tree structure for Category used previously. For the purposes of this chapter, we are ignoring that, considering a customer to belong to a category only via a direct relationship. So for example, if customer Foo belongs to category Child, which has a parent category Parent, Foo is not a member of Parent. The tree structure simply organizes categories, without impacting customers.

Static Entity Classes

Much of the time, your entity classes will be standard, top-level Java classes. Sometimes, though, you might have some utility class that you would rather have as a static class, nested inside something else. For example, in the case of a join entity, perhaps you might want to tuck it inside of one of the entities being joined, just to reduce the clutter of your namespace.

Fortunately, this works, albeit with a wrinkle.

In the sample project, the Customer class — which itself is an entity — has a static class named CategoryJoin that will serve as the join entity:

package com.commonsware.android.room.dao;

import android.arch.persistence.room.Embedded;
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.Date;
import java.util.Set;
import java.util.UUID;
import static android.arch.persistence.room.ForeignKey.CASCADE;

@Entity(indices={@Index(value="postalCode", unique=true)})
class Customer {
  @PrimaryKey
  @NonNull
  public final String id;

  public final String postalCode;
  public final String displayName;
  public final Date creationDate;

  @Embedded
  public final LocationColumns officeLocation;

  public final Set<String> tags;

  @Ignore
  Customer(String postalCode, String displayName, LocationColumns officeLocation,
           Set<String> tags) {
    this(UUID.randomUUID().toString(), postalCode, displayName, new Date(),
      officeLocation, tags);
  }

  Customer(String id, String postalCode, String displayName, Date creationDate,
           LocationColumns officeLocation, Set<String> tags) {
    this.id=id;
    this.postalCode=postalCode;
    this.displayName=displayName;
    this.creationDate=creationDate;
    this.officeLocation=officeLocation;
    this.tags=tags;
  }

  @Entity(
    tableName="customer_category_join",
    primaryKeys={"categoryId", "customerId"},
    foreignKeys={
      @ForeignKey(
        entity=Category.class,
        parentColumns="id",
        childColumns="categoryId",
        onDelete=CASCADE),
      @ForeignKey(
        entity=Customer.class,
        parentColumns="id",
        childColumns="customerId",
        onDelete=CASCADE)},
    indices={
      @Index(value="categoryId"),
      @Index(value="customerId")
    }
  )
  public static class CategoryJoin {
    @NonNull public final String categoryId;
    @NonNull public final String customerId;

    public CategoryJoin(String categoryId, String customerId) {
      this.categoryId=categoryId;
      this.customerId=customerId;
    }
  }
}

Room is perfectly content to work with this class, so long as you also register it with your RoomDatabase via its @Database annotation:

@Database(
  entities={Customer.class, Category.class, Customer.CategoryJoin.class},
  version=1
)

However, note that this is a static class. Room will not be able to work with a non-static nested class, as only instances of the outer class can create instances of the nested class.

Also, note that the default table name is based on the plain class name. In this case, the default table name is CategoryJoin. The outer class name (Customer) is not added into the table name. Normally, this will not be a problem, and you might be renaming the table anyway. However, where you can get tripped up is if you decided to have two (or more) classes with the same name, such as having CategoryJoin inside both Customer and some other entity. Then, you would wind up with two entity classes both trying to define the same table name by default, and Room will not like that very much.

Foreign Keys and Indices

Let’s take a closer look at the @Entity annotation on Customer.CategoryJoin:

  @Entity(
    tableName="customer_category_join",
    primaryKeys={"categoryId", "customerId"},
    foreignKeys={
      @ForeignKey(
        entity=Category.class,
        parentColumns="id",
        childColumns="categoryId",
        onDelete=CASCADE),
      @ForeignKey(
        entity=Customer.class,
        parentColumns="id",
        childColumns="customerId",
        onDelete=CASCADE)},
    indices={
      @Index(value="categoryId"),
      @Index(value="customerId")
    }
  )

Here, we declare four properties.

tableName renames the table to something that is more unique to this situation, incorporating both “customer” and “category” in the name. That way, if we do wind up with CategoryJoin elsewhere, we can avoid table name collisions.

primaryKeys is used, instead of @PrimaryKey, because we need a composite key. The uniqueness is determined by the combination of the IDs of the Customer and Category, held in customerId and categoryId columns, respectively.

A join entity will need foreign keys back to both entities that it is joining. So, here, we have two @ForeignKey annotations for the foreignKeys property, connecting to both Customer and Category by their respective IDs. We also use onDelete=CASCADE, so if the parent entity (Customer or Category) is deleted, we also delete all join entities associated with that parent.

And, since Room does not automatically add indices for foreign key columns, we add them ourselves, so we can rapidly find all of the join entity instances for a given Customer or Category.


Prev Table of Contents Next

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