Skip to content
Open
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
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ dependencies {
dokka(projects.exposed.exposedCore)
dokka(projects.exposed.exposedCrypt)
dokka(projects.exposed.exposedDao)
dokka(projects.exposed.exposedMigrationCore)
dokka(projects.exposed.exposedGradlePlugin)
dokka(projects.exposed.exposedMavenPlugin)
dokka(projects.exposed.exposedJavaTime)
dokka(projects.exposed.exposedJdbc)
dokka(projects.exposed.exposedJodatime)
Expand Down
21 changes: 2 additions & 19 deletions exposed-gradle-plugin/api/exposed-gradle-plugin.api
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public abstract interface class org/jetbrains/exposed/v1/gradle/plugin/GenerateM
public abstract fun getFileExtension ()Ljava/lang/String;
public abstract fun getFilePrefix ()Ljava/lang/String;
public abstract fun getFileSeparator ()Ljava/lang/String;
public abstract fun getFileVersionFormat ()Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public abstract fun getFileVersionFormat ()Lorg/jetbrains/exposed/v1/migration/plugin/core/VersionFormat;
public abstract fun getFullFileName ()Ljava/lang/String;
public abstract fun getTablesPackage ()Ljava/lang/String;
public abstract fun getTestContainersImageName ()Ljava/lang/String;
Expand All @@ -41,7 +41,7 @@ public abstract interface class org/jetbrains/exposed/v1/gradle/plugin/GenerateM
public abstract fun setFileExtension (Ljava/lang/String;)V
public abstract fun setFilePrefix (Ljava/lang/String;)V
public abstract fun setFileSeparator (Ljava/lang/String;)V
public abstract fun setFileVersionFormat (Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;)V
public abstract fun setFileVersionFormat (Lorg/jetbrains/exposed/v1/migration/plugin/core/VersionFormat;)V
public abstract fun setFullFileName (Ljava/lang/String;)V
public abstract fun setTablesPackage (Ljava/lang/String;)V
public abstract fun setTestContainersImageName (Ljava/lang/String;)V
Expand Down Expand Up @@ -73,11 +73,6 @@ public abstract class org/jetbrains/exposed/v1/gradle/plugin/GenerateMigrationsW
public fun execute ()V
}

public final class org/jetbrains/exposed/v1/gradle/plugin/GenerateMigrationsWorker$GradleLogger : org/jetbrains/exposed/v1/core/SqlLogger {
public fun <init> (Lorg/jetbrains/exposed/v1/gradle/plugin/GenerateMigrationsWorker;)V
public fun log (Lorg/jetbrains/exposed/v1/core/statements/StatementContext;Lorg/jetbrains/exposed/v1/core/Transaction;)V
}

