diff --git a/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt b/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt index a75fe70..b562bca 100644 --- a/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt +++ b/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt @@ -329,3 +329,165 @@ class DiskCatalog private constructor( return table } + /** + * Registers a brand new `CREATE TEMP TABLE`/`CREATE TEMPORARY TABLE` - see [temporaryTables]' + * own KDoc for why this never touches [engine]/[directory] at all, unlike [createTable]. Name + * collisions are checked against every namespace a permanent table already checks against + * ([rowsByName]/[viewsByName]) plus every other temp table, deliberately NOT allowing a temp + * table to shadow a same-named permanent table/view the way SQLite does - see [DiskExecutor]'s + * own resolution order for the reasoning (a single, simpler, unambiguous namespace is a + * deliberate, narrower scope-cut for this executor). + */ + fun createTemporaryTable(schema: TableSchema): Table { + val key = schema.name.lowercase() + if (rowsByName.containsKey(key)) throw DiskCatalogException("table '${schema.name}' already exists") + if (viewsByName.containsKey(key)) throw DiskCatalogException("a view named '${schema.name}' already exists") + if (temporaryTables.containsKey(key)) throw DiskCatalogException("table '${schema.name}' already exists") + val table = Table(schema) + temporaryTables[key] = table + return table + } + + fun temporaryTableOrNull(name: String): Table? = temporaryTables[name.lowercase()] + + fun dropTemporaryTable(name: String) { + temporaryTables.remove(name.lowercase()) ?: throw DiskCatalogException("no such temporary table: $name") + } + + /** + * Every currently-open temp table, oldest first - used by [DiskExecutor]'s BEGIN/SAVEPOINT/ + * ROLLBACK handling to snapshot/restore live [Table.rows] lists around a disk transaction. Since + * `CREATE`/`DROP TEMPORARY TABLE` are DDL and already rejected inside an explicit transaction + * (see [DiskExecutor.isDdl]), the *set* of temp tables never changes mid-transaction - only their + * row contents can - so returning the live objects (not a name list) is enough for that caller to + * snapshot/restore without a second lookup. + */ + val temporaryTableValues: List get() = temporaryTables.values.toList() + + /** + * Drops a table's directory entry so it is no longer visible, and frees every page its heap + * and indexes (primary and secondary) own back to the shared [kestrel.store.page.DiskManager] + * free list, via [DiskTable.destroyStorage] - so a later `CREATE TABLE`/`CREATE INDEX` reuses + * this space instead of only ever growing the data file. RESTRICT (default) is blocked by any view depending on it, directly or + * through another view, or by any other table's FOREIGN KEY referencing it (a self-referencing + * FK does not count - it disappears together with the table itself). [cascade] drops every + * dependent view (transitively, diamond-safe like [dropView]) and strips - never drops - every + * other table's FOREIGN KEY referencing this one, mirroring [kestrel.catalog.Catalog.dropTable]'s + * `dropDependentViews`/`dropDependentForeignKeys`: CASCADE never implies dropping a table that + * merely references the one being dropped, only the constraint pointing at it. Everything - the + * view deletions, the table's own row, and every stripped referencing schema - commits in one + * transaction so a mid-way failure leaves nothing half-dropped. A trigger defined on [name] is + * always dropped alongside it, CASCADE or not - mirrors [kestrel.catalog.Catalog.dropTable]: a + * trigger without its table makes no sense to keep around, unlike a FOREIGN KEY reference, so + * there is nothing to RESTRICT against. + */ + fun dropTable(name: String, cascade: Boolean = false) { + val key = name.lowercase() + val row = rowsByName[key] ?: run { + if (viewsByName.containsKey(key)) throw DiskCatalogException("'$name' is a view, use DROP VIEW") + throw DiskCatalogException("no such table: $name") + } + val viewClosure = dependentViewsOrThrow(name, cascade) + val referencingTables = dependentForeignKeysOrThrow(name, cascade) + val droppedTriggerKeys = triggersByName.filterValues { it.definition.tableName.equals(name, ignoreCase = true) }.keys.toList() + + val txn = engine.beginTransaction() + try { + for (viewKey in viewClosure) directory.delete(txn, viewsByName.getValue(viewKey).rid) + for (triggerKey in droppedTriggerKeys) directory.delete(txn, triggersByName.getValue(triggerKey).rid) + directory.delete(txn, row.rid) + row.table.destroyStorage(txn) + for ((otherKey, other) in referencingTables) { + val stripped = other.table.schema.foreignKeys.filterNot { it.referencedTable.equals(name, ignoreCase = true) } + other.table.replaceSchemaMetadata(withForeignKeys(other.table.schema, stripped)) + persistEntry(otherKey, txn) + } + engine.commit(txn) + } catch (failure: Exception) { + engine.abort(txn) + throw failure + } + for (viewKey in viewClosure) viewsByName.remove(viewKey) + for (triggerKey in droppedTriggerKeys) triggersByName.remove(triggerKey) + rowsByName.remove(key) + } + + /** + * RESTRICT check against every view depending on [name] - returns the transitive closure to + * delete instead when [cascade] is set. See [dropTable]'s KDoc. + */ + private fun dependentViewsOrThrow(name: String, cascade: Boolean): Set { + val closure = transitiveViewDependents(name.lowercase()) + if (closure.isEmpty() || cascade) return closure + val names = closure.mapNotNull { viewsByName[it]?.entry?.viewName } + throw DiskCatalogException("cannot drop '$name' because view(s) ${names.joinToString(", ")} depend on it") + } + + /** + * RESTRICT check against every other table's FOREIGN KEY referencing [name] - returns those + * rows instead when [cascade] is set, so the caller can strip the constraint from each. See + * [dropTable]'s KDoc. + */ + private fun dependentForeignKeysOrThrow(name: String, cascade: Boolean): List> { + val key = name.lowercase() + val referencing = rowsByName.entries.filter { (otherKey, other) -> + otherKey != key && other.table.schema.foreignKeys.any { fk -> fk.referencedTable.equals(name, ignoreCase = true) } + } + if (referencing.isEmpty() || cascade) return referencing + val names = referencing.map { it.value.table.schema.name } + throw DiskCatalogException( + "cannot drop '$name' because table(s) ${names.joinToString(", ")} have a FOREIGN KEY referencing it" + ) + } + + private fun rowOrThrow(tableName: String): DirectoryRow { + val key = tableName.lowercase() + rowsByName[key]?.let { return it } + if (viewsByName.containsKey(key)) throw DiskCatalogException("cannot modify view: $tableName") + throw DiskCatalogException("no such table: $tableName") + } + + /** + * Mirrors [kestrel.catalog.Catalog.truncateTables]: wiping every row of [tableNames] makes every + * row of any OTHER table with a FOREIGN KEY into one of them dangling at once, so RESTRICT + * (default) blocks on that regardless of the FK's own `onDelete` action - there is no per-row + * delete for it to act on - and [cascade] means "also truncate the whole referencing table", a + * transitive closure over FOREIGN KEY edges computed exactly like [dependentForeignKeysOrThrow]'s + * single-hop version, never a per-row [DiskForeignKeyEnforcer.cascadeDelete]-style plan. Every + * table in the closure is emptied against the SAME [txn] the caller already opened (see + * [DiskExecutor.executeTruncate]), so it shares that statement's commit/rollback exactly like a + * WHERE-less DELETE does - unlike RESTART IDENTITY's sequence reset, which + * [DiskExecutor.executeTruncate] deliberately runs outside any transaction, mirroring [nextValue]'s + * own already-non-transactional sequence position. + */ + fun truncateTables(txn: Long, tableNames: List, cascade: Boolean): List { + tableNames.forEach { rowOrThrow(it) } + val closure = truncateClosure(tableNames.map { it.lowercase() }.toCollection(linkedSetOf()), cascade, tableNames) + for (key in closure) { + val table = rowsByName.getValue(key).table + for ((rid, _) in table.scan(txn)) table.delete(txn, rid) + } + return closure.map { rowsByName.getValue(it).entry.tableName } + } + + private fun truncateClosure(found: LinkedHashSet, cascade: Boolean, requestedNames: List): LinkedHashSet { + var frontier: Set = found + while (true) { + val referencing = rowsByName.values.filter { row -> + row.entry.tableName.lowercase() !in found && + row.table.schema.foreignKeys.any { fk -> fk.referencedTable.lowercase() in frontier } + } + if (referencing.isEmpty()) return found + if (!cascade) { + val names = referencing.map { it.entry.tableName } + throw DiskCatalogException( + "cannot truncate ${requestedNames.joinToString(", ")} because table(s) " + + "${names.joinToString(", ")} have a FOREIGN KEY referencing them; use CASCADE" + ) + } + val next = referencing.map { it.entry.tableName.lowercase() }.toSet() + found.addAll(next) + frontier = next + } + } +