Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto Migrations #4

Merged
merged 6 commits into from
Jun 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Installation
Add the following dependency to your project's build.gradle file:

```kts
implementation('app.meetacy.wdater:wdater:$wdaterVersion')
implementation("app.meetacy.wdater:wdater:$wdaterVersion")
```

Replace `$wdaterVersion` with the latest version from release.
Expand Down Expand Up @@ -81,13 +81,7 @@ Usage

```kotlin
object UsersTable : Table() {
val USER_ID = long("USER_ID").autoIncrement()
val ACCESS_HASH = varchar("ACCESS_HASH", length = HASH_LENGTH)
val NICKNAME = varchar("NICKNAME", length = NICKNAME_MAX_LIMIT)
val USERNAME = varchar("USERNAME", length = USERNAME_MAX_LIMIT).nullable()
val EMAIL = varchar("EMAIL", length = EMAIL_MAX_LIMIT).nullable()
val EMAIL_VERIFIED = bool("EMAIL_VERIFIED").default(false)
val AVATAR_ID = long("AVATAR_ID").nullable()
}
```

Expand All @@ -101,6 +95,24 @@ Usage

You can also pass a list of migrations to update multiple steps at once.

Auto Migrations
---------------

To use automatic migrations, you need to include this dependency:

```kts
implementation("app.meetacy.wdater:auto-migrations:$wdaterVersion")
```

Then you can use this factory function to create a migration:

```kotlin
fun AutoMigration(
vararg tables: Table,
fromVersion: Int,
toVersion: Int = fromVersion + 1
): AutoMigration
```

Future Plans for Wdater
-----------------------
Expand Down
10 changes: 10 additions & 0 deletions auto-migrations/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
plugins {
id("library-convention")
}

version = libs.versions.wdater.get()

