From d6d8dadb3d9e180aa1554c2bb3c7cf19f5695097 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 18:20:54 +0200 Subject: [PATCH] feat(sql): add addUniqueConstraint and addCheckConstraint to DiskCatalog --- .../kotlin/kestrel/store/sql/DiskCatalog.kt | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt b/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt index 50b708a..b0351be 100644 --- a/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt +++ b/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt @@ -819,3 +819,168 @@ class DiskCatalog private constructor( } } + /** [addForeignKey]'s counterpart for `ADD CONSTRAINT name UNIQUE (...)` - see its own doc for the validate-before-install contract. */ + fun addUniqueConstraint(tableName: String, unique: UniqueConstraint) { + val key = tableName.lowercase() + val row = rowOrThrow(tableName) + val schema = row.table.schema + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks, schema.uniqueConstraints + unique, schema.compositePrimaryKey + ) + val txn = engine.beginTransaction() + try { + row.table.replaceSchemaMetadata(newSchema) + persistEntry(key, txn) + engine.commit(txn) + } catch (failure: Exception) { + engine.abort(txn) + throw failure + } + } + + /** [addForeignKey]'s counterpart for `ADD CONSTRAINT name CHECK (...)` - see its own doc for the validate-before-install contract. */ + fun addCheckConstraint(tableName: String, check: CheckConstraint) { + val key = tableName.lowercase() + val row = rowOrThrow(tableName) + val schema = row.table.schema + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks + check, schema.uniqueConstraints, schema.compositePrimaryKey + ) + val txn = engine.beginTransaction() + try { + row.table.replaceSchemaMetadata(newSchema) + persistEntry(key, txn) + engine.commit(txn) + } catch (failure: Exception) { + engine.abort(txn) + throw failure + } + } + + /** + * `ALTER TABLE t DROP CONSTRAINT name [RESTRICT | CASCADE]` - mirrors + * [kestrel.catalog.Catalog.dropConstraint]: finds a named CHECK, UNIQUE, or FOREIGN KEY + * constraint on [tableName] (case-insensitive; a PRIMARY KEY has no name to match against) and + * removes it. Only a UNIQUE constraint has cross-table fallout - see [dropUniqueConstraint]. + */ + fun dropConstraint(tableName: String, name: String, cascade: Boolean) { + val key = tableName.lowercase() + val row = rowOrThrow(tableName) + val schema = row.table.schema + val foreignKey = schema.foreignKeys.find { it.name?.equals(name, ignoreCase = true) == true } + val check = schema.checks.find { it.name?.equals(name, ignoreCase = true) == true } + val unique = schema.uniqueConstraints.find { it.name?.equals(name, ignoreCase = true) == true } + when { + foreignKey != null -> { + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys - foreignKey, schema.checks, + schema.uniqueConstraints, schema.compositePrimaryKey + ) + val txn = engine.beginTransaction() + try { + row.table.replaceSchemaMetadata(newSchema) + persistEntry(key, txn) + engine.commit(txn) + } catch (failure: Exception) { + engine.abort(txn) + throw failure + } + } + check != null -> { + val newSchema = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks - check, + schema.uniqueConstraints, schema.compositePrimaryKey + ) + val txn = engine.beginTransaction() + try { + row.table.replaceSchemaMetadata(newSchema) + persistEntry(key, txn) + engine.commit(txn) + } catch (failure: Exception) { + engine.abort(txn) + throw failure + } + } + unique != null -> dropUniqueConstraint(tableName, unique, cascade) + else -> throw DiskCatalogException("no such constraint: $name in table $tableName") + } + } + + /** + * Mirrors [kestrel.catalog.Catalog.dropUniqueConstraint]: a FOREIGN KEY's `referencedColumns` + * must exactly match one of the parent's enforced-unique key groups to have been accepted in the + * first place, so dropping a UNIQUE constraint can silently invalidate any FOREIGN KEY (on this + * table itself, via a self-reference, or on any other table) that leans on exactly this column + * set for its uniqueness guarantee, UNLESS another remaining group still covers it. RESTRICT + * (default) blocks the drop when a real dependent exists; CASCADE strips that FOREIGN KEY right + * along with it, mirroring [dropTable]'s own FOREIGN KEY handling. + */ + private fun dropUniqueConstraint(tableName: String, unique: UniqueConstraint, cascade: Boolean) { + val key = tableName.lowercase() + val schema = rowOrThrow(tableName).table.schema + val remainingUniques = schema.uniqueConstraints - unique + val targetSet = unique.columns.map { it.lowercase() }.toSet() + val stillEnforced = TableSchema( + schema.name, schema.columns, schema.foreignKeys, schema.checks, remainingUniques, schema.compositePrimaryKey + ).enforcedUniqueKeyGroups().any { group -> group.map { it.lowercase() }.toSet() == targetSet } + + fun dependsOnTarget(fk: ForeignKeyConstraint) = + fk.referencedTable.equals(tableName, ignoreCase = true) && fk.referencedColumns.map { it.lowercase() }.toSet() == targetSet + + val dependents = if (stillEnforced) emptyList() else rowsByName.values.filter { other -> other.table.schema.foreignKeys.any(::dependsOnTarget) } + if (!cascade && dependents.isNotEmpty()) { + val names = dependents.map { it.table.schema.name }.distinct() + throw DiskCatalogException( + "cannot drop constraint ${unique.name} because table(s) ${names.joinToString(", ")} have a " + + "FOREIGN KEY referencing it; use CASCADE" + ) + } + + val txn = engine.beginTransaction() + try { + for (other in dependents) { + val otherKey = other.table.schema.name.lowercase() + val strippedForeignKeys = other.table.schema.foreignKeys.filterNot(::dependsOnTarget) + other.table.replaceSchemaMetadata(withForeignKeys(other.table.schema, strippedForeignKeys)) + persistEntry(otherKey, txn) + } + // Re-fetched: a self-referencing FOREIGN KEY on tableName itself may have just been + // rewritten above (dependents is not restricted to OTHER tables). + val current = rowOrThrow(tableName) + val newSchema = TableSchema( + current.table.schema.name, current.table.schema.columns, current.table.schema.foreignKeys, + current.table.schema.checks, current.table.schema.uniqueConstraints - unique, current.table.schema.compositePrimaryKey + ) + current.table.replaceSchemaMetadata(newSchema) + persistEntry(key, txn) + engine.commit(txn) + } catch (failure: Exception) { + engine.abort(txn) + throw failure + } + } + + private fun renameInList(names: List, oldName: String, newName: String): List = + names.map { if (it.equals(oldName, ignoreCase = true)) newName else it } + + /** Convenience for a single-column secondary index; delegates to the general, composite-capable overload below. */ + fun createIndex(tableName: String, indexName: String, columnName: String, unique: Boolean = false): SecondaryIndexHandle = + createIndex(tableName, indexName, listOf(columnName), unique) + + /** + * Creates a secondary index over [columnNames] (more than one entry for a composite, + * multi-column index) - see [SecondaryIndexHandle]/[DiskTable] for how a composite key is + * encoded and how uniqueness is enforced. + */ + fun createIndex(tableName: String, indexName: String, columnNames: List, unique: Boolean = false): SecondaryIndexHandle { + require(columnNames.isNotEmpty()) { "index must cover at least one column" } + val key = tableName.lowercase() + val row = rowsByName[key] ?: throw DiskCatalogException("no such table: $tableName") + if (row.table.secondaryIndexes.any { it.name.equals(indexName, ignoreCase = true) }) { + throw DiskCatalogException("index '$indexName' already exists") + } + val columnIndices = columnNames.map { columnName -> + row.table.schema.columnIndex(columnName) + ?: throw DiskCatalogException("no such column: $columnName in table $tableName") + } +