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: Include DROP statements for unmapped indices in list of statements returned by statementsRequiredForDatabaseMigration function #2023

Merged
merged 2 commits into from
Mar 12, 2024
Merged
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
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)
}
}
}
}
Loading