From b362403ac5dd18c097310acd9049a24d7e60eed7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 23:01:40 +0200 Subject: [PATCH] feat(parser): extend Parser with parseDistinctClause --- src/main/kotlin/kestrel/sql/parser/Parser.kt | 198 +++++++++++++++++-- 1 file changed, 180 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/kestrel/sql/parser/Parser.kt b/src/main/kotlin/kestrel/sql/parser/Parser.kt index 404a5a4..986f4ec 100644 --- a/src/main/kotlin/kestrel/sql/parser/Parser.kt +++ b/src/main/kotlin/kestrel/sql/parser/Parser.kt @@ -723,9 +723,76 @@ class Parser(private val tokens: List) { val where = if (match(TokenType.Where)) parseExpression() else null val groupBy = parseGroupBy() val having = if (match(TokenType.Having)) parseExpression() else null + return SelectStatement( + projection, from, joins, where, groupBy.groupBy, groupBy.spec, having, + distinct = distinct, distinctOn = distinctOn + ) + } + + /** `SELECT [ALL | DISTINCT | DISTINCT ON (expr, ...)] ...` - `ALL` (the default) is accepted and discarded, matching Postgres. */ + private fun parseDistinctClause(): Pair> { + if (match(TokenType.All)) return false to emptyList() + if (!match(TokenType.Distinct)) return false to emptyList() + if (!match(TokenType.On)) return true to emptyList() + expect(TokenType.LeftParen, "expected '(' after DISTINCT ON") + val expressions = mutableListOf() + do { + expressions.add(parseExpression()) + } while (match(TokenType.Comma)) + expect(TokenType.RightParen, "expected ')' after DISTINCT ON expressions") + return true to expressions + } + + private fun parseQueryStatement(): QueryStatement { + if (check(TokenType.With)) return parseWithQuery() + return parseCompoundQuery() + } + + private fun parseWithQuery(): WithQuery { + expect(TokenType.With, "expected WITH") + val recursive = match(TokenType.Recursive) + val ctes = mutableListOf() + do { + val name = expectIdentifier("expected common table expression name") + var columns: List? = null + if (match(TokenType.LeftParen)) { + val names = mutableListOf() + do { + names.add(expectIdentifier("expected column name")) + } while (match(TokenType.Comma)) + expect(TokenType.RightParen, "expected ')' after column list") + columns = names + } + expect(TokenType.As, "expected AS after common table expression name") + expect(TokenType.LeftParen, "expected '(' after AS") + val query = parseCompoundQuery() + expect(TokenType.RightParen, "expected ')' after common table expression") + ctes.add(CommonTableExpression(name, columns, query)) + } while (match(TokenType.Comma)) + val body = parseCompoundQuery() + return WithQuery(recursive, ctes, body) + } + + private fun parseCompoundQuery(): QueryStatement { + var left: QueryStatement = parseSelectCore() + while (true) { + val operator = when { + match(TokenType.Union) -> if (match(TokenType.All)) SetOperator.UnionAll else SetOperator.Union + match(TokenType.Intersect) -> if (match(TokenType.All)) SetOperator.IntersectAll else SetOperator.Intersect + match(TokenType.Except) -> if (match(TokenType.All)) SetOperator.ExceptAll else SetOperator.Except + else -> break + } + val right = parseSelectCore() + left = SetOperationStatement(operator, left, right) + } val orderBy = parseOrderBy() - val limit = parseLimit() - return SelectStatement(projection, tableName, tableAlias, joins, where, groupBy, having, orderBy, limit) + val (limit, offset) = parseLimitOffset() + if (orderBy == null && limit == null && offset == null) return left + return when (left) { + is SelectStatement -> left.copy(orderBy = orderBy, limit = limit, offset = offset) + is SetOperationStatement -> left.copy(orderBy = orderBy, limit = limit, offset = offset) + is WithQuery -> error("unreachable: parseSelectCore never produces a WITH query") + } } private fun parseProjection(): Projection { @@ -739,14 +806,27 @@ class Parser(private val tokens: List) { return ExpressionProjection(items) } - private fun parseTableRef(): Pair { + private fun parseFromItem(): FromItem { + if (match(TokenType.LeftParen)) { + if (!check(TokenType.Select) && !check(TokenType.With)) { + throw error("expected SELECT or WITH after '(' in FROM but found '${describe(peek())}'", peek()) + } + val select = parseQueryStatement() + expect(TokenType.RightParen, "expected ')' after derived table subquery") + val alias = when { + match(TokenType.As) -> expectIdentifier("expected alias after AS") + check(TokenType.Identifier) -> advance().lexeme + else -> throw error("a derived table in FROM requires an alias", peek()) + } + return SubquerySource(select, alias) + } val tableName = expectIdentifier("expected table name") val alias = when { match(TokenType.As) -> expectIdentifier("expected alias after AS") check(TokenType.Identifier) -> advance().lexeme else -> null } - return tableName to alias + return TableSource(tableName, alias) } private fun parseJoins(): List { @@ -759,36 +839,118 @@ class Parser(private val tokens: List) { match(TokenType.Left) -> { match(TokenType.Outer); expect(TokenType.Join, "expected JOIN after LEFT"); JoinType.Left } + match(TokenType.Right) -> { + match(TokenType.Outer); expect(TokenType.Join, "expected JOIN after RIGHT"); JoinType.Right + } + match(TokenType.Full) -> { + match(TokenType.Outer); expect(TokenType.Join, "expected JOIN after FULL"); JoinType.Full + } + match(TokenType.Cross) -> { + expect(TokenType.Join, "expected JOIN after CROSS"); JoinType.Cross + } match(TokenType.Join) -> JoinType.Inner else -> break } - val (tableName, alias) = parseTableRef() - expect(TokenType.On, "expected ON after joined table") - joins.add(JoinClause(type, tableName, alias, parseExpression())) + val lateral = match(TokenType.Lateral) + // A LATERAL right-hand side has no independent row set (it is re-evaluated per left + // row), so there is nothing to pad with NULLs for an unmatched *right* row - RIGHT/FULL + // JOIN LATERAL is therefore meaningless and rejected here rather than at execution time. + if (lateral && (type == JoinType.Right || type == JoinType.Full)) { + throw error("RIGHT/FULL JOIN LATERAL is not supported: LATERAL has no independent right-hand row set", peek()) + } + val from = parseFromItem() + if (lateral && from !is SubquerySource) { + throw error("LATERAL is only allowed before a derived table subquery", peek()) + } + val taggedFrom = if (lateral) (from as SubquerySource).copy(lateral = true) else from + // CROSS JOIN has no ON clause at all - matches every left/right pairing unconditionally. + val on = if (type == JoinType.Cross) { + if (check(TokenType.On)) throw error("CROSS JOIN does not take an ON condition", peek()) + null + } else { + expect(TokenType.On, "expected ON after joined table") + parseExpression() + } + joins.add(JoinClause(type, taggedFrom, on)) } return joins } - private fun parseGroupBy(): List { - if (!match(TokenType.Group)) return emptyList() + private data class GroupByClause(val groupBy: List, val spec: GroupingSpec?) + + /** + * `ROLLUP`/`CUBE`/`GROUPING SETS` are not reserved keywords (matching how most SQL engines treat + * them - only meaningful right after `GROUP BY`), so they are recognized here by peeking at an + * [TokenType.Identifier]'s text rather than via a dedicated token type; a bare column actually + * named e.g. `rollup` is unaffected since the lookahead also requires the following token to open + * the parenthesized argument list. Mixing plain columns with one of these in the same clause (SQL + * allows it, producing a cross product of sets) is a scope cut - exactly one form fills the whole + * clause. + */ + private fun parseGroupBy(): GroupByClause { + if (!match(TokenType.Group)) return GroupByClause(emptyList(), null) expect(TokenType.By, "expected BY after GROUP") + if (isContextualKeyword("ROLLUP") && peekNext().type == TokenType.LeftParen) { + advance() + val expressions = parseParenthesizedExpressionList("ROLLUP") + return GroupByClause(expressions, RollupSpec(expressions)) + } + if (isContextualKeyword("CUBE") && peekNext().type == TokenType.LeftParen) { + advance() + val expressions = parseParenthesizedExpressionList("CUBE") + return GroupByClause(expressions, CubeSpec(expressions)) + } + if (isContextualKeyword("GROUPING") && peekNext().let { it.type == TokenType.Identifier && it.lexeme.equals("SETS", ignoreCase = true) }) { + advance() + advance() + expect(TokenType.LeftParen, "expected '(' after GROUPING SETS") + val sets = mutableListOf>() + do { + sets.add(parseGroupingSetElement()) + } while (match(TokenType.Comma)) + expect(TokenType.RightParen, "expected ')' to close GROUPING SETS") + val union = sets.flatten().distinct() + return GroupByClause(union, GroupingSetsSpec(sets)) + } val expressions = mutableListOf() do { expressions.add(parseExpression()) } while (match(TokenType.Comma)) + return GroupByClause(expressions, null) + } + + private fun isContextualKeyword(name: String): Boolean = + check(TokenType.Identifier) && peek().lexeme.equals(name, ignoreCase = true) + + private fun peekNext(): Token = tokens.getOrElse(position + 1) { tokens.last() } + + private fun parseParenthesizedExpressionList(clauseName: String): List { + expect(TokenType.LeftParen, "expected '(' after $clauseName") + val expressions = mutableListOf() + if (!check(TokenType.RightParen)) { + do { + expressions.add(parseExpression()) + } while (match(TokenType.Comma)) + } + expect(TokenType.RightParen, "expected ')' to close $clauseName argument list") + return expressions + } + + /** One element of `GROUPING SETS (...)`: `()` (the grand-total set) or a parenthesized expression list. */ + private fun parseGroupingSetElement(): List { + expect(TokenType.LeftParen, "expected '(' to open a GROUPING SETS element") + if (match(TokenType.RightParen)) return emptyList() + val expressions = mutableListOf() + do { + expressions.add(parseExpression()) + } while (match(TokenType.Comma)) + expect(TokenType.RightParen, "expected ')' to close a GROUPING SETS element") return expressions } private fun parseOrderBy(): OrderBy? { - if (!match(TokenType.Order)) return null - expect(TokenType.By, "expected BY after ORDER") - val expression = parseExpression() - val descending = when { - match(TokenType.Desc) -> true - match(TokenType.Asc) -> false - else -> false - } - return OrderBy(expression, descending) + val keys = parseOrderKeys() + return if (keys.isEmpty()) null else OrderBy(keys) } private fun parseLimit(): Int? {