Skip to content

Commit 6d6562e

Browse files
committed
Added scalar subquery support; rerouted Expression.Parse's grouped-expression dispatch to detect SELECT after '(', enforcing Msg 116 / Msg 512.
1 parent 521c0e6 commit 6d6562e

7 files changed

Lines changed: 255 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7777
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout.
7878
- `CONVERT` / `TRY_CONVERT` style-code coverage. Only styles `0`, `120`, and `121` are wired up for date-like sources targeting a character string (the EF Core code-generation defaults). Other style numbers raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date-parsing styles aren't modeled.
7979
- Boolean combinators in WHERE / MERGE-ON / CHECK predicates: `AND` / `OR` / `NOT`, parenthesized groupings, `IS [NOT] NULL`, and `[NOT] IN (literal, literal, ...)` all parse with standard SQL precedence (AND > OR; NOT highest). The pattern `where (arith_expr) cmp rhs` (parens-around-arithmetic as the left side of a comparison) is the one shape the simulator's parser doesn't accept; SQL Server does. `BooleanExpression.Run` returns `bool?` (three-valued: true / false / UNKNOWN); WHERE / MERGE-ON treat UNKNOWN as exclude, CHECK treats UNKNOWN as pass — matching SQL Server. `IS NULL` definitively resolves UNKNOWN to true/false (it's the canonical way to test nullability without falling through tri-state).
80-
- Subqueries: `EXISTS (SELECT ...)`, `[NOT] EXISTS`, `expr [NOT] IN (SELECT ...)` parse as boolean atoms in WHERE / HAVING / CHECK; both correlated and non-correlated forms work, with arbitrary outer-scope nesting depth. EXISTS counts rows only (multi-column inner SELECT allowed); `IN (SELECT ...)` requires exactly one inner column (Msg 116) and follows the same NULL semantics as the literal-list IN (NULL row in the subquery → UNKNOWN unless a non-NULL match wins first). Column resolution honors qualifiers (`alias.col` / `tableName.col`) so a correlated reference to an outer column with the same name as an inner column works correctly. The non-correlated case re-executes the inner SELECT per outer row (no result caching yet — fidelity over performance for now). `Selection.Parse` returns a deferred plan; `Selection.Execute(outerResolver)` materializes results, so the same plan re-runs against different outer rows. **Not modeled**: scalar subqueries (`WHERE col = (SELECT ...)` and projection-slot subqueries — these need cardinality enforcement / Msg 512), `ANY` / `SOME` / `ALL` quantifiers, `UNION` / `UNION ALL` inside a subquery body. Derived tables in FROM (already supported) don't see outer scope (no APPLY / lateral).
80+
- Subqueries: `EXISTS (SELECT ...)` / `[NOT] EXISTS` and `expr [NOT] IN (SELECT ...)` parse as boolean atoms in WHERE / HAVING / CHECK; scalar subqueries `(SELECT col FROM ...)` parse anywhere an expression is allowed (projection, WHERE comparison, arithmetic operand). All forms work both correlated and non-correlated, with arbitrary outer-scope nesting depth. EXISTS counts rows only (multi-column inner allowed); `IN (SELECT ...)` and scalar subqueries require exactly one inner column (Msg 116). Scalar subqueries also enforce single-row cardinality at runtime (Msg 512, fired per outer row for correlated cases); empty result → NULL of the inner's projected type. NULL semantics in `IN (SELECT ...)` mirror the literal-list IN (NULL row → UNKNOWN unless a non-NULL match wins first). Column resolution honors qualifiers (`alias.col` / `tableName.col`) so a correlated reference to an outer column with the same name as an inner column works correctly. The inner plan re-executes per outer row (no result caching yet — fidelity over performance for now). `Selection.Parse` returns a deferred plan; `Selection.Execute(outerResolver)` materializes results, so the same plan re-runs against different outer rows. **Not modeled**: `ANY` / `SOME` / `ALL` quantifiers, `UNION` / `UNION ALL` inside a subquery body, row-constructor `IN ((1,2), (3,4))`. Derived tables in FROM (already supported) don't see outer scope (no APPLY / lateral).
8181
- `LIKE` `COLLATE` override. The default collation (case-insensitive, Latin1_General-shaped) is what every `LIKE` runs under; explicit `COLLATE` clauses on the predicate aren't parsed yet.
8282
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
8383
- EF Core compatibility: the `SqlServerSimulator.EFCore` adapter (`UseSqlServerSimulator(...)`) covers the seven `SqlParameter`-downcast pairs — `DateOnly → date`, `DateTime → date`, `DateTime → smalldatetime`, `TimeOnly → time(N)`, `TimeSpan → time(N)`, `decimal → money`, `decimal → smallmoney`. Without the adapter (plain `UseSqlServer`), those mappings still throw at SaveChanges as before. The MAX-string family (default-mapped `string``nvarchar(max)`, `[Column(TypeName = "varchar(max)")]`, `[Column(TypeName = "varbinary(max)")]`) flows through plain `UseSqlServer` without needing the adapter. Other type pairs the SqlServer provider supports but aren't modeled here (e.g. `hierarchyid`, `geography`, `geometry`, `rowversion`) are not bridged.

SqlServerSimulator.Tests.EFCore/EFCoreSubquery.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,40 @@ public void Where_AnyWithAdditionalPredicate_FiltersInsideSubquery()
8585
.ToArray();
8686
CollectionAssert.AreEqual(new[] { 2 }, ids);
8787
}
88+
89+
[TestMethod]
90+
public void Projection_CountCorrelated_EmitsScalarSubquery()
91+
{
92+
// EF Core translates `Count` of a related set in projection to a
93+
// scalar subquery: `SELECT (SELECT COUNT(*) FROM CustomerOrders o
94+
// WHERE o.CustomerId = c.Id) FROM Customers c`. Customers 1, 2, 3
95+
// have order counts 2, 1, 0 respectively.
96+
using var context = SeededContext();
97+
var rows = context.Customers
98+
.OrderBy(c => c.Id)
99+
.Select(c => new
100+
{
101+
c.Id,
102+
OrderCount = context.CustomerOrders.Count(o => o.CustomerId == c.Id)
103+
})
104+
.ToArray();
105+
Assert.HasCount(3, rows);
106+
Assert.AreEqual(2, rows[0].OrderCount);
107+
Assert.AreEqual(1, rows[1].OrderCount);
108+
Assert.AreEqual(0, rows[2].OrderCount);
109+
}
110+
111+
[TestMethod]
112+
public void Where_ScalarComparisonAgainstSubquery_FiltersByMaxAmount()
113+
{
114+
// EF Core translates `o.Amount == context.CustomerOrders.Max(...)`
115+
// to `WHERE o.Amount = (SELECT MAX(o.Amount) FROM CustomerOrders)`.
116+
// Single highest-amount order: 30 (CustomerId=2).
117+
using var context = SeededContext();
118+
var customerIds = context.CustomerOrders
119+
.Where(o => o.Amount == context.CustomerOrders.Max(x => x.Amount))
120+
.Select(o => o.CustomerId)
121+
.ToArray();
122+
CollectionAssert.AreEqual(new[] { 2 }, customerIds);
123+
}
88124
}

