feat(catalog): extend Catalog with renameInList and replaceTable

This commit is contained in:
dualfroz committed 2026-09-01 13:29:31 +02:00
1 parent 39d9b61277
commit db58d7127a
1 file changed
+192 -8
+192 -8
View File
@@ -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<String>, oldName: String, newName: String): List<String> =
names.map { if (it.equals(oldName, ignoreCase = true)) newName else it }
private fun replaceTable(tableName: String, newSchema: TableSchema, rows: List<Row>) {
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<String> 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<String> {
val found = linkedSetOf<String>()
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<String> 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<SortedIndex> = indexes.values.filter {
it.definition.tableName.equals(tableName, ignoreCase = true)
}
val indexNames: List<String> 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<TriggerDefinition> =
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<String> get() = triggers.values.map { it.name }
fun triggerDefinitions(): List<TriggerDefinition> = 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<String> get() = sequences.values.map { it.definition.name }
fun sequenceDefinitions(): List<SequenceDefinition> = 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) {