From 778d08af24ddbf630be3336ffcc7e3ae36726470 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 13:52:11 +0200 Subject: [PATCH] feat(data): the Room database, its four tables and their DAOs alarms, timers, world_clocks and stopwatch_laps, with the schema exported to app/schemas and committed so migrations can be tested from v1 onwards. The DAOs return Flows for reads and keep every multi-step write inside a @Transaction, so a read-modify-write cannot lose a concurrent one. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 8 + .../1.json | 313 ++++++++++++++++++ .../clockula/data/db/ClockulaDatabaseTest.kt | 116 +++++++ .../clockula/data/alarms/AlarmDao.kt | 37 +++ .../clockula/data/alarms/AlarmEntity.kt | 30 ++ .../clockula/data/db/ClockulaDatabase.kt | 41 +++ .../clockula/data/stopwatch/LapDao.kt | 39 +++ .../clockula/data/stopwatch/LapEntity.kt | 15 + .../clockula/data/timers/TimerDao.kt | 58 ++++ .../clockula/data/timers/TimerEntity.kt | 23 ++ .../data/worldclocks/WorldClockDao.kt | 59 ++++ .../data/worldclocks/WorldClockEntity.kt | 18 + .../clockula/data/db/SchemaExportTest.kt | 30 ++ 13 files changed, 787 insertions(+) create mode 100644 app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapEntity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockEntity.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29b1088..b9c8a46 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -123,6 +123,14 @@ android { informational += listOf("MissingTranslation", "MissingQuantity") } + // The exported schema doubles as an androidTest asset, so M3's + // MigrationTestHelper can open a v1 database on the device. + sourceSets { + getByName("androidTest") { + assets.srcDir("$projectDir/schemas") + } + } + testOptions { unitTests { all { it.useJUnitPlatform() } diff --git a/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json new file mode 100644 index 0000000..ad9ae91 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json @@ -0,0 +1,313 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "719d264eb3b115b22cb8b1453b66edde", + "entities": [ + { + "tableName": "alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `label` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `repeat_days` INTEGER NOT NULL, `skip_next_occurrence` INTEGER NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER, `snooze_minutes` INTEGER, `snooze_limit` INTEGER, `volume_ramp_seconds` INTEGER, `dismiss_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatDays", + "columnName": "repeat_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipNextOccurrence", + "columnName": "skip_next_occurrence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ringtoneUri", + "columnName": "ringtone_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "vibrate", + "columnName": "vibrate", + "affinity": "INTEGER" + }, + { + "fieldPath": "snoozeMinutes", + "columnName": "snooze_minutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "snoozeLimit", + "columnName": "snooze_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "volumeRampSeconds", + "columnName": "volume_ramp_seconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "dismissChallenge", + "columnName": "dismiss_challenge", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_alarms_enabled", + "unique": false, + "columnNames": [ + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_enabled` ON `${TABLE_NAME}` (`enabled`)" + } + ] + }, + { + "tableName": "timers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_millis` INTEGER NOT NULL, `state` TEXT NOT NULL, `remaining_millis` INTEGER NOT NULL, `started_at_elapsed_realtime_millis` INTEGER, `ends_at_elapsed_realtime_millis` INTEGER, `ends_at_wall_clock_millis` INTEGER, `ringtone_uri` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMillis", + "columnName": "duration_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remainingMillis", + "columnName": "remaining_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAtElapsedRealtimeMillis", + "columnName": "started_at_elapsed_realtime_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "endsAtElapsedRealtimeMillis", + "columnName": "ends_at_elapsed_realtime_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "endsAtWallClockMillis", + "columnName": "ends_at_wall_clock_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "ringtoneUri", + "columnName": "ringtone_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_timers_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_timers_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ] + }, + { + "tableName": "world_clocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `label` TEXT, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zoneId", + "columnName": "zone_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_world_clocks_zone_id", + "unique": true, + "columnNames": [ + "zone_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_world_clocks_zone_id` ON `${TABLE_NAME}` (`zone_id`)" + }, + { + "name": "index_world_clocks_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_world_clocks_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ] + }, + { + "tableName": "stopwatch_laps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lap_index` INTEGER NOT NULL, `split_millis` INTEGER NOT NULL, `cumulative_millis` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lapIndex", + "columnName": "lap_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "splitMillis", + "columnName": "split_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cumulativeMillis", + "columnName": "cumulative_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_stopwatch_laps_lap_index", + "unique": true, + "columnNames": [ + "lap_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stopwatch_laps_lap_index` ON `${TABLE_NAME}` (`lap_index`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '719d264eb3b115b22cb8b1453b66edde')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt new file mode 100644 index 0000000..0377479 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.clockula.data.db + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The real DAOs against an in-memory Room database — the half of the data layer + * a fake cannot prove: generated SQL, unique indices and `@Transaction`. + * These run on a device only; the build gate compiles them. + */ +@RunWith(AndroidJUnit4::class) +class ClockulaDatabaseTest { + + private lateinit var db: ClockulaDatabase + + private fun alarm(hour: Int, enabled: Boolean) = AlarmEntity( + hour = hour, + minute = 30, + label = "Gym", + enabled = enabled, + repeatDays = 31, + skipNextOccurrence = false, + ringtoneUri = "content://media/42", + vibrate = true, + snoozeMinutes = 7, + snoozeLimit = 2, + volumeRampSeconds = 30, + dismissChallenge = "MATH", + createdAt = 1_700_000_000_000L, + updatedAt = 1_700_000_060_000L, + ) + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + ClockulaDatabase::class.java, + ).build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun anAlarmRoundTripsThroughTheRealDao() = runBlocking { + val id = db.alarmDao().insert(alarm(hour = 7, enabled = true)) + + val stored = db.alarmDao().observeAll().first().single() + + assertThat(stored).isEqualTo(alarm(hour = 7, enabled = true).copy(id = id)) + } + + @Test + fun enabledFiltersOnTheIndexedColumn() = runBlocking { + val enabledId = db.alarmDao().insert(alarm(hour = 7, enabled = true)) + db.alarmDao().insert(alarm(hour = 8, enabled = false)) + + val enabled = db.alarmDao().enabled() + + assertThat(enabled.map { it.id }).containsExactly(enabledId) + } + + @Test + fun theWorldClockZoneIdIsUnique() = runBlocking { + db.worldClockDao().insert(WorldClockEntity(zoneId = "Europe/Berlin", label = null, sortOrder = 0)) + + val second = db.worldClockDao() + .insert(WorldClockEntity(zoneId = "Europe/Berlin", label = null, sortOrder = 1)) + + assertThat(second).isEqualTo(-1L) + assertThat(db.worldClockDao().observeAll().first()).hasSize(1) + } + + @Test + fun appendLapIncrementsTheIndexAndDerivesTheSplit() = runBlocking { + val first = db.lapDao().appendLap(12_000L) + val second = db.lapDao().appendLap(20_000L) + + assertThat(listOf(first.lapIndex, second.lapIndex)).containsExactly(1, 2).inOrder() + assertThat(listOf(first.splitMillis, second.splitMillis)).containsExactly(12_000L, 8_000L).inOrder() + + db.lapDao().deleteAll() + + assertThat(db.lapDao().observeAll().first()).isEmpty() + } + + @Test + fun reorderRewritesEveryRowsSortOrder() = runBlocking { + val ids = listOf("Europe/Berlin", "America/New_York", "Pacific/Auckland").mapIndexed { index, zone -> + db.worldClockDao().insert(WorldClockEntity(zoneId = zone, label = null, sortOrder = index)) + } + + db.worldClockDao().reorder(ids.reversed()) + + assertThat(db.worldClockDao().observeAll().first().map { it.id }) + .containsExactlyElementsIn(ids.reversed()) + .inOrder() + } + + @Test + fun theDatabaseOpensAtVersionOne() { + assertThat(db.openHelper.readableDatabase.version).isEqualTo(1) + } +} 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 new file mode 100644 index 0000000..65e006d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface AlarmDao { + @Query("SELECT * FROM alarms ORDER BY hour, minute, id") + fun observeAll(): Flow> + + @Query("SELECT * FROM alarms WHERE id = :id LIMIT 1") + fun observeById(id: Long): Flow + + @Query("SELECT * FROM alarms WHERE id = :id LIMIT 1") + suspend fun findById(id: Long): AlarmEntity? + + @Query("SELECT * FROM alarms WHERE enabled = 1 ORDER BY hour, minute, id") + suspend fun enabled(): List + + @Insert + suspend fun insert(alarm: AlarmEntity): Long + + @Update + suspend fun update(alarm: AlarmEntity) + + @Query("DELETE FROM alarms WHERE id = :id") + 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) + + @Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id") + suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt new file mode 100644 index 0000000..bea3678 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * The `alarms` row. Primitives only — every enum is stored as [Enum.name], every + * instant as epoch millis and the repeat set as a 7-bit mask, so the whole + * entity/domain translation lives in [AlarmMapper] where it can be tested. + */ +@Entity(tableName = "alarms", indices = [Index("enabled")]) +data class AlarmEntity( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, + @ColumnInfo(name = "hour") val hour: Int, + @ColumnInfo(name = "minute") val minute: Int, + @ColumnInfo(name = "label") val label: String, + @ColumnInfo(name = "enabled") val enabled: Boolean, + @ColumnInfo(name = "repeat_days") val repeatDays: Int, + @ColumnInfo(name = "skip_next_occurrence") val skipNextOccurrence: Boolean, + @ColumnInfo(name = "ringtone_uri") val ringtoneUri: String?, + @ColumnInfo(name = "vibrate") val vibrate: Boolean?, + @ColumnInfo(name = "snooze_minutes") val snoozeMinutes: Int?, + @ColumnInfo(name = "snooze_limit") val snoozeLimit: Int?, + @ColumnInfo(name = "volume_ramp_seconds") val volumeRampSeconds: Int?, + @ColumnInfo(name = "dismiss_challenge") val dismissChallenge: String?, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt new file mode 100644 index 0000000..ffffa65 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.clockula.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import de.jeanlucmakiola.clockula.data.alarms.AlarmDao +import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.stopwatch.LapDao +import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity +import de.jeanlucmakiola.clockula.data.timers.TimerDao +import de.jeanlucmakiola.clockula.data.timers.TimerEntity +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity + +/** + * Clockula's own storage. Version 1; the schema is exported to `app/schemas/` + * and committed, so every later migration is reviewable and testable. + * + * No `TypeConverters` — every column is a primitive and the whole translation + * lives in the mappers — and no destructive-migration fallback: a schema change + * that arrived without a migration would otherwise eat the user's alarms. + */ +@Database( + entities = [ + AlarmEntity::class, + TimerEntity::class, + WorldClockEntity::class, + LapEntity::class, + ], + version = 1, + exportSchema = true, +) +abstract class ClockulaDatabase : RoomDatabase() { + abstract fun alarmDao(): AlarmDao + abstract fun timerDao(): TimerDao + abstract fun worldClockDao(): WorldClockDao + abstract fun lapDao(): LapDao + + companion object { + const val NAME: String = "clockula.db" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt new file mode 100644 index 0000000..3629738 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.clockula.data.stopwatch + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class LapDao { + @Query("SELECT * FROM stopwatch_laps ORDER BY lap_index") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM stopwatch_laps ORDER BY lap_index DESC LIMIT 1") + abstract suspend fun latest(): LapEntity? + + @Insert + abstract suspend fun insert(lap: LapEntity): Long + + @Query("DELETE FROM stopwatch_laps") + abstract suspend fun deleteAll() + + /** + * Appends a lap at [cumulativeMillis], deriving its 1-based index and its + * split from the previous lap. Transactional so two concurrent laps cannot + * collide on the unique lap_index. + */ + @Transaction + open suspend fun appendLap(cumulativeMillis: Long): LapEntity { + val previous = latest() + val entity = LapEntity( + lapIndex = (previous?.lapIndex ?: 0) + 1, + splitMillis = (cumulativeMillis - (previous?.cumulativeMillis ?: 0L)).coerceAtLeast(0L), + cumulativeMillis = cumulativeMillis.coerceAtLeast(0L), + ) + val id = insert(entity) + return entity.copy(id = id) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapEntity.kt new file mode 100644 index 0000000..0475a94 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapEntity.kt @@ -0,0 +1,15 @@ +package de.jeanlucmakiola.clockula.data.stopwatch + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** The `stopwatch_laps` row; `lap_index` carries a unique index. */ +@Entity(tableName = "stopwatch_laps", indices = [Index(value = ["lap_index"], unique = true)]) +data class LapEntity( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, + @ColumnInfo(name = "lap_index") val lapIndex: Int, + @ColumnInfo(name = "split_millis") val splitMillis: Long, + @ColumnInfo(name = "cumulative_millis") val cumulativeMillis: Long, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt new file mode 100644 index 0000000..0383aee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.clockula.data.timers + +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 +abstract class TimerDao { + @Query("SELECT * FROM timers ORDER BY sort_order, id") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM timers WHERE id = :id LIMIT 1") + abstract fun observeById(id: Long): Flow + + @Query("SELECT * FROM timers WHERE id = :id LIMIT 1") + abstract suspend fun findById(id: Long): TimerEntity? + + @Query("SELECT MAX(sort_order) FROM timers") + abstract suspend fun maxSortOrder(): Int? + + @Insert + abstract suspend fun insert(timer: TimerEntity): Long + + @Update + abstract suspend fun update(timer: TimerEntity) + + @Query("DELETE FROM timers WHERE id = :id") + abstract suspend fun deleteById(id: Long) + + @Query("UPDATE timers SET sort_order = :sortOrder WHERE id = :id") + abstract suspend fun setSortOrder(id: Long, sortOrder: Int) + + /** + * Reads the row, applies [transform] and writes the result back **inside one + * transaction**, so two writers — the ringing service marking a timer + * expired and the user adding a minute to it — cannot both read the same row + * and have one of the two writes disappear. A missing row, or a [transform] + * that returns null, writes nothing. + * + * [transform] must be pure and must not touch the database: it runs while + * the transaction is open. + */ + @Transaction + open suspend fun updateWithin(id: Long, transform: (TimerEntity) -> TimerEntity?) { + val current = findById(id) ?: return + val updated = transform(current) ?: return + update(updated) + } + + /** Rewrites sort_order to the position of each id. Unknown ids match no row and are ignored. */ + @Transaction + open suspend fun reorder(idsInOrder: List) { + idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt new file mode 100644 index 0000000..fd9d46a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.clockula.data.timers + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** The `timers` row. Primitives only; all three anchors are millis. */ +@Entity(tableName = "timers", indices = [Index("sort_order")]) +data class TimerEntity( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, + @ColumnInfo(name = "label") val label: String, + @ColumnInfo(name = "duration_millis") val durationMillis: Long, + @ColumnInfo(name = "state") val state: String, + @ColumnInfo(name = "remaining_millis") val remainingMillis: Long, + @ColumnInfo(name = "started_at_elapsed_realtime_millis") val startedAtElapsedRealtimeMillis: Long?, + @ColumnInfo(name = "ends_at_elapsed_realtime_millis") val endsAtElapsedRealtimeMillis: Long?, + @ColumnInfo(name = "ends_at_wall_clock_millis") val endsAtWallClockMillis: Long?, + @ColumnInfo(name = "ringtone_uri") val ringtoneUri: String?, + @ColumnInfo(name = "sort_order") val sortOrder: Int, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockDao.kt new file mode 100644 index 0000000..4d90af8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockDao.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.clockula.data.worldclocks + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class WorldClockDao { + @Query("SELECT * FROM world_clocks ORDER BY sort_order, id") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM world_clocks WHERE zone_id = :zoneId LIMIT 1") + abstract suspend fun findByZoneId(zoneId: String): WorldClockEntity? + + @Query("SELECT MAX(sort_order) FROM world_clocks") + abstract suspend fun maxSortOrder(): Int? + + /** Returns -1 on a `zone_id` conflict. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(clock: WorldClockEntity): Long + + @Query("UPDATE world_clocks SET label = :label WHERE id = :id") + abstract suspend fun setLabel(id: Long, label: String?) + + @Query("UPDATE world_clocks SET sort_order = :sortOrder WHERE id = :id") + abstract suspend fun setSortOrder(id: Long, sortOrder: Int) + + @Query("DELETE FROM world_clocks WHERE id = :id") + abstract suspend fun deleteById(id: Long) + + /** + * The idempotent add, as one transaction: look the zone up, and only insert + * it — at the next sort order — when it really is absent. Doing the lookup, + * the sort-order read and the insert in one transaction means no other + * writer can slip a row in, or delete the row that won, between them, so + * the id this returns is always a row that exists and never the insert + * sentinel. + */ + @Transaction + open suspend fun addIfAbsent(zoneId: String, label: String?): Long { + findByZoneId(zoneId)?.let { return it.id } + val id = insert( + WorldClockEntity(zoneId = zoneId, label = label, sortOrder = (maxSortOrder() ?: -1) + 1), + ) + if (id != -1L) return id + // Belt and braces: -1 means the unique index rejected the insert, which + // the lookup above has just ruled out for this transaction. + return findByZoneId(zoneId)?.id + ?: error("world clock for $zoneId was neither inserted nor found") + } + + @Transaction + open suspend fun reorder(idsInOrder: List) { + idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockEntity.kt new file mode 100644 index 0000000..e10a81e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockEntity.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.clockula.data.worldclocks + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** The `world_clocks` row; `zone_id` carries a unique index. */ +@Entity( + tableName = "world_clocks", + indices = [Index(value = ["zone_id"], unique = true), Index("sort_order")], +) +data class WorldClockEntity( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, + @ColumnInfo(name = "zone_id") val zoneId: String, + @ColumnInfo(name = "label") val label: String?, + @ColumnInfo(name = "sort_order") val sortOrder: Int, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt new file mode 100644 index 0000000..8e659fd --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.clockula.data.db + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.File + +/** + * The exported schema is the contract a migration is reviewed against. If it is + * not committed, docs/PLAN.md §12's "migrations tested from v1" has nothing to + * test against. + */ +class SchemaExportTest { + + private val schemasDir: File = + listOf(File("schemas"), File("app/schemas")).firstOrNull { it.isDirectory } ?: File("app/schemas") + + @Test + fun `the version 1 schema is exported and committed`() { + val schema = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/1.json") + + assertThat(schema.isFile).isTrue() + val text = schema.readText() + assertThat(text).isNotEmpty() + assertThat(text).contains("\"version\": 1") + assertThat(text).contains("\"tableName\": \"alarms\"") + assertThat(text).contains("\"tableName\": \"timers\"") + assertThat(text).contains("\"tableName\": \"world_clocks\"") + assertThat(text).contains("\"tableName\": \"stopwatch_laps\"") + } +}