feat(store): add the Room schema, DAOs and exported schema

Phase 1 of docs/OWN-STORE.md. Four tables — task_lists, tasks,
task_alarms, accounts — with the indices, cascades and converters the
plan specifies, plus a DAO per table and the v1 schema JSON committed for
migration testing.

Masters and RECURRENCE-ID overrides share the tasks table, so the unique
index is on (list_id, uid, recurrence_id): an override shares its
master's UID, and a key without recurrence_id would reject exactly the
rows recurrence depends on. SQLite treats NULLs as distinct, so that
index only enforces the override half; the master half is intent, noted
where the index is declared.

PRIORITY is stored as the raw iCalendar integer rather than through the
Priority enum. Priority buckets 1..4 into HIGH, so a converter would
rewrite a server's PRIORITY:3 as 1 before it ever reached disk — the
bucketing belongs in the mapper. Status keeps its converter: that mapping
is total.

Two cascades the plan left unstated: deleting a list takes its tasks,
deleting an account only detaches its lists.

Instrumented tests cover read-back, the cascades and the unique index —
app/src/androidTest is new.
This commit is contained in:
2026-08-13 16:09:11 +02:00
parent 96a2995df4
commit fd8363e356
11 changed files with 1396 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
package de.jeanlucmakiola.agendula.data.tasks.room
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.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Instant
/**
* The schema, exercised through the DAOs. Instrumented rather than JVM because
* the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
* real SQLite.
*/
@RunWith(AndroidJUnit4::class)
class TasksDatabaseTest {
private lateinit var db: TasksDatabase
private lateinit var lists: TaskListDao
private lateinit var tasks: TaskDao
private lateinit var alarms: TaskAlarmDao
private lateinit var accounts: AccountDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TasksDatabase::class.java,
).allowMainThreadQueries().build()
lists = db.taskLists()
tasks = db.tasks()
alarms = db.alarms()
accounts = db.accounts()
}
@After
fun tearDown() = db.close()
private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
private fun newTask(
listId: Long,
uid: String = "uid-${counter++}",
title: String? = "Buy milk",
status: TaskStatus = TaskStatus.NEEDS_ACTION,
parentId: Long? = null,
masterId: Long? = null,
recurrenceId: Instant? = null,
): Long = tasks.insert(
TaskEntity(
listId = listId,
uid = uid,
title = title,
status = status,
parentId = parentId,
masterId = masterId,
recurrenceId = recurrenceId,
),
)
@Test
fun writesAndReadsAListWithItsTasks() {
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
val listId = newList(accountId = accountId)
val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
val taskId = tasks.insert(
TaskEntity(
listId = listId,
uid = "uid-1",
title = "Buy milk",
description = "2%",
due = due,
priority = 3,
status = TaskStatus.IN_PROCESS,
percentComplete = 40,
),
)
val list = lists.lists().single()
assertThat(list.list.id).isEqualTo(listId)
assertThat(list.list.name).isEqualTo("Groceries")
assertThat(list.accountDisplayName).isEqualTo("Fastmail")
val row = tasks.task(taskId)!!
assertThat(row.task.title).isEqualTo("Buy milk")
assertThat(row.task.due).isEqualTo(due)
// Stored raw: an off-bucket PRIORITY must come back as it went in.
assertThat(row.task.priority).isEqualTo(3)
assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(row.task.percentComplete).isEqualTo(40)
assertThat(row.listName).isEqualTo("Groceries")
assertThat(row.accountDisplayName).isEqualTo("Fastmail")
}
@Test
fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
val a = newList("A")
val b = newList("B")
newTask(a, title = "open")
newTask(a, title = "done", status = TaskStatus.COMPLETED)
newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
newTask(b, title = "elsewhere")
assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
.containsExactly("open")
assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
}
@Test
fun readsSubtasksByParent() {
val listId = newList()
val parent = newTask(listId, title = "parent")
newTask(listId, title = "child", parentId = parent)
assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
}
@Test
fun hidesTombstonesFromReadsAndExports() {
val listId = newList()
val taskId = newTask(listId)
tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
assertThat(tasks.task(taskId)).isNull()
assertThat(tasks.exportTasks(listId)).isEmpty()
assertThat(tasks.entity(taskId)).isNotNull()
}
@Test
fun keepsOverridesOutOfTheMasterReads() {
val listId = newList()
val master = newTask(listId, uid = "series")
val override = newTask(
listId,
uid = "series",
masterId = master,
recurrenceId = Instant.fromEpochMilliseconds(5_000),
)
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
.containsExactly(master)
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
assertThat(tasks.overridesForList(listId).map { it.id }).containsExactly(override)
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
.isEqualTo(override)
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
}
// --- cascades -------------------------------------------------------------
@Test
fun deletingAListDeletesItsTasks() {
val listId = newList()
val taskId = newTask(listId)
lists.delete(listId)
assertThat(tasks.entity(taskId)).isNull()
}
@Test
fun deletingASeriesDeletesItsOverrides() {
val listId = newList()
val master = newTask(listId, uid = "series")
val override = newTask(
listId,
uid = "series",
masterId = master,
recurrenceId = Instant.fromEpochMilliseconds(5_000),
)
tasks.delete(master)
assertThat(tasks.entity(override)).isNull()
}
@Test
fun deletingAParentPromotesItsSubtasks() {
val listId = newList()
val parent = newTask(listId, title = "parent")
val child = newTask(listId, title = "child", parentId = parent)
tasks.delete(parent)
val promoted = tasks.entity(child)
assertThat(promoted).isNotNull()
assertThat(promoted!!.parentId).isNull()
}
@Test
fun deletingATaskDeletesItsAlarms() {
val listId = newList()
val taskId = newTask(listId)
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
assertThat(alarms.all()).hasSize(1)
tasks.delete(taskId)
assertThat(alarms.all()).isEmpty()
}
@Test
fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
val listId = newList(accountId = accountId)
accounts.delete(accountId)
assertThat(lists.entity(listId)!!.accountId).isNull()
}
@Test
fun replacingAnAlarmLeavesOnlyTheNewOne() {
val listId = newList()
val taskId = newTask(listId)
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
alarms.replaceForTask(taskId, null)
assertThat(alarms.forTask(taskId)).isEmpty()
}
// --- the unique index -----------------------------------------------------
@Test
fun anOverrideMayShareItsMastersUid() {
val listId = newList()
val master = newTask(listId, uid = "series")
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
assertThat(tasks.overrides(master)).hasSize(2)
}
@Test
fun rejectsTwoOverridesOfTheSameOccurrence() {
val listId = newList()
val master = newTask(listId, uid = "series")
val at = Instant.fromEpochMilliseconds(1)
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
val failure = runCatching {
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
}.exceptionOrNull()
assertThat(failure).isNotNull()
assertThat(failure!!.message).contains("UNIQUE")
}
@Test
fun theSameUidMayExistInAnotherList() {
val a = newList("A")
val b = newList("B")
newTask(a, uid = "shared")
newTask(b, uid = "shared")
assertThat(tasks.byUid(a, "shared")).isNotNull()
assertThat(tasks.byUid(b, "shared")).isNotNull()
}
private companion object {
var counter = 0
}
}

