Room multimaps を試す

はじめに

AndroidX RoomのVersion 2.4.0-alpha04で、multimapsという機能が追加されたようです。

developer.android.com

One-to-One や One-to-Many の関係があるテーブルをJoinした結果をMapで返すことができるようになるみたいです。面白そうなのでこれを試そうと思います。

なお上記のリリースノートではSongArtistを使ったサンプルコードが乗っていますが、実装の詳細はUnitTest実装を参照すると良さそうです。

https://android.googlesource.com/platform/frameworks/support/+/542fdb6c06373f52b6fedb163952ce6cb09188d2%5E1..542fdb6c06373f52b6fedb163952ce6cb09188d2/

環境

次の環境で検証しています。

準備

まずは適当にEntityを用意します。 One-To-OneとしてUser to Bank、One-To-ManyとしてUser to Paymentとしてみました。

@Entity(tableName = "User")
data class UserEntity(
    @PrimaryKey @ColumnInfo(name = "user_id") val id: String,
    @ColumnInfo(name = "user_name") val name: String,
    @ColumnInfo(name = "bank_id") val bankId: String
)

@Entity(tableName = "Bank")
data class BankEntity(
    @PrimaryKey @ColumnInfo(name = "bank_id") val id: String,
    val name: String
)

@Entity(tableName = "Payment")
data class PaymentEntity(
    @PrimaryKey @ColumnInfo(name = "payment_id") val id: String,
    val amount: Int,
    @ColumnInfo(name = "user_id") val userId: String
)

注意①

multimapで返すEntityに同名のカラムが存在すると、結果がおかしくなるバグが存在するようです。

https://issuetracker.google.com/issues/201306012

同名カラムが存在する場合にはワーニングを出力する方向で対応が行われそうです。 issuetrackerにもコメントされていますが、当面は、同名カラムにエイリアスをつけるなどして回避するのが良さそうです。 この記事では@ColumnInfoで重複しないカラム名を設定しています。

注意②

Entityにdata classを利用しないと、次のビルドエラーが発生します(Javaの場合には警告内容どおりにequalsとhashCodeを実装すればよさそうです)。

警告:The key of the provided method's multimap return type must implement equals() and hashCode().

肝心のmultimapのDaoメソッドはこんな感じにしてみました。

@Query("SELECT * FROM User JOIN Bank ON User.bank_id = Bank.bank_id")
suspend fun getUserAndBank(): Map<UserEntity, BankEntity>

@Query("SELECT * FROM User JOIN Payment ON User.user_id = Payment.user_id")
suspend fun getUserAndPayments(): Map<UserEntity, List<PaymentEntity>>

suspendを使うためandroidx.room:room-ktxをdependenciesに追加しています。

遊ぶ

UnitTestを書いて遊んでみます。

One-To-One(User to Bank)はこんな感じ。

// before
val bank1 = BankEntity("bank_id_1", "AAA")
val bank2 = BankEntity("bank_id_2", "BBB")
val banks = listOf(bank1, bank2)

val john = UserEntity("user_id_1", "John", "bank_id_1")
val bob = UserEntity("user_id_2", "Bob", "bank_id_2")
val andy = UserEntity("user_id_3", "Andy", "bank_id_2")
val users = listOf(john, bob, andy)

// during
bankDao.insertBanks(banks)
userDao.insertUsers(users)

// after
val userToBank = userDao.getUserAndBank()
val expected = mapOf(
    john to bank1,
    bob to bank2,
    andy to bank2
)
assertThat(userToBank).containsExactlyEntriesIn(expected)

One-To-Many(User to Payment)はこんな感じです。

// after
val bank1 = BankEntity("bank_id_1", "AAA")
val bank2 = BankEntity("bank_id_2", "BBB")
val banks = listOf(bank1, bank2)

val john = UserEntity("user_id_1", "John", "bank_id_1")
val bob = UserEntity("user_id_2", "Bob", "bank_id_2")
val andy = UserEntity("user_id_3", "Andy", "bank_id_2")
val users = listOf(john, bob, andy)

val payment1 = PaymentEntity("payment_id_1", 100, "user_id_1")
val payment2 = PaymentEntity("payment_id_2", 100, "user_id_2")
val payment3 = PaymentEntity("payment_id_3", 200, "user_id_2")
val payment4 = PaymentEntity("payment_id_4", 100, "user_id_3")
val payment5 = PaymentEntity("payment_id_5", 200, "user_id_3")
val payments = listOf(payment1, payment2, payment3, payment4, payment5)

// during
bankDao.insertBanks(banks)
userDao.insertUsers(users)
paymentDao.insertPayments(payments)

// after
val userToPayment = userDao.getUserAndPayments()
val expected = mapOf(
    john to listOf(payment1),
    bob to listOf(payment2, payment3),
    andy to listOf(payment4, payment5)
)
assertThat(userToPayment).containsExactlyEntriesIn(expected)

期待通りです。

さいごに

multimap、使い所はそこそこあるような気がします。JOINしたEntityの情報すべてが欲しい状況で、新たにデータクラスを作成するのではなくてmultimapを使えばいい場面もあるかもしれません。

またRoom 2.4.0-alpha05では MapInfo というアノテーションが追加されていて、multimapが使える幅が増えているようです。

利用したコードはこちらにおいています。記事中では省略しましたが、EntityにはforeignKeysを設定し、データ不整合が発生しないようにしています。

github.com