From 39d9b61277c32c0b5e3cc60e46e7660f1b1c9f78 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 13:22:49 +0200 Subject: [PATCH] feat(catalog): extend Catalog with renameTable and addForeignKey --- src/main/kotlin/kestrel/catalog/Catalog.kt | 173 +++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/main/kotlin/kestrel/catalog/Catalog.kt b/src/main/kotlin/kestrel/catalog/Catalog.kt index f63f039..198f455 100644 --- a/src/main/kotlin/kestrel/catalog/Catalog.kt +++ b/src/main/kotlin/kestrel/catalog/Catalog.kt @@ -404,6 +404,179 @@ class Catalog { * [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, + * set operations, scalar/EXISTS/IN subqueries - see [RelationRename]). + */ + fun renameTable(oldName: String, newName: String) { + val existing = table(oldName) + if (!oldName.equals(newName, ignoreCase = true)) { + val key = newName.lowercase() + if (tables.containsKey(key)) throw KestrelException("table '$newName' already exists") + if (views.containsKey(key)) throw KestrelException("a view named '$newName' already exists") + } + + val selfReferencing = existing.schema.foreignKeys.any { it.referencedTable.equals(oldName, ignoreCase = true) } + val newForeignKeys = if (selfReferencing) { + existing.schema.foreignKeys.map { fk -> + if (fk.referencedTable.equals(oldName, ignoreCase = true)) fk.copy(referencedTable = newName) else fk + } + } else { + existing.schema.foreignKeys + } + val newSchema = TableSchema( + newName, + existing.schema.columns, + newForeignKeys, + existing.schema.checks, + existing.schema.uniqueConstraints, + existing.schema.compositePrimaryKey, + existing.schema.temporary + ) + tables.remove(oldName.lowercase()) + val replacement = Table(newSchema) + replacement.rows.addAll(existing.rows) + tables[newName.lowercase()] = replacement + + for ((key, other) in tables.entries.toList()) { + if (other.schema.name.equals(newName, ignoreCase = true)) continue + val touched = other.schema.foreignKeys.any { it.referencedTable.equals(oldName, ignoreCase = true) } + if (!touched) continue + val updatedForeignKeys = other.schema.foreignKeys.map { fk -> + if (fk.referencedTable.equals(oldName, ignoreCase = true)) fk.copy(referencedTable = 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 replacementOther = Table(updatedSchema) + replacementOther.rows.addAll(other.rows) + tables[key] = replacementOther + } + + for ((key, index) in indexes.entries.toList()) { + if (!index.definition.tableName.equals(oldName, ignoreCase = true)) continue + val renamedDefinition = index.definition.copy(tableName = newName) + indexes[key] = SortedIndex(renamedDefinition, collationsFor(newSchema, renamedDefinition.columnNames)).also { renamed -> + reindexOne(tables.getValue(newName.lowercase()), renamed) + } + } + + for ((key, view) in views.entries.toList()) { + if (!DependencyAnalysis.referencesRelation(view.query, oldName)) continue + views[key] = view.copy(query = RelationRename.rename(view.query, oldName, newName)) + } + + for ((key, trigger) in triggers.entries.toList()) { + if (!trigger.tableName.equals(oldName, ignoreCase = true)) continue + triggers[key] = trigger.copy(tableName = newName) + } + } + + /** + * `ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY (...) REFERENCES ...` - installs a FOREIGN KEY + * onto an already-existing table. Unlike [createTable]'s FOREIGN KEY definitions, this never + * touches existing rows itself: [kestrel.engine.Executor.executeAddConstraint] validates every + * existing row against [foreignKey] (via [kestrel.engine.ForeignKeyEnforcer.validateRow]) BEFORE + * calling this, so by the time the schema actually changes the constraint is already known to + * hold for every row already in the table. + */ + fun addForeignKey(tableName: String, foreignKey: ForeignKeyConstraint) { + val table = table(tableName) + val schema = table.schema + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys + foreignKey, schema.checks, + schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary + ) + replaceTable(tableName, newSchema, table.rows) + } + + /** [addForeignKey]'s counterpart for `ADD CONSTRAINT name UNIQUE (...)` - see its own doc for the validate-before-install contract. */ + fun addUniqueConstraint(tableName: String, unique: UniqueConstraint) { + val table = table(tableName) + val schema = table.schema + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks, + schema.uniqueConstraints + unique, schema.compositePrimaryKey, schema.temporary + ) + replaceTable(tableName, newSchema, table.rows) + } + + /** [addForeignKey]'s counterpart for `ADD CONSTRAINT name CHECK (...)` - see its own doc for the validate-before-install contract. */ + fun addCheckConstraint(tableName: String, check: CheckConstraint) { + val table = table(tableName) + val schema = table.schema + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks + check, + schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary + ) + replaceTable(tableName, newSchema, table.rows) + } + + /** + * `ALTER TABLE t DROP CONSTRAINT name [RESTRICT | CASCADE]` - finds a named CHECK, UNIQUE, or + * FOREIGN KEY constraint on [tableName] (case-insensitive) and removes it. A PRIMARY KEY is + * never a match: it has no name to compare against (see [dropColumn]'s own doc on why + * redesigning the PRIMARY KEY, or dropping the table, is the only way to get rid of one), so a + * name that happens to target one simply falls through to "no such constraint" like any other + * unknown name. Only a UNIQUE constraint has cross-table fallout worth a RESTRICT/CASCADE + * decision - see [dropUniqueConstraint]. + */ + fun dropConstraint(tableName: String, name: String, cascade: Boolean) { + val table = table(tableName) + val schema = table.schema + val foreignKey = schema.foreignKeys.find { it.name?.equals(name, ignoreCase = true) == true } + val check = schema.checks.find { it.name?.equals(name, ignoreCase = true) == true } + val unique = schema.uniqueConstraints.find { it.name?.equals(name, ignoreCase = true) == true } + when { + foreignKey != null -> { + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys - foreignKey, schema.checks, + schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary + ) + replaceTable(tableName, newSchema, table.rows) + } + check != null -> { + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks - check, + schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary + ) + replaceTable(tableName, newSchema, table.rows) + } + unique != null -> dropUniqueConstraint(tableName, unique, cascade) + else -> throw KestrelException("no such constraint: $name in table $tableName") + } + } + + /** + * A FOREIGN KEY's `referencedColumns` must exactly match one of the parent's enforced-unique + * key groups ([TableSchema.enforcedUniqueKeyGroups] - PRIMARY KEY or a UNIQUE constraint) to + * have been accepted in the first place, so dropping a UNIQUE constraint can silently invalidate + * any FOREIGN KEY (on this table itself, via a self-reference, or on any other table) that leans + * on exactly this column set for its uniqueness guarantee - UNLESS another remaining group + * (another UNIQUE constraint over the same columns, or the PRIMARY KEY) still covers it, in + * which case nothing actually depends on THIS particular constraint continuing to exist. + * RESTRICT (default) blocks the drop when a real dependent exists; CASCADE drops that FOREIGN + * KEY right along with it, mirroring [dropDependentForeignKeys]. + */ + private fun dropUniqueConstraint(tableName: String, unique: UniqueConstraint, cascade: Boolean) { + val schema = table(tableName).schema + val remainingUniques = schema.uniqueConstraints - unique + val targetSet = unique.columns.map { it.lowercase() }.toSet() + val stillEnforced = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks, remainingUniques, + schema.compositePrimaryKey, schema.temporary + ).enforcedUniqueKeyGroups().any { group -> group.map { it.lowercase() }.toSet() == targetSet } + + fun dependsOnTarget(fk: ForeignKeyConstraint) = + fk.referencedTable.equals(tableName, ignoreCase = true) && + fk.referencedColumns.map { it.lowercase() }.toSet() == targetSet + + val dependents = if (stillEnforced) emptyList() else tables.values.filter { other -> other.schema.foreignKeys.any(::dependsOnTarget) } + if (!cascade && dependents.isNotEmpty()) { fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()]