From 7a2c8037e29d4a4a4aae1f822dfe6d61b8a95db5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 15:56:23 +0200 Subject: [PATCH] feat(parser): extend Parser with parseStringAggCall --- src/main/kotlin/kestrel/sql/parser/Parser.kt | 197 ++++++++++++++++++- 1 file changed, 194 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kestrel/sql/parser/Parser.kt b/src/main/kotlin/kestrel/sql/parser/Parser.kt index 5e19228..99336a5 100644 --- a/src/main/kotlin/kestrel/sql/parser/Parser.kt +++ b/src/main/kotlin/kestrel/sql/parser/Parser.kt @@ -1577,9 +1577,200 @@ class Parser(private val tokens: List) { arguments.add(parseExpression()) } while (match(TokenType.Comma)) } - val argument = parseExpression() - expect(TokenType.RightParen, "expected ')' after function argument") - return FunctionExpression(name, argument, star = false) + expect(TokenType.RightParen, "expected ')' after function arguments") + // `FILTER` is not a reserved keyword either (same reasoning as ROLLUP/CUBE/GROUPING above) - + // only meaningful right after an aggregate call's closing paren when immediately followed by + // `(`, so a column or table legitimately named `filter` is unaffected. + var filter: Expression? = null + if (isContextualKeyword("FILTER") && peekNext().type == TokenType.LeftParen) { + if (!isAggregate) throw error("FILTER can only be used with an aggregate function", peek()) + advance() + advance() + expect(TokenType.Where, "expected WHERE after FILTER (") + filter = parseExpression() + expect(TokenType.RightParen, "expected ')' to close FILTER clause") + } + if (check(TokenType.Over)) { + if (distinct) throw error("DISTINCT is not supported in window functions", peek()) + if (filter != null) throw error("FILTER is not supported in window functions", peek()) + return parseWindowExpression(name, upperName, isAggregate, arguments, star) + } + if (isAggregate) { + if (star) return FunctionExpression(name, argument = null, star = true, filter = filter) + if (arguments.size != 1) { + throw error("aggregate function $name requires exactly one argument", peek()) + } + return FunctionExpression(name, arguments.single(), star = false, distinct = distinct, filter = filter) + } + if (upperName == "NEXTVAL" || upperName == "CURRVAL") { + return parseSequenceFunctionCall(name, upperName, arguments) + } + if (upperName == "MERGE_ACTION") { + if (arguments.isNotEmpty()) throw error("MERGE_ACTION takes no arguments", peek()) + return MergeActionExpression + } + return CallExpression(name, arguments) + } + + /** + * `STRING_AGG(value, separator [ORDER BY key [ASC|DESC], ...])` - shaped nothing like the rest of + * [AGGREGATE_FUNCTIONS] (two required arguments, no `*` form, an optional ORDER BY *inside* the + * parens that sorts values within each group independently of the query's own ORDER BY), so it + * gets its own parse path called before `(` is even consumed for the generic aggregate branch. + * The left paren was already consumed by [parseFunctionCall]. Never valid with `OVER` - unlike + * COUNT/SUM/AVG/MIN/MAX, [kestrel.engine.AggregateFunctions] has no per-row windowed form for it + * (concatenation order inside a sliding frame is a separate, unimplemented feature), so `OVER` + * right after the closing `)` is rejected here with a clear message instead of falling through to + * a generic "expected ';'" parse error later. + */ + private fun parseStringAggCall(name: String): Expression { + val value = parseExpression() + expect(TokenType.Comma, "expected ',' after STRING_AGG value argument") + val separator = parseExpression() + val orderBy = parseOrderKeys() + expect(TokenType.RightParen, "expected ')' after STRING_AGG arguments") + if (check(TokenType.Over)) throw error("STRING_AGG cannot be used as a window function", peek()) + return FunctionExpression(name, value, star = false, separator = separator, orderBy = orderBy) + } + + /** + * Unlike every other function call, [arguments] must be exactly one string literal - the + * sequence's name is resolved once here, at parse time, rather than carried as a general + * [Expression] evaluated per row (see [NextValueExpression]'s own doc for why). A bad argument + * (wrong count, wrong shape - a column reference, another call, ...) is a parse-time error here + * rather than something [kestrel.engine.Evaluator] discovers later, matching how this parser + * already treats e.g. a non-identifier index/table name elsewhere. + */ + private fun parseSequenceFunctionCall(name: String, upperName: String, arguments: List): Expression { + val sequenceNameArgument = arguments.singleOrNull() as? LiteralExpression + val sequenceName = (sequenceNameArgument?.value as? Value.TextValue)?.value + ?: throw error("$name requires a single string literal argument naming the sequence", peek()) + return if (upperName == "NEXTVAL") NextValueExpression(sequenceName) else CurrentValueExpression(sequenceName) + } + + /** + * `name(args) OVER (...)`, reached once `name(args)` is already parsed and `OVER` is next. + * Ranking functions (ROW_NUMBER/RANK/DENSE_RANK) take no arguments at all; the aggregate + * vocabulary (COUNT/SUM/AVG/MIN/MAX) is exactly the [AGGREGATE_FUNCTIONS] set, reused here with + * per-row windowed semantics instead of collapsing a group. LAG/LEAD take 1-3 arguments (value, + * offset, default); FIRST_VALUE/LAST_VALUE/NTILE take exactly one. Any other name before OVER is + * a clear error rather than silently falling back to a plain (non-window) call. + */ + private fun parseWindowExpression( + name: String, + upperName: String, + isAggregate: Boolean, + arguments: List, + star: Boolean + ): Expression { + val kind = WINDOW_RANKING_FUNCTIONS[upperName] + ?: WINDOW_OTHER_FUNCTIONS[upperName] + ?: (if (isAggregate) WindowFunctionKind.Aggregate else null) + ?: throw error("function $name cannot be used as a window function", peek()) + when (kind) { + WindowFunctionKind.RowNumber, WindowFunctionKind.Rank, WindowFunctionKind.DenseRank -> + if (star || arguments.isNotEmpty()) throw error("$name does not take arguments", peek()) + WindowFunctionKind.Aggregate -> + if (!star && arguments.size != 1) throw error("window function $name requires exactly one argument", peek()) + WindowFunctionKind.Lag, WindowFunctionKind.Lead -> + if (star || arguments.isEmpty() || arguments.size > 3) { + throw error("$name requires between 1 and 3 arguments", peek()) + } + WindowFunctionKind.FirstValue, WindowFunctionKind.LastValue, WindowFunctionKind.Ntile -> + if (star || arguments.size != 1) throw error("$name requires exactly one argument", peek()) + } + expect(TokenType.Over, "expected OVER") + expect(TokenType.LeftParen, "expected '(' after OVER") + val partitionBy = mutableListOf() + if (match(TokenType.Partition)) { + expect(TokenType.By, "expected BY after PARTITION") + do { + partitionBy.add(parseExpression()) + } while (match(TokenType.Comma)) + } + val orderBy = parseOrderKeys() + val frame = parseWindowFrame() + expect(TokenType.RightParen, "expected ')' to close OVER clause") + val argument = if (kind == WindowFunctionKind.Aggregate) arguments.singleOrNull() else arguments.getOrNull(0) + val offset = if (kind == WindowFunctionKind.Lag || kind == WindowFunctionKind.Lead) arguments.getOrNull(1) else null + val defaultValue = if (kind == WindowFunctionKind.Lag || kind == WindowFunctionKind.Lead) arguments.getOrNull(2) else null + return WindowExpression(name, kind, argument, star, partitionBy, orderBy, offset, defaultValue, frame) + } + + /** + * `ROWS|RANGE|GROUPS BETWEEN AND `, or the shorthand `ROWS|RANGE|GROUPS ` + * (equivalent to `... BETWEEN AND CURRENT ROW`, exactly like every other SQL engine's + * shorthand). + */ + private fun parseWindowFrame(): WindowFrame? { + val unit = when { + match(TokenType.Rows) -> FrameUnit.Rows + match(TokenType.Range) -> FrameUnit.Range + match(TokenType.Groups) -> FrameUnit.Groups + else -> return null + } + val (start, end) = if (match(TokenType.Between)) { + val start = parseFrameBound() + expect(TokenType.And, "expected AND between the two bounds of a frame") + val end = parseFrameBound() + start to end + } else { + parseFrameBound() to FrameBound(FrameBoundType.CurrentRow) + } + if (frameBoundRank(start.type) > frameBoundRank(end.type)) { + throw error("frame start cannot come after frame end", peek()) + } + return WindowFrame(unit, start, end, parseFrameExclude()) + } + + /** + * `EXCLUDE CURRENT ROW | EXCLUDE GROUP | EXCLUDE TIES | EXCLUDE NO OTHERS`, optional and always + * following the frame's bounds. Absent entirely is [FrameExclude.NoOthers], identical to writing + * `EXCLUDE NO OTHERS` out - SQL engines never require it and this parser accepts either. + */ + private fun parseFrameExclude(): FrameExclude { + if (!match(TokenType.Exclude)) return FrameExclude.NoOthers + return when { + match(TokenType.Current) -> { + expect(TokenType.Row, "expected ROW after EXCLUDE CURRENT") + FrameExclude.CurrentRow + } + match(TokenType.Group) -> FrameExclude.Group + match(TokenType.Ties) -> FrameExclude.Ties + match(TokenType.No) -> { + expect(TokenType.Others, "expected OTHERS after EXCLUDE NO") + FrameExclude.NoOthers + } + else -> throw error("expected CURRENT ROW, GROUP, TIES, or NO OTHERS after EXCLUDE", peek()) + } + } + + private fun frameBoundRank(type: FrameBoundType): Int = when (type) { + FrameBoundType.UnboundedPreceding -> 0 + FrameBoundType.Preceding -> 1 + FrameBoundType.CurrentRow -> 2 + FrameBoundType.Following -> 3 + FrameBoundType.UnboundedFollowing -> 4 + } + + private fun parseFrameBound(): FrameBound { + if (match(TokenType.Unbounded)) { + return when { + match(TokenType.Preceding) -> FrameBound(FrameBoundType.UnboundedPreceding) + match(TokenType.Following) -> FrameBound(FrameBoundType.UnboundedFollowing) + else -> throw error("expected PRECEDING or FOLLOWING after UNBOUNDED", peek()) + } + } + if (match(TokenType.Current)) { + expect(TokenType.Row, "expected ROW after CURRENT") + return FrameBound(FrameBoundType.CurrentRow) + } + val offset = parseExpression() + return when { + match(TokenType.Preceding) -> FrameBound(FrameBoundType.Preceding, offset) + match(TokenType.Following) -> FrameBound(FrameBoundType.Following, offset) + else -> throw error("expected PRECEDING or FOLLOWING", peek()) + } } private fun peek(): Token = tokens[position]