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

feat: Add migrate function #2043

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
3 changes: 1 addition & 2 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -2014,10 +2014,9 @@ public final class org/jetbrains/exposed/sql/SchemaUtils {
public static synthetic fun dropSchema$default (Lorg/jetbrains/exposed/sql/SchemaUtils;[Lorg/jetbrains/exposed/sql/Schema;ZZILjava/lang/Object;)V
public final fun dropSequence ([Lorg/jetbrains/exposed/sql/Sequence;Z)V
public static synthetic fun dropSequence$default (Lorg/jetbrains/exposed/sql/SchemaUtils;[Lorg/jetbrains/exposed/sql/Sequence;ZILjava/lang/Object;)V
public final fun generateMigrationScript ([Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;Z)Ljava/io/File;
public static synthetic fun generateMigrationScript$default (Lorg/jetbrains/exposed/sql/SchemaUtils;[Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Ljava/io/File;
public final fun listDatabases ()Ljava/util/List;
public final fun listTables ()Ljava/util/List;
public final fun logTimeSpent (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;)Ljava/lang/Object;
public final fun setSchema (Lorg/jetbrains/exposed/sql/Schema;Z)V
public static synthetic fun setSchema$default (Lorg/jetbrains/exposed/sql/SchemaUtils;Lorg/jetbrains/exposed/sql/Schema;ZILjava/lang/Object;)V
public final fun sortTablesByReferences (Ljava/lang/Iterable;)Ljava/util/List;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.SqlExpressionBuilder.asLiteral
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.vendors.*
import java.io.File
import java.math.BigDecimal

/** Utility functions that assist with creating, altering, and dropping database schema objects. */
@Suppress("TooManyFunctions", "LargeClass")
object SchemaUtils {
private inline fun <R> logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R {
inline fun <R> logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R {
return if (withLogs) {
val start = System.currentTimeMillis()
val answer = block()
Expand Down Expand Up @@ -510,11 +509,11 @@ object SchemaUtils {
* Returns the SQL statements that need to be executed to make the existing database schema compatible with
* the table objects defined using Exposed.
*
* **Note:** Some dialects, like SQLite, do not support `ALTER TABLE ADD COLUMN` syntax completely,
* which restricts the behavior when adding some missing columns. Please check the documentation.
*
* By default, a description for each intermediate step, as well as its execution time, is logged at the INFO level.
* This can be disabled by setting [withLogs] to `false`.
*
* **Note:** Some dialects, like SQLite, do not support `ALTER TABLE ADD COLUMN` syntax completely,
* which restricts the behavior when adding some missing columns. Please check the documentation.
*/
fun statementsRequiredToActualizeScheme(vararg tables: Table, withLogs: Boolean = true): List<String> {
val (tablesToCreate, tablesToAlter) = tables.partition { !it.exists() }
Expand Down Expand Up @@ -832,44 +831,6 @@ object SchemaUtils {
return toDrop.toList()
}

/**
* @param tables The tables whose changes will be used to generate the migration script.
* @param scriptName The name to be used for the generated migration script.
* @param scriptDirectory The directory (path from repository root) in which to create the migration script.
* @param withLogs By default, a description for each intermediate step, as well as its execution time, is logged at
* the INFO level. This can be disabled by setting [withLogs] to `false`.
*
* @return The generated migration script.
*
* @throws IllegalArgumentException if no argument is passed for the [tables] parameter.
*
* This function simply generates the migration script without applying the migration. Its purpose is to show what
* the migration script will look like before applying the migration.
* If a migration script with the same name already exists, its content will be overwritten.
*/
@ExperimentalDatabaseMigrationApi
fun generateMigrationScript(vararg tables: Table, scriptDirectory: String, scriptName: String, withLogs: Boolean = true): File {
require(tables.isNotEmpty()) { "Tables argument must not be empty" }

val allStatements = statementsRequiredForDatabaseMigration(*tables, withLogs = withLogs)

val migrationScript = File("$scriptDirectory/$scriptName.sql")
migrationScript.createNewFile()

// Clear existing content
migrationScript.writeText("")

// Append statements
allStatements.forEach { statement ->
// Add semicolon only if it's not already there
val conditionalSemicolon = if (statement.last() == ';') "" else ";"

migrationScript.appendText("$statement$conditionalSemicolon\n")
}

return migrationScript
}

/**
* Returns the SQL statements that need to be executed to make the existing database schema compatible with
* the table objects defined using Exposed. Unlike [statementsRequiredToActualizeScheme], DROP/DELETE statements are
Expand All @@ -881,6 +842,7 @@ object SchemaUtils {
* By default, a description for each intermediate step, as well as its execution time, is logged at the INFO level.
* This can be disabled by setting [withLogs] to `false`.
*/
@ExperimentalDatabaseMigrationApi
fun statementsRequiredForDatabaseMigration(vararg tables: Table, withLogs: Boolean = true): List<String> {
val (tablesToCreate, tablesToAlter) = tables.partition { !it.exists() }
val createStatements = logTimeSpent("Preparing create tables statements", withLogs) {
Expand Down
16 changes: 16 additions & 0 deletions exposed-migration/api/exposed-migration.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
public final class ExposedMigrationException : java/lang/RuntimeException {
public fun <init> (Ljava/lang/Exception;Ljava/lang/String;)V
}

public final class MigrationUtils {
public static final field INSTANCE LMigrationUtils;
public final fun generateMigrationScript ([Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Ljava/io/File;
public final fun generateMigrationScript ([Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;Z)Ljava/io/File;
public static synthetic fun generateMigrationScript$default (LMigrationUtils;[Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Ljava/io/File;
public static synthetic fun generateMigrationScript$default (LMigrationUtils;[Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Ljava/io/File;
public final fun migrate (Lorg/jetbrains/exposed/sql/Database;[Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
public final fun migrate (Lorg/jetbrains/exposed/sql/Database;[Lorg/jetbrains/exposed/sql/Table;Ljavax/sql/DataSource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
public static synthetic fun migrate$default (LMigrationUtils;Lorg/jetbrains/exposed/sql/Database;[Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)V
public static synthetic fun migrate$default (LMigrationUtils;Lorg/jetbrains/exposed/sql/Database;[Lorg/jetbrains/exposed/sql/Table;Ljavax/sql/DataSource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)V
}

31 changes: 31 additions & 0 deletions exposed-migration/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
plugins {
kotlin("jvm")
}

repositories {
mavenCentral()
}

dependencies {
api(project(":exposed-core"))

api(libs.flyway)
api(libs.flyway.mysql)
api(libs.flyway.oracle)
api(libs.flyway.sqlserver)

testImplementation(project(":exposed-tests"))

testImplementation(libs.junit)
testImplementation(kotlin("test-junit"))

testCompileOnly(libs.pgjdbc.ng)
}

tasks.test {
useJUnitPlatform()
}

kotlin {
jvmToolchain(8)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** An exception thrown when a database migration fails. */
class ExposedMigrationException(exception: Exception, message: String) : RuntimeException(message, exception)
222 changes: 222 additions & 0 deletions exposed-migration/src/main/kotlin/MigrationUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import org.flywaydb.core.Flyway
import org.flywaydb.core.api.FlywayException
import org.flywaydb.core.api.output.MigrateResult
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.ExperimentalDatabaseMigrationApi
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.SchemaUtils.statementsRequiredForDatabaseMigration
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.exposedLogger
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.io.File
import javax.sql.DataSource

object MigrationUtils {
/**
* Applies a database migration from [oldVersion] to [newVersion]. If a migration script with the same name already
* exists, the existing one will be used as is and a new one will not be generated. This allows you to generate a
* migration script before the migration and modify it manually if needed.
*
* To generate a migration script without applying a migration, @see [generateMigrationScript].
*
* @param tables The tables to which the migration will be applied.
* @param user The user of the database.
* @param password The password of the database.
* @param oldVersion The version to migrate from. Pending migrations up to [oldVersion] are applied before applying the migration to [newVersion].
* @param newVersion The version to migrate to.
* @param migrationTitle The title of the migration.
* @param migrationScriptDirectory The directory in which to create the migration script.
* @param withLogs By default, a description for each intermediate step, as well as its execution time, is logged at
* the INFO level. This can be disabled by setting [withLogs] to `false`.
*
* @throws ExposedMigrationException if the migration fails.
*/
@ExperimentalDatabaseMigrationApi
@Suppress("LongParameterList", "TooGenericExceptionCaught")
fun Database.migrate(
vararg tables: Table,
user: String,
password: String,
oldVersion: String,
newVersion: String,
migrationTitle: String,
migrationScriptDirectory: String,
withLogs: Boolean = true
) {
val flyway = Flyway
.configure()
.baselineOnMigrate(true)
.baselineVersion(oldVersion)
.dataSource(url, user, password)
.locations("filesystem:$migrationScriptDirectory")
.load()

attemptMigration(
*tables,
flyway = flyway,
oldVersion = oldVersion,
newVersion = newVersion,
migrationTitle = migrationTitle,
migrationScriptDirectory = migrationScriptDirectory,
withLogs = withLogs
)
}

/**
* Applies a database migration from [oldVersion] to [newVersion]. If a migration script with the same name already
* exists, the existing one will be used as is and a new one will not be generated. This allows you to generate a
* migration script before the migration and modify it manually if needed.
*
* To generate a migration script without applying a migration, @see [generateMigrationScript].
*
* @param tables The tables to which the migration will be applied.
* @param dataSource The [DataSource] object to be used as a means of getting a connection.
* @param oldVersion The version to migrate from. Pending migrations up to [oldVersion] are applied before applying the migration to [newVersion].
* @param newVersion The version to migrate to.
* @param migrationTitle The title of the migration.
* @param migrationScriptDirectory The directory in which to create the migration script.
* @param withLogs By default, a description for each intermediate step, as well as its execution time, is logged at
* the INFO level. This can be disabled by setting [withLogs] to `false`.
*
* @throws ExposedMigrationException if the migration fails.
*/
@ExperimentalDatabaseMigrationApi
@Suppress("LongParameterList", "TooGenericExceptionCaught")
fun Database.migrate(
vararg tables: Table,
dataSource: DataSource,
oldVersion: String,
newVersion: String,
migrationTitle: String,
migrationScriptDirectory: String,
withLogs: Boolean = true
) {
val flyway = Flyway
.configure()
.baselineOnMigrate(true)
.baselineVersion(oldVersion)
.dataSource(dataSource)
.locations("filesystem:$migrationScriptDirectory")
.load()

attemptMigration(
*tables,
flyway = flyway,
oldVersion = oldVersion,
newVersion = newVersion,
migrationTitle = migrationTitle,
migrationScriptDirectory = migrationScriptDirectory,
withLogs = withLogs
)
}

@ExperimentalDatabaseMigrationApi
@Suppress("TooGenericExceptionCaught")
private fun attemptMigration(
vararg tables: Table,
flyway: Flyway,
oldVersion: String,
newVersion: String,
migrationTitle: String,
migrationScriptDirectory: String,
withLogs: Boolean
) {
with(TransactionManager.current()) {
db.dialect.resetCaches()

try {
val migrationScript = File("$migrationScriptDirectory/$migrationTitle.sql")
if (!migrationScript.exists()) {
generateMigrationScript(
tables = *tables,
newVersion = newVersion,
title = migrationTitle,
scriptDirectory = migrationScriptDirectory,
withLogs = withLogs
)
}
} catch (exception: Exception) {
throw ExposedMigrationException(
exception = exception,
message = "Failed to generate migration script for migration from $oldVersion to $newVersion: ${exception.message.orEmpty()}"
)
}

try {
SchemaUtils.logTimeSpent("Migrating database from $oldVersion to $newVersion", withLogs = true) {
val migrateResult: MigrateResult = flyway.migrate()
if (withLogs) {
exposedLogger.info("Migration of database ${if (migrateResult.success) "succeeded" else "failed"}.")
}
}
} catch (exception: FlywayException) {
flyway.repair()
throw ExposedMigrationException(
exception = exception,
message = "Migration failed from version $oldVersion to $newVersion: ${exception.message.orEmpty()}"
)
}

db.dialect.resetCaches()
}
}

/**
* This function simply generates the migration script, using the Flyway naming convention, without applying the
* migration. Its purpose is to show the user what the migration script will look like before applying the
* migration. If a migration script with the same name already exists, its content will be overwritten.
*
* @param tables The tables whose changes will be used to generate the migration script.
* @param newVersion The version to migrate to.
* @param title The title of the migration.
* @param scriptDirectory The directory in which to create the migration script.
* @param withLogs By default, a description for each intermediate step, as well as its execution time, is logged at
* the INFO level. This can be disabled by setting [withLogs] to `false`.
*
* @return The generated migration script.
*
* @throws IllegalArgumentException if no argument is passed for the [tables] parameter.
*/
@ExperimentalDatabaseMigrationApi
fun generateMigrationScript(vararg tables: Table, newVersion: String, title: String, scriptDirectory: String, withLogs: Boolean = true): File {
return generateMigrationScript(*tables, scriptName = "V${newVersion}__$title", scriptDirectory = scriptDirectory, withLogs = withLogs)
}

/**
* This function simply generates the migration script without applying the migration. Its purpose is to show what
* the migration script will look like before applying the migration. If a migration script with the same name
* already exists, its content will be overwritten.
*
* @param tables The tables whose changes will be used to generate the migration script.
* @param scriptName The name to be used for the generated migration script.
* @param scriptDirectory The directory (path from repository root) in which to create the migration script.
* @param withLogs By default, a description for each intermediate step, as well as its execution time, is logged at
* the INFO level. This can be disabled by setting [withLogs] to `false`.
*
* @return The generated migration script.
*
* @throws IllegalArgumentException if no argument is passed for the [tables] parameter.
*/
@ExperimentalDatabaseMigrationApi
fun generateMigrationScript(vararg tables: Table, scriptDirectory: String, scriptName: String, withLogs: Boolean = true): File {
require(tables.isNotEmpty()) { "Tables argument must not be empty" }

val allStatements = statementsRequiredForDatabaseMigration(*tables, withLogs = withLogs)

val migrationScript = File("$scriptDirectory/$scriptName.sql")
migrationScript.createNewFile()

// Clear existing content
migrationScript.writeText("")

// Append statements
allStatements.forEach { statement ->
// Add semicolon only if it's not already there
val conditionalSemicolon = if (statement.last() == ';') "" else ";"

migrationScript.appendText("$statement$conditionalSemicolon\n")
}

return migrationScript
}
}
1 change: 1 addition & 0 deletions exposed-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies {
implementation(project(":exposed-jdbc"))
implementation(project(":exposed-dao"))
implementation(project(":exposed-kotlin-datetime"))
implementation(project(":exposed-migration"))

implementation(libs.slf4j)
implementation(libs.log4j.slf4j.impl)
Expand Down
Loading
Loading