Skip to content

Commit

Permalink
Migrations: Add
Browse files Browse the repository at this point in the history
Linear: EM-2038
GitHub: #82
  • Loading branch information
Johni0702 authored Oct 10, 2023
1 parent 8113582 commit 10c424e
Show file tree
Hide file tree
Showing 6 changed files with 308 additions and 0 deletions.
5 changes: 5 additions & 0 deletions api/Vigilance.api
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public abstract class gg/essential/vigilance/Vigilant {
public final fun getCategories ()Ljava/util/List;
public final fun getCategoryFromSearch (Ljava/lang/String;)Lgg/essential/vigilance/data/Category;
public final fun getGuiTitle ()Ljava/lang/String;
protected fun getMigrations ()Ljava/util/List;
public final fun getSortingBehavior ()Lgg/essential/vigilance/data/SortingBehavior;
public final fun gui ()Lgg/essential/vigilance/gui/SettingsGui;
public final fun hiddenIf (Lkotlin/reflect/KProperty;Lkotlin/jvm/functions/Function0;)V
Expand Down Expand Up @@ -153,6 +154,10 @@ public final class gg/essential/vigilance/data/MethodBackedPropertyValue : gg/es
public fun invoke (Lgg/essential/vigilance/Vigilant;)V
}

public abstract interface class gg/essential/vigilance/data/Migration {
public abstract fun apply (Ljava/util/Map;)V
}

