feat(sql): add requireUniqueConstraintName and executeAddColumn

This commit is contained in:
dualfroz committed 2026-09-06 22:33:52 +02:00
1 parent faad477a32
commit d5bb145d4c
1 file changed
+144
@@ -1616,3 +1616,147 @@ class DiskExecutor(private val catalog: DiskCatalog) : SequenceAccess {
schema.checks, schema.uniqueConstraints, schema.compositePrimaryKey schema.checks, schema.uniqueConstraints, schema.compositePrimaryKey
) )
val using = action.using ?: ColumnExpression(action.columnName)
catalog.changeColumnType(tableName, action.columnName, newColumn) { row ->
val value = coerceForColumn(Evaluator(schema, row).evaluate(using), newColumn)
if (newColumn.notNull && value is Value.NullValue) {
throw DiskExecutorException("NOT NULL constraint failed: $tableName.${action.columnName}")
}
val newRow = Row(row.values.toMutableList().also { it[columnIndex] = value })
for (check in newSchema.checks) {
val result = Evaluator(newSchema, newRow).evaluate(check.expression)
if (result is Value.BooleanValue && !result.value) {
val label = check.name?.let { " $it" } ?: ""
throw DiskExecutorException("CHECK constraint$label failed: $tableName")
}
}
value
}
}
/** Mirrors [kestrel.engine.Executor.requireUniqueConstraintName] - see that method's own doc. */
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 DiskExecutorException("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.
* Mirrors [kestrel.engine.Executor.executeAddColumn] (same reasoning as [coerceForColumn]):
* duplicated rather than shared, since this executor's [DiskTable] has no equivalent of the
* in-memory engine's `Table.rows` to check emptiness against without a transaction of its own.
* GENERATED ALWAYS AS sidesteps the NOT NULL/DEFAULT requirement entirely - see
* [addGeneratedColumnValue].
*/
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) }
val probeTxn = catalog.beginTransaction()
val hasRows = runInTransaction(probeTxn) { table.scan(probeTxn).isNotEmpty() }
if (column.notNull && column.default == null && hasRows) {
throw DiskExecutorException(
"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 }
}
/** Mirrors [kestrel.engine.Executor.validateAddGeneratedColumn] - see that method's own doc. */
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)
}
/** Mirrors [kestrel.engine.Executor.addGeneratedColumnValue] - see that method's own doc. */
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 DiskExecutorException("NOT NULL constraint failed: ${schema.name}.${column.name}")
}
return value
}
/**
* A DEFAULT must be a constant expression: no column reference (there is no row to read one
* from when backfilling `ALTER TABLE ... ADD COLUMN`), no subquery, and no aggregate. Ported
* 1:1 from [kestrel.engine.Executor.validateConstantDefault] - see [checkColumns] for why this
* executor duplicates rather than shares this kind of validation.
*/
private fun validateConstantDefault(expression: Expression) {
when (expression) {
is ColumnExpression -> throw DiskExecutorException("DEFAULT expression cannot reference a column")
is ScalarSubqueryExpression, is ExistsExpression, is InSubqueryExpression ->
throw DiskExecutorException("DEFAULT expression cannot contain a subquery")
is FunctionExpression -> throw DiskExecutorException("DEFAULT expression cannot contain an aggregate function")
is BinaryExpression -> {
validateConstantDefault(expression.left)
validateConstantDefault(expression.right)
}
is UnaryExpression -> validateConstantDefault(expression.operand)
is CallExpression -> expression.arguments.forEach { validateConstantDefault(it) }
is IsNullExpression -> validateConstantDefault(expression.operand)
is InExpression -> {
validateConstantDefault(expression.operand)
expression.values.forEach { validateConstantDefault(it) }
}
is BetweenExpression -> {
validateConstantDefault(expression.operand)
validateConstantDefault(expression.low)
validateConstantDefault(expression.high)
}
is LikeExpression -> {
validateConstantDefault(expression.operand)
validateConstantDefault(expression.pattern)
}
is CaseExpression -> {
expression.operand?.let { validateConstantDefault(it) }
expression.branches.forEach {
validateConstantDefault(it.condition)
validateConstantDefault(it.result)
}
expression.elseExpression?.let { validateConstantDefault(it) }
}
is CastExpression -> validateConstantDefault(expression.operand)
is CollateExpression -> validateConstantDefault(expression.operand)
is WindowExpression -> throw DiskExecutorException("DEFAULT expression cannot contain a window function")
is NextValueExpression ->
throw DiskExecutorException(
"NEXTVAL cannot be used as an ADD COLUMN DEFAULT - it would give every existing row " +
"the exact same value instead of a growing sequence, silently defeating the one thing NEXTVAL is for"
)
is CurrentValueExpression -> throw DiskExecutorException("DEFAULT expression cannot contain CURRVAL")
is MergeActionExpression -> throw DiskExecutorException("DEFAULT expression cannot contain MERGE_ACTION()")
is ParameterExpression -> throw DiskExecutorException("DEFAULT expression cannot contain a parameter reference")
is LiteralExpression -> Unit
}
}
/**
* Builds every row's final values first (in [newRows], none written yet) and only then validates
* and inserts them one at a time - not inline in a single pass - so that a self-referencing FK
* can see sibling rows from the same multi-row INSERT regardless of their order in the statement,
* exactly like [kestrel.engine.Executor.executeInsert]'s own `uncommittedSiblings = newRows`.
*/
private fun executeInsert(statement: InsertStatement, triggerContext: TriggerRowContext? = null): StatementResult {
catalog.temporaryTableOrNull(statement.tableName)?.let { return executeTemporaryInsert(statement, it) }
val table = catalog.table(statement.tableName)
val schema = table.schema
return withStatementTransaction { txn ->
withTriggerAtomicity(schema.name, txn, triggerContext) {
executeInsertInTxn(statement, table, schema, txn, triggerContext)
}
}
}