View File

@@ -0,0 +1,30 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlin.time.Instant
/** Reads and writes over `accounts`. Unused until sync lands. */
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY display_name")
fun all(): List<AccountEntity>
@Query("SELECT * FROM accounts WHERE id = :accountId")
fun account(accountId: Long): AccountEntity?
@Insert
fun insert(account: AccountEntity): Long
@Update
fun update(account: AccountEntity)
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
fun recordSync(accountId: Long, at: Instant?, error: String?)
@Query("DELETE FROM accounts WHERE id = :accountId")
fun delete(accountId: Long): Int
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.TypeConverter
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.statusFromInt
import de.jeanlucmakiola.agendula.domain.toInt
import kotlin.time.Instant
/**
* Storage encodings for the entity types SQLite has no column type for. Time is
* epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering
* keeps its single home.
*
* `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar
* integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy
* bucketing and a converter would apply it before the value reaches disk.
*/
object Converters {
@TypeConverter
fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds()
@TypeConverter
fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds)
@TypeConverter
fun statusToInt(value: TaskStatus): Int = value.toInt()
@TypeConverter
fun statusFrom(value: Int): TaskStatus = statusFromInt(value)
@TypeConverter
fun alarmReferenceToString(value: AlarmReference): String = value.name
@TypeConverter
fun alarmReferenceFrom(value: String): AlarmReference =
runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE)
}

