feat(engine): extend Executor with requireUniqueConstraintName

This commit is contained in:
dualfroz committed 2026-09-01 18:30:00 +02:00
1 parent 5508c9d1a4
commit ebfc2d4d67
1 file changed
+142 -1
+142 -1
View File
@@ -804,8 +804,149 @@ class Executor(private val catalog: Catalog) : SequenceAccess {
) )
} }
val referencingTables = catalog.tableNames.filter { other -> 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 { 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) return CreateIndexResult(statement.indexName)
} }