feat(sql): add renameColumn and renameTable to DiskCatalog

This commit is contained in:
dualfroz committed 2026-09-06 18:20:13 +02:00
1 parent 3972e07d37
commit dafa4f8940
1 file changed
+167
@@ -652,3 +652,170 @@ class DiskCatalog private constructor(
}
}
/**
* Always fully propagates rather than being rejected the way [dropColumn] can be - mirrors
* [kestrel.catalog.Catalog.renameColumn]: this table's own PRIMARY KEY/FOREIGN KEY/UNIQUE/CHECK
* definitions, any other table's FOREIGN KEY referencing this column, and any secondary index
* defined on it (see [DiskTable.renameColumnAndIndexes]) all get the new name.
*/
fun renameColumn(tableName: String, oldName: String, newName: String) {
val key = tableName.lowercase()
val row = rowOrThrow(tableName)
val schema = row.table.schema
if (schema.columnIndex(oldName) == null) {
throw DiskCatalogException("no such column: $oldName in table $tableName")
}
if (!oldName.equals(newName, ignoreCase = true) && schema.columnIndex(newName) != null) {
throw DiskCatalogException("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 - mirrors kestrel.catalog.Catalog.renameColumn.
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 { CheckConstraint(it.name, CheckExpressionColumns.rename(it.expression, oldName, newName)) }
val newCompositePrimaryKey = renameInList(schema.compositePrimaryKey, oldName, newName)
val newSchema = TableSchema(schema.name, newColumns, newForeignKeys, newChecks, newUniqueConstraints, newCompositePrimaryKey)
val affectedOthers = rowsByName.entries.filter { (otherKey, other) ->
otherKey != key && other.table.schema.foreignKeys.any { fk ->
fk.referencedTable.equals(tableName, ignoreCase = true) && fk.referencedColumns.any { it.equals(oldName, ignoreCase = true) }
}
}
val txn = engine.beginTransaction()
try {
row.table.renameColumnAndIndexes(newSchema, oldName, newName)
persistEntry(key, txn)
for ((otherKey, other) in affectedOthers) {
val updatedForeignKeys = other.table.schema.foreignKeys.map { fk ->
if (fk.referencedTable.equals(tableName, ignoreCase = true)) {
fk.copy(referencedColumns = renameInList(fk.referencedColumns, oldName, newName))
} else {
fk
}
}
other.table.replaceSchemaMetadata(withForeignKeys(other.table.schema, updatedForeignKeys))
persistEntry(otherKey, txn)
}
engine.commit(txn)
} catch (failure: Exception) {
engine.abort(txn)
throw failure
}
}
/**
* Renames a table and propagates the identifier everywhere it is used by name - mirrors
* [kestrel.catalog.Catalog.renameTable]: this table's own FOREIGN KEY(s) that self-reference it,
* any other table's FOREIGN KEY pointing at it, every view whose defining query mentions it
* anywhere (see [RelationRename]), and every trigger defined ON it (just the [TriggerDefinition.
* tableName] field, re-rendered and re-persisted - trigger body statements are NOT rewritten,
* same documented scope-cut as [kestrel.catalog.Catalog.renameTable]). Unlike the in-memory
* engine there is no separate global index map to fix up - every secondary index lives inside its
* own [DiskTable], keyed by column name, which a table rename never touches.
*/
fun renameTable(oldName: String, newName: String) {
val oldKey = oldName.lowercase()
val row = rowOrThrow(oldName)
val newKey = newName.lowercase()
if (!oldName.equals(newName, ignoreCase = true)) {
if (rowsByName.containsKey(newKey)) throw DiskCatalogException("table '$newName' already exists")
if (viewsByName.containsKey(newKey)) throw DiskCatalogException("a view named '$newName' already exists")
}
val schema = row.table.schema
val selfReferencing = schema.foreignKeys.any { it.referencedTable.equals(oldName, ignoreCase = true) }
val ownNewForeignKeys = if (selfReferencing) {
schema.foreignKeys.map { fk -> if (fk.referencedTable.equals(oldName, ignoreCase = true)) fk.copy(referencedTable = newName) else fk }
} else {
schema.foreignKeys
}
val newSchema = TableSchema(newName, schema.columns, ownNewForeignKeys, schema.checks, schema.uniqueConstraints, schema.compositePrimaryKey)
val affectedOthers = rowsByName.entries.filter { (otherKey, other) ->
otherKey != oldKey && other.table.schema.foreignKeys.any { it.referencedTable.equals(oldName, ignoreCase = true) }
}
val affectedViews = viewsByName.entries.filter { (_, view) -> DependencyAnalysis.referencesRelation(view.query, oldName) }
val affectedTriggers = triggersByName.entries.filter { (_, trigger) -> trigger.definition.tableName.equals(oldName, ignoreCase = true) }
val txn = engine.beginTransaction()
try {
row.table.replaceSchemaMetadata(newSchema)
if (oldKey != newKey) {
rowsByName.remove(oldKey)
row.keyBox.value = newKey
rowsByName[newKey] = row
}
persistEntry(newKey, txn)
for ((otherKey, other) in affectedOthers) {
val updatedForeignKeys = other.table.schema.foreignKeys.map { fk ->
if (fk.referencedTable.equals(oldName, ignoreCase = true)) fk.copy(referencedTable = newName) else fk
}
other.table.replaceSchemaMetadata(withForeignKeys(other.table.schema, updatedForeignKeys))
persistEntry(otherKey, txn)
}
for ((viewKey, viewRow) in affectedViews) {
val renamedQuery = RelationRename.rename(viewRow.query, oldName, newName)
val newEntry = ViewDirectoryEntry(
viewRow.entry.viewName, SqlWriter.write(CreateViewStatement(viewRow.entry.viewName, renamedQuery, orReplace = false))
)
val rid = directory.update(txn, viewRow.rid, taggedView(newEntry))
viewsByName[viewKey] = ViewRow(rid, newEntry, renamedQuery)
}
for ((triggerKey, triggerRow) in affectedTriggers) {
val renamedDefinition = triggerRow.definition.copy(tableName = newName)
val newEntry = TriggerDirectoryEntry(triggerRow.entry.triggerName, SqlWriter.triggerText(renamedDefinition))
val rid = directory.update(txn, triggerRow.rid, taggedTrigger(newEntry))
triggersByName[triggerKey] = TriggerRow(rid, newEntry, renamedDefinition)
}
engine.commit(txn)
} catch (failure: Exception) {
engine.abort(txn)
throw failure
}
}
private fun withForeignKeys(schema: TableSchema, foreignKeys: List<ForeignKeyConstraint>): TableSchema = TableSchema(
schema.name, schema.columns, foreignKeys, schema.checks, schema.uniqueConstraints, schema.compositePrimaryKey
)
/**
* `ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY (...) REFERENCES ...` - mirrors
* [kestrel.catalog.Catalog.addForeignKey]: [kestrel.store.sql.DiskExecutor.executeAddConstraint]
* has already validated every existing row against [foreignKey] before calling this, so installing
* it is pure schema metadata - [DiskTable.replaceSchemaMetadata] plus a re-persisted directory
* entry, no row rewrite (row shape is unchanged) and no index to build.
*/
fun addForeignKey(tableName: String, foreignKey: ForeignKeyConstraint) {
val key = tableName.lowercase()
val row = rowOrThrow(tableName)
val schema = row.table.schema
val newSchema = TableSchema(
schema.name, schema.columns, schema.foreignKeys + foreignKey, schema.checks, schema.uniqueConstraints, schema.compositePrimaryKey
)
val txn = engine.beginTransaction()
try {
row.table.replaceSchemaMetadata(newSchema)
persistEntry(key, txn)
engine.commit(txn)
} catch (failure: Exception) {
engine.abort(txn)
throw failure
}
}