Skip to content

Commit 2d787b7

Browse files
committed
Basic support for TOP.
1 parent 1ece12c commit 2d787b7

6 files changed

Lines changed: 107 additions & 7 deletions

File tree

Example/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
// The simulation state is preserved across EF DbContexts.
2525
using (var context = new SimulatedContext(simulation))
2626
{
27-
var receivedValue = context.ExampleRecord.Select(x => x.Id).AsEnumerable();
27+
var receivedValue = context.ExampleRecord.Select(x => x.Id);
2828

2929
Console.Write(receivedValue.FirstOrDefault()); // Will write "1", as we stored earlier.
3030
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ using (var context = new SimulatedContext(simulation))
3131
// The simulation state is preserved across EF DbContexts.
3232
using (var context = new SimulatedContext(simulation))
3333
{
34-
var receivedValue = context.ExampleRecord.Select(x => x.Id).AsEnumerable();
34+
var receivedValue = context.ExampleRecord.Select(x => x.Id);
3535

3636
Console.Write(receivedValue.FirstOrDefault()); // Will write "1", as we stored earlier.
3737
}

SqlServerSimulator.Tests.EFCore/EFCoreBasics.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,50 @@ public void RoundTrip()
103103
Assert.AreEqual(storedValue, receivedValue.FirstOrDefault());
104104
}
105105
}
106+
107+
[TestMethod]
108+
public void SeparateInserts()
109+
{
110+
var simulation = CreateDefaultSimulation();
111+
int[] storedValues = [2, 3];
112+
113+
using (var context = new TestDbContext(simulation))
114+
{
115+
foreach (var value in storedValues)
116+
{
117+
var row = new TestRow { Id = value };
118+
_ = context.Rows.Add(row);
119+
_ = context.SaveChanges();
120+
}
121+
}
122+
123+
using (var context = new TestDbContext(simulation))
124+
{
125+
CollectionAssert.AreEquivalent(storedValues, context.Rows.Select(x => x.Id).ToArray());
126+
}
127+
}
128+
129+
[TestMethod]
130+
public void FirstOrDefault()
131+
{
132+
var simulation = CreateDefaultSimulation();
133+
int[] storedValues = [4, 5];
134+
135+
using (var context = new TestDbContext(simulation))
136+
{
137+
foreach (var value in storedValues)
138+
{
139+
var row = new TestRow { Id = value };
140+
_ = context.Rows.Add(row);
141+
_ = context.SaveChanges();
142+
}
143+
}
144+
145+
using (var context = new TestDbContext(simulation))
146+
{
147+
var receivedValue = context.Rows.Select(x => x.Id);
148+
// Without an OrderBy, we can't guarantee which of the two possibilities is returned.
149+
CollectionAssert.Contains(storedValues, receivedValue.FirstOrDefault());
150+
}
151+
}
106152
}

SqlServerSimulator.Tests/SelectTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,18 @@ public void DerivedTable(string commandText, string name, object value)
196196