SqlServerSimulator.Tests/SubqueryTests.cs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,136 @@ public void InSelect_NonCorrelated_ScansSubqueryPerOuterRow()
289289
CollectionAssert.AreEquivalent(new int?[] { 2, 4 }, matched);
290290
}
291291

292+
// === Scalar subqueries ===
293+
294+
[TestMethod]
295+
public void Scalar_InProjection_ReturnsValue()
296+
{
297+
AreEqual(1, new Simulation().ExecuteScalar("select (select 1)"));
298+
}
299+
300+
[TestMethod]
301+
public void Scalar_InArithmetic_FlowsThroughOperator()
302+
{
303+
AreEqual(6, new Simulation().ExecuteScalar("select (select 1) + 5"));
304+
}
305+
306+
[TestMethod]
307+
public void Scalar_EmptyResult_IsNull()
308+
{
309+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select (select 1 where 1=0)"));
310+
}
311+
312+
[TestMethod]
313+
public void Scalar_EmptyInArithmetic_PropagatesNull()
314+
{
315+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select (select 1 where 1=0) + 5"));
316+
}
317+
318+
[TestMethod]
319+
public void Scalar_InWhereComparison_FiltersByValue()
320+
{
321+
var simulation = new Simulation();
322+
_ = simulation.ExecuteNonQuery("create table t (x int)");
323+
_ = simulation.ExecuteNonQuery("insert into t values (5), (10), (15)");
324+
325+
using var connection = simulation.CreateOpenConnection();
326+
var matched = ReadIntColumn(connection.CreateCommand("select x from t where x = (select max(x) from t)"));
327+
CollectionAssert.AreEqual(new int?[] { 15 }, matched);
328+
}
329+
330+
[TestMethod]
331+
public void Scalar_NullValueInRow_PropagatesNull()
332+
{
333+
// Single-row inner with a NULL column value flows through normally.
334+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select (select cast(null as int)) + 5"));
335+
}
336+
337+
[TestMethod]
338+
public void Scalar_MultiRow_RaisesMsg512()
339+
{
340+
var simulation = new Simulation();
341+
_ = simulation.ExecuteNonQuery("create table t (x int)");
342+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2)");
343+
344+
var ex = Throws<DbException>(() => _ = simulation.ExecuteScalar("select (select x from t)"));
345+
AreEqual("512", ex.Data["HelpLink.EvtID"]);
346+
AreEqual("Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <= , >, >= or when the subquery is used as an expression.", ex.Message);
347+
}
348+
349+
[TestMethod]
350+
public void Scalar_MultiColumn_RaisesMsg116()
351+
{
352+
var ex = Throws<DbException>(() =>
353+
_ = new Simulation().ExecuteScalar("select (select 1, 2)"));
354+
AreEqual("116", ex.Data["HelpLink.EvtID"]);
355+
}
356+
357+
[TestMethod]
358+
public void Scalar_Correlated_ResolvesPerRow()
359+
{
360+
var simulation = new Simulation();
361+
_ = simulation.ExecuteNonQuery("create table a (id int)");
362+
_ = simulation.ExecuteNonQuery("create table b (id int, val int)");
363+
_ = simulation.ExecuteNonQuery("insert into a values (1), (2), (3)");
364+
_ = simulation.ExecuteNonQuery("insert into b values (1, 100), (2, 200)");
365+
366+
// For each a.id, look up b.val. id=3 has no match → NULL.
367+
using var connection = simulation.CreateOpenConnection();
368+
using var reader = connection.CreateCommand("select a.id, (select b.val from b where b.id = a.id) as v from a").ExecuteReader();
369+
var ids = new List<int>();
370+
var vals = new List<int?>();
371+
while (reader.Read())
372+
{
373+
ids.Add(reader.GetInt32(0));
374+
vals.Add(reader.IsDBNull(1) ? null : reader.GetInt32(1));
375+
}
376+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, ids);
377+
CollectionAssert.AreEqual(new int?[] { 100, 200, null }, vals);
378+
}
379+
380+
[TestMethod]
381+
public void Scalar_CorrelatedMultiRow_RaisesMsg512()
382+
{
383+
// Per-outer-row Msg 512: a.id=1 matches two b rows, hits the limit.
384+
var simulation = new Simulation();
385+
_ = simulation.ExecuteNonQuery("create table a (id int)");
386+
_ = simulation.ExecuteNonQuery("create table b (id int, val int)");
387+
_ = simulation.ExecuteNonQuery("insert into a values (1)");
388+
_ = simulation.ExecuteNonQuery("insert into b values (1, 100), (1, 200)");
389+
390+
using var connection = simulation.CreateOpenConnection();
391+
var ex = Throws<DbException>(() =>
392+
{
393+
using var reader = connection.CreateCommand("select (select b.val from b where b.id = a.id) from a").ExecuteReader();
394+
while (reader.Read()) { /* exhaust */ }
395+
});
396+
AreEqual("512", ex.Data["HelpLink.EvtID"]);
397+
}
398+
399+
[TestMethod]
400+
public void Scalar_AggregateInner_ReturnsAggregate()
401+
{
402+
// Common EF Core pattern: scalar subquery with COUNT.
403+
var simulation = new Simulation();
404+
_ = simulation.ExecuteNonQuery("create table a (id int)");
405+
_ = simulation.ExecuteNonQuery("create table b (id int)");
406+
_ = simulation.ExecuteNonQuery("insert into a values (1), (2)");
407+
_ = simulation.ExecuteNonQuery("insert into b values (1), (2), (3)");
408+
409+
using var connection = simulation.CreateOpenConnection();
410+
using var reader = connection.CreateCommand("select a.id, (select count(*) from b) as c from a").ExecuteReader();
411+
var ids = new List<int>();
412+
var counts = new List<int>();
413+
while (reader.Read())
414+
{
415+
ids.Add(reader.GetInt32(0));
416+
counts.Add(reader.GetInt32(1));
417+
}
418+
CollectionAssert.AreEqual(new[] { 1, 2 }, ids);
419+
CollectionAssert.AreEqual(new[] { 3, 3 }, counts);
420+
}
421+
292422
// === EXISTS with table-alias resolution ===
293423

