feat(catalog): extend Catalog with renameColumn

This commit is contained in:
dualfroz committed 2026-09-01 13:16:35 +02:00
1 parent 66483d080e
commit f5e28b640b
1 file changed
+173
+173
View File
@@ -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()]