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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:11 +02:00
co-authored by Claude Opus 5
parent a2beae94ec
commit 778d08af24
13 changed files with 787 additions and 0 deletions
+8
View File
@@ -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() }
@@ -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')"
]
}
}
@@ -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)
}
}
@@ -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<List<AlarmEntity>>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
fun observeById(id: Long): Flow<AlarmEntity?>
@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<AlarmEntity>
@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)
}
@@ -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,
)
@@ -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"
}
}
@@ -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<List<LapEntity>>
@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)
}
}
@@ -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,
)
@@ -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<List<TimerEntity>>
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
abstract fun observeById(id: Long): Flow<TimerEntity?>
@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<Long>) {
idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) }
}
}
@@ -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,
)
@@ -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<List<WorldClockEntity>>
@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<Long>) {
idsInOrder.forEachIndexed { index, id -> setSortOrder(id, index) }
}
}
@@ -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,
)
@@ -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\"")
}
}