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 befcbc8bbc..eff1c97f31 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 @@ -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 logTimeSpent(message: String, withLogs: Boolean, block: () -> R): R { return if (withLogs) { @@ -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() } } /** @@ -550,6 +550,7 @@ object SchemaUtils { */ private fun mappingConsistenceRequiredStatements(vararg tables: Table, withLogs: Boolean = true): List { 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() } } @@ -639,38 +640,49 @@ object SchemaUtils { } } - /** Returns list of indices missed in database **/ - private fun checkMissingIndices(vararg tables: Table, withLogs: Boolean): List { + /** + * 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 { fun Collection.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.filterFKeys() = if (isMysql) { - filterNot { it.table to LinkedHashSet(it.columns) in fKeyConstraints } + + fun List.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.filterInternalIndices() = if (isSQLite) { + fun List.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() val notMappedIndices = HashMap>() val nameDiffers = HashSet() - 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 @@ -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 { + fun Collection.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.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.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() + val nameDiffers = HashSet() + + 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 { + fun Collection.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.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.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>() + val nameDiffers = HashSet() + + 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() + 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. 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 5450024e01..c9f0956a1d 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 @@ -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) + } + } + } }