|
| 1 | +using System.Data.Common; |
| 2 | +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; |
| 3 | + |
| 4 | +namespace SqlServerSimulator; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// Behavioral tests for window functions — currently only |
| 8 | +/// <c>ROW_NUMBER() OVER(PARTITION BY ... ORDER BY ...)</c>, the single |
| 9 | +/// shape EF Core 10 emits for <c>Take</c>-per-group / <c>Skip+Take</c>-per-group |
| 10 | +/// patterns. The function's value depends on the entire row stream |
| 11 | +/// (sorted within partition), so the executor buffers post-WHERE tuples |
| 12 | +/// before binding per-row results. |
| 13 | +/// </summary> |
| 14 | +[TestClass] |
| 15 | +public sealed class WindowFunctionTests |
| 16 | +{ |
| 17 | + private static DbConnection SeededPosts() |
| 18 | + { |
| 19 | + var connection = new Simulation().CreateOpenConnection(); |
| 20 | + _ = connection.CreateCommand("create table posts (id int, blog_id int, score int)").ExecuteNonQuery(); |
| 21 | + _ = connection.CreateCommand( |
| 22 | + "insert into posts values (1, 1, 10), (2, 1, 30), (3, 1, 20), (4, 2, 5), (5, 2, 50), (6, 2, 5)").ExecuteNonQuery(); |
| 23 | + return connection; |
| 24 | + } |
| 25 | + |
| 26 | + private static List<(int Id, long Row)> ReadIdRow(DbCommand command) |
| 27 | + { |
| 28 | + using var reader = command.ExecuteReader(); |
| 29 | + var rows = new List<(int, long)>(); |
| 30 | + while (reader.Read()) |
| 31 | + rows.Add((reader.GetInt32(0), reader.GetInt64(1))); |
| 32 | + return rows; |
| 33 | + } |
| 34 | + |
| 35 | + [TestMethod] |
| 36 | + public void RowNumber_PartitionBy_AssignsPerPartitionSequence() |
| 37 | + { |
| 38 | + // Partition by blog_id, order by score asc. Each partition gets 1, 2, 3... |
| 39 | + using var connection = SeededPosts(); |
| 40 | + var rows = ReadIdRow(connection.CreateCommand( |
| 41 | + "select id, row_number() over(partition by blog_id order by score) as rn from posts")); |
| 42 | + // blog_id=1: id=1 (score=10) → 1, id=3 (score=20) → 2, id=2 (score=30) → 3 |
| 43 | + // blog_id=2: id=4 or id=6 (score=5, tie) → 1 / 2 in some order, id=5 (score=50) → 3 |
| 44 | + var byId = rows.ToDictionary(r => r.Id, r => r.Row); |
| 45 | + AreEqual(1L, byId[1]); |
| 46 | + AreEqual(3L, byId[2]); |
| 47 | + AreEqual(2L, byId[3]); |
| 48 | + AreEqual(3L, byId[5]); |
| 49 | + // The tied rows get 1 and 2 (in some stable order); both must appear. |
| 50 | + IsTrue((byId[4] == 1 && byId[6] == 2) || (byId[4] == 2 && byId[6] == 1)); |
| 51 | + } |
| 52 | + |
| 53 | + [TestMethod] |
| 54 | + public void RowNumber_OrderByDesc_AssignsLargestFirst() |
| 55 | + { |
| 56 | + using var connection = SeededPosts(); |
| 57 | + var rows = ReadIdRow(connection.CreateCommand( |
| 58 | + "select id, row_number() over(partition by blog_id order by score desc) as rn from posts")); |
| 59 | + var byId = rows.ToDictionary(r => r.Id, r => r.Row); |
| 60 | + // blog_id=1: id=2 (30) → 1, id=3 (20) → 2, id=1 (10) → 3 |
| 61 | + AreEqual(1L, byId[2]); |
| 62 | + AreEqual(2L, byId[3]); |
| 63 | + AreEqual(3L, byId[1]); |
| 64 | + // blog_id=2: id=5 (50) → 1, ties for 2/3 between id=4 and id=6 |
| 65 | + AreEqual(1L, byId[5]); |
| 66 | + } |
| 67 | + |
| 68 | + [TestMethod] |
| 69 | + public void RowNumber_NoPartitionBy_AssignsGlobalSequence() |
| 70 | + { |
| 71 | + using var connection = SeededPosts(); |
| 72 | + var rows = ReadIdRow(connection.CreateCommand( |
| 73 | + "select id, row_number() over(order by score) as rn from posts")); |
| 74 | + // 6 rows total; row numbers 1..6 (ties get adjacent numbers). |
| 75 | + var rowNumbers = rows.Select(r => r.Row).OrderBy(n => n).ToArray(); |
| 76 | + CollectionAssert.AreEqual(new long[] { 1, 2, 3, 4, 5, 6 }, rowNumbers); |
| 77 | + } |
| 78 | + |
| 79 | + [TestMethod] |
| 80 | + public void RowNumber_WrappedInDerivedTable_FilterByRow() |
| 81 | + { |
| 82 | + // The exact shape EF Core 10 emits for SelectMany.OrderBy.Take(2): |
| 83 | + // wrap ROW_NUMBER in a derived table, outer WHERE filters by the row column. |
| 84 | + using var connection = SeededPosts(); |
| 85 | + var ids = new List<int>(); |
| 86 | + using var reader = connection.CreateCommand( |
| 87 | + "select id from (select id, row_number() over(partition by blog_id order by score desc) as rn from posts) as p where rn <= 2 order by id").ExecuteReader(); |
| 88 | + while (reader.Read()) ids.Add(reader.GetInt32(0)); |
| 89 | + // blog_id=1 top-2 by score desc: id=2 (30), id=3 (20). blog_id=2: id=5 (50), then one of {id=4, id=6}. |
| 90 | + HasCount(4, ids); |
| 91 | + Contains(2, ids); |
| 92 | + Contains(3, ids); |
| 93 | + Contains(5, ids); |
| 94 | + IsTrue(ids.Contains(4) || ids.Contains(6)); |
| 95 | + } |
| 96 | + |
| 97 | + [TestMethod] |
| 98 | + public void RowNumber_WrappedInDerivedTable_SkipTakeRange() |
| 99 | + { |
| 100 | + // Skip+Take per group: WHERE 1 < rn AND rn <= 3 → ranks 2 and 3. |
| 101 | + using var connection = SeededPosts(); |
| 102 | + var ids = new List<int>(); |
| 103 | + using var reader = connection.CreateCommand( |
| 104 | + "select id from (select id, row_number() over(partition by blog_id order by score) as rn from posts) as p where 1 < rn and rn <= 3 order by id").ExecuteReader(); |
| 105 | + while (reader.Read()) ids.Add(reader.GetInt32(0)); |
| 106 | + // blog_id=1 ranks 2-3 (score asc): id=3 (20), id=2 (30). |
| 107 | + // blog_id=2 ranks 2-3 (score asc, ties on score=5): one of {4,6} at rank 2, id=5 (50) at rank 3. |
| 108 | + HasCount(4, ids); |
| 109 | + Contains(2, ids); |
| 110 | + Contains(3, ids); |
| 111 | + Contains(5, ids); |
| 112 | + } |
| 113 | + |
| 114 | + [TestMethod] |
| 115 | + public void RowNumber_PartitionByMultipleColumns() |
| 116 | + { |
| 117 | + using var connection = SeededPosts(); |
| 118 | + var rows = ReadIdRow(connection.CreateCommand( |
| 119 | + "select id, row_number() over(partition by blog_id, score order by id) as rn from posts")); |
| 120 | + // Tied (blog_id=2, score=5) rows partition by (2, 5): id=4 and id=6 each get their own sequence. |
| 121 | + // Order by id: id=4 → 1, id=6 → 2. |
| 122 | + var byId = rows.ToDictionary(r => r.Id, r => r.Row); |
| 123 | + AreEqual(1L, byId[4]); |
| 124 | + AreEqual(2L, byId[6]); |
| 125 | + // Other partitions are singleton: each gets row 1. |
| 126 | + AreEqual(1L, byId[1]); |
| 127 | + AreEqual(1L, byId[2]); |
| 128 | + AreEqual(1L, byId[3]); |
| 129 | + AreEqual(1L, byId[5]); |
| 130 | + } |
| 131 | + |
| 132 | + // === Parser rejections === |
| 133 | + |
| 134 | + [TestMethod] |
| 135 | + public void RowNumber_RequiresOver() |
| 136 | + { |
| 137 | + using var connection = SeededPosts(); |
| 138 | + _ = Throws<DbException>(() => |
| 139 | + _ = connection.CreateCommand("select row_number() from posts").ExecuteScalar()); |
| 140 | + } |
| 141 | + |
| 142 | + [TestMethod] |
| 143 | + public void RowNumber_RequiresOrderByInsideOver() |
| 144 | + { |
| 145 | + // ROW_NUMBER without ORDER BY in OVER is invalid SQL. |
| 146 | + using var connection = SeededPosts(); |
| 147 | + _ = Throws<DbException>(() => |
| 148 | + _ = connection.CreateCommand("select row_number() over() from posts").ExecuteScalar()); |
| 149 | + } |
| 150 | + |
| 151 | + [TestMethod] |
| 152 | + public void RowNumber_CombinedWithGroupBy_NotSupported() |
| 153 | + { |
| 154 | + // SQL Server allows this in real life, but EF Core 10 doesn't emit |
| 155 | + // the combination, so the simulator hasn't built it. |
| 156 | + using var connection = SeededPosts(); |
| 157 | + _ = Throws<NotSupportedException>(() => |
| 158 | + _ = connection.CreateCommand( |
| 159 | + "select blog_id, row_number() over(order by blog_id), count(*) from posts group by blog_id").ExecuteScalar()); |
| 160 | + } |
| 161 | +} |
0 commit comments