197197
Assert.AreEqual(value, result);
198198
}
199+
200+
[TestMethod]
201+
public void TopParenthesizedConstantUnsorted()
202+
{
203+
CollectionAssert.AreEquivalent([1], [.. new Simulation()
204+
.ExecuteReader("select top (1) 1")
205+
.EnumerateRecords()
206+
.Select(reader => (int)reader[0])], EqualityComparer<int>.Default);
207+
208+
CollectionAssert.AreEquivalent([], [.. new Simulation()
209+
.ExecuteReader("select top (0) 1")
210+
.EnumerateRecords()
211+
.Select(reader => (int)reader[0])], EqualityComparer<int>.Default);
212+
}
199213
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,34 @@ public static Selection Parse(Simulation simulation, IEnumerator<Token> tokens,
1212
{
1313
token = tokens.RequireNext();
1414

15+
int? topCount = null;
16+
17+
if (token is ReservedKeyword { Keyword: Keyword.Top })
18+
{
19+
// SQL Server doesn't require outer parentheses.
20+
// When Expression.Parse supports them, the checks for them here should be removed.
21+
token = tokens.RequireNext<OpenParentheses>();
22+
token = tokens.RequireNext();
23+
24+
var resolvedExpression = Expression.Parse(simulation, tokens, ref token, getVariableValue).Run(name => throw SimulatedSqlException.ColumnReferenceNotAllowed(name));
25+
topCount = resolvedExpression is int unboxed ? unboxed : throw SimulatedSqlException.TopFetchRequiresInteger();
26+
27+
if (token is not null and not CloseParentheses)
28+
throw SimulatedSqlException.SyntaxErrorNear(token);
29+
30+
token = tokens.RequireNext();
31+
}
32+
1533
List<Expression> expressions = [];
1634

35+
IEnumerable<object?[]> ApplyClauses(IEnumerable<object?[]> records)
36+
{
37+
if (topCount is not null)
38+
records = records.Take(topCount.GetValueOrDefault());
39+
40+
return records;
41+
}
42+
1743
do
1844
{
1945
expressions.Add(Expression.Parse(simulation, tokens, ref token, getVariableValue));
@@ -32,7 +58,7 @@ public static Selection Parse(Simulation simulation, IEnumerator<Token> tokens,
3258
case null: // "Select" with no "From".
3359
return new(new(
3460
expressions,
35-
[[.. expressions.Select(x => x.Run(column => throw SimulatedSqlException.InvalidColumnName(column)))]]
61+
ApplyClauses([[.. expressions.Select(x => x.Run(column => throw SimulatedSqlException.InvalidColumnName(column)))]])
3662
));
3763

3864
case ReservedKeyword expectFrom:
@@ -60,11 +86,11 @@ public static Selection Parse(Simulation simulation, IEnumerator<Token> tokens,
6086

6187
return new(new(
6288
expressions,
63-
table.Rows.Select<object?[], object?[]>(row => [..expressions.Select(x => x.Run(columnName =>
89+
ApplyClauses(table.Rows.Select<object?[], object?[]>(row => [..expressions.Select(x => x.Run(columnName =>
6490
{
6591
var columnIndex = table.Columns.FindIndex(column => Collation.Default.Equals(column.Name, columnName.Last()));
6692
return columnIndex == -1 ? throw SimulatedSqlException.InvalidColumnName(columnName) : row[columnIndex];
67-
}))])
93+
}))]))
6894
));
6995

7096
case OpenParentheses:
@@ -86,11 +112,11 @@ public static Selection Parse(Simulation simulation, IEnumerator<Token> tokens,
86112

87113
return new(new(
88114
expressions,
89-
derived.Select<object?[], object?[]>(row => [..expressions.Select(x => x.Run(columnName =>
115+
ApplyClauses(derived.Select<object?[], object?[]>(row => [..expressions.Select(x => x.Run(columnName =>
90116
{
91117
var columnIndex = Array.FindIndex(derived.columnNames, name => Collation.Default.Equals(name, columnName.Last()));
92118
return columnIndex == -1 ? throw SimulatedSqlException.InvalidColumnName(columnName) : row[columnIndex];
93-
}))])
119+
}))]))
94120
));
95121
}
96122
}

SqlServerSimulator/SimulatedSqlException.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ private SimulatedSqlException(string? message, params ReadOnlySpan<SimulatedSqlE
9292
/// <returns>The exception.</returns>
9393
internal static SimulatedSqlException CannotFindDataType(string name, int index) => new($"Column, parameter, or variable #{index}: Cannot find data type {name}.", 2715, 16, 6);
9494

95+
/// <summary>
96+
/// Mimics the SqlException that occurs then when a TOP/OFFSET/FETCH clause has an inappropriate column reference.
97+
/// </summary>
98+
/// <param name="name">The name of the column.</param>
99+
/// <returns>The exception.</returns>
100+
internal static SimulatedSqlException ColumnReferenceNotAllowed(IEnumerable<string> name)
101+
=> new($"The reference to column \"{string.Join('.', name)}\" is not allowed in an argument to a TOP, OFFSET, or FETCH clause. Only references to columns at an outer scope or standalone expressions and subqueries are allowed here.", 4115, 15, 1);
102+
95103
internal static SimulatedSqlException InvalidColumnName(string name) => new($"Invalid column name '{name}'.", 207, 16, 1);
96104

97105
internal static SimulatedSqlException InvalidColumnName(IEnumerable<string> name) => InvalidColumnName(string.Join('.', name));
@@ -104,5 +112,11 @@ private SimulatedSqlException(string? message, params ReadOnlySpan<SimulatedSqlE
104112

105113
internal static SimulatedSqlException ThereIsAlreadyAnObject(string name) => new($"There is already an object named '{name}' in the database.", 2714, 16, 6);
106114

115+
/// <summary>
116+
/// Mimics the SqlException that occurs then when a TOP or FETCH clause returns something other than an integer.
117+
/// </summary>
118+
/// <returns>The exception.</returns>
119+
internal static SimulatedSqlException TopFetchRequiresInteger() => new("The number of rows provided for a TOP or FETCH clauses row count parameter must be an integer.", 1060, 15, 1);
120+
107121
internal static SimulatedSqlException UnrecognizedBuiltInFunction(string name) => new($"'{name}' is not a recognized built-in function name.", 195, 15, 10);
108122
}

0 commit comments

Comments
 (0)