From 5d788f64c4335ff501da3116d07ab6bb8aca98d0 Mon Sep 17 00:00:00 2001 From: Jocelyne Date: Mon, 8 Apr 2024 13:02:07 +0200 Subject: [PATCH 1/2] feat: Add migrate function --- exposed-core/api/exposed-core.api | 10 ++ exposed-core/build.gradle.kts | 4 + .../org/jetbrains/exposed/sql/Database.kt | 159 ++++++++++++++++++ .../org/jetbrains/exposed/sql/Exceptions.kt | 3 + .../org/jetbrains/exposed/sql/SchemaUtils.kt | 24 ++- .../shared/ddl/DatabaseMigrationTests.kt | 107 ++++++++++++ gradle/libs.versions.toml | 6 + 7 files changed, 312 insertions(+), 1 deletion(-) diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index b22685d836..3edfc8c9c8 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -73,6 +73,10 @@ public final class org/jetbrains/exposed/exceptions/DuplicateColumnException : j public fun (Ljava/lang/String;Ljava/lang/String;)V } +public final class org/jetbrains/exposed/exceptions/ExposedMigrationException : java/lang/RuntimeException { + public fun (Ljava/lang/Exception;Ljava/lang/String;)V +} + public final class org/jetbrains/exposed/exceptions/ExposedSQLException : java/sql/SQLException { public fun (Ljava/lang/Throwable;Ljava/util/List;Lorg/jetbrains/exposed/sql/Transaction;)V public final fun causedByQueries ()Ljava/util/List; @@ -609,6 +613,10 @@ public final class org/jetbrains/exposed/sql/Database { public final fun getVendor ()Ljava/lang/String; public final fun getVersion ()Ljava/math/BigDecimal; public final fun isVersionCovers (Ljava/math/BigDecimal;)Z + public final fun migrate ([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/Table;Ljavax/sql/DataSource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public static synthetic fun migrate$default (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 (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 public final fun setUseNestedTransactions (Z)V public fun toString ()Ljava/lang/String; } @@ -2014,7 +2022,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;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 (Lorg/jetbrains/exposed/sql/SchemaUtils;[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 (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; diff --git a/exposed-core/build.gradle.kts b/exposed-core/build.gradle.kts index e25c77fd89..559bbede35 100644 --- a/exposed-core/build.gradle.kts +++ b/exposed-core/build.gradle.kts @@ -15,4 +15,8 @@ dependencies { api(kotlin("reflect")) api(libs.kotlinx.coroutines) api(libs.slf4j) + api(libs.flyway) + api(libs.flyway.mysql) + api(libs.flyway.oracle) + api(libs.flyway.sqlserver) } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt index b80f756ac7..99d2cb9d99 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt @@ -1,11 +1,16 @@ package org.jetbrains.exposed.sql +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.FlywayException +import org.flywaydb.core.api.output.MigrateResult import org.jetbrains.annotations.TestOnly +import org.jetbrains.exposed.exceptions.ExposedMigrationException import org.jetbrains.exposed.sql.statements.api.ExposedConnection import org.jetbrains.exposed.sql.statements.api.ExposedDatabaseMetadata import org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManager import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.vendors.* +import java.io.File import java.math.BigDecimal import java.sql.Connection import java.sql.DriverManager @@ -104,6 +109,160 @@ class Database private constructor( */ internal var dataSourceReadOnly: Boolean = false + /** + * @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. + * + * 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. + */ + @ExperimentalDatabaseMigrationApi + @Suppress("LongParameterList", "TooGenericExceptionCaught") + fun 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() + + with(TransactionManager.current()) { + db.dialect.resetCaches() + + try { + val migrationScript = File("$migrationScriptDirectory/$migrationTitle.sql") + if (!migrationScript.exists()) { + SchemaUtils.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() + } + } + + /** + * @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. + * + * Applies a database migration from [oldVersion] to [newVersion]. + * For PostgreSQLNG, "jdbc:pgsql" in the database URL is replaced with "jdbc:postgresql" because the former is not + * supported by Flyway. + */ + @ExperimentalDatabaseMigrationApi + @Suppress("LongParameterList", "TooGenericExceptionCaught") + fun 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() + + with(TransactionManager.current()) { + db.dialect.resetCaches() + + try { + val migrationScript = File("$migrationScriptDirectory/$migrationTitle.sql") + if (!migrationScript.exists()) { + SchemaUtils.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() + } + } + companion object { internal val dialects = ConcurrentHashMap DatabaseDialect>() diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt index 08f68ba960..c6f329d231 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt @@ -77,3 +77,6 @@ internal fun Transaction.throwUnsupportedException(message: String): Nothing = t message, db.dialect ) + +/** An exception thrown when a database migration fails. */ +class ExposedMigrationException(exception: Exception, message: String) : RuntimeException(message, exception) diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt index de94fc40f2..4af7b40425 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt @@ -10,7 +10,7 @@ import java.math.BigDecimal /** Utility functions that assist with creating, altering, and dropping database schema objects. */ @Suppress("TooManyFunctions", "LargeClass") object SchemaUtils { - private inline fun logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R { + internal inline fun logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R { return if (withLogs) { val start = System.currentTimeMillis() val answer = block() @@ -832,6 +832,28 @@ object SchemaUtils { return toDrop.toList() } + /** + * @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. + * + * This function simply generates the migration script without applying the migration. The purpose of it is to show + * the user what the migration script will look like before applying the migration. + * This function uses the Flyway naming convention when generating the migration script. + * If a migration script with the same name already exists, its content will be overwritten. + */ + @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) + } + /** * @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. diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt index c9f0956a1d..fb069bb061 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt @@ -1,5 +1,7 @@ package org.jetbrains.exposed.sql.tests.shared.ddl +import com.impossibl.postgres.jdbc.PGDataSource +import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.ExperimentalDatabaseMigrationApi import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.Table @@ -12,7 +14,9 @@ import org.jetbrains.exposed.sql.tests.shared.assertEqualLists import org.jetbrains.exposed.sql.tests.shared.assertEquals import org.jetbrains.exposed.sql.tests.shared.assertTrue import org.jetbrains.exposed.sql.tests.shared.expectException +import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.vendors.PrimaryKeyMetadata +import org.junit.Assume import org.junit.Test import java.io.File import kotlin.properties.Delegates @@ -247,4 +251,107 @@ class DatabaseMigrationTests : DatabaseTestsBase() { } } } + + @Test + fun testMigration() { + val testTableWithoutIndex = object : Table("tester") { + val id = integer("id") + val name = varchar("name", length = 42) + + override val primaryKey = PrimaryKey(id) + } + + val testTableWithIndex = object : Table("tester") { + val id = integer("id") + val name = varchar("name", length = 42) + + override val primaryKey = PrimaryKey(id) + val byName = index("test_table_by_name", false, name) + } + + val migrationTitle = "AddIndex" + val migrationScriptDirectory = "src/test/resources" + + withDb(excludeSettings = listOf(TestDB.POSTGRESQLNG)) { + if (!isOldMySql()) { + try { + SchemaUtils.create(testTableWithoutIndex) + assertTrue(testTableWithoutIndex.exists()) + + val database = it.db!! + database.migrate( + testTableWithIndex, + user = it.user, + password = it.pass, + oldVersion = "1", + newVersion = "2.0", + migrationTitle = migrationTitle, + migrationScriptDirectory = migrationScriptDirectory + ) + + assertEquals(1, currentDialectTest.existingIndices(testTableWithoutIndex).size) + } finally { + SchemaUtils.drop(testTableWithoutIndex) + assertTrue(File("$migrationScriptDirectory/V2.0__$migrationTitle.sql").delete()) + } + } + } + } + + @Test + fun testPostgreSQLNGMigration() { + Assume.assumeTrue(TestDB.POSTGRESQLNG in TestDB.enabledDialects()) + + val testTableWithoutIndex = object : Table("tester") { + val id = integer("id") + val name = varchar("name", length = 42) + + override val primaryKey = PrimaryKey(id) + } + + val testTableWithIndex = object : Table("tester") { + val id = integer("id") + val name = varchar("name", length = 42) + + override val primaryKey = PrimaryKey(id) + val byName = index("test_table_by_name", false, name) + } + + val schemaHistoryTable = object : Table("flyway_schema_history") {} + + val migrationTitle = "AddIndex" + val migrationScriptDirectory = "src/test/resources" + + val dataSource = PGDataSource().apply { + url = "jdbc:pgsql://127.0.0.1:3004/postgres?lc_messages=en_US.UTF-8" + user = "root" + password = "Exposed_password_1!" + } + + val database = Database.connect(dataSource) + + transaction(database) { + try { + SchemaUtils.drop(schemaHistoryTable) + + SchemaUtils.create(testTableWithoutIndex) + assertTrue(testTableWithoutIndex.exists()) + + database.migrate( + testTableWithIndex, + dataSource = dataSource, + oldVersion = "1", + newVersion = "2.0", + migrationTitle = migrationTitle, + migrationScriptDirectory = migrationScriptDirectory + ) + + assertEquals(1, currentDialectTest.existingIndices(testTableWithoutIndex).size) + } finally { + SchemaUtils.drop(testTableWithoutIndex) + SchemaUtils.drop(schemaHistoryTable) + assertTrue(File("$migrationScriptDirectory/V2.0__$migrationTitle.sql").delete()) + } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89482f18b1..9972cd91b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ javax-money = "1.1" moneta = "1.4.4" hikariCP = "4.0.3" logcaptor = "2.9.2" +flyway = "9.22.3" [libraries] jvm = { group = "org.jetbrains.kotlin.jvm", name = "org.jetbrains.kotlin.jvm.gradle.plugin", version.ref = "kotlin" } @@ -81,6 +82,11 @@ mssql = { group = "com.microsoft.sqlserver", name = "mssql-jdbc", version.ref = logcaptor = { group = "io.github.hakky54", name = "logcaptor", version.ref = "logcaptor" } +flyway = { group = "org.flywaydb", name = "flyway-core", version.ref = "flyway" } +flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql", version.ref = "flyway" } +flyway-oracle = { group = "org.flywaydb", name = "flyway-database-oracle", version.ref = "flyway" } +flyway-sqlserver = { group = "org.flywaydb", name = "flyway-sqlserver", version.ref = "flyway" } + [plugins] jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } From d620f76bd25eefca923dcd51e78167450aa531d8 Mon Sep 17 00:00:00 2001 From: Jocelyne Date: Mon, 22 Apr 2024 20:24:54 +0200 Subject: [PATCH 2/2] chore: Add new module for migration --- exposed-core/api/exposed-core.api | 13 +- exposed-core/build.gradle.kts | 4 - .../org/jetbrains/exposed/sql/Database.kt | 159 ------------- .../org/jetbrains/exposed/sql/Exceptions.kt | 3 - .../org/jetbrains/exposed/sql/SchemaUtils.kt | 70 +----- exposed-migration/api/exposed-migration.api | 16 ++ exposed-migration/build.gradle.kts | 31 +++ .../main/kotlin/ExposedMigrationException.kt | 2 + .../src/main/kotlin/MigrationUtils.kt | 222 ++++++++++++++++++ exposed-tests/build.gradle.kts | 1 + .../shared/ddl/DatabaseMigrationTests.kt | 71 +++++- settings.gradle.kts | 1 + 12 files changed, 342 insertions(+), 251 deletions(-) create mode 100644 exposed-migration/api/exposed-migration.api create mode 100644 exposed-migration/build.gradle.kts create mode 100644 exposed-migration/src/main/kotlin/ExposedMigrationException.kt create mode 100644 exposed-migration/src/main/kotlin/MigrationUtils.kt diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index 3edfc8c9c8..ba9c585b43 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -73,10 +73,6 @@ public final class org/jetbrains/exposed/exceptions/DuplicateColumnException : j public fun (Ljava/lang/String;Ljava/lang/String;)V } -public final class org/jetbrains/exposed/exceptions/ExposedMigrationException : java/lang/RuntimeException { - public fun (Ljava/lang/Exception;Ljava/lang/String;)V -} - public final class org/jetbrains/exposed/exceptions/ExposedSQLException : java/sql/SQLException { public fun (Ljava/lang/Throwable;Ljava/util/List;Lorg/jetbrains/exposed/sql/Transaction;)V public final fun causedByQueries ()Ljava/util/List; @@ -613,10 +609,6 @@ public final class org/jetbrains/exposed/sql/Database { public final fun getVendor ()Ljava/lang/String; public final fun getVersion ()Ljava/math/BigDecimal; public final fun isVersionCovers (Ljava/math/BigDecimal;)Z - public final fun migrate ([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/Table;Ljavax/sql/DataSource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V - public static synthetic fun migrate$default (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 (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 public final fun setUseNestedTransactions (Z)V public fun toString ()Ljava/lang/String; } @@ -2022,12 +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;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 (Lorg/jetbrains/exposed/sql/SchemaUtils;[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 (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; diff --git a/exposed-core/build.gradle.kts b/exposed-core/build.gradle.kts index 559bbede35..e25c77fd89 100644 --- a/exposed-core/build.gradle.kts +++ b/exposed-core/build.gradle.kts @@ -15,8 +15,4 @@ dependencies { api(kotlin("reflect")) api(libs.kotlinx.coroutines) api(libs.slf4j) - api(libs.flyway) - api(libs.flyway.mysql) - api(libs.flyway.oracle) - api(libs.flyway.sqlserver) } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt index 99d2cb9d99..b80f756ac7 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Database.kt @@ -1,16 +1,11 @@ package org.jetbrains.exposed.sql -import org.flywaydb.core.Flyway -import org.flywaydb.core.api.FlywayException -import org.flywaydb.core.api.output.MigrateResult import org.jetbrains.annotations.TestOnly -import org.jetbrains.exposed.exceptions.ExposedMigrationException import org.jetbrains.exposed.sql.statements.api.ExposedConnection import org.jetbrains.exposed.sql.statements.api.ExposedDatabaseMetadata import org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManager import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.vendors.* -import java.io.File import java.math.BigDecimal import java.sql.Connection import java.sql.DriverManager @@ -109,160 +104,6 @@ class Database private constructor( */ internal var dataSourceReadOnly: Boolean = false - /** - * @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. - * - * 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. - */ - @ExperimentalDatabaseMigrationApi - @Suppress("LongParameterList", "TooGenericExceptionCaught") - fun 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() - - with(TransactionManager.current()) { - db.dialect.resetCaches() - - try { - val migrationScript = File("$migrationScriptDirectory/$migrationTitle.sql") - if (!migrationScript.exists()) { - SchemaUtils.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() - } - } - - /** - * @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. - * - * Applies a database migration from [oldVersion] to [newVersion]. - * For PostgreSQLNG, "jdbc:pgsql" in the database URL is replaced with "jdbc:postgresql" because the former is not - * supported by Flyway. - */ - @ExperimentalDatabaseMigrationApi - @Suppress("LongParameterList", "TooGenericExceptionCaught") - fun 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() - - with(TransactionManager.current()) { - db.dialect.resetCaches() - - try { - val migrationScript = File("$migrationScriptDirectory/$migrationTitle.sql") - if (!migrationScript.exists()) { - SchemaUtils.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() - } - } - companion object { internal val dialects = ConcurrentHashMap DatabaseDialect>() diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt index c6f329d231..08f68ba960 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Exceptions.kt @@ -77,6 +77,3 @@ internal fun Transaction.throwUnsupportedException(message: String): Nothing = t message, db.dialect ) - -/** An exception thrown when a database migration fails. */ -class ExposedMigrationException(exception: Exception, message: String) : RuntimeException(message, exception) diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt index 4af7b40425..b5fb06ac11 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt @@ -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 { - internal inline fun logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R { + inline fun logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R { return if (withLogs) { val start = System.currentTimeMillis() val answer = block() @@ -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 { val (tablesToCreate, tablesToAlter) = tables.partition { !it.exists() } @@ -832,66 +831,6 @@ object SchemaUtils { return toDrop.toList() } - /** - * @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. - * - * This function simply generates the migration script without applying the migration. The purpose of it is to show - * the user what the migration script will look like before applying the migration. - * This function uses the Flyway naming convention when generating the migration script. - * If a migration script with the same name already exists, its content will be overwritten. - */ - @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) - } - - /** - * @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 @@ -903,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 { val (tablesToCreate, tablesToAlter) = tables.partition { !it.exists() } val createStatements = logTimeSpent("Preparing create tables statements", withLogs) { diff --git a/exposed-migration/api/exposed-migration.api b/exposed-migration/api/exposed-migration.api new file mode 100644 index 0000000000..814f432836 --- /dev/null +++ b/exposed-migration/api/exposed-migration.api @@ -0,0 +1,16 @@ +public final class ExposedMigrationException : java/lang/RuntimeException { + public fun (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 +} + diff --git a/exposed-migration/build.gradle.kts b/exposed-migration/build.gradle.kts new file mode 100644 index 0000000000..b2b9bc7e81 --- /dev/null +++ b/exposed-migration/build.gradle.kts @@ -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) +} diff --git a/exposed-migration/src/main/kotlin/ExposedMigrationException.kt b/exposed-migration/src/main/kotlin/ExposedMigrationException.kt new file mode 100644 index 0000000000..67e2368e6a --- /dev/null +++ b/exposed-migration/src/main/kotlin/ExposedMigrationException.kt @@ -0,0 +1,2 @@ +/** An exception thrown when a database migration fails. */ +class ExposedMigrationException(exception: Exception, message: String) : RuntimeException(message, exception) diff --git a/exposed-migration/src/main/kotlin/MigrationUtils.kt b/exposed-migration/src/main/kotlin/MigrationUtils.kt new file mode 100644 index 0000000000..91778ce719 --- /dev/null +++ b/exposed-migration/src/main/kotlin/MigrationUtils.kt @@ -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 + } +} diff --git a/exposed-tests/build.gradle.kts b/exposed-tests/build.gradle.kts index f5b19fe63c..5b42ba07b5 100644 --- a/exposed-tests/build.gradle.kts +++ b/exposed-tests/build.gradle.kts @@ -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) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt index fb069bb061..b38fd677b5 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/ddl/DatabaseMigrationTests.kt @@ -1,5 +1,7 @@ package org.jetbrains.exposed.sql.tests.shared.ddl +import MigrationUtils +import MigrationUtils.migrate import com.impossibl.postgres.jdbc.PGDataSource import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.ExperimentalDatabaseMigrationApi @@ -18,6 +20,7 @@ import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.vendors.PrimaryKeyMetadata import org.junit.Assume import org.junit.Test +import org.sqlite.SQLiteDataSource import java.io.File import kotlin.properties.Delegates import kotlin.test.assertNull @@ -44,7 +47,7 @@ class DatabaseMigrationTests : DatabaseTestsBase() { try { SchemaUtils.create(noPKTable) - val script = SchemaUtils.generateMigrationScript(singlePKTable, scriptDirectory = scriptDirectory, scriptName = scriptName) + val script = MigrationUtils.generateMigrationScript(singlePKTable, scriptDirectory = scriptDirectory, scriptName = scriptName) assertTrue(script.exists()) assertEquals("src/test/resources/$scriptName.sql", script.path) @@ -90,7 +93,7 @@ class DatabaseMigrationTests : DatabaseTestsBase() { } // Generate script with the same name of initial script - val newScript = SchemaUtils.generateMigrationScript(singlePKTable, scriptDirectory = directory, scriptName = name) + val newScript = MigrationUtils.generateMigrationScript(singlePKTable, scriptDirectory = directory, scriptName = name) val expectedStatements: List = SchemaUtils.statementsRequiredForDatabaseMigration(singlePKTable) assertEquals(1, expectedStatements.size) @@ -110,7 +113,7 @@ class DatabaseMigrationTests : DatabaseTestsBase() { fun testNoTablesPassedWhenGeneratingMigrationScript() { withDb { expectException { - SchemaUtils.generateMigrationScript(scriptDirectory = "src/test/resources", scriptName = "V2__Test") + MigrationUtils.generateMigrationScript(scriptDirectory = "src/test/resources", scriptName = "V2__Test") } } } @@ -272,13 +275,13 @@ class DatabaseMigrationTests : DatabaseTestsBase() { val migrationTitle = "AddIndex" val migrationScriptDirectory = "src/test/resources" - withDb(excludeSettings = listOf(TestDB.POSTGRESQLNG)) { + withDb(excludeSettings = listOf(TestDB.POSTGRESQLNG, TestDB.SQLITE)) { if (!isOldMySql()) { try { SchemaUtils.create(testTableWithoutIndex) assertTrue(testTableWithoutIndex.exists()) - val database = it.db!! + val database = this.db database.migrate( testTableWithIndex, user = it.user, @@ -323,9 +326,9 @@ class DatabaseMigrationTests : DatabaseTestsBase() { val migrationScriptDirectory = "src/test/resources" val dataSource = PGDataSource().apply { - url = "jdbc:pgsql://127.0.0.1:3004/postgres?lc_messages=en_US.UTF-8" - user = "root" - password = "Exposed_password_1!" + url = TestDB.POSTGRESQLNG.connection() + user = TestDB.POSTGRESQLNG.user + password = TestDB.POSTGRESQLNG.pass } val database = Database.connect(dataSource) @@ -354,4 +357,56 @@ class DatabaseMigrationTests : DatabaseTestsBase() { } } } + + @Test + fun testSQLiteMigration() { + Assume.assumeTrue(TestDB.SQLITE in TestDB.enabledDialects()) + + val testTableWithoutIndex = object : Table("tester") { + val id = integer("id") + val name = varchar("name", length = 42) + + override val primaryKey = PrimaryKey(id) + } + + val testTableWithIndex = object : Table("tester") { + val id = integer("id") + val name = varchar("name", length = 42) + + override val primaryKey = PrimaryKey(id) + val byName = index("test_table_by_name", false, name) + } + + val migrationTitle = "AddIndex" + val migrationScriptDirectory = "src/test/resources" + + val dataSource = SQLiteDataSource().apply { + url = "jdbc:sqlite:file:testDb.db?mode=memory" + } + + val database = Database.connect( + dataSource + ) + + transaction(database) { + try { + SchemaUtils.create(testTableWithoutIndex) + assertTrue(testTableWithoutIndex.exists()) + + database.migrate( + testTableWithIndex, + dataSource = dataSource, + oldVersion = "1", + newVersion = "2.0", + migrationTitle = migrationTitle, + migrationScriptDirectory = migrationScriptDirectory + ) + + assertEquals(1, currentDialectTest.existingIndices(testTableWithoutIndex).size) + } finally { + SchemaUtils.drop(testTableWithoutIndex) + assertTrue(File("$migrationScriptDirectory/V2.0__$migrationTitle.sql").delete()) + } + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8df81ff262..13f9fb4c8d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,7 @@ include("exposed-bom") include("exposed-kotlin-datetime") include("exposed-crypt") include("exposed-json") +include("exposed-migration") pluginManagement { repositories {