feat(sql): add validateForeignKeyDefinitions

This commit is contained in:
dualfroz committed 2026-09-06 21:22:35 +02:00
1 parent c51129b8b1
commit f477f22e5b
1 file changed
+173
@@ -961,3 +961,176 @@ class DiskExecutor(private val catalog: DiskCatalog) : SequenceAccess {
} }
} }
/**
* Deliberately duplicated from [kestrel.engine.Executor.validateForeignKeyDefinitions] (same
* reasoning as [coerceForColumn]): [columns] is already normalized by [applyCompositePrimaryKey],
* matching the in-memory engine's own call order. A self-reference (`referencedTable` equal to
* the table being created) is checked against a [TableSchema] built from [columns] rather than
* the catalog, because the table doesn't exist there yet while it's being created.
*/
private fun validateForeignKeyDefinitions(statement: CreateTableStatement, columns: List<Column>) {
for (fk in statement.foreignKeys) {
if (fk.columns.size != fk.referencedColumns.size) {
throw DiskExecutorException(
"FOREIGN KEY (${fk.columns.joinToString(", ")}) column count does not match " +
"REFERENCES ${fk.referencedTable}(${fk.referencedColumns.joinToString(", ")})"
)
}
for (columnName in fk.columns) {
if (columns.none { it.name.equals(columnName, ignoreCase = true) }) {
throw DiskExecutorException("FOREIGN KEY refers to unknown column: $columnName")
}
}
val selfReference = fk.referencedTable.equals(statement.tableName, ignoreCase = true)
val hasEnforcedUniqueness = if (selfReference) {
for (columnName in fk.referencedColumns) {
if (columns.none { it.name.equals(columnName, ignoreCase = true) }) {
throw DiskExecutorException("FOREIGN KEY references unknown column: ${fk.referencedTable}.$columnName")
}
}
val selfSchema = TableSchema(
statement.tableName, columns,
uniqueConstraints = statement.uniqueConstraints,
compositePrimaryKey = statement.compositePrimaryKey
)
selfSchema.hasEnforcedUniqueness(fk.referencedColumns)
} else {
val referencedTable = catalog.tableOrNull(fk.referencedTable)
?: throw DiskExecutorException("FOREIGN KEY references unknown table: ${fk.referencedTable}")
for (columnName in fk.referencedColumns) {
if (referencedTable.schema.column(columnName) == null) {
throw DiskExecutorException("FOREIGN KEY references unknown column: ${fk.referencedTable}.$columnName")
}
}
referencedTable.schema.hasEnforcedUniqueness(fk.referencedColumns)
}
if (!hasEnforcedUniqueness) {
throw DiskExecutorException(
"FOREIGN KEY must reference a PRIMARY KEY or UNIQUE column(s), but " +
"${fk.referencedTable}(${fk.referencedColumns.joinToString(", ")}) is not one"
)
}
}
}
/**
* Deliberately duplicated from [kestrel.engine.Executor.validateCheckDefinitions] (same
* reasoning as [coerceForColumn]): every CHECK expression must only reference a real column of
* the table being created and must contain no subquery, aggregate, or window function - there is
* no row yet to run one against, and this executor's [Evaluator] has no aggregation pass of its
* own outside [GroupAggregation].
*/
private fun validateCheckDefinitions(statement: CreateTableStatement) {
val schema = TableSchema(statement.tableName, statement.columns)
for (check in statement.checks) {
checkColumns(check.expression, schema)
requireNoSubquery(check.expression)
}
}
/** Ported 1:1 from [kestrel.engine.Executor.checkColumns] - see [validateCheckDefinitions]. */
private fun checkColumns(expression: Expression, schema: TableSchema) {
when (expression) {
is ColumnExpression -> if (schema.columnIndex(expression.name) == null) {
throw DiskExecutorException("no such column: ${expression.name}")
}
is BinaryExpression -> {
checkColumns(expression.left, schema)
checkColumns(expression.right, schema)
}
is UnaryExpression -> checkColumns(expression.operand, schema)
is FunctionExpression -> throw DiskExecutorException("aggregate functions are not allowed here")
is WindowExpression -> throw DiskExecutorException("window functions are not allowed here")
is LiteralExpression -> Unit
is CallExpression -> expression.arguments.forEach { checkColumns(it, schema) }
is IsNullExpression -> checkColumns(expression.operand, schema)
is InExpression -> {
checkColumns(expression.operand, schema)
expression.values.forEach { checkColumns(it, schema) }
}
is BetweenExpression -> {
checkColumns(expression.operand, schema)
checkColumns(expression.low, schema)
checkColumns(expression.high, schema)
}
is LikeExpression -> {
checkColumns(expression.operand, schema)
checkColumns(expression.pattern, schema)
}
is CaseExpression -> {
expression.operand?.let { checkColumns(it, schema) }
expression.branches.forEach {
checkColumns(it.condition, schema)
checkColumns(it.result, schema)
}
expression.elseExpression?.let { checkColumns(it, schema) }
}
is ScalarSubqueryExpression -> Unit
is ExistsExpression -> Unit
is InSubqueryExpression -> checkColumns(expression.operand, schema)
is CastExpression -> checkColumns(expression.operand, schema)
is CollateExpression -> checkColumns(expression.operand, schema)
is NextValueExpression, is CurrentValueExpression ->
throw DiskExecutorException("NEXTVAL/CURRVAL are not allowed in a CHECK constraint or GENERATED column expression")
is MergeActionExpression -> throw DiskExecutorException("MERGE_ACTION() is not allowed in a CHECK constraint or GENERATED column expression")
is ParameterExpression -> throw DiskExecutorException("a parameter reference is not allowed in a CHECK constraint or GENERATED column expression")
}
}
/**
* Ported 1:1 from [kestrel.engine.Executor.checkColumns] (the general-purpose variant used for
* WHERE/SET/RETURNING) - NOT [checkColumns] above, which is CHECK-constraint specific and
* rejects NEXTVAL/CURRVAL outright. Used only by [evaluateReturning]: validates every column
* reference in a RETURNING projection item exists on [schema] and that no aggregate/window
* function sneaks in.
*/
private fun checkReturningColumns(expression: Expression, schema: TableSchema) {
when (expression) {
is ColumnExpression -> if (schema.columnIndex(expression.name) == null) {
throw DiskExecutorException("no such column: ${expression.name}")
}
is BinaryExpression -> {
checkReturningColumns(expression.left, schema)
checkReturningColumns(expression.right, schema)
}
is UnaryExpression -> checkReturningColumns(expression.operand, schema)
is FunctionExpression -> throw DiskExecutorException("aggregate functions are not allowed here")
is WindowExpression -> throw DiskExecutorException("window functions are not allowed here")
is LiteralExpression -> Unit
is CallExpression -> expression.arguments.forEach { checkReturningColumns(it, schema) }
is IsNullExpression -> checkReturningColumns(expression.operand, schema)
is InExpression -> {
checkReturningColumns(expression.operand, schema)
expression.values.forEach { checkReturningColumns(it, schema) }
}
is BetweenExpression -> {
checkReturningColumns(expression.operand, schema)
checkReturningColumns(expression.low, schema)
checkReturningColumns(expression.high, schema)
}
is LikeExpression -> {
checkReturningColumns(expression.operand, schema)
checkReturningColumns(expression.pattern, schema)
}
is CaseExpression -> {
expression.operand?.let { checkReturningColumns(it, schema) }
expression.branches.forEach {
checkReturningColumns(it.condition, schema)
checkReturningColumns(it.result, schema)
}
expression.elseExpression?.let { checkReturningColumns(it, schema) }
}
is ScalarSubqueryExpression -> Unit
is ExistsExpression -> Unit
is InSubqueryExpression -> checkReturningColumns(expression.operand, schema)
is CastExpression -> checkReturningColumns(expression.operand, schema)
is CollateExpression -> checkReturningColumns(expression.operand, schema)
// Structurally harmless (no column reference to check) - whether MERGE_ACTION() is
// actually legal at this particular call site (MERGE's own RETURNING: yes; plain INSERT/
// UPDATE/DELETE's RETURNING: no) is enforced by whether the Evaluator this feeds into was
// actually given a mergeAction, exactly like NEXTVAL/CURRVAL is enforced by SequenceAccess -
// see evaluateMergeReturning, the one call site that binds one.
is NextValueExpression, is CurrentValueExpression, is MergeActionExpression, is ParameterExpression -> Unit
}
}