Skip to content

Commit

Permalink
feat: Include DROP statements for unmapped indices in list of stateme…
Browse files Browse the repository at this point in the history
…nts returned by `statementsRequiredForDatabaseMigration` function (#2023)

* feat: Include DROP statements for unmapped indices in list of statements returned by `statementsRequiredForDatabaseMigration` function

A separate function was introduced to keep the behaviour in the older functions the same.

* chore: Add local extension functions `existingIndices` and `mappedIndices`
  • Loading branch information
joc-a committed Mar 12, 2024
1 parent b2ae0ea commit 4d17f56
Show file tree
Hide file tree
Showing 2 changed files with 176 additions and 13 deletions.
159 changes: 146 additions & 13 deletions exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import java.io.File
import java.math.BigDecimal

/** Utility functions that assist with creating, altering, and dropping database schema objects. */
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
object SchemaUtils {
private inline fun <R> logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R {
return if (withLogs) {
Expand Down Expand Up @@ -541,7 +541,7 @@ object SchemaUtils {
checkExcessiveForeignKeyConstraints(tables = tables, withLogs = true)
checkExcessiveIndices(tables = tables, withLogs = true)
}
return checkMissingIndices(tables = tables, withLogs).flatMap { it.createStatement() }
return checkMissingAndUnmappedIndices(tables = tables, withLogs).flatMap { it.createStatement() }
}

/**
Expand All @@ -550,6 +550,7 @@ object SchemaUtils {
*/
private fun mappingConsistenceRequiredStatements(vararg tables: Table, withLogs: Boolean = true): List<String> {
return checkMissingIndices(tables = tables, withLogs).flatMap { it.createStatement() } +
checkUnmappedIndices(tables = tables, withLogs).flatMap { it.dropStatement() } +
checkExcessiveForeignKeyConstraints(tables = tables, withLogs).flatMap { it.dropStatement() } +
checkExcessiveIndices(tables = tables, withLogs).flatMap { it.dropStatement() }
}
Expand Down Expand Up @@ -639,38 +640,49 @@ object SchemaUtils {
}
}

/** Returns list of indices missed in database **/
private fun checkMissingIndices(vararg tables: Table, withLogs: Boolean): List<Index> {
/**
* Checks all [tables] for any that have indices that are missing in the database but are defined in the code. If
* found, this function also logs the SQL statements that can be used to create these indices.
* Checks all [tables] for any that have indices that exist in the database but are not mapped in the code. If
* found, this function only logs the SQL statements that can be used to drop these indices, but does not include
* them in the returned list.
*
* @return List of indices that are missing and can be created.
*/
private fun checkMissingAndUnmappedIndices(vararg tables: Table, withLogs: Boolean): List<Index> {
fun Collection<Index>.log(mainMessage: String) {
if (withLogs && isNotEmpty()) {
exposedLogger.warn(joinToString(prefix = "$mainMessage\n\t\t", separator = "\n\t\t"))
}
}

val isMysql = currentDialect is MysqlDialect
val isSQLite = currentDialect is SQLiteDialect
val fKeyConstraints = currentDialect.columnConstraints(*tables).keys
val foreignKeyConstraints = currentDialect.columnConstraints(*tables).keys
val existingIndices = currentDialect.existingIndices(*tables)
fun List<Index>.filterFKeys() = if (isMysql) {
filterNot { it.table to LinkedHashSet(it.columns) in fKeyConstraints }

fun List<Index>.filterForeignKeys() = if (currentDialect is MysqlDialect) {
filterNot { it.table to LinkedHashSet(it.columns) in foreignKeyConstraints }
} else {
this
}

// SQLite: indices whose names start with "sqlite_" are meant for internal use
fun List<Index>.filterInternalIndices() = if (isSQLite) {
fun List<Index>.filterInternalIndices() = if (currentDialect is SQLiteDialect) {
filter { !it.indexName.startsWith("sqlite_") }
} else {
this
}

fun Table.existingIndices() = existingIndices[this].orEmpty().filterForeignKeys().filterInternalIndices()

fun Table.mappedIndices() = this.indices.filterForeignKeys().filterInternalIndices()

val missingIndices = HashSet<Index>()
val notMappedIndices = HashMap<String, MutableSet<Index>>()
val nameDiffers = HashSet<Index>()

for (table in tables) {
val existingTableIndices = existingIndices[table].orEmpty().filterFKeys().filterInternalIndices()
val mappedIndices = table.indices.filterFKeys().filterInternalIndices()
tables.forEach { table ->
val existingTableIndices = table.existingIndices()
val mappedIndices = table.mappedIndices()

for (index in existingTableIndices) {
val mappedIndex = mappedIndices.firstOrNull { it.onlyNameDiffer(index) } ?: continue
Expand Down Expand Up @@ -698,6 +710,127 @@ object SchemaUtils {
return toCreate.toList()
}

/**
* Checks all [tables] for any that have indices that are missing in the database but are defined in the code. If
* found, this function also logs the SQL statements that can be used to create these indices.
*
* @return List of indices that are missing and can be created.
*/
private fun checkMissingIndices(vararg tables: Table, withLogs: Boolean): List<Index> {
fun Collection<Index>.log(mainMessage: String) {
if (withLogs && isNotEmpty()) {
exposedLogger.warn(joinToString(prefix = "$mainMessage\n\t\t", separator = "\n\t\t"))
}
}

val fKeyConstraints = currentDialect.columnConstraints(*tables).keys
val existingIndices = currentDialect.existingIndices(*tables)

fun List<Index>.filterForeignKeys() = if (currentDialect is MysqlDialect) {
filterNot { it.table to LinkedHashSet(it.columns) in fKeyConstraints }
} else {
this
}

// SQLite: indices whose names start with "sqlite_" are meant for internal use
fun List<Index>.filterInternalIndices() = if (currentDialect is SQLiteDialect) {
filter { !it.indexName.startsWith("sqlite_") }
} else {
this
}

fun Table.existingIndices() = existingIndices[this].orEmpty().filterForeignKeys().filterInternalIndices()

fun Table.mappedIndices() = this.indices.filterForeignKeys().filterInternalIndices()

val missingIndices = HashSet<Index>()
val nameDiffers = HashSet<Index>()

tables.forEach { table ->
val existingTableIndices = table.existingIndices()
val mappedIndices = table.mappedIndices()

for (index in existingTableIndices) {
val mappedIndex = mappedIndices.firstOrNull { it.onlyNameDiffer(index) } ?: continue
if (withLogs) {
exposedLogger.info(
"Index on table '${table.tableName}' differs only in name: in db ${index.indexName} -> in mapping ${mappedIndex.indexName}"
)
}
nameDiffers.add(index)
nameDiffers.add(mappedIndex)
}

missingIndices.addAll(mappedIndices.subtract(existingTableIndices))
}

val toCreate = missingIndices.subtract(nameDiffers)
toCreate.log("Indices missed from database (will be created):")
return toCreate.toList()
}

/**
* Checks all [tables] for any that have indices that exist in the database but are not mapped in the code. If
* found, this function also logs the SQL statements that can be used to drop these indices.
*
* @return List of indices that are unmapped and can be dropped.
*/
private fun checkUnmappedIndices(vararg tables: Table, withLogs: Boolean): List<Index> {
fun Collection<Index>.log(mainMessage: String) {
if (withLogs && isNotEmpty()) {
exposedLogger.warn(joinToString(prefix = "$mainMessage\n\t\t", separator = "\n\t\t"))
}
}

val foreignKeyConstraints = currentDialect.columnConstraints(*tables).keys
val existingIndices = currentDialect.existingIndices(*tables)

fun List<Index>.filterForeignKeys() = if (currentDialect is MysqlDialect) {
filterNot { it.table to LinkedHashSet(it.columns) in foreignKeyConstraints }
} else {
this
}

// SQLite: indices whose names start with "sqlite_" are meant for internal use
fun List<Index>.filterInternalIndices() = if (currentDialect is SQLiteDialect) {
filter { !it.indexName.startsWith("sqlite_") }
} else {
this
}

fun Table.existingIndices() = existingIndices[this].orEmpty().filterForeignKeys().filterInternalIndices()

fun Table.mappedIndices() = this.indices.filterForeignKeys().filterInternalIndices()

val unmappedIndices = HashMap<String, MutableSet<Index>>()
val nameDiffers = HashSet<Index>()

tables.forEach { table ->
val existingTableIndices = table.existingIndices()
val mappedIndices = table.mappedIndices()

for (index in existingTableIndices) {
val mappedIndex = mappedIndices.firstOrNull { it.onlyNameDiffer(index) } ?: continue
nameDiffers.add(index)
nameDiffers.add(mappedIndex)
}

unmappedIndices.getOrPut(table.nameInDatabaseCase()) {
hashSetOf()
}.addAll(existingTableIndices.subtract(mappedIndices))
}

val toDrop = mutableSetOf<Index>()
unmappedIndices.forEach { (name, indices) ->
toDrop.addAll(
indices.subtract(nameDiffers).also {
it.log("Indices exist in database and not mapped in code on class '$name':")
}
)
}
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,34 @@ class DatabaseMigrationTests : DatabaseTestsBase() {
}
}
}

@Test
fun testDropUnmappedIndex() {
val testTableWithIndex = object : Table("test_table") {
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 testTableWithoutIndex = object : Table("test_table") {
val id = integer("id")
val name = varchar("name", length = 42)

override val primaryKey = PrimaryKey(id)
}

withTables(tables = arrayOf(testTableWithIndex)) {
try {
SchemaUtils.create(testTableWithIndex)
assertTrue(testTableWithIndex.exists())

val statements = SchemaUtils.statementsRequiredForDatabaseMigration(testTableWithoutIndex)
assertEquals(1, statements.size)
} finally {
SchemaUtils.drop(testTableWithIndex)
}
}
}
}

0 comments on commit 4d17f56

Please sign in to comment.