From 40197f5dc9b26082183d863c061160ec53a9135b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 16:39:47 +0200 Subject: [PATCH] test: cover mergeWithNoWhenClauseFailsToParse --- src/test/kotlin/kestrel/ParserTest.kt | 173 ++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/test/kotlin/kestrel/ParserTest.kt b/src/test/kotlin/kestrel/ParserTest.kt index 2228199..0476269 100644 --- a/src/test/kotlin/kestrel/ParserTest.kt +++ b/src/test/kotlin/kestrel/ParserTest.kt @@ -938,4 +938,177 @@ class ParserTest { ) as MergeStatement assertEquals(3, statement.whenClauses.size) assertTrue(statement.whenClauses[0] is MergeMatchedDelete) + assertTrue(statement.whenClauses[1] is MergeMatchedUpdate) + assertTrue(statement.whenClauses[2] is MergeNotMatchedInsert) + } + + @Test + fun mergeWithNoWhenClauseFailsToParse() { + assertFailsWith { parseOne("MERGE INTO t USING s ON t.id = s.id") } + } + + @Test + fun mergeWhenMatchedRequiresUpdateOrDelete() { + assertFailsWith { parseOne("MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN INSERT (id) VALUES (s.id)") } + } + + @Test + fun mergeWhenNotMatchedOnlySupportsInsert() { + assertFailsWith { parseOne("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN DELETE") } + } + + @Test + fun mergeWithoutReturningLeavesItNull() { + val statement = parseOne("MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE") as MergeStatement + assertNull(statement.returning) + } + + @Test + fun mergeReturningStarParsesAsStarProjectionAfterTheLastWhenClause() { + val statement = parseOne( + "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE RETURNING *" + ) as MergeStatement + assertEquals(StarProjection, statement.returning) + } + + @Test + fun mergeReturningExpressionListParsesLikeASelectProjection() { + val statement = parseOne( + "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE RETURNING id, MERGE_ACTION() AS kind" + ) as MergeStatement + val projection = statement.returning as ExpressionProjection + assertEquals(2, projection.items.size) + assertEquals("id", (projection.items[0].expression as ColumnExpression).name) + assertNull(projection.items[0].alias) + assertEquals(MergeActionExpression, projection.items[1].expression) + assertEquals("kind", projection.items[1].alias) + } + + @Test + fun mergeActionParsesAsItsOwnExpressionNode() { + val select = parseOne("SELECT MERGE_ACTION() FROM t") as SelectStatement + val items = (select.projection as ExpressionProjection).items + assertEquals(MergeActionExpression, items.single().expression) + } + + @Test + fun mergeActionTakesNoArguments() { + assertFailsWith { parse("SELECT MERGE_ACTION(1)") } + assertFailsWith { parse("SELECT MERGE_ACTION('x')") } + } + + @Test + fun parsesDecimalColumnWithAndWithoutPrecisionAndScale() { + val statement = parseOne( + "CREATE TABLE t (bare DECIMAL, scaled DECIMAL(10), precise DECIMAL(10, 2))" + ) as CreateTableStatement + assertEquals(ColumnType.Decimal, statement.columns[0].type) + assertNull(statement.columns[0].precision) + assertNull(statement.columns[0].scale) + assertEquals(10, statement.columns[1].precision) + assertEquals(0, statement.columns[1].scale) + assertEquals(10, statement.columns[2].precision) + assertEquals(2, statement.columns[2].scale) + } + + @Test + fun parsesDateColumn() { + val statement = parseOne("CREATE TABLE t (born DATE)") as CreateTableStatement + assertEquals(ColumnType.Date, statement.columns[0].type) + } + + @Test + fun parsesCastExpressionWithAndWithoutPrecisionAndScale() { + val plain = firstProjection("SELECT CAST(a AS INTEGER) FROM t") as CastExpression + assertEquals(ColumnType.Integer, plain.targetType) + assertNull(plain.precision) + + val decimal = firstProjection("SELECT CAST(a AS DECIMAL(10, 2)) FROM t") as CastExpression + assertEquals(ColumnType.Decimal, decimal.targetType) + assertEquals(10, decimal.precision) + assertEquals(2, decimal.scale) + } + + @Test + fun parsesCollateExpressionWithEachBuiltInSequenceCaseInsensitively() { + val nocase = firstProjection("SELECT name COLLATE NOCASE FROM t") as CollateExpression + assertEquals(Collation.NoCase, nocase.collation) + assertEquals(ColumnExpression("name"), nocase.operand) + + val rtrim = firstProjection("SELECT name COLLATE rtrim FROM t") as CollateExpression + assertEquals(Collation.Rtrim, rtrim.collation) + + val binary = firstProjection("SELECT name COLLATE BINARY FROM t") as CollateExpression + assertEquals(Collation.Binary, binary.collation) + } + + @Test + fun collateWithAnUnknownSequenceNameFailsToParse() { + assertFailsWith { parse("SELECT name COLLATE BOGUS FROM t") } + } + + @Test + fun collateWithoutAFollowingNameFailsToParse() { + assertFailsWith { parse("SELECT name COLLATE FROM t") } + } + + @Test + fun collateBindsTighterThanComparisonSoItWrapsOnlyTheColumnItFollows() { + val where = whereOf("SELECT 1 FROM t WHERE name COLLATE NOCASE = 'bob'") + val comparison = where as BinaryExpression + assertEquals(BinaryOperator.Equal, comparison.operator) + val left = comparison.left as CollateExpression + assertEquals(Collation.NoCase, left.collation) + assertEquals(ColumnExpression("name"), left.operand) + assertEquals(LiteralExpression(Value.TextValue("bob")), comparison.right) + } + + @Test + fun collateBindsTighterThanMultiplicationSoItWrapsOnlyItsOwnOperand() { + val projection = firstProjection("SELECT a * b COLLATE NOCASE FROM t") as BinaryExpression + assertEquals(BinaryOperator.Multiply, projection.operator) + assertEquals(ColumnExpression("a"), projection.left) + val right = projection.right as CollateExpression + assertEquals(Collation.NoCase, right.collation) + assertEquals(ColumnExpression("b"), right.operand) + } + + @Test + fun collateBindsLooserThanUnaryMinusSoItWrapsTheWholeNegation() { + val projection = firstProjection("SELECT -a COLLATE NOCASE FROM t") as CollateExpression + assertEquals(Collation.NoCase, projection.collation) + val negated = projection.operand as UnaryExpression + assertEquals(UnaryOperator.Negate, negated.operator) + assertEquals(ColumnExpression("a"), negated.operand) + } + + @Test + fun chainedCollateWrapsEachPreviousCollateExpression() { + val projection = firstProjection("SELECT a COLLATE BINARY COLLATE NOCASE FROM t") as CollateExpression + assertEquals(Collation.NoCase, projection.collation) + val inner = projection.operand as CollateExpression + assertEquals(Collation.Binary, inner.collation) + assertEquals(ColumnExpression("a"), inner.operand) + } + + @Test + fun parsesColumnLevelCollateInCreateTable() { + val statement = parseOne("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, other TEXT)") as CreateTableStatement + assertEquals(Collation.NoCase, statement.columns[1].collation) + assertEquals(Collation.Binary, statement.columns[0].collation) + assertEquals(Collation.Binary, statement.columns[2].collation) + } + + /** + * COLLATE before DEFAULT is unambiguous: the column-definition loop consumes it as its own + * clause before ever calling [Parser.parseExpression] for DEFAULT's value. The other order + * (`DEFAULT 'x' COLLATE NOCASE`) is deliberately not covered here - `parseExpression` itself + * accepts a trailing `COLLATE` (see `parseCollate`), so it greedily becomes part of the DEFAULT + * expression instead (`DEFAULT ('x' COLLATE NOCASE)`), not a separate column-level clause; not a + * bug, just the same expression-grammar greediness this column-level COLLATE reuses. + */ + @Test + fun columnLevelCollateBeforeDefaultAttachesToTheColumnNotTheDefaultExpression() { + val statement = parseOne("CREATE TABLE t (name TEXT COLLATE NOCASE DEFAULT 'x')") as CreateTableStatement + assertEquals(Collation.NoCase, statement.columns[0].collation) }