feat(engine): extend Executor with requireUniqueConstraintName
This commit is contained in:
1 parent
5508c9d1a4
commit
ebfc2d4d67
1 file changed
+142
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user