diff --git a/src/main/kotlin/kestrel/catalog/Catalog.kt b/src/main/kotlin/kestrel/catalog/Catalog.kt index 198f455..08a4042 100644 --- a/src/main/kotlin/kestrel/catalog/Catalog.kt +++ b/src/main/kotlin/kestrel/catalog/Catalog.kt @@ -577,23 +577,135 @@ class Catalog { val dependents = if (stillEnforced) emptyList() else tables.values.filter { other -> other.schema.foreignKeys.any(::dependsOnTarget) } if (!cascade && dependents.isNotEmpty()) { + val names = dependents.map { it.schema.name }.distinct() + throw KestrelException( + "cannot drop constraint ${unique.name} because table(s) ${names.joinToString(", ")} have a " + + "FOREIGN KEY referencing it; use CASCADE" + ) + } + for (other in dependents) { + val key = other.schema.name.lowercase() + val newSchema = TableSchema( + other.schema.name, other.schema.columns, other.schema.foreignKeys.filterNot(::dependsOnTarget), + other.schema.checks, other.schema.uniqueConstraints, other.schema.compositePrimaryKey, other.schema.temporary + ) + val replacement = Table(newSchema) + replacement.rows.addAll(other.rows) + tables[key] = replacement + } + // Re-fetched: a self-referencing FOREIGN KEY on tableName itself may have just been rewritten + // above (dependents is not restricted to OTHER tables - a self-reference is a real dependent + // here, unlike dropDependentForeignKeys' table-drop case where it disappears for free). + val current = table(tableName) + val newSchema = TableSchema( + current.schema.name, current.schema.columns, current.schema.foreignKeys, current.schema.checks, + current.schema.uniqueConstraints - unique, current.schema.compositePrimaryKey, current.schema.temporary + ) + replaceTable(tableName, newSchema, current.rows) + } + + private fun renameInList(names: List, oldName: String, newName: String): List = + names.map { if (it.equals(oldName, ignoreCase = true)) newName else it } + + private fun replaceTable(tableName: String, newSchema: TableSchema, rows: List) { + val key = tableName.lowercase() + val replacement = Table(newSchema) + replacement.rows.addAll(rows) + tables[key] = replacement + } + + fun table(tableName: String): Table { + val key = tableName.lowercase() + tables[key]?.let { return it } + if (views.containsKey(key)) { + throw KestrelException("cannot modify view: $tableName") + } + throw KestrelException("no such table: $tableName") + } fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()] val tableNames: List get() = tables.values.map { it.schema.name } + fun createView(name: String, query: QueryStatement, orReplace: Boolean) { + val key = name.lowercase() + if (tables.containsKey(key)) { + throw KestrelException("a table named '$name' already exists") + } + if (!orReplace && views.containsKey(key)) { + throw KestrelException("view '$name' already exists") + } + views[key] = ViewDefinition(name, query) + } + + fun dropView(name: String, cascade: Boolean = false) { + val key = name.lowercase() + if (!views.containsKey(key)) { + if (tables.containsKey(key)) { + throw KestrelException("'$name' is a table, use DROP TABLE") + } + throw KestrelException("no such view: $name") + } + dropDependentViews(name, cascade) + views.remove(key) + } + + /** + * Computes the full transitive closure of views depending on [name] (directly or + * through other views) before touching anything, so a "diamond" - two different + * dependents that both end up pulling in the same further-removed view - can't + * make a later removal fail with "no such view" because an earlier one already + * deleted it. + */ + private fun dropDependentViews(name: String, cascade: Boolean) { + val closure = transitiveViewDependents(name.lowercase()) + if (closure.isEmpty()) return + if (!cascade) { + val names = closure.mapNotNull { views[it]?.name } + throw KestrelException( + "cannot drop '$name' because view(s) ${names.joinToString(", ")} depend on it; use CASCADE" + ) + } + for (key in closure) views.remove(key) + } + + private fun transitiveViewDependents(rootKey: String): Set { + val found = linkedSetOf() + var frontier = setOf(rootKey) + while (true) { + val next = views.values + .filter { it.name.lowercase() != rootKey && it.name.lowercase() !in found } + .filter { view -> frontier.any { DependencyAnalysis.referencesRelation(view.query, it) } } + .map { it.name.lowercase() } + .toSet() + if (next.isEmpty()) return found + found.addAll(next) + frontier = next + } + } + + fun viewOrNull(name: String): ViewDefinition? = views[name.lowercase()] + + val viewNames: List get() = views.values.map { it.name } + fun createIndex(definition: IndexDefinition) { val key = definition.name.lowercase() if (indexes.containsKey(key)) { throw KestrelException("index '${definition.name}' already exists") } + require(definition.columnNames.isNotEmpty()) { "index must cover at least one column" } val table = table(definition.tableName) - if (table.schema.columnIndex(definition.columnName) == null) { - throw KestrelException("no such column: ${definition.columnName} in table ${definition.tableName}") + for (columnName in definition.columnNames) { + if (table.schema.columnIndex(columnName) == null) { + throw KestrelException("no such column: $columnName in table ${definition.tableName}") + } } - val index = SortedIndex(definition) - indexes[key] = index + // Populated before registering in [indexes] so that a UNIQUE index created over a table + // with pre-existing duplicate values fails cleanly, without leaving a half-built index + // behind for later statements to trip over. + val index = SortedIndex(definition, collationsFor(table.schema, definition.columnNames)) reindexOne(table, index) + indexes[key] = index } fun dropIndex(indexName: String) { @@ -602,9 +714,9 @@ class Catalog { } } - fun indexOn(tableName: String, columnName: String): SortedIndex? = indexes.values.firstOrNull { - it.definition.tableName.equals(tableName, ignoreCase = true) && - it.definition.columnName.equals(columnName, ignoreCase = true) + /** Every index defined on [tableName], single-column or composite, in creation order. */ + fun indexesOn(tableName: String): List = indexes.values.filter { + it.definition.tableName.equals(tableName, ignoreCase = true) } val indexNames: List get() = indexes.values.map { it.definition.name } @@ -618,9 +730,81 @@ class Catalog { .forEach { reindexOne(table, it) } } + /** + * Fires in [name]'s creation order for a given table/timing/event, matching the order + * multiple triggers on the same event fire in Postgres/SQLite. + */ + fun createTrigger(definition: TriggerDefinition) { + val key = definition.name.lowercase() + if (triggers.containsKey(key)) { + throw KestrelException("trigger '${definition.name}' already exists") + } + table(definition.tableName) + triggers[key] = definition + } + + fun dropTrigger(name: String) { + if (triggers.remove(name.lowercase()) == null) { + throw KestrelException("no such trigger: $name") + } + } + + fun triggersOn(tableName: String, timing: TriggerTiming, event: TriggerEvent): List = + triggers.values.filter { + it.tableName.equals(tableName, ignoreCase = true) && it.timing == timing && it.event == event + } + + fun hasTriggers(tableName: String): Boolean = triggers.values.any { it.tableName.equals(tableName, ignoreCase = true) } + + val triggerNames: List get() = triggers.values.map { it.name } + + fun triggerDefinitions(): List = triggers.values.toList() + + /** + * `name` must not already be a sequence (redundant CREATE); unlike tables/views, sequence + * names live in their own namespace here (Postgres puts them in the same relation namespace as + * tables, but this engine has no need for that overlap check to matter for anything it + * implements). + */ + fun createSequence(definition: SequenceDefinition) { + val key = definition.name.lowercase() + if (sequences.containsKey(key)) { + throw KestrelException("sequence '${definition.name}' already exists") + } + sequences[key] = Sequence(definition) + } + + fun dropSequence(name: String) { + if (sequences.remove(name.lowercase()) == null) { + throw KestrelException("no such sequence: $name") + } + } + + /** + * Always installs a brand new [Sequence] instance (via [Sequence.altered]) rather than mutating + * the existing one in place - see [Sequence]'s own doc for why that is what makes ALTER SEQUENCE + * (unlike NEXTVAL) transactional. + */ + fun alterSequence(name: String, newDefinition: SequenceDefinition, restartAt: Long?) { + val key = name.lowercase() + val existing = sequences[key] ?: throw KestrelException("no such sequence: $name") + sequences[key] = existing.altered(newDefinition, restartAt) + } + + fun sequence(name: String): Sequence = sequenceOrNull(name) ?: throw KestrelException("no such sequence: $name") + + fun sequenceOrNull(name: String): Sequence? = sequences[name.lowercase()] + + val sequenceNames: List get() = sequences.values.map { it.definition.name } + + fun sequenceDefinitions(): List = sequences.values.map { it.definition } + fun snapshot(): CatalogSnapshot = CatalogSnapshot( tables = tables.mapValues { (_, table) -> table.schema to table.rows.toList() }, - indexDefinitions = indexes.values.map { it.definition } + indexDefinitions = indexes.values.map { it.definition }, + views = views.toMap(), + triggers = triggers.toMap(), + sequences = sequences.toMap() ) fun restore(snapshot: CatalogSnapshot) {