feat(catalog): extend Catalog with dropDependentForeignKeys
This commit is contained in:
1 parent
4801fc424e
commit
66483d080e
1 file changed
+173
-2
@@ -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()]
|
||||
|
||||
|
||||
Reference in new issue
Block a user