Infinito Nirone 7

白羽の矢を刺すスタイル

Unique Index があるときの @Insert(onConflict = OnConflictStrategy.REPLACE), @Update, @Upsert

次のような Entity を例に @Insert, @Update, @Upsert の動きを見てみます。

@Entity(
    tableName = "sample_table",
    indices = [Index(value = ["unique_id"], unique = true)]
)
data class SampleEntity(
    @PrimaryKey
    @ColumnInfo(name = "id")
    val id: String,
    @ColumnInfo(name = "name")
    val name: String,
    @ColumnInfo(name = "age")
    val age: Int,
    @ColumnInfo(name = "unique_id")
    val uniqueId: String
)

@Dao
interface SampleDao {
    @Insert(onConflict = OnConflictStrategy.ABORT)
    suspend fun insert(entity: SampleEntity)

    @Update
    suspend fun update(entity: SampleEntity)

    @Upsert
    suspend fun upsert(entity: SampleEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertOrReplace(entity: SampleEntity)

    @Query("SELECT * FROM sample_table")
    suspend fun getAll(): List<SampleEntity>
}

この記事では Room の in memory database builder を使い、簡単にユニットテストで動きを確かめています。

@RunWith(RobolectricTestRunner::class)
class ExampleUnitTest {
    private lateinit var db: SampleDatabase
    private lateinit var dao: SampleDao

    @Before
    fun prep() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, SampleDatabase::class.java).build()
        dao = db.companionDeviceDao()
    }

    @After
    fun tearDown() {
        db.close()
    }

    // ... unit test follows
}

@Insert(onConflict = OnConflictStrategy.REPLACE)

ここで説明する REPLACE の挙動はすべて SQLite のドキュメントと整合しています。

sqlite.org

PrimaryKey が重複したとき

REPLACE なので、PrimaryKey が既存レコードと重複した場合は既存レコードが更新されます。 内部的に REPLACE は既存レコードを一旦削除したうえで新たにレコードを作成することで更新とします。

    @Test
    fun `insertOrReplace - insert then replace`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert
        dao.insertOrReplace(entity1)
        try {
            // replace with entity2, can't fail
            dao.insertOrReplace(entity2)
        } catch (e: SQLiteConstraintException) {
            fail("error is not expected: $e")
        }

        val result = dao.getAll()
        // only entity2 exists
        assertTrue(result.containsAll(listOf(entity2)))
    }

Unique Index が重複したとき1

既存レコードを replace する場面で Unique Index が重複すると、既存レコードの削除に加え重複する Unique Index を持つ別の既存レコードも削除します。

    @Test
    fun `insertOrReplace - insert then replace - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid2")
        val entity3 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert as first entity
        dao.insertOrReplace(entity1)
        // insert as pk/unique id is different
        dao.insertOrReplace(entity2)
        // replace entity1 with unique id conflicting with entity 2
        // then remove both entity1 and entity2 then insert entity3
        dao.insertOrReplace(entity3)

        val result = dao.getAll()
        // only entity3 exists
        assertTrue(result.containsAll(listOf(entity3)))
    }

Unique Index が重複したとき2

PrimaryKey は重複せず Unique Index が重複するときも、重複する Unique Index を持つ既存レコードが削除されます。

    @Test
    fun `insertOrReplace - insert 2 times`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid1")
        // insert
        dao.insertOrReplace(entity1)
        // insert as pk is different from entity1, but conflicting unique id
        dao.insertOrReplace(entity2)

        val result = dao.getAll()
        // only entity2
        assertTrue(result.containsAll(listOf(entity2)))
    }

@Update

Unique Index が重複したとき

Unique Index の重複はゆるされないので update は失敗し、SQLiteConstraintException がスローされます。 @Insert(onConflict = OnConflictStrategy.REPLACE) とは異なり、元のデータは変更されず update を呼び出す直前のままです。

    @Test
    fun `insert and update - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid2")
        val entity3 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert
        dao.insert(entity1)
        // insert
        dao.insert(entity2)
        try {
            // update entity 1 to make unique id conflict with entity2
            dao.update(entity3)
            fail("can't complete update as unique id conflicts")
        } catch (_: SQLiteConstraintException) {
            // expected
        }

        val result = dao.getAll()
        assertTrue(result.containsAll(listOf(entity1, entity2)))
    }

@Upsert

@Upsert は PrimaryKey の重複するデータがあれば Update, なければ Insert とする処理です。 次に示すテストでは entity1 の upsert は Insert、entity2 の upsert は Update になります。

    @Test
    fun `upsert - insert then update`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert
        dao.upsert(entity1)
        try {
            // update entity2, can't fail
            dao.upsert(entity2)
        } catch (e: SQLiteConstraintException) {
            fail("error is not expected: $e")
        }

        val result = dao.getAll()
        assertTrue(result.containsAll(listOf(entity2)))
    }

Unique Index が重複する Update 1

Unique Index が重複することになる Update をかける Upsert をする場合、Unique Index の制約に違反する Update を実行した時点で SQLiteConstraintExcpetion がスローされます。 @Insert(onConflict = OnConflictStrategy.REPLACE) とは異なり、元のデータは変更されず upsert を呼び出す直前のままです。

    @Test
    fun `upsert - insert then update - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid2")
        val entity3 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert as first entity
        dao.upsert(entity1)
        // insert as pk is different
        dao.upsert(entity2)
        try {
            // update entity1 with unique id conflicts with entity 2
            dao.upsert(entity3)
            fail("can't complete upsert as unique id conflicts")
        } catch (_: SQLiteConstraintException) {
            // expected
        }

        val result = dao.getAll()
        assertTrue(result.containsAll(listOf(entity1, entity2)))
    }

Unique Index が重複する Update 2

PrimaryKey が異なり Unique Index が重複する Upsert をする場合、処理自体は Insert 相当になるはずですが Upsert の関数は例外をスローせず、かつ @Insert(onConflict = OnConflictStrategy.REPLACE) のような削除もしません。 結果的にデータの変更は何も起きないが例外もないことになります。この場合 upsert 関数に返り値を定義し、変更された行数を数えることで upsert がうまくいったかどうか判断する必要があります。

    @Test
    fun `upsert - insert 2 times - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid1")
        // insert
        dao.upsert(entity1)
        // try insert as pk is different from entity1, but conflicting unique id with entity1
        // this function won't throw exception for SQL Constraint violation, but just
        // fails to insert entity2
        dao.upsert(entity2)

        val result = dao.getAll()
        // only entity1 exists
        assertTrue(result.containsAll(listOf(entity1)))
    }