diff --git a/src/main/kotlin/kestrel/engine/Executor.kt b/src/main/kotlin/kestrel/engine/Executor.kt index cd040f1..7207fd9 100644 --- a/src/main/kotlin/kestrel/engine/Executor.kt +++ b/src/main/kotlin/kestrel/engine/Executor.kt @@ -955,7 +955,179 @@ class Executor(private val catalog: Catalog) : SequenceAccess { return DropIndexResult(statement.indexName) } - private fun executeInsert(statement: InsertStatement): StatementResult { + private fun executeCreateView(statement: CreateViewStatement): StatementResult { + selectRunner.explain(statement.query) + catalog.createView(statement.viewName, statement.query, statement.orReplace) + return CreateViewResult(statement.viewName) + } + + private fun executeDropView(statement: DropViewStatement): StatementResult { + catalog.dropView(statement.viewName, cascade = statement.mode == DropMode.Cascade) + return DropViewResult(statement.viewName) + } + + /** + * Body statements are deliberately NOT deep-validated here (no eager check that every column + * referenced in a body statement's WHERE/SET/VALUES actually exists) - unlike CREATE VIEW's + * eager `selectRunner.explain`, a trigger body can target a different table per statement and + * SQLite itself defers exactly this kind of check to the moment the trigger actually fires. + * What IS checked eagerly, matching this codebase's usual "catch it before it's the user's + * runtime problem" bar for constraint-shaped input: the trigger's own table and every body + * statement's target table both exist right now, and the WHEN clause - evaluated directly by + * this method rather than through a nested statement executor, so nothing else will ever + * validate it - contains no subquery/aggregate and only ever references NEW/OLD columns that + * both exist on this table and are actually available for this trigger's event (NEW is not + * available on DELETE, OLD is not available on INSERT). + */ + private fun executeCreateTrigger(statement: CreateTriggerStatement): StatementResult { + val schema = catalog.table(statement.tableName).schema + statement.whenClause?.let { whenClause -> + requireNoSubquery(whenClause) + validateTriggerRowReferences(whenClause, statement.event, schema) + } + for (bodyStatement in statement.body) { + validateTriggerBodyStatementTarget(bodyStatement) + } + catalog.createTrigger( + TriggerDefinition(statement.triggerName, statement.timing, statement.event, statement.tableName, statement.whenClause, statement.body) + ) + return CreateTriggerResult(statement.triggerName) + } + + private fun validateTriggerBodyStatementTarget(statement: Statement) { + when (statement) { + is InsertStatement -> { catalog.table(statement.tableName); rejectReturning(statement.returning) } + is UpdateStatement -> { catalog.table(statement.tableName); rejectReturning(statement.returning) } + is DeleteStatement -> { catalog.table(statement.tableName); rejectReturning(statement.returning) } + else -> throw KestrelException("trigger body only supports INSERT, UPDATE, or DELETE statements") + } + } + + /** A trigger body statement's own [runTriggerBody] discards whatever [StatementResult] it returns, so a RETURNING clause there would compute output nobody could ever see - rejected eagerly, matching [validateTriggerRowReferences]'s "structurally without sense" bar. */ + private fun rejectReturning(returning: Projection?) { + if (returning != null) { + throw KestrelException("RETURNING is not allowed in a trigger body statement - its result would never be visible to anything") + } + } + + private fun validateTriggerRowReferences(expression: Expression, event: TriggerEvent, schema: TableSchema) { + when (expression) { + is ColumnExpression -> { + val qualifier = expression.qualifier + ?: throw KestrelException("column '${expression.name}' in trigger WHEN clause must be qualified with NEW or OLD") + val isNew = qualifier.equals("new", ignoreCase = true) + val isOld = qualifier.equals("old", ignoreCase = true) + if (!isNew && !isOld) throw KestrelException("no such table or alias: $qualifier") + val availableForEvent = when (event) { + TriggerEvent.Insert -> isNew + TriggerEvent.Delete -> isOld + TriggerEvent.Update -> true + } + if (!availableForEvent) { + throw KestrelException("${if (isNew) "NEW" else "OLD"} is not available in a trigger ON ${event.name.uppercase()}") + } + if (schema.columnIndex(expression.name) == null) { + throw KestrelException("no such column: ${expression.name}") + } + } + is BinaryExpression -> { + validateTriggerRowReferences(expression.left, event, schema) + validateTriggerRowReferences(expression.right, event, schema) + } + is UnaryExpression -> validateTriggerRowReferences(expression.operand, event, schema) + is CallExpression -> expression.arguments.forEach { validateTriggerRowReferences(it, event, schema) } + is IsNullExpression -> validateTriggerRowReferences(expression.operand, event, schema) + is InExpression -> { + validateTriggerRowReferences(expression.operand, event, schema) + expression.values.forEach { validateTriggerRowReferences(it, event, schema) } + } + is BetweenExpression -> { + validateTriggerRowReferences(expression.operand, event, schema) + validateTriggerRowReferences(expression.low, event, schema) + validateTriggerRowReferences(expression.high, event, schema) + } + is LikeExpression -> { + validateTriggerRowReferences(expression.operand, event, schema) + validateTriggerRowReferences(expression.pattern, event, schema) + } + is CaseExpression -> { + expression.operand?.let { validateTriggerRowReferences(it, event, schema) } + expression.branches.forEach { + validateTriggerRowReferences(it.condition, event, schema) + validateTriggerRowReferences(it.result, event, schema) + } + expression.elseExpression?.let { validateTriggerRowReferences(it, event, schema) } + } + is CastExpression -> validateTriggerRowReferences(expression.operand, event, schema) + is CollateExpression -> validateTriggerRowReferences(expression.operand, event, schema) + is LiteralExpression -> Unit + is FunctionExpression, is WindowExpression, is ScalarSubqueryExpression, + is ExistsExpression, is InSubqueryExpression, + is NextValueExpression, is CurrentValueExpression, is MergeActionExpression, is ParameterExpression -> + error("unreachable: banned by requireNoSubquery") + } + } + + private fun executeDropTrigger(statement: DropTriggerStatement): StatementResult { + catalog.dropTrigger(statement.triggerName) + return DropTriggerResult(statement.triggerName) + } + + /** + * Resolves the parser's "not written" `null`s into Postgres' own ascending/descending + * defaults - MINVALUE 1/MAXVALUE `Long.MAX_VALUE`/START WITH MINVALUE for a positive + * INCREMENT BY (1 if not given either), the mirror image for a negative one - then validates + * the combination as a whole via [SequenceDefinition]'s own `init` plus the one check it can't + * make itself: START WITH must actually fall within [MINVALUE, MAXVALUE]. + */ + private fun executeCreateSequence(statement: CreateSequenceStatement): StatementResult { + val incrementBy = statement.incrementBy ?: 1L + if (incrementBy == 0L) throw KestrelException("sequence '${statement.sequenceName}' INCREMENT BY cannot be zero") + val ascending = incrementBy > 0 + val minValue = statement.minValue ?: if (ascending) 1L else Long.MIN_VALUE + val maxValue = statement.maxValue ?: if (ascending) Long.MAX_VALUE else -1L + if (minValue > maxValue) { + throw KestrelException("sequence '${statement.sequenceName}' MINVALUE ($minValue) must not exceed MAXVALUE ($maxValue)") + } + val startValue = statement.startValue ?: if (ascending) minValue else maxValue + if (startValue < minValue || startValue > maxValue) { + throw KestrelException( + "START WITH ($startValue) must be between MINVALUE ($minValue) and MAXVALUE ($maxValue)" + ) + } + catalog.createSequence(SequenceDefinition(statement.sequenceName, incrementBy, minValue, maxValue, startValue, statement.cycle)) + return CreateSequenceResult(statement.sequenceName) + } + + private fun executeDropSequence(statement: DropSequenceStatement): StatementResult { + catalog.dropSequence(statement.sequenceName) + return DropSequenceResult(statement.sequenceName) + } + + /** + * Unlike [executeCreateSequence], an option left `null`/`false` here means "keep whatever this + * sequence already has" rather than "apply a default" - only [AlterSequenceStatement.clearMinValue]/ + * [AlterSequenceStatement.clearMaxValue] (Postgres' `NO MINVALUE`/`NO MAXVALUE`) recompute a bound + * from [SequenceDefinition.incrementBy]'s sign the way a fresh CREATE SEQUENCE would. + * [SequenceDefinition.startValue] is never touched by this method (see its own doc) regardless + * of what RESTART is asked for. + */ + private fun executeAlterSequence(statement: AlterSequenceStatement): StatementResult { + val existing = catalog.sequence(statement.sequenceName).definition + val incrementBy = statement.incrementBy ?: existing.incrementBy + if (incrementBy == 0L) throw KestrelException("sequence '${statement.sequenceName}' INCREMENT BY cannot be zero") + val ascending = incrementBy > 0 + val minValue = when { + statement.clearMinValue -> if (ascending) 1L else Long.MIN_VALUE + statement.minValue != null -> statement.minValue + else -> existing.minValue + } + val maxValue = when { + statement.clearMaxValue -> if (ascending) Long.MAX_VALUE else -1L + statement.maxValue != null -> statement.maxValue + else -> existing.maxValue + } + if (minValue > maxValue) { val table = catalog.table(statement.tableName) val schema = table.schema val targetIndices = resolveInsertTargets(schema, statement.columns)