diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt index 65e006d..e638962 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt @@ -3,35 +3,48 @@ package de.jeanlucmakiola.clockula.data.alarms import androidx.room.Dao import androidx.room.Insert import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao -interface AlarmDao { +abstract class AlarmDao { @Query("SELECT * FROM alarms ORDER BY hour, minute, id") - fun observeAll(): Flow> + abstract fun observeAll(): Flow> @Query("SELECT * FROM alarms WHERE id = :id LIMIT 1") - fun observeById(id: Long): Flow + abstract fun observeById(id: Long): Flow @Query("SELECT * FROM alarms WHERE id = :id LIMIT 1") - suspend fun findById(id: Long): AlarmEntity? + abstract suspend fun findById(id: Long): AlarmEntity? @Query("SELECT * FROM alarms WHERE enabled = 1 ORDER BY hour, minute, id") - suspend fun enabled(): List + abstract suspend fun enabled(): List @Insert - suspend fun insert(alarm: AlarmEntity): Long + abstract suspend fun insert(alarm: AlarmEntity): Long @Update - suspend fun update(alarm: AlarmEntity) + abstract suspend fun update(alarm: AlarmEntity) @Query("DELETE FROM alarms WHERE id = :id") - suspend fun deleteById(id: Long) + abstract suspend fun deleteById(id: Long) @Query("UPDATE alarms SET enabled = :enabled, updated_at = :updatedAt WHERE id = :id") - suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long) + abstract suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long) @Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id") - suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long) + abstract suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long) + + /** + * Reads the row, applies [transform] and writes the result back inside one + * transaction. A missing row, or a null result, writes nothing. [transform] + * must be pure and must not touch the database. + */ + @Transaction + open suspend fun updateWithin(id: Long, transform: (AlarmEntity) -> AlarmEntity?) { + val current = findById(id) ?: return + val next = transform(current) ?: return + update(next) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt index 616518c..d1b683c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt @@ -14,4 +14,11 @@ interface AlarmRepository { suspend fun delete(id: Long) suspend fun setEnabled(id: Long, enabled: Boolean) suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) + + /** + * Transactional read-modify-write. Stamps `updated_at` from the wall clock + * and keeps the id, so a concurrent `setEnabled` from the engine cannot be + * clobbered by a stale whole-row write. A missing id writes nothing. + */ + suspend fun edit(id: Long, transform: (Alarm) -> Alarm) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt index 80cc84d..25f0656 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt @@ -41,4 +41,15 @@ class AlarmRepositoryImpl @Inject constructor( override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) = dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds()) + + override suspend fun edit(id: Long, transform: (Alarm) -> Alarm) { + val updatedAt = wallClock.now().toEpochMilliseconds() + dao.updateWithin(id) { entity -> + // The id is forced back on, so a transform can change a field but + // never move the row; the read and the write are one transaction, so + // a concurrent `setEnabled` from the engine is not clobbered. + AlarmMapper.toEntity(transform(AlarmMapper.toDomain(entity))) + .copy(id = entity.id, createdAt = entity.createdAt, updatedAt = updatedAt) + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt index 59a1667..e33cb12 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.clockula.domain.Alarm import de.jeanlucmakiola.clockula.domain.AlarmDraft import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.domain.Ringtones import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.testing.T0 import de.jeanlucmakiola.clockula.testing.fullAlarm @@ -124,4 +125,12 @@ class AlarmMapperTest { ), ) } + @Test + fun `the silent sentinel round-trips, because blank-to-null does not eat it`() { + val entity = fullAlarmEntity().copy(ringtoneUri = Ringtones.SILENT_URI) + + val roundTripped = AlarmMapper.toEntity(AlarmMapper.toDomain(entity)) + + assertThat(roundTripped.ringtoneUri).isEqualTo(Ringtones.SILENT_URI) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt index 5bba639..99e4660 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.alarms import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.domain.AlarmDraft +import de.jeanlucmakiola.clockula.domain.Ringtones import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.testing.FakeAlarmDao import de.jeanlucmakiola.clockula.testing.FakeWallClock @@ -160,4 +161,71 @@ class AlarmRepositoryTest { cancel() } } + // --- M5: the transactional read-modify-write (D25) --- + + @Test + fun `editing changes the one field the transform touched`() = runTest { + val repository = repository(FakeAlarmDao()) + val id = repository.create(AlarmDraft(TimeOfDay(7, 0))) + val original = repository.find(id)!! + + repository.edit(id) { it.copy(label = "Gym") } + + assertThat(repository.find(id)).isEqualTo(original.copy(label = "Gym")) + } + + @Test + fun `editing stamps the wall clock's current value`() = runTest { + val repository = repository(FakeAlarmDao()) + val id = repository.create(AlarmDraft(TimeOfDay(7, 0))) + + wallClock.advance(5.minutes) + repository.edit(id) { it.copy(label = "Gym") } + + assertThat(repository.find(id)!!.updatedAt).isEqualTo(T0 + 5.minutes) + } + + @Test + fun `editing an id no alarm has writes nothing and throws nothing`() = runTest { + val dao = FakeAlarmDao() + val repository = repository(dao) + repository.create(AlarmDraft(TimeOfDay(7, 0))) + val before = dao.stored + + repository.edit(999L) { it.copy(label = "Gym") } + + assertThat(dao.stored).isEqualTo(before) + } + + @Test + fun `a transform cannot move a row to another id`() = runTest { + val repository = repository(FakeAlarmDao()) + val id = repository.create(AlarmDraft(TimeOfDay(7, 0))) + + repository.edit(id) { it.copy(id = id + 100L, label = "Gym") } + + assertThat(repository.find(id)!!.label).isEqualTo("Gym") + } + + @Test + fun `two edits to different fields both survive`() = runTest { + val repository = repository(FakeAlarmDao()) + val id = repository.create(AlarmDraft(TimeOfDay(7, 0))) + + repository.edit(id) { it.copy(snoozeLimit = 1) } + repository.edit(id) { it.copy(time = TimeOfDay(6, 45)) } + + val alarm = repository.find(id)!! + assertThat(alarm.snoozeLimit to alarm.time).isEqualTo(1 to TimeOfDay(6, 45)) + } + + @Test + fun `the silent sentinel survives a create and an edit`() = runTest { + val repository = repository(FakeAlarmDao()) + val id = repository.create(AlarmDraft(TimeOfDay(7, 0), ringtoneUri = Ringtones.SILENT_URI)) + + repository.edit(id) { it.copy(label = "Gym") } + + assertThat(repository.find(id)!!.ringtoneUri).isEqualTo(Ringtones.SILENT_URI) + } }