Skip to content

Commit

Permalink
Merge pull request #4 from meetacy/#1-auto-migrations
Browse files Browse the repository at this point in the history
Auto Migrations
  • Loading branch information
y9san9 authored Jun 13, 2023
2 parents 7ba4b7e + 9561222 commit 0c93477
Show file tree
Hide file tree
Showing 14 changed files with 397 additions and 18 deletions.
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

0 comments on commit 0c93477

Please sign in to comment.