feat(catalog): extend Catalog with renameTable and addForeignKey

This commit is contained in:
dualfroz committed 2026-09-01 13:22:49 +02:00
1 parent f5e28b640b
commit 39d9b61277
1 file changed
+173
+173
View File
@@ -404,6 +404,179 @@ class Catalog {
* [renameColumn]'s "always fully propagates" contract: this table's own FOREIGN KEY(s) that * [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, * 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, * 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()] fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()]