feat(catalog): extend Catalog with renameColumn
This commit is contained in:
1 parent
66483d080e
commit
f5e28b640b
1 file changed
+173
@@ -231,6 +231,179 @@ class Catalog {
|
||||
val schema = table.schema
|
||||
schema.columnIndex(columnName) ?: throw KestrelException("no such column: $columnName in table $tableName")
|
||||
if (schema.columns.size == 1) {
|
||||
throw KestrelException("cannot drop $columnName: table $tableName would be left with no columns")
|
||||
}
|
||||
if (schema.primaryKeyColumns.any { it.equals(columnName, ignoreCase = true) }) {
|
||||
throw KestrelException("cannot drop column $columnName because it is part of the PRIMARY KEY of $tableName")
|
||||
}
|
||||
|
||||
// A GENERATED column can only ever read this table's own non-GENERATED columns (never
|
||||
// another GENERATED one - validateGeneratedDefinitions forbids that chain), so dropping
|
||||
// columnName orphans any GENERATED column whose expression reads it, exactly like a CHECK
|
||||
// constraint referencing it - it must be dropped right along with columnName (RESTRICT
|
||||
// blocks the whole drop instead, same as every other dependent kind below). GENERATED can
|
||||
// never be a PRIMARY KEY column (Executor.validateGeneratedDefinitions), so there is no
|
||||
// "cascaded into dropping the PRIMARY KEY" case to guard against here.
|
||||
val ownGeneratedDependents = schema.columns.filter { column ->
|
||||
!column.name.equals(columnName, ignoreCase = true) &&
|
||||
column.generated?.let { CheckExpressionColumns.references(it, columnName) } == true
|
||||
}
|
||||
val droppedNames = (ownGeneratedDependents.map { it.name } + columnName).map { it.lowercase() }.toSet()
|
||||
val droppedIndices = schema.columns.withIndex()
|
||||
.filter { (_, column) -> column.name.lowercase() in droppedNames }
|
||||
.map { it.index }
|
||||
.toSet()
|
||||
|
||||
val referencingForeignKeys = tables.values.filter { other ->
|
||||
!other.schema.name.equals(tableName, ignoreCase = true) &&
|
||||
other.schema.foreignKeys.any { fk ->
|
||||
fk.referencedTable.equals(tableName, ignoreCase = true) &&
|
||||
fk.referencedColumns.any { it.lowercase() in droppedNames }
|
||||
}
|
||||
}
|
||||
val ownForeignKeys = schema.foreignKeys.filter { fk -> fk.columns.any { it.lowercase() in droppedNames } }
|
||||
val ownUniqueConstraints =
|
||||
schema.uniqueConstraints.filter { unique -> unique.columns.any { it.lowercase() in droppedNames } }
|
||||
val ownChecks = schema.checks.filter { check -> droppedNames.any { name -> CheckExpressionColumns.references(check.expression, name) } }
|
||||
val dependentIndexes = indexes.values.filter {
|
||||
it.definition.tableName.equals(tableName, ignoreCase = true) &&
|
||||
it.definition.columnNames.any { col -> col.lowercase() in droppedNames }
|
||||
}
|
||||
|
||||
if (!cascade) {
|
||||
val reasons = mutableListOf<String>()
|
||||
if (ownGeneratedDependents.isNotEmpty()) {
|
||||
reasons.add(
|
||||
"column(s) ${ownGeneratedDependents.joinToString(", ") { it.name }} are GENERATED columns that depend on it"
|
||||
)
|
||||
}
|
||||
if (referencingForeignKeys.isNotEmpty()) {
|
||||
reasons.add(
|
||||
"table(s) ${referencingForeignKeys.joinToString(", ") { it.schema.name }} have a FOREIGN KEY referencing it"
|
||||
)
|
||||
}
|
||||
if (ownForeignKeys.isNotEmpty()) reasons.add("it is used by one of its own FOREIGN KEY constraints")
|
||||
if (ownUniqueConstraints.isNotEmpty()) reasons.add("it is used by a UNIQUE constraint")
|
||||
if (ownChecks.isNotEmpty()) reasons.add("it is used by a CHECK constraint")
|
||||
if (dependentIndexes.isNotEmpty()) {
|
||||
reasons.add("index(es) ${dependentIndexes.joinToString(", ") { it.definition.name }} are defined on it")
|
||||
}
|
||||
if (reasons.isNotEmpty()) {
|
||||
throw KestrelException(
|
||||
"cannot drop column $columnName from $tableName because ${reasons.joinToString("; ")}; use CASCADE"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (other in referencingForeignKeys) {
|
||||
val key = other.schema.name.lowercase()
|
||||
val newSchema = TableSchema(
|
||||
other.schema.name,
|
||||
other.schema.columns,
|
||||
other.schema.foreignKeys.filterNot { fk ->
|
||||
fk.referencedTable.equals(tableName, ignoreCase = true) &&
|
||||
fk.referencedColumns.any { it.lowercase() in droppedNames }
|
||||
},
|
||||
other.schema.checks,
|
||||
other.schema.uniqueConstraints,
|
||||
other.schema.compositePrimaryKey,
|
||||
other.schema.temporary
|
||||
)
|
||||
val replacement = Table(newSchema)
|
||||
replacement.rows.addAll(other.rows)
|
||||
tables[key] = replacement
|
||||
}
|
||||
for (index in dependentIndexes) indexes.remove(index.definition.name.lowercase())
|
||||
|
||||
val newColumns = schema.columns.filterNot { it.name.lowercase() in droppedNames }
|
||||
val newForeignKeys = schema.foreignKeys - ownForeignKeys.toSet()
|
||||
val newUniqueConstraints = schema.uniqueConstraints - ownUniqueConstraints.toSet()
|
||||
val newChecks = schema.checks - ownChecks.toSet()
|
||||
val newSchema = TableSchema(schema.name, newColumns, newForeignKeys, newChecks, newUniqueConstraints, schema.compositePrimaryKey, schema.temporary)
|
||||
val newRows = table.rows.map { row -> Row(row.values.filterIndexed { valueIndex, _ -> valueIndex !in droppedIndices }) }
|
||||
replaceTable(tableName, newSchema, newRows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Always fully propagates rather than being rejected the way [dropColumn] can be: the
|
||||
* identifier is rewritten everywhere it is used by name rather than removed, so nothing
|
||||
* becomes newly invalid - this table's own PRIMARY KEY/FOREIGN KEY/UNIQUE/CHECK definitions,
|
||||
* any other table's FOREIGN KEY referencing this column, and any index defined on it.
|
||||
*/
|
||||
fun renameColumn(tableName: String, oldName: String, newName: String) {
|
||||
val table = table(tableName)
|
||||
val schema = table.schema
|
||||
if (schema.columnIndex(oldName) == null) {
|
||||
throw KestrelException("no such column: $oldName in table $tableName")
|
||||
}
|
||||
if (!oldName.equals(newName, ignoreCase = true) && schema.columnIndex(newName) != null) {
|
||||
throw KestrelException("column $newName already exists in table $tableName")
|
||||
}
|
||||
|
||||
val newColumns = schema.columns.map { column ->
|
||||
val renamed = if (column.name.equals(oldName, ignoreCase = true)) column.copy(name = newName) else column
|
||||
// A GENERATED column can only ever reference this table's own columns, so renaming one
|
||||
// of them must rewrite that reference too, exactly like a CHECK constraint's own
|
||||
// reference to it (below) - otherwise the GENERATED expression would silently keep
|
||||
// pointing at a name that no longer exists.
|
||||
renamed.generated?.let { renamed.copy(generated = CheckExpressionColumns.rename(it, oldName, newName)) } ?: renamed
|
||||
}
|
||||
val newForeignKeys = schema.foreignKeys.map { fk ->
|
||||
val selfReference = fk.referencedTable.equals(tableName, ignoreCase = true)
|
||||
fk.copy(
|
||||
columns = renameInList(fk.columns, oldName, newName),
|
||||
referencedColumns = if (selfReference) renameInList(fk.referencedColumns, oldName, newName) else fk.referencedColumns
|
||||
)
|
||||
}
|
||||
val newUniqueConstraints = schema.uniqueConstraints.map { it.copy(columns = renameInList(it.columns, oldName, newName)) }
|
||||
val newChecks = schema.checks.map { it.copy(expression = CheckExpressionColumns.rename(it.expression, oldName, newName)) }
|
||||
val newCompositePrimaryKey = renameInList(schema.compositePrimaryKey, oldName, newName)
|
||||
val newSchema = TableSchema(schema.name, newColumns, newForeignKeys, newChecks, newUniqueConstraints, newCompositePrimaryKey, schema.temporary)
|
||||
replaceTable(tableName, newSchema, table.rows)
|
||||
|
||||
for ((key, other) in tables.entries.toList()) {
|
||||
if (other.schema.name.equals(tableName, ignoreCase = true)) continue
|
||||
val touched = other.schema.foreignKeys.any { fk ->
|
||||
fk.referencedTable.equals(tableName, ignoreCase = true) &&
|
||||
fk.referencedColumns.any { it.equals(oldName, ignoreCase = true) }
|
||||
}
|
||||
if (!touched) continue
|
||||
val updatedForeignKeys = other.schema.foreignKeys.map { fk ->
|
||||
if (fk.referencedTable.equals(tableName, ignoreCase = true)) {
|
||||
fk.copy(referencedColumns = renameInList(fk.referencedColumns, oldName, newName))
|
||||
} else {
|
||||
fk
|
||||
}
|
||||
}
|
||||
val updatedSchema = TableSchema(
|
||||
other.schema.name,
|
||||
other.schema.columns,
|
||||
updatedForeignKeys,
|
||||
other.schema.checks,
|
||||
other.schema.uniqueConstraints,
|
||||
other.schema.compositePrimaryKey,
|
||||
other.schema.temporary
|
||||
)
|
||||
val replacement = Table(updatedSchema)
|
||||
replacement.rows.addAll(other.rows)
|
||||
tables[key] = replacement
|
||||
}
|
||||
|
||||
for ((key, index) in indexes.entries.toList()) {
|
||||
if (!index.definition.tableName.equals(tableName, ignoreCase = true)) continue
|
||||
if (index.definition.columnNames.none { it.equals(oldName, ignoreCase = true) }) continue
|
||||
val renamedDefinition = index.definition.copy(columnNames = renameInList(index.definition.columnNames, oldName, newName))
|
||||
val renamed = SortedIndex(renamedDefinition, collationsFor(newSchema, renamedDefinition.columnNames))
|
||||
reindexOne(tables.getValue(tableName.lowercase()), renamed)
|
||||
indexes[key] = renamed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a table and propagates the identifier everywhere it is used by name, mirroring
|
||||
* [renameColumn]'s "always fully propagates" contract: this table's own FOREIGN KEY(s) that
|
||||
* self-reference it, any other table's FOREIGN KEY pointing at it, every index defined on it,
|
||||
* and every view whose defining query mentions it anywhere (FROM/JOIN, derived tables, CTEs,
|
||||
|
||||
fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()]
|
||||
|
||||
|
||||
Reference in new issue
Block a user