diff --git a/src/main/kotlin/kestrel/store/sql/DiskExecutor.kt b/src/main/kotlin/kestrel/store/sql/DiskExecutor.kt index 835a2b7..b40824e 100644 --- a/src/main/kotlin/kestrel/store/sql/DiskExecutor.kt +++ b/src/main/kotlin/kestrel/store/sql/DiskExecutor.kt @@ -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) { + 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 + } + } +