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
|
* [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()]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user