feat(catalog): extend Catalog with dropDependentForeignKeys

This commit is contained in:
dualfroz committed 2026-09-01 13:03:19 +02:00
1 parent 4801fc424e
commit 66483d080e
1 file changed
+173 -2
+173 -2
View File
@@ -58,8 +58,179 @@ class Catalog {
triggers.entries.removeAll { it.value.tableName.equals(tableName, ignoreCase = true) }
}
fun table(tableName: String): Table =
tables[tableName.lowercase()] ?: throw KestrelException("no such table: $tableName")
/**
* Mirrors Postgres semantics: a FOREIGN KEY pointing at [tableName] blocks the drop under
* RESTRICT, while CASCADE only drops the constraint itself (the referencing table and its
* rows survive) - dropping a table never implies dropping every table that happens to
* reference it. A self-referencing FK is excluded because it disappears together with the
* table being dropped, with nothing left dangling in any other table.
*/
private fun dropDependentForeignKeys(tableName: String, cascade: Boolean) {
val referencing = tables.values.filter { table ->
!table.schema.name.equals(tableName, ignoreCase = true) &&
table.schema.foreignKeys.any { it.referencedTable.equals(tableName, ignoreCase = true) }
}
if (referencing.isEmpty()) return
if (!cascade) {
val names = referencing.map { it.schema.name }
throw KestrelException(
"cannot drop '$tableName' because table(s) ${names.joinToString(", ")} have a FOREIGN KEY " +
"referencing it; use CASCADE"
)
}
for (table in referencing) {
val key = table.schema.name.lowercase()
val newSchema = TableSchema(
table.schema.name,
table.schema.columns,
table.schema.foreignKeys.filterNot { it.referencedTable.equals(tableName, ignoreCase = true) },
table.schema.checks,
table.schema.uniqueConstraints,
table.schema.compositePrimaryKey,
table.schema.temporary
)
val replacement = Table(newSchema)
replacement.rows.addAll(table.rows)
tables[key] = replacement
}
}
/**
* TRUNCATE's own dependency handling, deliberately NOT built on [dropDependentForeignKeys] or
* `ForeignKeyEnforcer.cascadeDelete`: wiping every row of [tableNames] makes EVERY row of a
* table with a FOREIGN KEY into any of them dangling at once, not just rows that happened to
* match specific parent rows about to disappear one at a time - so RESTRICT (default) blocks on
* any other table's FOREIGN KEY referencing one of [tableNames] regardless of that FK's own
* `onDelete` action (real TRUNCATE never consults it - there is no per-row delete to run it
* against), and [cascade] means "also truncate the whole referencing table", computed as a
* transitive closure exactly like [dropDependentViews]'s [transitiveViewDependents], not a
* per-row plan/apply cascade. A self-reference (a table whose own FOREIGN KEY points at itself,
* or two requested tables that reference each other) never blocks or duplicates, since every
* requested table starts in the closure before the first pass over `tables` even runs.
*
* Returns every table actually truncated - [tableNames] plus, under CASCADE, every transitive
* referencer - in an unspecified order, so the caller can report and (for RESTART IDENTITY)
* restart sequences for all of them, not just the ones named in the statement.
*/
fun truncateTables(tableNames: List<String>, cascade: Boolean, restartIdentity: Boolean): List<String> {
val requestedKeys = tableNames.map { name ->
val key = name.lowercase()
if (!tables.containsKey(key)) {
if (views.containsKey(key)) throw KestrelException("'$name' is a view, TRUNCATE only applies to tables")
throw KestrelException("no such table: $name")
}
key
}
val closure = truncateClosure(requestedKeys.toCollection(linkedSetOf()), cascade, tableNames)
for (key in closure) {
val table = tables.getValue(key)
table.rows.clear()
if (restartIdentity) restartOwnedSequences(table)
}
for (key in closure) reindexTable(tables.getValue(key).schema.name)
return closure.map { tables.getValue(it).schema.name }
}
private fun truncateClosure(found: LinkedHashSet<String>, cascade: Boolean, requestedNames: List<String>): LinkedHashSet<String> {
var frontier: Set<String> = found
while (true) {
val referencing = tables.values.filter { table ->
table.schema.name.lowercase() !in found &&
table.schema.foreignKeys.any { fk -> fk.referencedTable.lowercase() in frontier }
}
if (referencing.isEmpty()) return found
if (!cascade) {
val names = referencing.map { it.schema.name }
throw KestrelException(
"cannot truncate ${requestedNames.joinToString(", ")} because table(s) " +
"${names.joinToString(", ")} have a FOREIGN KEY referencing them; use CASCADE"
)
}
val next = referencing.map { it.schema.name.lowercase() }.toSet()
found.addAll(next)
frontier = next
}
}
/**
* `ALTER TABLE t ALTER COLUMN col TYPE ...` - replaces an EXISTING column's declared
* [Column.type]/[Column.precision]/[Column.scale] in place, keeping its name/position/
* notNull/default/generated/collation untouched. Unlike [addColumn]'s per-row `valueForRow`
* callback, [newRows] arrives already fully built by [kestrel.engine.Executor] - every row's
* `USING` (or implicit "recompute the column as its own old value") expression evaluated and
* coerced, and every NOT NULL/CHECK re-validated against the NEW schema - BEFORE this is ever
* called, so this method never observes a row that fails validation partway through: either
* all of [newRows] is already known-good and gets installed atomically, or the caller never
* reaches this method at all.
*/
fun changeColumnType(tableName: String, columnName: String, newColumn: Column, newRows: List<Row>) {
val schema = table(tableName).schema
val columnIndex = schema.columnIndex(columnName)
?: throw KestrelException("no such column: $columnName in table $tableName")
val newColumns = schema.columns.toMutableList().also { it[columnIndex] = newColumn }
val newSchema = TableSchema(
schema.name, newColumns, schema.foreignKeys, schema.checks,
schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, newRows)
}
/**
* RESTART IDENTITY has no explicit "sequence OWNED BY column" catalog entry to consult (Postgres
* has one; this engine does not) - a column counts as identity-backed exactly when its own
* DEFAULT is a bare `NEXTVAL('seq')`, the same structural signal `SqlWriter`/`executeAddColumn`
* already treat as "this column is an auto-increment". A dangling reference to an
* already-dropped sequence (DROP SEQUENCE has no dependent-column check of its own) is silently
* skipped rather than thrown, matching how the rest of this engine only ever discovers that gap
* lazily, at the next NEXTVAL/INSERT.
*/
private fun restartOwnedSequences(table: Table) {
for (column in table.schema.columns) {
val default = column.default
if (default is NextValueExpression) {
val sequence = sequenceOrNull(default.sequenceName) ?: continue
sequences[default.sequenceName.lowercase()] = sequence.altered(sequence.definition, restartAt = sequence.definition.startValue)
}
}
}
/**
* Appends a new column (already validated by the executor) to every existing row.
* [valueForRow] computes the new column's value from each row's OLD (pre-append) values - a
* constant lambda for a plain DEFAULT, or a fresh per-row expression evaluation for GENERATED
* ALWAYS AS, mirroring how INSERT already treats those two cases differently.
*/
fun addColumn(tableName: String, column: Column, valueForRow: (Row) -> Value) {
val table = table(tableName)
val schema = table.schema
if (schema.columnIndex(column.name) != null) {
throw KestrelException("duplicate column name: ${column.name}")
}
val newSchema = TableSchema(
schema.name,
schema.columns + column,
schema.foreignKeys,
schema.checks,
schema.uniqueConstraints,
schema.compositePrimaryKey,
schema.temporary
)
val newRows = table.rows.map { row -> Row(row.values + valueForRow(row)) }
replaceTable(tableName, newSchema, newRows)
}
/**
* RESTRICT (default) rejects the drop if [columnName] is used by another table's FOREIGN KEY,
* this table's own FOREIGN KEY, a UNIQUE constraint, a CHECK constraint, or an index - CASCADE
* removes all of those alongside the column itself. Dropping a PRIMARY KEY column is never
* allowed, with or without CASCADE: this engine has no standalone "DROP CONSTRAINT", so
* redesign the PRIMARY KEY (or drop the table) instead.
*/
fun dropColumn(tableName: String, columnName: String, cascade: Boolean) {
val table = table(tableName)
val schema = table.schema
schema.columnIndex(columnName) ?: throw KestrelException("no such column: $columnName in table $tableName")
if (schema.columns.size == 1) {
fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()]