dependencies {
implementation(libs.exposedCore)
api(projects.wdater)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package app.meetacy.database.updater.auto

import app.meetacy.database.updater.Migration
import app.meetacy.database.updater.MigrationContext
import app.meetacy.database.updater.log.Logger
import app.meetacy.database.updater.log.SQL
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.vendors.ColumnMetadata

public fun AutoMigration(
vararg tables: Table,
fromVersion: Int,
toVersion: Int = fromVersion + 1
): AutoMigration = AutoMigration(tables.toList(), fromVersion, toVersion)

/**
* Auto migrations do not support renaming/deleting
*/
public class AutoMigration(
private val tables: List<Table>,
override val fromVersion: Int,
override val toVersion: Int = fromVersion + 1
) : Migration {

override suspend fun MigrationContext.migrate() {
logger.log("Trying to auto migrate from current state")
migrateTables()
migrateColumns()
}

private fun MigrationContext.migrateTables() {
logger.log("Migrating tables...")
val logger = logger["tables-migration"]

val rawNames = transaction.db.dialect.allTablesNames()
logger.log("Tables from server: $rawNames")
val schema = transaction.connection.schema
logger.log("Default schema: $schema")

val tablesOnServer = rawNames.map { name -> name.removePrefix("$schema.") }

createMissingTables(logger, tablesOnServer)
}

private fun MigrationContext.createMissingTables(
baseLogger: Logger,
tablesOnServer: List<String>
) {
baseLogger.log("Creating missing tables...")
val logger = baseLogger["create"]

val newTables = tables.filter { table ->
tablesOnServer.none { name -> normalizedTableName(table) == name }
}

if (newTables.isEmpty()) {
logger.log("No new tables were found")
return
}

logger.log("New tables were found: $newTables, creating them...")

val create = SchemaUtils.createStatements(*newTables.toTypedArray())
execInBatch(logger, create)

logger.log("New tables were created")
}

private fun MigrationContext.migrateColumns() {
logger.log("Migrating columns...")
val logger = logger["columns-migration"]
val columnsOnServer = transaction.db.dialect.tableColumns(*tables.toTypedArray())
createMissingColumns(logger, columnsOnServer)
val updatedColumnOnServer = transaction.db.dialect.tableColumns(*tables.toTypedArray())
modifyAllColumns(logger, updatedColumnOnServer)
logger.log("Migration completed!")
}

private fun MigrationContext.createMissingColumns(
baseLogger: Logger,
databaseColumns: Map<Table, List<ColumnMetadata>>
) {
baseLogger.log("Creating missing columns...")
val parentLogger = baseLogger["create"]

tables.forEach { table ->
parentLogger.log("Working on: ${normalizedTableName(table)}")

val logger = parentLogger[normalizedTableName(table)]
val columnsOnServer = databaseColumns.getValue(table)

val newColumns = table.columns.filter { column ->
columnsOnServer.none { metadata -> normalizedColumnName(column) == metadata.name }
}

if (newColumns.isEmpty()) {
logger.log("No new columns were found")
return@forEach
}

logger.log("New columns were found: ${newColumns.map { normalizedColumnName(it) } }, creating them...")

val create = newColumns.flatMap { column -> column.createStatement() }
execInBatch(logger, create)

logger.log("New columns were created")
}
}

private fun MigrationContext.modifyAllColumns(
baseLogger: Logger,
databaseColumns: Map<Table, List<ColumnMetadata>>
) {
baseLogger.log("Modifying all columns...")
val parentLogger = baseLogger["modify-all"]

tables.forEach { table ->
parentLogger.log("Working on ${normalizedTableName(table)}")
val logger = parentLogger[normalizedTableName(table)]

val columnsOnServer = databaseColumns.getValue(table)

val modify = table.columns.map { column ->
column to columnsOnServer.first { it.name == normalizedColumnName(column) }
}.flatMap { (column, columnOnServer) ->
// exposed doesn't support migration of auto inc
if (column.columnType.isAutoInc) return@flatMap emptyList()

column.modifyStatements(
ColumnDiff(
nullability = column.columnType.nullable != columnOnServer.nullable,
autoInc = false,
defaults = true,
caseSensitiveName = true
)
)
}
execInBatch(logger, modify)
logger.log("Columns were modified")
}
}

private fun MigrationContext.execInBatch(
baseLogger: Logger,
statements: List<String>
) {
statements.forEach(baseLogger.SQL::log)
transaction.execInBatch(statements)
}

private fun MigrationContext.normalizedTableName(table: Table): String {
return table.nameInDatabaseCase().removePrefix("${transaction.connection.schema}.")
}

private fun MigrationContext.normalizedColumnName(column: Column<*>): String {
return column.nameInDatabaseCase()
}

override val displayName: String = "AutoMigration"

private fun MigrationContext.cannotMigrate(): Nothing {
transaction.rollback()
throw CannotAutoMigrateException()
}

public class CannotAutoMigrateException : RuntimeException(
/* message = */"Ambiguity occurred while migrating, cannot make auto migration for current state"
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package app.meetacy.database.updater.auto

import org.jetbrains.exposed.sql.ColumnType

internal object NoOpColumnType : ColumnType() {
override fun sqlType(): String = error("No operation")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package app.meetacy.database.updater.auto

import org.jetbrains.exposed.sql.Table

internal fun Table.dropColumnStatement(columnName: String): List<String> {
val column = Table(tableName).registerColumn<Nothing>(columnName, NoOpColumnType)
return column.dropStatement()
}

internal fun Table.createColumnStatement(columnName: String): List<String> {
val column = Table(tableName).registerColumn<Nothing>(columnName, NoOpColumnType)
return column.createStatement()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package app.meetacy.database.updater.auto

internal data class TableName(
val schema: Schema,
val name: String
) {
sealed interface Schema {
object Default : Schema
data class Custom(val string: String) : Schema
}
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

kotlin = "1.8.10"
exposed = "0.41.1"
wdater = "0.0.1"
wdater = "0.0.2"

[libraries]

Expand Down
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ dependencyResolutionManagement {
}

includeBuild("build-logic")

include("auto-migrations")
5 changes: 5 additions & 0 deletions src/main/kotlin/app/meetacy/database/updater/Migration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public interface Migration {
*/
public val toVersion: Int get() = fromVersion + 1

/**
* The string used as name when printing this migration
*/
public val displayName: String get() = "Migration"

/**
* Function that defines the migration logic
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.meetacy.database.updater

import app.meetacy.database.updater.log.Logger
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Table.Dual.default
import org.jetbrains.exposed.sql.Transaction
Expand All @@ -10,7 +11,10 @@ import org.jetbrains.exposed.sql.Transaction
*
* @param transaction The database transaction in which the migration is executed.
*/
public class MigrationContext(public val transaction: Transaction) {
public class MigrationContext(
public val transaction: Transaction,
public val logger: Logger
) {

/**
* Creates the column in the associated table.
Expand Down
Loading