diff --git a/src/test/kotlin/kestrel/WindowFunctionTest.kt b/src/test/kotlin/kestrel/WindowFunctionTest.kt index 2a9c763..0640e99 100644 --- a/src/test/kotlin/kestrel/WindowFunctionTest.kt +++ b/src/test/kotlin/kestrel/WindowFunctionTest.kt @@ -618,3 +618,164 @@ class WindowFunctionTest { assertEquals(listOf(3L, 3L, 4L, 2L), result.rows.map { (it[1] as Value.IntegerValue).value }) } + @Test + fun rangeFrameMeasuresDistanceInDaysOnADateColumn() { + run("CREATE TABLE events (id INTEGER PRIMARY KEY, day DATE)") + run( + "INSERT INTO events VALUES " + + "(1, CAST('2024-01-01' AS DATE)), (2, CAST('2024-01-02' AS DATE)), " + + "(3, CAST('2024-01-05' AS DATE)), (4, CAST('2024-01-06' AS DATE)), (5, CAST('2024-01-10' AS DATE))" + ) + val result = select( + "SELECT id, COUNT(*) OVER (ORDER BY day RANGE BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS c " + + "FROM events ORDER BY id ASC" + ) + assertEquals(listOf(2L, 2L, 2L, 2L, 1L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun rangeFrameEntirelyPastThePartitionEdgeAggregatesToNullAndCountsZero() { + seedSales() + val result = select( + "SELECT id, SUM(amount) OVER (ORDER BY amount RANGE BETWEEN 100 FOLLOWING AND 200 FOLLOWING) AS s, " + + "COUNT(*) OVER (ORDER BY amount RANGE BETWEEN 100 FOLLOWING AND 200 FOLLOWING) AS c " + + "FROM sales ORDER BY id ASC" + ) + assertEquals( + (1L..5L).map { listOf(int(it), Value.NullValue, int(0)) }, + result.rows + ) + } + + @Test + fun rangeFrameWithAPrecedingBoundRequiresExactlyOneOrderByColumn() { + seedSales() + val error = assertFailsWith { + run( + "SELECT SUM(amount) OVER (ORDER BY amount, id RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWING) " + + "FROM sales" + ) + } + assertTrue(error.message!!.contains("exactly one ORDER BY column"), error.message!!) + } + + @Test + fun rangeFrameWithAPrecedingBoundRequiresANumericOrDateOrderColumn() { + seed() + val error = assertFailsWith { + run("SELECT SUM(salary) OVER (ORDER BY name RANGE BETWEEN 1 PRECEDING AND CURRENT ROW) FROM employees") + } + assertTrue(error.message!!.contains("numeric or DATE"), error.message!!) + } + + @Test + fun rangeFrameOffsetMustBeNumeric() { + seedSales() + val error = assertFailsWith { + run("SELECT SUM(amount) OVER (ORDER BY amount RANGE BETWEEN 'x' PRECEDING AND CURRENT ROW) FROM sales") + } + assertTrue(error.message!!.contains("numeric"), error.message!!) + } + + @Test + fun rangeFrameOffsetMustNotBeNegative() { + seedSales() + val error = assertFailsWith { + run("SELECT SUM(amount) OVER (ORDER BY amount RANGE BETWEEN -1 PRECEDING AND CURRENT ROW) FROM sales") + } + assertTrue(error.message!!.contains("negative"), error.message!!) + } + + @Test + fun rangeFrameSurvivesViewDefinitionRoundTrip() { + seedSales() + run( + "CREATE VIEW valueTotals AS SELECT id, " + + "SUM(amount) OVER (ORDER BY amount RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWING) AS s " + + "FROM sales" + ) + val result = select("SELECT id, s FROM valueTotals ORDER BY id ASC") + assertEquals(listOf(50L, 80L, 80L, 70L, 50L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun excludeCurrentRowRemovesOnlyTheCurrentRowFromTheWholePartitionFrame() { + seedSales() + val result = select( + "SELECT id, SUM(amount) OVER (" + + "ORDER BY amount ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW" + + ") AS s FROM sales ORDER BY id ASC" + ) + assertEquals(listOf(120L, 110L, 110L, 100L, 80L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun excludeGroupRemovesTheWholePeerGroupIncludingTheCurrentRow() { + seedSales() + val result = select( + "SELECT id, SUM(amount) OVER (" + + "ORDER BY amount RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP" + + ") AS s FROM sales ORDER BY id ASC" + ) + assertEquals(listOf(120L, 90L, 90L, 100L, 80L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun excludeTiesRemovesOnlyTheOtherTiedRowsAndKeepsTheCurrentRow() { + seedSales() + val result = select( + "SELECT id, SUM(amount) OVER (" + + "ORDER BY amount RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE TIES" + + ") AS s FROM sales ORDER BY id ASC" + ) + assertEquals(listOf(130L, 110L, 110L, 130L, 130L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun excludeCurrentRowInteractsWithABoundedFrameInsteadOfOnlyTheWholePartition() { + seedSales() + val result = select( + "SELECT id, SUM(amount) OVER (" + + "ORDER BY amount ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW" + + ") AS s FROM sales ORDER BY id ASC" + ) + assertEquals(listOf(20L, 30L, 50L, 70L, 30L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun excludeNoOthersExplicitlyWrittenOutBehavesLikeNoExcludeClauseAtAll() { + seedSales() + val explicit = select( + "SELECT id, SUM(amount) OVER (" + + "ORDER BY amount ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE NO OTHERS" + + ") AS s FROM sales ORDER BY id ASC" + ) + val implicit = select( + "SELECT id, SUM(amount) OVER (ORDER BY amount ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS s " + + "FROM sales ORDER BY id ASC" + ) + assertEquals(implicit.rows, explicit.rows) + } + + @Test + fun excludeGroupWithNoOrderByTreatsTheWholePartitionAsOnePeerGroup() { + seedSales() + val result = select( + "SELECT id, COUNT(*) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) AS c " + + "FROM sales ORDER BY id ASC" + ) + assertEquals(listOf(0L, 0L, 0L, 0L, 0L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } + + @Test + fun excludeClauseSurvivesViewDefinitionRoundTrip() { + seedSales() + run( + "CREATE VIEW excludingSelf AS SELECT id, " + + "SUM(amount) OVER (ORDER BY amount ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) AS s " + + "FROM sales" + ) + val result = select("SELECT id, s FROM excludingSelf ORDER BY id ASC") + assertEquals(listOf(120L, 110L, 110L, 100L, 80L), result.rows.map { (it[1] as Value.IntegerValue).value }) + } +