public class org/jetbrains/exposed/v1/gradle/plugin/MigrationsExtension {
public static final field Companion Lorg/jetbrains/exposed/v1/gradle/plugin/MigrationsExtension$Companion;
public static final field NAME Ljava/lang/String;
Expand All @@ -98,15 +93,3 @@ public class org/jetbrains/exposed/v1/gradle/plugin/MigrationsExtension {
public final class org/jetbrains/exposed/v1/gradle/plugin/MigrationsExtension$Companion {
}

public final class org/jetbrains/exposed/v1/gradle/plugin/VersionFormat : java/lang/Enum {
public static final field MAJOR_MINOR Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static final field MAJOR_ONLY Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static final field MAJOR_TIMESTAMP Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static final field MAJOR_TIMESTAMP_WITHOUT_SECONDS Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static final field TIMESTAMP_ONLY Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static final field TIMESTAMP_WITHOUT_SECONDS Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
public static fun values ()[Lorg/jetbrains/exposed/v1/gradle/plugin/VersionFormat;
}

1 change: 1 addition & 0 deletions exposed-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies {

implementation(project(":exposed-jdbc"))
implementation(project(":exposed-migration-jdbc"))
implementation(project(":exposed-migration-plugin-core"))

implementation(libs.kotlin.stdlib)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.jetbrains.exposed.v1.gradle.plugin

import org.gradle.api.file.DirectoryProperty
import org.gradle.workers.WorkParameters
import org.jetbrains.exposed.v1.migration.plugin.core.VersionFormat
import java.net.URL

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option
import org.gradle.workers.WorkerExecutor
import org.jetbrains.exposed.v1.migration.plugin.core.VersionFormat
import javax.inject.Inject

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,233 +1,58 @@
package org.jetbrains.exposed.v1.gradle.plugin

import org.flywaydb.core.Flyway
import org.gradle.api.logging.Logger
import org.gradle.api.logging.Logging
import org.gradle.workers.WorkAction
import org.jetbrains.exposed.v1.core.SqlLogger
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.statements.StatementContext
import org.jetbrains.exposed.v1.core.statements.expandArgs
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.testcontainers.containers.JdbcDatabaseContainer
import org.testcontainers.containers.OracleContainer
import org.testcontainers.containers.wait.strategy.Wait
import org.testcontainers.mariadb.MariaDBContainer
import org.testcontainers.mssqlserver.MSSQLServerContainer
import org.testcontainers.mysql.MySQLContainer
import org.testcontainers.postgresql.PostgreSQLContainer
import java.io.File
import java.io.File.separator
import java.io.IOException
import java.net.URLClassLoader
import kotlin.reflect.KClass
import kotlin.reflect.full.isSubclassOf
import kotlin.time.Clock
import org.jetbrains.exposed.v1.migration.plugin.core.MigrationConfig
import org.jetbrains.exposed.v1.migration.plugin.core.MigrationGenerator
import org.jetbrains.exposed.v1.migration.plugin.core.MigrationLogger

/**
* Represents the implementation of a unit of work to be used when submitting work to the migrations extension work executor.
*/
abstract class GenerateMigrationsWorker : WorkAction<GenerateMigrationsParameters> {
private val logger: Logger = Logging.getLogger(GenerateMigrationsWorker::class.java)
private val classExtensionLength: Int = ".class".length

override fun execute() {
val params = parameters
val migrationsDirectory = params.fileDirectory.get().asFile
if (!migrationsDirectory.exists()) {
migrationsDirectory.mkdirs()
}
val expectedFileName = params.fullFileName
// throws IOException if user-defined filename would write outside the provided migrations directory
val migrationFile = expectedFileName?.validateFilePath(migrationsDirectory)

val generated: List<String> = if (expectedFileName != null && migrationFile != null) {
withClassloader { classloader ->
withDatabase { database ->
val tables = classloader
.getClassesInPackage(params.tablesPackage)
.mapNotNull { it.tableOrNull() }
.toList()
.toTypedArray()
transaction(database) {
addLogger(GradleLogger())
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(
tables = tables,
withLogs = params.debug
)
migrationFile.writeText(statements.joinToString(";\n", postfix = ";"))
listOf(expectedFileName)
}
val migrationGenerator = MigrationGenerator(
config = params.toMigrationConfig(),
logger = object : MigrationLogger {
override fun lifecycle(message: String) {
logger.lifecycle(message)
}
}
} else {
val filePrefix = params.filePrefix
val fileSeparator = params.fileSeparator
val fileExtension = params.fileExtension
val versionGenerator = params.fileVersionFormat.nextVersion(migrationsDirectory, Clock.System, filePrefix, fileSeparator)

withClassloader { classloader ->
withDatabase { database ->
var ignored = 0
val foundTables = classloader
.getClassesInPackage(params.tablesPackage)
.mapNotNull { it.tableOrNull() }
val sortedTables = SchemaUtils.sortTablesByReferences(foundTables.toList())
sortedTables.mapIndexedNotNull { index, table ->
transaction(database) {
addLogger(GradleLogger())
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(
table,
withLogs = params.debug
)
if (statements.isNotEmpty()) {
val description = statements.first().statementToFileDescription(params.useUpperCaseDescription)
val version = versionGenerator(index - ignored)
val fileName = "$version$description$fileExtension"
val migrationFile = File(migrationsDirectory, fileName)
migrationFile.writeText(statements.joinToString(";\n", postfix = ";"))
fileName
} else {
ignored++
null
}
}
}.distinct()
override fun debug(message: String) {
if (!isDebugEnabled) return
logger.debug(message)
}
}
}

logger.lifecycle("")
logger.lifecycle("# Exposed Migrations Generated ${generated.size} migrations:")
generated.forEach { logger.lifecycle(" * $it") }
logger.lifecycle("")
}

private inline fun <A> withDatabase(block: (Database) -> A): A = if (parameters.testContainersImageName != null) {
container(parameters.testContainersImageName!!).use { container ->
withDatabase(container.jdbcUrl, container.username, container.password) { database ->
val migrationsDirectory = parameters.fileDirectory.get().asFile
if (migrationsDirectory.walk().any()) {
Flyway.configure()
.dataSource(container.jdbcUrl, container.username, container.password)
.locations("filesystem:${migrationsDirectory.absolutePath}")
.load()
.migrate()
}
block(database)
}
}
} else {
require(
parameters.databaseUrl != null &&
parameters.databaseUser != null &&
parameters.databasePassword != null
) {
"Database properties (url, user, password) must be provided when not using TestContainers"
}
withDatabase(parameters.databaseUrl!!, parameters.databaseUser!!, parameters.databasePassword!!, block)
}

private fun container(imageName: String): JdbcDatabaseContainer<*> = when {
SupportedImage.POSTGRES.prefixMatches(imageName) -> PostgreSQLContainer(imageName)
SupportedImage.MYSQL.prefixMatches(imageName) -> MySQLContainer(imageName)
SupportedImage.MARIADB.prefixMatches(imageName) -> MariaDBContainer(imageName)
SupportedImage.ORACLE.prefixMatches(imageName) -> OracleContainer(imageName)
SupportedImage.SQLSERVER.prefixMatches(imageName) -> MSSQLServerContainer(imageName)

else -> throw IllegalArgumentException(
"Unsupported database container image: $imageName. ${SupportedImage.supportedPrefixesMessage}"
override val isDebugEnabled: Boolean = params.debug
},
)
}.apply {
waitingFor(Wait.forListeningPort())
start()
}

private inline fun <A> withDatabase(url: String, user: String, password: String, block: (Database) -> A): A {
val db = Database.connect(url = url, user = user, password = password)
return try {
block(db)
} finally {
TransactionManager.closeAndUnregister(db)
}
}

private fun KClass<*>.tableOrNull(): Table? = if (isSubclassOf(Table::class) && !isAbstract) {
(objectInstance as? Table)
} else {
null
}

private inline fun <A> withClassloader(block: (URLClassLoader) -> A): A {
val original = Thread.currentThread().contextClassLoader
return try {
val urls = parameters.classpathUrls.toTypedArray()
val classLoader = URLClassLoader(urls, original)
Thread.currentThread().contextClassLoader = classLoader
block(classLoader)
} finally {
Thread.currentThread().contextClassLoader = original
}
migrationGenerator.generate()
}
}

private fun URLClassLoader.getClassesInPackage(packageName: String): Sequence<KClass<*>> = getResources(
packageName.replace('.', '/')
private fun GenerateMigrationsParameters.toMigrationConfig(): MigrationConfig {
val fileDirectory = requireNotNull(fileDirectory.asFile.orNull) {
"File directory must be set"
}
return MigrationConfig(
tablesPackage = tablesPackage,
classpathUrls = classpathUrls,
fileDirectory = fileDirectory,
filePrefix = filePrefix,
fileVersionFormat = fileVersionFormat,
fileSeparator = fileSeparator,
useUpperCaseDescription = useUpperCaseDescription,
fileExtension = fileExtension,
fullFileName = fullFileName,
databaseUrl = databaseUrl,
databaseUser = databaseUser,
databasePassword = databasePassword,
testContainersImageName = testContainersImageName,
debug = debug,
)
.asSequence()
.flatMap { resource ->
File(resource.toURI())
.walk()
.filter { file -> file.isFile && file.name.endsWith(".class") }
.map { file ->
val baseDir = File(resource.toURI())
val subPackageName = file.relativeTo(baseDir)
.path
.replace(separator, ".")
.dropLast(file.name.length + 1)
val fullPackage = "$packageName.${if (subPackageName.isBlank()) "" else "$subPackageName."}"
val clazzName = file.name.dropLast(classExtensionLength)
Class.forName("$fullPackage$clazzName", true, this).kotlin
}
}

private fun String.validateFilePath(parentPath: File): File {
val baseDir = parentPath.canonicalFile
val requestedFile = File(baseDir, this)

// Get the canonical path (resolves ../, and other edge cases)
val canonicalPath = requestedFile.canonicalPath
if (!canonicalPath.startsWith(baseDir.path)) {
throw IOException("Provided fileName is on a different path than provided migrations directory: $parentPath")
}

return requestedFile
}

inner class GradleLogger : SqlLogger {
override fun log(context: StatementContext, transaction: Transaction) {
if (parameters.debug) {
logger.debug(context.expandArgs(transaction))
}
}
}

private enum class SupportedImage(vararg val prefixes: String) {
MYSQL("mysql"),
MARIADB("mariadb"),
POSTGRES("postgres"),
SQLSERVER("mcr.microsoft.com/mssql/server"),
ORACLE("container-registry.oracle.com/", "gvenzl/oracle-", "oracle/");

fun prefixMatches(name: String): Boolean = prefixes.any { prefix -> name.startsWith(prefix) }

companion object {
val supportedPrefixesMessage: String
get() = "Supported prefixes are: ${entries.joinToString { si -> si.prefixes.joinToString { it } }}"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.api.tasks.SourceSetContainer
import org.jetbrains.exposed.v1.gradle.plugin.ExposedGradlePlugin.Companion.TASK_GROUP
import org.jetbrains.exposed.v1.migration.plugin.core.VersionFormat
import javax.inject.Inject

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.jetbrains.exposed.v1.gradle.plugin

import org.gradle.api.Project
import org.gradle.testfixtures.ProjectBuilder
import org.jetbrains.exposed.v1.migration.plugin.core.VersionFormat
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package org.jetbrains.exposed.v1.gradle.plugin.migrations
import org.gradle.api.Project
import org.gradle.testfixtures.ProjectBuilder
import org.jetbrains.exposed.v1.gradle.plugin.MigrationsExtension
import org.jetbrains.exposed.v1.gradle.plugin.VersionFormat
import org.jetbrains.exposed.v1.migration.plugin.core.VersionFormat
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
Expand Down
Loading