diff --git a/src/test/kotlin/kestrel/EvaluatorTest.kt b/src/test/kotlin/kestrel/EvaluatorTest.kt index 78f1df8..0702abc 100644 --- a/src/test/kotlin/kestrel/EvaluatorTest.kt +++ b/src/test/kotlin/kestrel/EvaluatorTest.kt @@ -462,4 +462,102 @@ class EvaluatorTest { private fun cast(operand: Expression, type: ColumnType, precision: Int? = null, scale: Int? = null): Expression = CastExpression(operand, type, precision, scale) + + @Test + fun castTextToIntegerRealAndBoolean() { + assertEquals(Value.IntegerValue(42), evalConstant(cast(textLit("42"), ColumnType.Integer))) + assertEquals(Value.RealValue(3.5), evalConstant(cast(textLit("3.5"), ColumnType.Real))) + assertEquals(Value.BooleanValue(true), evalConstant(cast(textLit("true"), ColumnType.Boolean))) + assertEquals(Value.BooleanValue(false), evalConstant(cast(textLit("FALSE"), ColumnType.Boolean))) + } + + @Test + fun castTextToDecimalAppliesScale() { + val result = evalConstant(cast(textLit("19.999"), ColumnType.Decimal, 10, 2)) as Value.DecimalValue + assertEquals("20.00", result.value.toPlainString()) + } + + @Test + fun castTextToDateParsesIsoFormat() { + val result = evalConstant(cast(textLit("2024-01-15"), ColumnType.Date)) + assertEquals(Value.DateValue(LocalDate.of(2024, 1, 15)), result) + } + + @Test + fun castNumbersAndDatesToText() { + assertEquals(Value.TextValue("42"), evalConstant(cast(intLit(42), ColumnType.Text))) + assertEquals( + Value.TextValue("2024-01-15"), + evalConstant(cast(LiteralExpression(Value.DateValue(LocalDate.of(2024, 1, 15))), ColumnType.Text)) + ) + } + + @Test + fun castOfNullIsAlwaysNull() { + assertEquals(Value.NullValue, evalConstant(cast(nullLit, ColumnType.Integer))) + assertEquals(Value.NullValue, evalConstant(cast(nullLit, ColumnType.Decimal))) + } + + @Test + fun castRejectsUnparseableText() { + assertFailsWith { evalConstant(cast(textLit("not a number"), ColumnType.Integer)) } + assertFailsWith { evalConstant(cast(textLit("not a date"), ColumnType.Date)) } + assertFailsWith { evalConstant(cast(textLit("nope"), ColumnType.Boolean)) } + } + + @Test + fun decimalOverflowBeyondDeclaredPrecisionFails() { + assertFailsWith { evalConstant(cast(textLit("123456"), ColumnType.Decimal, 5, 0)) } + } + + @Test + fun decimalArithmeticIsExactUnlikeDoubles() { + val a = cast(textLit("0.1"), ColumnType.Decimal, 10, 2) + val b = cast(textLit("0.2"), ColumnType.Decimal, 10, 2) + val sum = evalConstant(bin(BinaryOperator.Add, a, b)) as Value.DecimalValue + assertEquals("0.30", sum.value.toPlainString()) + } + + @Test + fun decimalEqualityIgnoresTrailingZeroScale() { + val fifteenTenths = LiteralExpression(Value.DecimalValue(BigDecimal("1.50"))) + val fifteen = LiteralExpression(Value.DecimalValue(BigDecimal("1.5"))) + assertEquals(Value.BooleanValue(true), evalConstant(bin(BinaryOperator.Equal, fifteenTenths, fifteen))) + } + + @Test + fun decimalDivisionByZeroFails() { + val a = cast(textLit("1"), ColumnType.Decimal, 10, 2) + val zero = cast(textLit("0"), ColumnType.Decimal, 10, 2) + assertFailsWith { evalConstant(bin(BinaryOperator.Divide, a, zero)) } + } + + @Test + fun decimalComparesCorrectlyAgainstIntegerAndReal() { + val decimal = LiteralExpression(Value.DecimalValue(BigDecimal("19.99"))) + assertEquals(Value.BooleanValue(true), evalConstant(bin(BinaryOperator.Less, decimal, intLit(20)))) + assertEquals(Value.BooleanValue(true), evalConstant(bin(BinaryOperator.Greater, decimal, realLit(19.98)))) + } + + private fun dateLit(value: LocalDate): Expression = LiteralExpression(Value.DateValue(value)) + + @Test + fun addingAndSubtractingIntegerDaysFromADate() { + val start = dateLit(LocalDate.of(2024, 1, 1)) + assertEquals(Value.DateValue(LocalDate.of(2024, 1, 6)), evalConstant(bin(BinaryOperator.Add, start, intLit(5)))) + assertEquals(Value.DateValue(LocalDate.of(2023, 12, 27)), evalConstant(bin(BinaryOperator.Subtract, start, intLit(5)))) + } + + @Test + fun subtractingTwoDatesYieldsIntegerDayDifference() { + val later = dateLit(LocalDate.of(2024, 1, 10)) + val earlier = dateLit(LocalDate.of(2024, 1, 1)) + assertEquals(Value.IntegerValue(9), evalConstant(bin(BinaryOperator.Subtract, later, earlier))) + } + + @Test + fun multiplyingADateFails() { + val start = dateLit(LocalDate.of(2024, 1, 1)) + assertFailsWith { evalConstant(bin(BinaryOperator.Multiply, start, intLit(2))) } + } } diff --git a/src/test/kotlin/kestrel/ExecutorTest.kt b/src/test/kotlin/kestrel/ExecutorTest.kt index a0713d5..17fa8ba 100644 --- a/src/test/kotlin/kestrel/ExecutorTest.kt +++ b/src/test/kotlin/kestrel/ExecutorTest.kt @@ -77,6 +77,72 @@ class ExecutorTest { assertEquals(listOf(text("grace")), result.rows[0]) } + @Test + fun offsetSkipsLeadingRowsWithoutALimit() { + seedUsers() + val result = select("SELECT name FROM users ORDER BY age ASC OFFSET 1") + assertEquals(listOf(listOf(text("linus")), listOf(text("grace"))), result.rows) + } + + @Test + fun limitAndOffsetTogetherPageThroughTheOrderedResult() { + seedUsers() + val result = select("SELECT name FROM users ORDER BY age ASC LIMIT 1 OFFSET 1") + assertEquals(listOf(listOf(text("linus"))), result.rows) + } + + @Test + fun offsetPastTheEndOfTheResultReturnsNoRows() { + seedUsers() + val result = select("SELECT name FROM users ORDER BY age ASC OFFSET 100") + assertEquals(emptyList>(), result.rows) + } + + @Test + fun offsetWrittenBeforeLimitStillAppliesLimitAfterTheSkip() { + seedUsers() + val result = select("SELECT name FROM users ORDER BY age ASC OFFSET 1 LIMIT 1") + assertEquals(listOf(listOf(text("linus"))), result.rows) + } + + @Test + fun orderByMultipleColumnsBreaksTiesWithTheSecondKey() { + run("CREATE TABLE staff (id INTEGER PRIMARY KEY, dept TEXT NOT NULL, name TEXT NOT NULL)") + run( + "INSERT INTO staff VALUES " + + "(1, 'eng', 'guido'), (2, 'eng', 'ada'), (3, 'ops', 'grace'), (4, 'eng', 'linus')" + ) + val result = select("SELECT dept, name FROM staff ORDER BY dept ASC, name ASC") + assertEquals( + listOf( + listOf(text("eng"), text("ada")), + listOf(text("eng"), text("guido")), + listOf(text("eng"), text("linus")), + listOf(text("ops"), text("grace")) + ), + result.rows + ) + } + + @Test + fun orderByMultipleColumnsCanMixAscendingAndDescending() { + run("CREATE TABLE staff (id INTEGER PRIMARY KEY, dept TEXT NOT NULL, name TEXT NOT NULL)") + run( + "INSERT INTO staff VALUES " + + "(1, 'eng', 'guido'), (2, 'eng', 'ada'), (3, 'ops', 'grace'), (4, 'eng', 'linus')" + ) + val result = select("SELECT dept, name FROM staff ORDER BY dept ASC, name DESC") + assertEquals( + listOf( + listOf(text("eng"), text("linus")), + listOf(text("eng"), text("guido")), + listOf(text("eng"), text("ada")), + listOf(text("ops"), text("grace")) + ), + result.rows + ) + } + @Test fun projectionSupportsExpressionsAndAliases() { seedUsers() @@ -197,4 +263,22 @@ class ExecutorTest { val result = select("SELECT name FROM users WHERE age > 40 AND age < 80") assertEquals(listOf(listOf(text("linus"))), result.rows) } + + @Test + fun temporaryTableSupportsFullCrudJustLikeARegularTable() { + run("CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, note TEXT NOT NULL)") + run("INSERT INTO scratch VALUES (1, 'first'), (2, 'second')") + run("UPDATE scratch SET note = 'updated' WHERE id = 1") + run("DELETE FROM scratch WHERE id = 2") + val result = select("SELECT id, note FROM scratch") + assertEquals(listOf(listOf(int(1), text("updated"))), result.rows) + } + + @Test + fun temporaryTableEnforcesItsOwnPrimaryKeyAndCanBeIndexed() { + run("CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, tag TEXT)") + run("CREATE INDEX scratchTag ON scratch (tag)") + run("INSERT INTO scratch VALUES (1, 'a')") + assertFailsWith { run("INSERT INTO scratch VALUES (1, 'b')") } + } }