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
|
val schema = table.schema
|
||||||
schema.columnIndex(columnName) ?: throw KestrelException("no such column: $columnName in table $tableName")
|
schema.columnIndex(columnName) ?: throw KestrelException("no such column: $columnName in table $tableName")
|
||||||
if (schema.columns.size == 1) {
|
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()]
|
fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user