294424
[TestMethod]

SqlServerSimulator/Parser/Expression.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public static Expression Parse(ParserContext context)
7171
// surrounding loop hands the call shape off to ResolveBuiltIn.
7272
ReservedKeyword { Keyword: Keyword.Left or Keyword.Right or Keyword.Convert or Keyword.Try_Convert or Keyword.Coalesce } reserved => new Reference(reserved.ToString()),
7373
Name name => new Reference(name),
74-
Operator { Character: '(' } => new Parenthesized(context),
74+
Operator { Character: '(' } => ParseGroupedExpression(context),
7575
_ => throw SimulatedSqlException.SyntaxErrorNear(context)
7676
};
7777

@@ -164,6 +164,34 @@ public static Expression Parse(ParserContext context)
164164
/// </summary>
165165
internal abstract string DebugDisplay();
166166

167+
/// <summary>
168+
/// Parses a grouped expression starting at the opening <c>(</c>. Two
169+
/// shapes share the leading paren: a parenthesized expression
170+
/// (<c>(1 + 2)</c>, parses as <see cref="Parenthesized"/>) or a scalar
171+
/// subquery (<c>(SELECT col FROM t)</c>, parses as
172+
/// <see cref="ScalarSubqueryExpression"/>). Dispatch is by peeking the
173+
/// token immediately after <c>(</c>: a <c>SELECT</c> keyword routes to
174+
/// the subquery path; anything else falls through to the standard
175+
/// parenthesized form. Both leave the cursor on the closing <c>)</c>,
176+
/// matching the lookahead contract <see cref="Parse"/>'s
177+
/// binary loop expects.
178+
/// </summary>
179+
private static Expression ParseGroupedExpression(ParserContext context)
180+
{
181+
context.MoveNextRequired();
182+
if (context.Token is ReservedKeyword { Keyword: Keyword.Select })
183+
{
184+
var inner = Selection.Parse(context, depth: 1, outerTypeResolver: context.OuterTypeResolver);
185+
return inner.Schema.Length != 1
186+
? throw SimulatedSqlException.SubqueryNotIntroducedWithExists()
187+
: context.Token is not Operator { Character: ')' }
188+
? throw SimulatedSqlException.SyntaxErrorNear(context)
189+
: (Expression)new ScalarSubqueryExpression(inner);
190+
}
191+
192+
return new Parenthesized(Expression.Parse(context));
193+
}
194+
167195
private static Expression ResolveBuiltIn(string name, ParserContext context)
168196
{
169197
Span<char> uppercaseName = stackalloc char[name.Length];

SqlServerSimulator/Parser/Expressions/Parenthesized.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
namespace SqlServerSimulator.Parser.Expressions;
1+
namespace SqlServerSimulator.Parser.Expressions;
22

33
/// <summary>
4-
/// An expression that's wrapped in parentheses, potentially affecting the order of operations.
4+
/// An expression that's wrapped in parentheses, potentially affecting the
5+
/// order of operations. Constructed by <see cref="Expression.Parse"/>'s
6+
/// grouped-expression dispatch after the inner body is parsed; that
7+
/// dispatch also handles the alternative <c>(SELECT ...)</c> shape, which
8+
/// produces a <see cref="ScalarSubqueryExpression"/> instead.
59
/// </summary>
6-
internal sealed class Parenthesized(ParserContext context) : Expression
10+
internal sealed class Parenthesized(Expression wrapped) : Expression
711
{
8-
private readonly Expression wrapped = Parse(context.MoveNextRequiredReturnSelf());
9-
1012
public override Storage.SqlValue Run(Func<List<string>, Storage.SqlValue> getColumnValue) => wrapped.Run(getColumnValue);
1113

1214
public override Storage.SqlType GetSqlType(Func<List<string>, Storage.SqlType> resolveColumnType) => wrapped.GetSqlType(resolveColumnType);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// A <c>(SELECT col FROM ...)</c> subquery used in an expression slot
7+
/// (projection, WHERE comparison, arithmetic operand, etc.). The inner
8+
/// SELECT must project exactly one column (Msg 116, enforced at parse time)
9+
/// and at runtime must return at most one row (Msg 512, enforced per
10+
/// evaluation). Empty result → NULL of the inner's projected type.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// Each <see cref="Run"/> re-executes the inner plan against the caller's
15+
/// outer-scope resolver, so correlated scalar subqueries see fresh outer-row
16+
/// values per evaluation. Cardinality enforcement is greedy: pull the first
17+
/// row, then check whether a second exists by advancing the enumerator one
18+
/// more time. If it does, raise Msg 512 immediately — no need to materialize
19+
/// the rest of the result set.
20+
/// </para>
21+
/// <para>
22+
/// <see cref="GetSqlType"/> reads the inner plan's static schema, so the
23+
/// projection column type is known at parse time without executing.
24+
/// </para>
25+
/// </remarks>
26+
internal sealed class ScalarSubqueryExpression(Selection inner) : Expression
27+
{
28+
public override SqlValue Run(Func<List<string>, SqlValue> getColumnValue)
29+
{
30+
var resultSet = inner.Execute(getColumnValue);
31+
using var enumerator = resultSet.RowBytes.GetEnumerator();
32+
if (!enumerator.MoveNext())
33+
return SqlValue.Null(resultSet.Schema[0]);
34+
var firstRow = enumerator.Current;
35+
return enumerator.MoveNext()
36+
? throw SimulatedSqlException.SubqueryReturnedMoreThanOneValue()
37+
: RowDecoder.DecodeColumn(resultSet.Schema, firstRow, 0);
38+
}
39+
40+
public override SqlType GetSqlType(Func<List<string>, SqlType> resolveColumnType) => inner.Schema[0];
41+
42+
internal override string DebugDisplay() => "(SELECT ...)";
43+
}

SqlServerSimulator/SimulatedSqlException.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ internal static SimulatedSqlException IdentifierTooLong(ReadOnlySpan<char> first
268268
internal static SimulatedSqlException SubqueryNotIntroducedWithExists() =>
269269
new("Only one expression can be specified in the select list when the subquery is not introduced with EXISTS.", 116, 16, 1);
270270

271+
/// <summary>
272+
/// Mimics SQL Server's Msg 512 — fired when a scalar subquery (or one
273+
/// behind a comparison operator) returns more than one row. Verbatim
274+
/// text from the probed real SQL Server, including the literal extra
275+
/// space in the <c>&lt;= , &gt;</c> sequence.
276+
/// </summary>
277+
internal static SimulatedSqlException SubqueryReturnedMoreThanOneValue() =>
278+
new("Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <= , >, >= or when the subquery is used as an expression.", 512, 16, 1);
279+
271280
internal static SimulatedSqlException MissingEndCommentMark() => new("Missing end comment mark '*/'.", 113, 15, 1);
272281

273282
internal static SimulatedSqlException MustDeclareScalarVariable(string name) => new($"Must declare the scalar variable \"@{name}\".", 137, 15, 2);

0 commit comments

Comments
 (0)