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:
@@ -0,0 +1,522 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c94852274d874fe255ee76e1e46a3003",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "display_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principalUrl",
|
||||
"columnName": "principal_url",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetUrl",
|
||||
"columnName": "home_set_url",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncAt",
|
||||
"columnName": "last_sync_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncError",
|
||||
"columnName": "last_sync_error",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "task_lists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "account_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVisible",
|
||||
"columnName": "is_visible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSynced",
|
||||
"columnName": "is_synced",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "owner",
|
||||
"columnName": "owner",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "is_read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "href",
|
||||
"columnName": "href",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ctag",
|
||||
"columnName": "ctag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncToken",
|
||||
"columnName": "sync_token",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirty",
|
||||
"columnName": "is_dirty",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_task_lists_account_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"account_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"account_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "tasks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "listId",
|
||||
"columnName": "list_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "href",
|
||||
"columnName": "href",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "etag",
|
||||
"columnName": "etag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "location",
|
||||
"columnName": "location",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "percentComplete",
|
||||
"columnName": "percent_complete",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "completedAt",
|
||||
"columnName": "completed_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "classification",
|
||||
"columnName": "classification",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "dtstart",
|
||||
"columnName": "dtstart",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "due",
|
||||
"columnName": "due",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAllDay",
|
||||
"columnName": "is_all_day",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rrule",
|
||||
"columnName": "rrule",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rdate",
|
||||
"columnName": "rdate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "exdate",
|
||||
"columnName": "exdate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "recurrenceId",
|
||||
"columnName": "recurrence_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "masterId",
|
||||
"columnName": "master_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parent_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "last_modified",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sequence",
|
||||
"columnName": "sequence",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirty",
|
||||
"columnName": "is_dirty",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDeleted",
|
||||
"columnName": "is_deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "unknownProperties",
|
||||
"columnName": "unknown_properties",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_tasks_list_id_is_deleted",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"list_id",
|
||||
"is_deleted"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_parent_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parent_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_master_id_recurrence_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"master_id",
|
||||
"recurrence_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_is_dirty",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_dirty"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tasks_list_id_uid_recurrence_id",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"list_id",
|
||||
"uid",
|
||||
"recurrence_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "task_lists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"list_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"master_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parent_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "task_alarms",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "taskId",
|
||||
"columnName": "task_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minutesBefore",
|
||||
"columnName": "minutes_before",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reference",
|
||||
"columnName": "reference",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'DUE'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_task_alarms_task_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"task_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "tasks",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"task_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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, 'c94852274d874fe255ee76e1e46a3003')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 1–4 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,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user