Coverage Summary for Class: CategoryAppearanceMigrationKt (com.georgeci.moneysurfer.data.db.migration)

Class Method, % Branch, % Line, % Instruction, %
CategoryAppearanceMigrationKt 50% (1/2) 0% (0/6) 5.3% (1/19) 4% (6/150)
CategoryAppearanceMigrationKt$MIGRATION_25_26$1 50% (1/2) 25% (1/4) 33.3% (4/12)
Total 50% (2/4) 0% (0/6) 8.7% (2/23) 6.2% (10/162)


 package com.georgeci.moneysurfer.data.db.migration
 
 import androidx.room.migration.Migration
 import androidx.sqlite.SQLiteConnection
 import androidx.sqlite.execSQL
 import com.georgeci.moneysurfer.domain.model.CategoryAppearance
 import com.georgeci.moneysurfer.domain.primitives.CategorySystemKind
 
 /**
  * v25 → v26: give `categories` the `iconKey` and `hue` columns the creation screen has been
  * collecting and throwing away.
  *
  * Additive — two `ALTER TABLE ... ADD COLUMN`s, no table rebuild — followed by a deterministic
  * backfill. The backfill is the point: leaving existing rows on the column defaults would paint
  * every category the user already has in one identical colour, which reads as a broken screen
  * rather than as "no colour chosen". Deriving both values from a stable hash of the category id
  * (see [CategoryAppearance]) instead reproduces exactly what the pre-column UI drew, so the
  * upgrade is visually a no-op.
  *
  * The hash runs in Kotlin rather than SQL because SQLite has no portable string-hash function,
  * and it has to agree bit-for-bit with the one the domain uses on every platform.
  */
 val MIGRATION_25_26: Migration = object : Migration(25, 26) {
     override fun migrate(connection: SQLiteConnection) {
         connection.execSQL("ALTER TABLE `categories` ADD COLUMN `iconKey` TEXT NOT NULL DEFAULT ''")
         connection.execSQL("ALTER TABLE `categories` ADD COLUMN `hue` INTEGER NOT NULL DEFAULT -1")
         backfillCategoryAppearance(connection)
     }
 }
 
 /**
  * Reads every category id (with its system kind, so the seeded Transfer row keeps its fixed
  * appearance) and writes the derived icon key and hue back.
  */
 internal fun backfillCategoryAppearance(connection: SQLiteConnection) {
     val rows = mutableListOf<Pair<String, CategorySystemKind?>>()
     connection.prepare("SELECT `id`, `systemKind` FROM `categories`").use { statement ->
         while (statement.step()) {
             val id = statement.getText(0)
             val systemKind = if (statement.isNull(1)) {
                 null
             } else {
                 runCatching { CategorySystemKind.valueOf(statement.getText(1)) }.getOrNull()
             }
             rows += id to systemKind
         }
     }
 
     connection.prepare("UPDATE `categories` SET `iconKey` = ?, `hue` = ? WHERE `id` = ?").use { statement ->
         rows.forEach { (id, systemKind) ->
             statement.reset()
             statement.bindText(BIND_ICON_KEY, CategoryAppearance.defaultIconKey(id, systemKind))
             statement.bindInt(BIND_HUE, CategoryAppearance.defaultHue(id, systemKind))
             statement.bindText(BIND_ID, id)
             statement.step()
         }
     }
 }
 
 // Bind indices of the UPDATE above — SQLite parameters are 1-based.
 private const val BIND_ICON_KEY = 1
 private const val BIND_HUE = 2
 private const val BIND_ID = 3