View File

@@ -0,0 +1,213 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK
* from [TaskListEntity] exists from v1 so turning sync on never needs a
* migration. The app password is never stored here — Keystore only.
*/
@Entity(tableName = "accounts")
data class AccountEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "display_name") val displayName: String,
@ColumnInfo(name = "principal_url") val principalUrl: String? = null,
@ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null,
@ColumnInfo(name = "username") val username: String? = null,
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
)
/**
* A task list. [accountId] is nullable: `NULL` is a device-only list, and
* attaching one to an account later is a plain `UPDATE` rather than a data
* migration.
*
* Deleting an account detaches its lists (`SET NULL`) instead of deleting them,
* for the same reason [TaskEntity.parentId] does — removing an account is not
* an instruction to destroy the tasks it held.
*/
@Entity(
tableName = "task_lists",
foreignKeys = [
ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["account_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [Index(value = ["account_id"])],
)
data class TaskListEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
/** ARGB. */
@ColumnInfo(name = "color") val color: Int,
@ColumnInfo(name = "account_id") val accountId: Long? = null,
@ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true,
@ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true,
/** CalDAV owner display name. */
@ColumnInfo(name = "owner") val owner: String? = null,
@ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false,
/** User ordering. */
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
/** Collection URL, relative to the account root. */
@ColumnInfo(name = "href") val href: String? = null,
@ColumnInfo(name = "ctag") val ctag: String? = null,
/** RFC 6578 sync token, per collection. */
@ColumnInfo(name = "sync_token") val syncToken: String? = null,
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
)
/**
* A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an
* override is a row with [recurrenceId] set and [masterId] pointing at its
* master, sharing the master's [uid].
*
* [masterId] and [parentId] are different things: [parentId] is task hierarchy
* (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both.
*/
@Entity(
tableName = "tasks",
foreignKeys = [
ForeignKey(
entity = TaskListEntity::class,
parentColumns = ["id"],
childColumns = ["list_id"],
onDelete = ForeignKey.CASCADE,
),
// Deleting a series takes its overrides with it — they would otherwise be
// unreachable rows that still sync.
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["master_id"],
onDelete = ForeignKey.CASCADE,
),
// Deleting a parent promotes its subtasks to top level rather than
// destroying work the user did not ask to lose.
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["parent_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [
Index(value = ["list_id", "is_deleted"]),
Index(value = ["parent_id"]),
Index(value = ["master_id", "recurrence_id"]),
Index(value = ["is_dirty"]),
// An override shares its master's UID, so (list_id, uid) alone would
// reject the very rows recurrence depends on. With recurrence_id NULL on
// the master and set on each override this reads as: one master and at
// most one override per occurrence, per UID, per list. Note SQLite treats
// NULLs as distinct in a unique index, so the master half is a statement
// of intent, not an enforced constraint.
Index(value = ["list_id", "uid", "recurrence_id"], unique = true),
],
)
data class TaskEntity(
// identity
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "list_id") val listId: Long,
/** RFC 4122 UUID, minted at creation in every mode, synced or not. */
@ColumnInfo(name = "uid") val uid: String,
@ColumnInfo(name = "href") val href: String? = null,
@ColumnInfo(name = "etag") val etag: String? = null,
// content
@ColumnInfo(name = "title") val title: String? = null,
@ColumnInfo(name = "description") val description: String? = null,
@ColumnInfo(name = "location") val location: String? = null,
@ColumnInfo(name = "url") val url: String? = null,
/** ARGB override for the list colour. */
@ColumnInfo(name = "color") val color: Int? = null,
// state
@ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION,
@ColumnInfo(name = "percent_complete") val percentComplete: Int? = null,
@ColumnInfo(name = "completed_at") val completedAt: Instant? = null,
/**
* Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed —
* [de.jeanlucmakiola.agendula.domain.Priority] folds 14 into HIGH, so
* converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and
* lose it on the next round-trip. The bucketing belongs to the mapper, which
* is where the UI needs it.
*/
@ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE,
/** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */
@ColumnInfo(name = "classification") val classification: Int? = null,
// time
@ColumnInfo(name = "dtstart") val dtstart: Instant? = null,
@ColumnInfo(name = "due") val due: Instant? = null,
/** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */
@ColumnInfo(name = "duration") val duration: String? = null,
@ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false,
@ColumnInfo(name = "timezone") val timezone: String? = null,
// recurrence
@ColumnInfo(name = "rrule") val rrule: String? = null,
@ColumnInfo(name = "rdate") val rdate: String? = null,
@ColumnInfo(name = "exdate") val exdate: String? = null,
/** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */
@ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null,
/** The series this row overrides; `NULL` on a master. */
@ColumnInfo(name = "master_id") val masterId: Long? = null,
// hierarchy
@ColumnInfo(name = "parent_id") val parentId: Long? = null,
@ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0,
// audit
@ColumnInfo(name = "created_at") val createdAt: Instant? = null,
@ColumnInfo(name = "last_modified") val lastModified: Instant? = null,
@ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0,
// sync
@ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false,
/** Tombstone: deleted locally, still owed to a server. */
@ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false,
/**
* Raw unfolded iCalendar lines of every property we do not model, re-emitted
* verbatim on write so a round-trip cannot silently lose a field.
*/
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
)
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
enum class AlarmReference { DUE, START }
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
@Entity(
tableName = "task_alarms",
foreignKeys = [
ForeignKey(
entity = TaskEntity::class,
parentColumns = ["id"],
childColumns = ["task_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["task_id"])],
)
data class TaskAlarmEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "task_id") val taskId: Long,
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
@ColumnInfo(name = "message") val message: String? = null,
)

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Embedded
/**
* A list plus its account's display name, which the domain
* [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by.
* `null` means a device-only list.
*/
data class TaskListRow(
@Embedded val list: TaskListEntity,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
)
/**
* A task plus the three columns of its list the domain
* [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is
* one query rather than one per list.
*/
data class TaskRow(
@Embedded val task: TaskEntity,
@ColumnInfo(name = "list_name") val listName: String,
@ColumnInfo(name = "list_color") val listColor: Int,
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
)

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
/** Reads and writes over `task_alarms`. */
@Dao
interface TaskAlarmDao {
/** Every reminder in the store, for one scheduler pass. */
@Query("SELECT * FROM task_alarms")
fun all(): List<TaskAlarmEntity>
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId")
fun forTask(taskId: Long): List<TaskAlarmEntity>
@Insert
fun insert(alarm: TaskAlarmEntity): Long
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
/** Set the task's only reminder, or clear it with `null`. */
@Transaction
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
deleteForTask(taskId)
alarm?.let { insert(it.copy(taskId = taskId)) }
}
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* Reads and writes over `tasks`.
*
* Reads split masters from overrides on purpose: [tasks] returns the rows a
* recurrence expander expands (a non-recurring task is its own single
* occurrence), and [overridesForList] / [overrides] return the
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
* expands anything — that is phase 2's job, in Kotlin.
*/
@Dao
interface TaskDao {
// --- reads ----------------------------------------------------------------
/**
* Master (and non-recurring) rows, optionally narrowed to one list. Closed
* tasks — `COMPLETED` and `CANCELLED` — are excluded unless
* [includeCompleted]; tombstones always are.
*/
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.is_deleted = 0
AND t.master_id IS NULL
AND (:listId IS NULL OR t.list_id = :listId)
AND (:includeCompleted = 1 OR t.status NOT IN (2, 3))
"""
)
fun tasks(listId: Long?, includeCompleted: Boolean): List<TaskRow>
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.id = :taskId AND t.is_deleted = 0
"""
)
fun task(taskId: Long): TaskRow?
@Query(
"""
SELECT t.*, l.name AS list_name, l.color AS list_color,
a.display_name AS account_display_name
FROM tasks t
JOIN task_lists l ON l.id = t.list_id
LEFT JOIN accounts a ON a.id = l.account_id
WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL
"""
)
fun subtasks(parentTaskId: Long): List<TaskRow>
@Query("SELECT * FROM tasks WHERE id = :taskId")
fun entity(taskId: Long): TaskEntity?
/** Every override in [listId], for expansion alongside [tasks]. */
@Query("SELECT * FROM tasks WHERE list_id = :listId AND master_id IS NOT NULL AND is_deleted = 0")
fun overridesForList(listId: Long): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
fun overrides(masterId: Long): List<TaskEntity>
@Query(
"SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0"
)
fun override(masterId: Long, recurrenceId: Instant?): TaskEntity?
@Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId")
fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity?
/** Masters only, tombstones excluded — what an `.ics` export writes. */
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
fun exportTasks(listId: Long): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
fun dirty(): List<TaskEntity>
// --- writes ---------------------------------------------------------------
@Insert
fun insert(task: TaskEntity): Long
@Update
fun update(task: TaskEntity): Int
@Query(
"""
UPDATE tasks SET status = :status, percent_complete = :percentComplete,
completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty
WHERE id = :taskId
"""
)
fun setCompletion(
taskId: Long,
status: TaskStatus,
percentComplete: Int?,
completedAt: Instant?,
lastModified: Instant?,
dirty: Boolean,
): Int
/** Hard delete. Used when the row was never on a server. */
@Query("DELETE FROM tasks WHERE id = :taskId")
fun delete(taskId: Long): Int
/** Tombstone, for a row a server still knows about. */
@Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId")
fun markDeleted(taskId: Long, at: Instant?): Int
}

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */
@Dao
interface TaskListDao {
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
ORDER BY a.display_name, l.sort_order, l.name
"""
)
fun lists(): List<TaskListRow>
@Query(
"""
SELECT l.*, a.display_name AS account_display_name
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
WHERE l.id = :listId
"""
)
fun list(listId: Long): TaskListRow?
@Query("SELECT * FROM task_lists WHERE id = :listId")
fun entity(listId: Long): TaskListEntity?
@Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId")
fun exists(listId: Long): Int
@Insert
fun insert(list: TaskListEntity): Long
@Update
fun update(list: TaskListEntity)
@Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId")
fun setVisible(listId: Long, visible: Boolean)
/** Attach a list to an account, or detach it with `null`. */
@Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId")
fun setAccount(listId: Long, accountId: Long?)
@Query("DELETE FROM task_lists WHERE id = :listId")
fun delete(listId: Long)
}

View File

@@ -0,0 +1,34 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
/**
* Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from
* what the app actually reads and writes plus RFC 5545's `VTODO`.
*
* Schemas are exported to `app/schemas/` and committed, so a future version can
* be migration-tested against this one.
*/
@Database(
entities = [
AccountEntity::class,
TaskListEntity::class,
TaskEntity::class,
TaskAlarmEntity::class,
],
version = 1,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class TasksDatabase : RoomDatabase() {
abstract fun taskLists(): TaskListDao
abstract fun tasks(): TaskDao
abstract fun alarms(): TaskAlarmDao
abstract fun accounts(): AccountDao
companion object {
const val NAME = "agendula-tasks.db"
}
}

View File

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.toICal
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ConvertersTest {
@Test
fun `round-trips an instant through epoch millis`() {
val value = Instant.fromEpochMilliseconds(1_700_000_000_123)
val stored = Converters.instantToMillis(value)
assertThat(stored).isEqualTo(1_700_000_000_123)
assertThat(Converters.instantFromMillis(stored)).isEqualTo(value)
}
@Test
fun `maps null time both ways`() {
assertThat(Converters.instantToMillis(null)).isNull()
assertThat(Converters.instantFromMillis(null)).isNull()
}
@Test
fun `round-trips every status through the domain encoding`() {
TaskStatus.entries.forEach { status ->
assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status)
}
}
@Test
fun `priority is stored raw, so an off-bucket value survives`() {
// There is no priority converter on purpose: PRIORITY:3 is a legitimate
// value a server can send, and Priority buckets 1..4 into HIGH. Bucketing
// on the way in would rewrite it as 1 and lose it on the next round-trip.
assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH)
assertThat(Priority.HIGH.toICal()).isEqualTo(1)
}
@Test
fun `round-trips an alarm reference and falls back on an unknown one`() {
AlarmReference.entries.forEach { reference ->
assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference)))
.isEqualTo(reference)
}
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
}
}