public abstract interface annotation class gg/essential/vigilance/data/Property : java/lang/annotation/Annotation {
public abstract fun allowAlpha ()Z
public abstract fun category ()Ljava/lang/String;
Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ apiValidation {
ignoredPackages.add("gg.essential.vigilance.example")
nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal")
}

tasks.test {
useJUnitPlatform()
}
34 changes: 34 additions & 0 deletions src/main/kotlin/gg/essential/vigilance/Vigilant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import gg.essential.universal.UChat
import gg.essential.vigilance.data.*
import gg.essential.vigilance.gui.SettingsGui
import gg.essential.vigilance.impl.I18n
import gg.essential.vigilance.impl.migrate
import gg.essential.vigilance.impl.nightconfig.core.file.FileConfig
import java.awt.Color
import java.io.File
Expand Down Expand Up @@ -38,7 +39,38 @@ abstract class Vigilant @JvmOverloads constructor(
c.initEmptyFile(f)
false
}.build()

private val categoryDescription = mutableMapOf<String, CategoryDescription>()

/**
* List of migrations to apply to the config file when it is loaded.
*
* Each entry in the list is a "migration" which should be a pure function that transforms a given old config
* to a newer format.
* The config is passed to the migration as a [MutableMap] of paths to arbitrary values.
* Each path consist of one or more parts joint by `.` characters.
* To get the path for a given property, join its category, (optionally) subcategory, and name with a `.`, lowercase
* everything and replace all spaces with `_` (other special characters are unaffected).
* E.g. `@Property(name = "My Fancy Setting", category = "General", subcategory = "Fancy Stuff")`
* becomes `general.fancy_stuff.my_fancy_setting`.
* See also [gg.essential.vigilance.data.fullPropertyPath].
*
* The config file keeps track of how many migrations have already been applied to it, so a migration will only be
* applied if it has not yet been applied.
* Note that for this to work properly, the list must effectively be treated as append-only. Removing or re-ordering
* migrations in the list will change their index and may cause them to be re-applied / other migrations to not be
* applied at all.
*
* The config file also keeps track of what changes each migration made and stores this information in the file,
* such that, if the mod is downgraded, it can roll back those changes.
* Note that this will only roll back things which have actually changed. If a new version of your mod adds a new
* option to a selector, and the user manually selects that option and then downgrades the mod, the old version
* will see that new index and likely error. If you wish to prevent this via the migrations/rollback system, you
* must artificially modify that setting in a migration (e.g. increase its value by 1) and then change it back to
* its actual value in a second migration (e.g. decrease its value again by 1).
*/
protected open val migrations: List<Migration> = emptyList()

private var dirty = false
private var hasError = false

Expand Down Expand Up @@ -265,6 +297,8 @@ abstract class Vigilant @JvmOverloads constructor(
private fun readData() {
fileConfig.load()

migrate(fileConfig, migrations)

propertyCollector.getProperties().filter { it.value.writeDataToFile }.forEach {
val fullPath = it.attributesExt.fullPropertyPath()

Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/gg/essential/vigilance/data/Migration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package gg.essential.vigilance.data

import gg.essential.vigilance.Vigilant

/** See [Vigilant.migrations]. */
fun interface Migration {
fun apply(config: MutableMap<String, Any?>)
}
108 changes: 108 additions & 0 deletions src/main/kotlin/gg/essential/vigilance/impl/migration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package gg.essential.vigilance.impl

import gg.essential.vigilance.data.Migration
import gg.essential.vigilance.impl.nightconfig.core.Config

private const val META_KEY = "__meta"
private val VERSION_KEY = listOf(META_KEY, "version")
private fun migrationLogKey(migration: Int): List<String> = listOf(META_KEY, "migration_log", "$migration")

internal fun migrate(root: Config, migrations: List<Migration>) {
val fileVersion = root[VERSION_KEY] ?: 0
if (fileVersion < migrations.size) {
var oldMap = root.toMap()
for (migration in fileVersion until migrations.size) {
val newMap = oldMap.toMutableMap()

migrations[migration].apply(newMap)

applyMigration(root, migration, oldMap, newMap)

root.update(VERSION_KEY, migration + 1)

oldMap = newMap
assert(oldMap == root.toMap())
}
} else if (fileVersion > migrations.size) {
for (migration in fileVersion - 1 downTo migrations.size) {
rollbackMigration(root, migration)
root.update(VERSION_KEY, migration)
}
}
}

private fun applyMigration(root: Config, migration: Int, oldMap: Map<String, Any?>, newMap: Map<String, Any?>) {
val migrationLog = root.createSubConfig()

for ((key, oldValue) in oldMap) {
if (key !in newMap) {
migrationLog.update(listOf("changed", key), oldValue)
root.purge<Any?>(key.split("."))
} else {
val newValue = newMap[key]
if (newValue != oldValue) {
migrationLog.update(listOf("changed", key), oldValue)
root.update(key, newValue)
}
}
}

val added = mutableListOf<String>()
for ((key, newValue) in newMap) {
if (key !in oldMap) {
added.add(key)
root.update(key, newValue)
}
}
if (added.isNotEmpty()) {
migrationLog.update(listOf("added"), added)
}

if (!migrationLog.isEmpty) {
root.update(migrationLogKey(migration), migrationLog)
}
}

private fun rollbackMigration(root: Config, migration: Int) {
val migrationLog = root.purge<Config?>(migrationLogKey(migration)) ?: return
val added = migrationLog.get<List<String>>(listOf("added")) ?: emptyList()
val changed = migrationLog.get<Config>(listOf("changed"))?.valueMap() ?: emptyMap()

for (key in added) {
root.purge<Any?>(key.split("."))
}
for ((key, oldValue) in changed) {
root.update(key, oldValue)
}
}

/** Removes the value at the given key as well as any now-empty intermediate nodes. */
private fun <T> Config.purge(keys: List<String>): T? {
return if (keys.size > 1) {
val child = get<Any?>(listOf(keys[0])) as? Config ?: return null
val removed = child.purge<T>(keys.subList(1, keys.size))
if (child.isEmpty) {
remove<Any?>(listOf(keys[0]))
}
removed
} else {
remove(keys)
}
}

/** Converts the nested [Config] into a flat [Map]. Does not include any [META_KEY] entries. */
private fun Config.toMap(): Map<String, Any?> {
val result = mutableMapOf<String, Any?>()
fun visit(config: Config, prefix: String) {
for ((key, value) in config.valueMap()) {
if (key == META_KEY) continue
if (value is Config) {
visit(value, "$prefix$key.")
} else {
result[prefix + key] = value
}
}
}
visit(this, "")
return result
}
149 changes: 149 additions & 0 deletions src/test/kotlin/gg/essential/vigilance/impl/MigrationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package gg.essential.vigilance.impl

import gg.essential.vigilance.data.Migration
import gg.essential.vigilance.impl.nightconfig.core.Config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class MigrationTest {
@Test
fun testUpToDate() {
val input = config("__meta" to mapOf("version" to 2), "test" to 42)
migrate(input, listOf(Migration {}, Migration {}))
assertEquals(config("__meta" to mapOf("version" to 2), "test" to 42), input)
}

@Test
fun testNoOpMigration() {
val input = config("__meta" to mapOf("version" to 1), "test" to 42)
migrate(input, listOf(Migration {}, Migration {}))
assertEquals(config("__meta" to mapOf("version" to 2), "test" to 42), input)
}

@Test
fun testAddMigration() {
val input = config("__meta" to mapOf("version" to 1), "test" to 42)
migrate(input, listOf(Migration {}, Migration { it["new"] = 43 }))
assertEquals(config(
"__meta" to mapOf("version" to 2, "migration_log" to mapOf("1" to mapOf("added" to listOf("new")))),
"test" to 42,
"new" to 43,
), input)
}

@Test
fun testChangeMigration() {
val input = config("__meta" to mapOf("version" to 1), "test" to 42)
migrate(input, listOf(Migration {}, Migration { it["test"] = it["test"] as Int + 1 }))
assertEquals(config(
"__meta" to mapOf("version" to 2, "migration_log" to mapOf("1" to mapOf("changed" to mapOf("test" to 42)))),
"test" to 43,
), input)
}

@Test
fun testRemoveMigration() {
val input = config("__meta" to mapOf("version" to 1), "test" to 42)
migrate(input, listOf(Migration {}, Migration { it.remove("test") }))
assertEquals(config(
"__meta" to mapOf("version" to 2, "migration_log" to mapOf("1" to mapOf("changed" to mapOf("test" to 42)))),
), input)
}

@Test
fun testMultipleMigrations() {
val input = config("__meta" to mapOf("version" to 1), "test" to 42)
migrate(input, listOf(Migration {}, Migration { it["test"] = 1 }, Migration { it["test"] = it["test"] as Int + 1 }))
assertEquals(config(
"__meta" to mapOf("version" to 3, "migration_log" to mapOf(
"1" to mapOf("changed" to mapOf("test" to 42)),
"2" to mapOf("changed" to mapOf("test" to 1)),
)),
"test" to 2,
), input)
}

@Test
fun testAddRollback() {
val input = config(
"__meta" to mapOf("version" to 2, "migration_log" to mapOf("1" to mapOf("added" to listOf("new")))),
"test" to 42,
"new" to 43,
)
migrate(input, listOf(Migration {}))
assertEquals(config("__meta" to mapOf("version" to 1), "test" to 42), input)
}

@Test
fun testChangeRollback() {
val input = config(
"__meta" to mapOf("version" to 2, "migration_log" to mapOf("1" to mapOf("changed" to mapOf("test" to 42)))),
"test" to 43,
)
migrate(input, listOf(Migration {}))
assertEquals(config("__meta" to mapOf("version" to 1), "test" to 42), input)
}

@Test
fun testRemoveRollback() {
val input = config(
"__meta" to mapOf("version" to 2, "migration_log" to mapOf("1" to mapOf("changed" to mapOf("test" to 42)))),
)
migrate(input, listOf(Migration {}))
assertEquals(config("__meta" to mapOf("version" to 1), "test" to 42), input)
}

@Test
fun testMultipleRollback() {
val input = config(
"__meta" to mapOf("version" to 3, "migration_log" to mapOf(
"1" to mapOf("changed" to mapOf("test" to 42)),
"2" to mapOf("changed" to mapOf("test" to 1)),
)),
"test" to 2,
)
migrate(input, listOf(Migration {}))
assertEquals(config("__meta" to mapOf("version" to 1), "test" to 42), input)
}

@Test
fun testNoMigrations() {
val input = config("test" to 42)
migrate(input, listOf())
assertEquals(config("test" to 42), input)
}

@Test
fun testInitialMigration() {
val input = config("test" to 42)
migrate(input, listOf(Migration { config ->
assert(config == mapOf("test" to 42))
config["test"] = 43
}))
assertEquals(config(
"__meta" to mapOf("version" to 1, "migration_log" to mapOf("0" to mapOf("changed" to mapOf("test" to 42)))),
"test" to 43,
), input)
}

private fun config(vararg entries: Pair<String, Any?>): Config =
config(entries.toMap())

private fun config(map: Map<String, Any?>): Config {
fun visit(map: Map<String, Any?>, config: Config) {
for ((key, value) in map) {
if (value is Map<*, *>) {
val inner = config.createSubConfig()
@Suppress("UNCHECKED_CAST")
visit(value as Map<String, Any?>, inner)
config.update(listOf(key), inner)
} else {
config.update(listOf(key), value)
}
}
}
val config = Config.inMemory()
visit(map, config)
return config
}
}

0 comments on commit 10c424e

Please sign in to comment.