feat(catalog): extend Catalog with renameTable and addForeignKey
This commit is contained in:
1 parent
f5e28b640b
commit
39d9b61277
1 file changed
+173
@@ -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()]
|
||||
|
||||
|
||||
Reference in new issue
Block a user