diff --git a/src/main/kotlin/kestrel/sql/parser/Parser.kt b/src/main/kotlin/kestrel/sql/parser/Parser.kt index 986f4ec..a93a68a 100644 --- a/src/main/kotlin/kestrel/sql/parser/Parser.kt +++ b/src/main/kotlin/kestrel/sql/parser/Parser.kt @@ -953,9 +953,59 @@ class Parser(private val tokens: List) { return if (keys.isEmpty()) null else OrderBy(keys) } - private fun parseLimit(): Int? { - if (!match(TokenType.Limit)) return null - return expect(TokenType.IntLiteral, "expected an integer after LIMIT").intValue!!.toInt() + /** Shared by top-level ORDER BY and a window function's `OVER (... ORDER BY ...)` clause. */ + private fun parseOrderKeys(): List { + if (!match(TokenType.Order)) return emptyList() + expect(TokenType.By, "expected BY after ORDER") + val keys = mutableListOf() + do { + val expression = parseExpression() + val descending = when { + match(TokenType.Desc) -> true + match(TokenType.Asc) -> false + else -> false + } + val nullsFirst = parseOptionalNullsClause() + keys.add(OrderKey(expression, descending, nullsFirst)) + } while (match(TokenType.Comma)) + return keys + } + + /** + * `NULLS FIRST` / `NULLS LAST`, optional after each ORDER BY key's own `ASC`/`DESC`. Like + * `ROLLUP`/`CUBE`/`FILTER`/`GENERATED` elsewhere in this parser, `NULLS`/`FIRST`/`LAST` are not + * reserved keywords - a column legitimately named any of the three is unaffected outside this + * exact position, since [isContextualKeyword] only fires here, right after an order key. + */ + private fun parseOptionalNullsClause(): Boolean? { + if (!isContextualKeyword("NULLS")) return null + advance() + return when { + isContextualKeyword("FIRST") -> { advance(); true } + isContextualKeyword("LAST") -> { advance(); false } + else -> throw error("expected FIRST or LAST after NULLS but found '${describe(peek())}'", peek()) + } + } + + /** + * `LIMIT`/`OFFSET` may appear in either order (matching Postgres, unlike the standard's + * `OFFSET ... FETCH ...`), but not repeated - a second `LIMIT` or `OFFSET` stops the loop and is + * left for whatever follows (typically a parse error from the caller, e.g. a stray token before + * `;`) rather than silently overwriting the first. + */ + private fun parseLimitOffset(): Pair { + var limit: Int? = null + var offset: Int? = null + while (true) { + if (limit == null && match(TokenType.Limit)) { + limit = expect(TokenType.IntLiteral, "expected an integer after LIMIT").intValue!!.toInt() + } else if (offset == null && match(TokenType.Offset)) { + offset = expect(TokenType.IntLiteral, "expected an integer after OFFSET").intValue!!.toInt() + } else { + break + } + } + return limit to offset } private fun parseDelete(): DeleteStatement { @@ -963,29 +1013,119 @@ class Parser(private val tokens: List) { expect(TokenType.From, "expected FROM after DELETE") val tableName = expectIdentifier("expected table name") val where = if (match(TokenType.Where)) parseExpression() else null - return DeleteStatement(tableName, where) + val returning = parseReturning() + return DeleteStatement(tableName, where, returning) } private fun parseUpdate(): UpdateStatement { expect(TokenType.Update, "expected UPDATE") val tableName = expectIdentifier("expected table name") expect(TokenType.Set, "expected SET after table name") + val assignments = parseAssignmentList() + val where = if (match(TokenType.Where)) parseExpression() else null + val returning = parseReturning() + return UpdateStatement(tableName, assignments, where, returning) + } + + private fun parseAssignmentList(): List { val assignments = mutableListOf() do { val columnName = expectIdentifier("expected column name") expect(TokenType.Equal, "expected '=' in assignment") - assignments.add(Assignment(columnName, parseExpression())) + val value = if (match(TokenType.Default)) null else parseExpression() + assignments.add(Assignment(columnName, value)) } while (match(TokenType.Comma)) - val where = if (match(TokenType.Where)) parseExpression() else null - return UpdateStatement(tableName, assignments, where) + return assignments + } + + /** + * `MERGE INTO target [[AS] alias] USING source ON condition (WHEN ...)+`. [parseFromItem] is + * reused verbatim for the `USING` source: it is exactly a `FROM`-shaped item (bare table name + * or a parenthesized, aliased derived-table subquery), not a MERGE-specific grammar production. + */ + private fun parseMerge(): MergeStatement { + expect(TokenType.Merge, "expected MERGE") + expect(TokenType.Into, "expected INTO after MERGE") + val targetTable = expectIdentifier("expected target table name") + val targetAlias = parseOptionalAlias() + expect(TokenType.Using, "expected USING after MERGE target") + val source = parseFromItem() + expect(TokenType.On, "expected ON after MERGE USING source") + val onCondition = parseExpression() + val whenClauses = mutableListOf() + while (match(TokenType.When)) { + whenClauses.add(parseMergeWhenClause()) + } + if (whenClauses.isEmpty()) { + throw error("MERGE requires at least one WHEN clause", peek()) + } + val returning = parseReturning() + return MergeStatement(targetTable, targetAlias, source, onCondition, whenClauses, returning) + } + + private fun parseOptionalAlias(): String? = when { + match(TokenType.As) -> expectIdentifier("expected alias after AS") + check(TokenType.Identifier) -> advance().lexeme + else -> null + } + + /** + * `WHEN [NOT] MATCHED [AND condition] THEN action` - the `AND condition` sits between the + * MATCHED/NOT MATCHED keyword and THEN in both categories, matching Postgres. A NOT MATCHED + * action is always INSERT (there is no target row for NOT MATCHED to UPDATE or DELETE); + * [MergeNotMatchedInsert.values] uses the same "bare DEFAULT keyword or expression" per-slot + * grammar as plain INSERT's own VALUES list ([parseInsert]). + */ + private fun parseMergeWhenClause(): MergeWhenClause { + val matched = when { + match(TokenType.Matched) -> true + match(TokenType.Not) -> { + expect(TokenType.Matched, "expected MATCHED after NOT") + false + } + else -> throw error("expected MATCHED or NOT MATCHED after WHEN but found '${describe(peek())}'", peek()) + } + val condition = if (match(TokenType.And)) parseExpression() else null + expect(TokenType.Then, "expected THEN after WHEN ${if (matched) "MATCHED" else "NOT MATCHED"}") + if (matched) { + return when { + match(TokenType.Update) -> { + expect(TokenType.Set, "expected SET after WHEN MATCHED THEN UPDATE") + MergeMatchedUpdate(condition, parseAssignmentList()) + } + match(TokenType.Delete) -> MergeMatchedDelete(condition) + else -> throw error("expected UPDATE or DELETE after WHEN MATCHED THEN but found '${describe(peek())}'", peek()) + } + } + expect(TokenType.Insert, "expected INSERT after WHEN NOT MATCHED THEN") + 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.Values, "expected VALUES after WHEN NOT MATCHED THEN INSERT") + expect(TokenType.LeftParen, "expected '(' before value list") + val values = mutableListOf() + do { + values.add(if (match(TokenType.Default)) null else parseExpression()) + } while (match(TokenType.Comma)) + expect(TokenType.RightParen, "expected ')' after value list") + return MergeNotMatchedInsert(condition, columns, values) } private fun parseDrop(): Statement { expect(TokenType.Drop, "expected DROP") return when { - match(TokenType.Table) -> DropTableStatement(expectIdentifier("expected table name")) + match(TokenType.Table) -> DropTableStatement(expectIdentifier("expected table name"), parseDropMode()) match(TokenType.Index) -> DropIndexStatement(expectIdentifier("expected index name")) - else -> throw error("expected TABLE or INDEX after DROP but found '${describe(peek())}'", peek()) + match(TokenType.View) -> DropViewStatement(expectIdentifier("expected view name"), parseDropMode()) + match(TokenType.Trigger) -> DropTriggerStatement(expectIdentifier("expected trigger name")) + match(TokenType.Sequence) -> DropSequenceStatement(expectIdentifier("expected sequence name")) + else -> throw error("expected TABLE, INDEX, VIEW, TRIGGER, or SEQUENCE after DROP but found '${describe(peek())}'", peek()) } }