Coverage Summary for Class: TransactionEntity (com.georgeci.moneysurfer.data.db.entity)

Class Class, % Method, % Branch, % Line, % Instruction, %
TransactionEntity 100% (1/1) 100% (2/2) 100% (21/21) 100% (117/117)


 package com.georgeci.moneysurfer.data.db.entity
 
 import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.ForeignKey
 import androidx.room.Index
 import androidx.room.PrimaryKey
 
 @Entity(
     tableName = "transactions",
     foreignKeys = [
         ForeignKey(
             entity = WorkspaceEntity::class,
             parentColumns = ["id"],
             childColumns = ["workspaceId"],
         ),
         ForeignKey(
             entity = AccountEntity::class,
             parentColumns = ["id"],
             childColumns = ["accountId"],
         ),
         ForeignKey(
             entity = CategoryEntity::class,
             parentColumns = ["id"],
             childColumns = ["categoryId"],
         ),
     ],
     // Composite indices for the workspace- and account-scoped list queries in
     // TransactionDao: their ORDER BY matches the index, so SQLite walks it instead of
     // building a temp B-tree per Flow emission. (The unscoped getAll* queries sort the
     // whole table and cannot use an index whose leftmost column they don't filter on.)
     // Each starts with the FK column, which also satisfies Room's FK-index requirement
     // — no separate single-column index on workspaceId / accountId is needed.
     indices = [
         Index(
             value = ["workspaceId", "operationDate", "operationAt", "createdAt"],
             orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.DESC, Index.Order.DESC],
         ),
         Index(
             value = ["accountId", "operationDate", "operationAt", "createdAt"],
             orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.DESC, Index.Order.DESC],
         ),
         Index("categoryId"),
         // Unlike `transferId`, which is read once when a details screen opens, `splitId` is
         // correlated per row by the list window query (see TransactionDao.getCategorizedWindow)
         // to learn how many legs a group has. That runs on every emission of the list Flow, so
         // the index earns its write amplification.
         Index("splitId"),
     ],
 )
 data class TransactionEntity(
     @PrimaryKey
     @ColumnInfo(name = "id") val id: String,
     @ColumnInfo(name = "workspaceId") val workspaceId: String,
     @ColumnInfo(name = "accountId") val accountId: String,
     @ColumnInfo(name = "amount") val amount: Long,
     @ColumnInfo(name = "currencyCode") val currencyCode: String,
     @ColumnInfo(name = "categoryId") val categoryId: String?,
     @ColumnInfo(name = "note") val note: String,
     @ColumnInfo(name = "merchant", defaultValue = "") val merchant: String = "",
     // Tags live in one CSV column, matching BudgetEntity.categoryIds. See TransactionTags
     // for why the list is embedded rather than normalized into a join table, and why a tag
     // can never contain the separator.
     @ColumnInfo(name = "tags", defaultValue = "") val tags: String = "",
     @ColumnInfo(name = "operationAt") val operationAt: Long,
     @ColumnInfo(name = "operationDate", defaultValue = "") val operationDate: String = "",
     @ColumnInfo(name = "type") val type: String,
     @ColumnInfo(name = "status", defaultValue = "ACTUAL") val status: String = "ACTUAL",
     @ColumnInfo(name = "createdAt", defaultValue = "0") val createdAt: Long = 0L,
     @ColumnInfo(name = "updatedAt", defaultValue = "0") val updatedAt: Long = 0L,
     @ColumnInfo(name = "transferId") val transferId: String? = null,
     // Shared id of the sibling rows one receipt was split across. Null for the overwhelming
     // majority of rows — see the domain model for why a split is N transactions rather than a
     // child allocation table.
     @ColumnInfo(name = "splitId") val splitId: String? = null,
     // No ForeignKey to recurring_rules and no index: the rules table is not synced yet, so a
     // pulled transaction can name a rule this device has never seen — an FK would reject the
     // row outright. An index would also be dead weight, since the "Recurring only" filter runs
     // inside a workspace scan already served by the composite indices above.
     @ColumnInfo(name = "recurringRuleId") val recurringRuleId: String? = null,
     // Tombstone: non-null means the user deleted this row (issue #346). Every read query in
     // TransactionDao adds `deletedAt IS NULL`; the row itself survives so Undo is a single
     // UPDATE rather than a re-insert, and survives process death rather than living in a
     // Snackbar lambda. Mirrors `TransactionDoc.deletedAt` on the wire, so a pulled tombstone
     // and a local delete are the same state. Purged after a retention window — see
     // PurgeDeletedTransactionsUseCase.
     //
     // Deliberately unindexed. It is a low-cardinality column (almost every row is NULL) that
     // never appears alone in a WHERE clause — it always rides along with a workspace or
     // account filter already served by the composite indices above, so SQLite would ignore a
     // separate index on it while every insert paid to maintain it.
     @ColumnInfo(name = "deletedAt") val deletedAt: Long? = null,
 )