feat(ast): extend Ast with MergeStatement and MergeWhenClause

This commit is contained in:
dualfroz committed 2026-09-02 20:30:15 +02:00
1 parent 3eb5cd7fca
commit e816d4edf1
1 file changed
+172 -8
+172 -8
View File
@@ -222,10 +222,138 @@ data class DeleteStatement(
data class UpdateStatement(
val tableName: String,
val assignments: List<Assignment>,
val where: Expression?
val where: Expression?,
val returning: Projection? = null
) : PreparableStatement
/**
* `MERGE INTO target [AS targetAlias] USING source ON onCondition whenClauses...`. [source] is the
* same [FromItem] a `FROM`/`USING`-shaped clause anywhere else in this grammar produces (a bare
* table name or a derived-table subquery, carrying its own alias), reused rather than inventing a
* MERGE-specific source type. [whenClauses] preserves the exact order they were written in - see
* [kestrel.engine.Executor]'s executeMerge for why that order (not a MATCHED-then-NOT-MATCHED split)
* is what standard MERGE semantics actually require: for a given source row, at most one category
* (MATCHED or NOT MATCHED) can ever apply, so evaluating the whole list in written order and
* skipping clauses of the other category naturally reproduces "first matching WHEN clause wins".
* Use from inside a trigger body is deliberately unsupported (the parser's trigger-body grammar only
* ever accepts INSERT/UPDATE/DELETE, a structural scope cut, not a runtime check). [returning], when
* present, projects one result row per action MERGE actually took (insert/update/delete), in the
* order those actions ran - see [kestrel.engine.Executor.evaluateMergeReturning].
*/
data class MergeStatement(
val targetTable: String,
val targetAlias: String?,
val source: FromItem,
val onCondition: Expression,
val whenClauses: List<MergeWhenClause>,
val returning: Projection? = null
) : PreparableStatement
sealed interface MergeWhenClause {
val condition: Expression?
}
/** WHEN MATCHED [AND condition] THEN UPDATE SET assignments... - `assignments` targets are always unqualified target columns, matching plain UPDATE. */
data class MergeMatchedUpdate(override val condition: Expression?, val assignments: List<Assignment>) : MergeWhenClause
/** WHEN MATCHED [AND condition] THEN DELETE */
data class MergeMatchedDelete(override val condition: Expression?) : MergeWhenClause
/**
* WHEN NOT MATCHED [AND condition] THEN INSERT [(columns)] VALUES (values). `columns == null` means
* every target column in schema order, exactly like a column-list-less plain INSERT. Unlike an
* ordinary INSERT, [values] is always exactly one row (MERGE inserts at most one row per
* unmatched source row) and can only reference the source side - there is no target row yet.
*/
data class MergeNotMatchedInsert(override val condition: Expression?, val columns: List<String>?, val values: List<Expression?>) : MergeWhenClause
data class ExplainStatement(val select: QueryStatement) : Statement
enum class TriggerTiming { Before, After }
enum class TriggerEvent { Insert, Update, Delete }
/**
* `body` is restricted by the parser to INSERT/UPDATE/DELETE statements (matching SQLite's own
* trigger-body grammar) - those are the only statement kinds with a side effect worth firing, and
* the only ones [kestrel.engine.Executor] knows how to run with a [kestrel.engine.TriggerRowContext]
* bound. `whenClause`, when present, is evaluated once per candidate row before [body] runs at all;
* a row for which it is false or NULL never fires this trigger.
*/
data class CreateTriggerStatement(
val triggerName: String,
val timing: TriggerTiming,
val event: TriggerEvent,
val tableName: String,
val whenClause: Expression?,
val body: List<Statement>
) : Statement
data class ExplainStatement(val select: SelectStatement) : Statement
data class DropTriggerStatement(val triggerName: String) : Statement
/**
* `incrementBy`/`minValue`/`maxValue`/`startValue` are already fully resolved by the parser's
* defaulting rules (see `Parser.parseSequenceOptions`) - `null` only ever means "not written by the
* user", never "no bound"; [kestrel.engine.Executor.executeCreateSequence] is what turns a `null`
* into Postgres' own ascending/descending default (MINVALUE 1 / MAXVALUE Long.MAX_VALUE and the
* reverse for a negative [incrementBy]) and validates the combination.
*/
data class CreateSequenceStatement(
val sequenceName: String,
val incrementBy: Long? = null,
val minValue: Long? = null,
val maxValue: Long? = null,
val startValue: Long? = null,
val cycle: Boolean = false
) : Statement
data class DropSequenceStatement(val sequenceName: String) : Statement
/**
* Every field besides [sequenceName] is "not mentioned in this ALTER" when `null`/`false` -
* [clearMinValue]/[clearMaxValue] (Postgres' `NO MINVALUE`/`NO MAXVALUE`) are tracked separately
* from [minValue]/[maxValue] because, unlike CREATE SEQUENCE, "not mentioned" and "explicitly
* cleared back to the type default" are different instructions on an ALTER: the former leaves
* today's bound untouched, the latter recomputes it from [incrementBy]'s sign exactly like a fresh
* CREATE SEQUENCE with no bound written at all would. [restart] with a `null` [restartWith] is a
* bare `RESTART` (reset to the sequence's original `START WITH`, unaffected by any earlier ALTER);
* [restart] `false` means no RESTART clause at all, leaving the counter wherever NEXTVAL last left it.
*/
data class AlterSequenceStatement(
val sequenceName: String,
val incrementBy: Long? = null,
val minValue: Long? = null,
val clearMinValue: Boolean = false,
val maxValue: Long? = null,
val clearMaxValue: Boolean = false,
val restart: Boolean = false,
val restartWith: Long? = null,
val cycle: Boolean? = null
) : Statement
/**
* `PREPARE name AS <target>` - parses and stores [target] under [name] without running it.
* [kestrel.engine.Executor] keeps prepared statements in a session-scoped registry OUTSIDE
* [kestrel.catalog.Catalog] (unlike CREATE VIEW/TRIGGER/SEQUENCE) - a deliberate choice, not an
* oversight: they carry no data of their own (only an AST plus, at EXECUTE time, caller-supplied
* argument values), so BEGIN/ROLLBACK's catalog snapshot/restore has nothing meaningful to undo for
* them, and a name PREPARE-d before a transaction started should not vanish just because that
* transaction later rolled back. See [kestrel.engine.ParameterSubstitution] for how `$1`, `$2`, ...
* inside [target] are resolved once an EXECUTE actually supplies values.
*/
data class PrepareStatement(val name: String, val target: PreparableStatement) : Statement
/**
* `EXECUTE name(arg, arg, ...)` (or `EXECUTE name` for a statement with no parameters) -
* [kestrel.engine.Executor.resolveExecute] looks [name] up, evaluates each [arguments] expression
* as a constant (no column/row context exists to resolve against), and substitutes the results for
* every [ParameterExpression] in the prepared statement before it actually runs - so whatever this
* resolves to is always one of [PreparableStatement]'s own concrete kinds, never `EXECUTE` itself.
*/
data class ExecuteStatement(val name: String, val arguments: List<Expression> = emptyList()) : Statement
/** `DEALLOCATE name` removes one prepared statement; `DEALLOCATE ALL` ([all] `true`, [name] `null`) removes every one. */
data class DeallocateStatement(val name: String?, val all: Boolean = false) : Statement
data object BeginStatement : Statement
@@ -233,15 +361,22 @@ data object CommitStatement : Statement
data object RollbackStatement : Statement
data class Assignment(val columnName: String, val value: Expression)
data class SavepointStatement(val name: String) : Statement
enum class JoinType { Inner, Left }
data class ReleaseSavepointStatement(val name: String) : Statement
data class RollbackToSavepointStatement(val name: String) : Statement
/** `value == null` means the assignment was written as `col = DEFAULT` - the column's declared DEFAULT (or NULL without one), evaluated fresh per row rather than carrying a literal at parse time. */
data class Assignment(val columnName: String, val value: Expression?)
enum class JoinType { Inner, Left, Right, Full, Cross }
/** [on] is null exactly for [JoinType.Cross] - a CROSS JOIN has no condition, matching every pairing. */
data class JoinClause(
val type: JoinType,
val tableName: String,
val alias: String?,
val on: Expression
val from: FromItem,
val on: Expression?
)
sealed interface Projection
@@ -252,7 +387,16 @@ data class ExpressionProjection(val items: List<SelectItem>) : Projection
data class SelectItem(val expression: Expression, val alias: String?)
data class OrderBy(val expression: Expression, val descending: Boolean)
/**
* [nullsFirst] is `null` when the SQL text had no explicit `NULLS FIRST`/`NULLS LAST` - every
* comparator consuming this (see [kestrel.engine.compareOrderKeyValues]) then falls back to
* `!descending`, preserving this engine's original convention (NULL as the smallest value
* regardless of direction) from before this syntax existed. `true`/`false` here always means an
* explicit clause was present, independent of [descending].
*/
data class OrderKey(val expression: Expression, val descending: Boolean, val nullsFirst: Boolean? = null)
data class OrderBy(val keys: List<OrderKey>)
sealed interface Expression
@@ -260,6 +404,26 @@ data class LiteralExpression(val value: Value) : Expression
data class ColumnExpression(val name: String, val qualifier: String? = null) : Expression
/**
* [separator] and [orderBy] exist only for `STRING_AGG(value, separator [ORDER BY ...])` - every
* other aggregate (COUNT/SUM/AVG/MIN/MAX) leaves both null/empty. Kept on the shared node rather than
* a dedicated `StringAggExpression` variant so STRING_AGG rides every existing [FunctionExpression]
* validator (GROUP BY membership, CHECK/DEFAULT aggregate bans, subquery bans, ...) for free instead
* of needing a twelfth near-parallel `when` branch across the codebase.
*
* [distinct] is `COUNT(DISTINCT x)`/`SUM(DISTINCT x)`/`AVG(DISTINCT x)`/`MIN(DISTINCT x)`/
* `MAX(DISTINCT x)` - never true together with [star] or alongside [separator] (the parser rejects
* `DISTINCT` on STRING_AGG's own call shape and on `*`), and never true on a windowed aggregate (the
* parser rejects `DISTINCT ... OVER (...)` outright, matching Postgres - there is no per-row windowed
* notion of "distinct so far").
*
* [filter] is `aggregate_name(args) FILTER (WHERE condition)` - a per-row predicate evaluated against
* the same group the aggregate would otherwise see in full, restricting which rows reach the aggregate
* without needing a separate `CASE WHEN ... END` inside every argument. Never set on STRING_AGG (its
* own call shape returns before this field would ever be populated) or on a windowed aggregate (the
* parser rejects `FILTER` together with `OVER`, matching how [distinct] is rejected there too - there
* is no per-row windowed notion of a group to filter).
*/
data class FunctionExpression(
val name: String,
val argument: Expression?,