From ebfc2d4d67402714065a4bfd84c2bc063ef03f91 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 18:30:00 +0200 Subject: [PATCH] feat(engine): extend Executor with requireUniqueConstraintName --- src/main/kotlin/kestrel/engine/Executor.kt | 143 ++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kestrel/engine/Executor.kt b/src/main/kotlin/kestrel/engine/Executor.kt index 960afb8..cd040f1 100644 --- a/src/main/kotlin/kestrel/engine/Executor.kt +++ b/src/main/kotlin/kestrel/engine/Executor.kt @@ -804,8 +804,149 @@ class Executor(private val catalog: Catalog) : SequenceAccess { ) } val referencingTables = catalog.tableNames.filter { other -> + !other.equals(tableName, ignoreCase = true) && + catalog.tableOrNull(other)!!.schema.foreignKeys.any { fk -> + fk.referencedTable.equals(tableName, ignoreCase = true) && + fk.referencedColumns.any { it.equals(action.columnName, ignoreCase = true) } + } + } + if (referencingTables.isNotEmpty()) { + throw KestrelException( + "cannot change the type of column ${action.columnName} because table(s) ${referencingTables.joinToString(", ")} " + + "have a FOREIGN KEY referencing it; drop the constraint first" + ) + } + if (schema.uniqueConstraints.any { unique -> unique.columns.any { it.equals(action.columnName, ignoreCase = true) } }) { + throw KestrelException( + "cannot change the type of column ${action.columnName} because it is used by a UNIQUE constraint of $tableName; " + + "drop the constraint first" + ) + } + val dependentIndexes = catalog.indexesOn(tableName).filter { index -> + index.definition.columnNames.any { it.equals(action.columnName, ignoreCase = true) } + } + if (dependentIndexes.isNotEmpty()) { + throw KestrelException( + "cannot change the type of column ${action.columnName} because index(es) " + + "${dependentIndexes.joinToString(", ") { it.definition.name }} are defined on it; drop the index first" + ) + } + + val newColumn = column.copy(type = action.newType, precision = action.precision, scale = action.scale) + newColumn.default?.let { default -> + try { + coerceForColumn(Evaluator().evaluate(default), newColumn) + } catch (e: KestrelException) { + throw KestrelException( + "cannot change the type of column ${action.columnName}: its DEFAULT expression is not compatible " + + "with ${action.newType.displayWith(action.precision, action.scale)} (${e.message})" + ) + } + } + val newSchema = TableSchema( + schema.name, schema.columns.toMutableList().also { it[columnIndex] = newColumn }, schema.foreignKeys, + schema.checks, schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary + ) + + // Every row's new value is computed and validated against newSchema BEFORE any of them is + // handed to the catalog, so a mid-batch NOT NULL/CHECK failure never leaves the table + // schema pointing at the new type while some rows still hold values coerced for the old + // one - either every row passes and the whole table is replaced atomically, or none of it + // is (see Catalog.changeColumnType's own doc on this contract). + val using = action.using ?: ColumnExpression(action.columnName) + val newRows = catalog.table(tableName).rows.map { row -> + val values = row.values.toMutableList() + values[columnIndex] = coerceForColumn(Evaluator(schema, row, subqueryRunner).evaluate(using), newColumn) + val newRow = Row(values) + validateNotNull(newSchema, newRow.values) + enforceChecks(newSchema, newRow) + newRow + } + catalog.changeColumnType(tableName, action.columnName, newColumn, newRows) + } + + /** A constraint name must be unique among every named CHECK/UNIQUE/FOREIGN KEY already on [schema]'s table (case-insensitive) - mirrors Postgres, which keeps one namespace of constraint names per table. */ + private fun requireUniqueConstraintName(schema: TableSchema, name: String) { + val taken = schema.foreignKeys.any { it.name?.equals(name, ignoreCase = true) == true } || + schema.checks.any { it.name?.equals(name, ignoreCase = true) == true } || + schema.uniqueConstraints.any { it.name?.equals(name, ignoreCase = true) == true } + if (taken) throw KestrelException("constraint '$name' already exists on table ${schema.name}") + } + + /** + * A NOT NULL column added to a table that already has rows must carry a DEFAULT (there is no + * value to backfill existing rows with otherwise) - an empty table has no such requirement, + * matching the same rule this engine already applies at the row level via [validateNotNull]. + * GENERATED ALWAYS AS sidesteps that requirement entirely (see [addGeneratedColumnValue]): it + * backfills by evaluating the expression fresh against each row's own other columns, so a NOT + * NULL GENERATED column can be added to a non-empty table with no DEFAULT at all - it only fails, + * per row, if that row's own expression happens to evaluate to NULL. + */ + private fun executeAddColumn(tableName: String, column: Column) { + val table = catalog.table(tableName) + if (column.generated != null) { + validateAddGeneratedColumn(table.schema, column) + catalog.addColumn(tableName, column) { row -> addGeneratedColumnValue(table.schema, column, row) } + return + } + column.default?.let { validateConstantDefault(it) } + // Unlike CREATE TABLE, ADD COLUMN backfills every existing row with ONE already-evaluated + // Value (Catalog.addColumn's contract - see its own doc), not a fresh per-row expression + // evaluation. A NEXTVAL DEFAULT would therefore hand every pre-existing row the exact same + // value instead of a growing sequence, silently defeating the one thing NEXTVAL is for - + // rejected outright rather than allowed to produce that surprising result. INSERT's DEFAULT + // has no such problem (see buildInsertRow, which evaluates fresh per row). + if (column.default is NextValueExpression) { + throw KestrelException( + "NEXTVAL cannot be used as an ADD COLUMN DEFAULT - it would give every existing row " + + "the same value; CREATE TABLE's DEFAULT evaluates fresh per row instead" + ) + } + if (column.notNull && column.default == null && table.rows.isNotEmpty()) { + throw KestrelException( + "cannot add NOT NULL column ${column.name} to table $tableName with existing rows without a DEFAULT value" + ) + } + val defaultValue = column.default?.let { coerceForColumn(Evaluator().evaluate(it), column) } ?: Value.NullValue + catalog.addColumn(tableName, column) { defaultValue } + } + + /** + * Validates a GENERATED ALWAYS AS expression being added via ALTER TABLE ADD COLUMN against the + * table's CURRENT [schema] (before the new column exists) - the same rules + * [validateGeneratedDefinitions] applies at CREATE TABLE time (no unknown column, no aggregate/ + * window function, no subquery, no reference to another GENERATED column), just checked one + * column at a time against an already-existing schema instead of a freshly-declared column list. + * A self-reference is rejected for free: the new column is not part of [schema] yet, so + * [checkColumns] already reports it as "no such column". + */ + private fun validateAddGeneratedColumn(schema: TableSchema, column: Column) { + val generated = column.generated!! + checkColumns(generated, schema) + requireNoSubquery(generated) + val generatedNames = schema.columns.filter { it.generated != null }.map { it.name.lowercase() }.toSet() + requireNoGeneratedColumnReference(generated, generatedNames, column.name) + } + + /** + * Backfills GENERATED ALWAYS AS for one pre-existing [row], evaluated against the table's OLD + * [schema] (the new column is not part of it yet, matching what [validateAddGeneratedColumn] + * already checked against). Enforces NOT NULL the same way INSERT/UPDATE do post- + * [applyGeneratedColumns] via [validateNotNull] - inlined here instead, since there is no + * full new-shape row to hand that function until after this value is appended. + */ + private fun addGeneratedColumnValue(schema: TableSchema, column: Column, row: Row): Value { + val value = coerceForColumn(Evaluator(schema, row).evaluate(column.generated!!), column) + if (column.notNull && value is Value.NullValue) { + throw KestrelException("NOT NULL constraint failed: ${schema.name}.${column.name}") + } + return value + } + private fun executeCreateIndex(statement: CreateIndexStatement): StatementResult { - catalog.createIndex(IndexDefinition(statement.indexName, statement.tableName, statement.columnName)) + catalog.createIndex( + IndexDefinition(statement.indexName, statement.tableName, statement.columnNames, statement.unique) + ) return CreateIndexResult(statement.indexName) }