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:
@@ -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\"")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user