From 66483d080e160e4cbe7e14385d9ed9e65d8e9f75 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 13:03:19 +0200 Subject: [PATCH] feat(catalog): extend Catalog with dropDependentForeignKeys --- src/main/kotlin/kestrel/catalog/Catalog.kt | 175 ++++++++++++++++++++- 1 file changed, 173 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kestrel/catalog/Catalog.kt b/src/main/kotlin/kestrel/catalog/Catalog.kt index 998c211..64324f7 100644 --- a/src/main/kotlin/kestrel/catalog/Catalog.kt +++ b/src/main/kotlin/kestrel/catalog/Catalog.kt @@ -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, cascade: Boolean, restartIdentity: Boolean): List { + 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, cascade: Boolean, requestedNames: List): LinkedHashSet { + var frontier: Set = 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) { + 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()]