diff --git a/src/main/kotlin/kestrel/catalog/Catalog.kt b/src/main/kotlin/kestrel/catalog/Catalog.kt index 64324f7..f63f039 100644 --- a/src/main/kotlin/kestrel/catalog/Catalog.kt +++ b/src/main/kotlin/kestrel/catalog/Catalog.kt @@ -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() + 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()]