Skip to content

Commit 521c0e6

Browse files
committed
Added EXISTS / IN(SELECT) subqueries with correlated outer-scope chaining; refactored Selection into a deferred plan.
1 parent 66db17b commit 521c0e6

9 files changed

Lines changed: 885 additions & 136 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7676
- Aggregate functions: `COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT expr)` / `COUNT_BIG`, `SUM` / `AVG` (with the documented integer-truncating behavior and `decimal(38, max(s, 6))` widening for AVG over decimals), `MAX` / `MIN`, the statistical family (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, and `APPROX_COUNT_DISTINCT` all parse and execute, both standalone and inside `GROUP BY` / `HAVING`. Window-function form (`OVER (...)`) and `STRING_AGG`'s `WITHIN GROUP (ORDER BY ...)` ordering aren't parsed yet. `CHECKSUM_AGG` returns an order-independent XOR fold whose semantic guarantee matches SQL Server (same multiset → same checksum) but whose exact bit pattern won't byte-match a real-server reproduction. `APPROX_COUNT_DISTINCT` is implemented as exact `COUNT(DISTINCT)` since memory optimization isn't a goal here.
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.
79-
- 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. `IN` accepts only an expression list, not a subquery (`IN (SELECT ...)`). `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).
79+
- 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).
8081
- `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.
8182
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
8283
- 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.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end regression tests for EF Core's subquery emission patterns:
5+
/// LINQ <c>Any</c> against another DbSet inside a WHERE predicate
6+
/// translates to <c>EXISTS (SELECT 1 ...)</c>; LINQ <c>Contains</c> over a
7+
/// subquery projection translates to <c>IN (SELECT ...)</c>. Validates the
8+
/// simulator's correlated-subquery support against the SqlServer provider's
9+
/// actual emit shapes.
10+
/// </summary>
11+
[TestClass]
12+
public class EFCoreSubquery
13+
{
14+
public TestContext TestContext { get; set; } = null!;
15+
16+
private static TestDbContext SeededContext()
17+
{
18+
var context = new TestDbContext(TestDbContext.CreateCustomersSimulation());
19+
context.Customers.AddRange(
20+
new Customer { Name = "alpha" },
21+
new Customer { Name = "beta" },
22+
new Customer { Name = "gamma" });
23+
_ = context.SaveChanges();
24+
// Customer ids are 1, 2, 3 after the IDENTITY assignment.
25+
context.CustomerOrders.AddRange(
26+
new CustomerOrder { CustomerId = 1, Amount = 10m },
27+
new CustomerOrder { CustomerId = 1, Amount = 20m },
28+
new CustomerOrder { CustomerId = 2, Amount = 30m });
29+
_ = context.SaveChanges();
30+
return context;
31+
}
32+
33+
[TestMethod]
34+
public void Where_AnyCorrelated_EmitsExistsSubquery()
35+
{
36+
// EF Core translates `Any` against another DbSet to `WHERE EXISTS
37+
// (SELECT 1 FROM CustomerOrders o WHERE o.CustomerId = c.Id)`.
38+
// Customers 1 and 2 have orders; customer 3 doesn't.
39+
using var context = SeededContext();
40+
var ids = context.Customers
41+
.Where(c => context.CustomerOrders.Any(o => o.CustomerId == c.Id))
42+
.OrderBy(c => c.Id)
43+
.Select(c => c.Id)
44+
.ToArray();
45+
CollectionAssert.AreEqual(new[] { 1, 2 }, ids);
46+
}
47+
48+
[TestMethod]
49+
public void Where_NotAnyCorrelated_EmitsNotExistsSubquery()
50+
{
51+
// The complement: customers without any order → only id 3.
52+
using var context = SeededContext();
53+
var ids = context.Customers
54+
.Where(c => !context.CustomerOrders.Any(o => o.CustomerId == c.Id))
55+
.OrderBy(c => c.Id)
56+
.Select(c => c.Id)
57+
.ToArray();
58+
CollectionAssert.AreEqual(new[] { 3 }, ids);
59+
}
60+
61+
[TestMethod]
62+
public void Where_ContainsSubquery_EmitsInSelect()
63+
{
64+
// EF Core translates `Contains` against a subquery to `IN (SELECT
65+
// ...)`. The subquery projects CustomerId from CustomerOrders;
66+
// customers with at least one order match.
67+
using var context = SeededContext();
68+
var ids = context.Customers
69+
.Where(c => context.CustomerOrders.Select(o => o.CustomerId).Contains(c.Id))
70+
.OrderBy(c => c.Id)
71+
.Select(c => c.Id)
72+
.ToArray();
73+
CollectionAssert.AreEqual(new[] { 1, 2 }, ids);
74+
}
75+
76+
[TestMethod]
77+
public void Where_AnyWithAdditionalPredicate_FiltersInsideSubquery()
78+
{
79+
// Inner WHERE is also correlated: only customers with an order >= 25.
80+
using var context = SeededContext();
81+
var ids = context.Customers
82+
.Where(c => context.CustomerOrders.Any(o => o.CustomerId == c.Id && o.Amount >= 25m))
83+
.OrderBy(c => c.Id)
84+
.Select(c => c.Id)
85+
.ToArray();
86+
CollectionAssert.AreEqual(new[] { 2 }, ids);
87+
}
88+
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,36 @@ internal class Sticker
254254
public string Tag { get; set; } = null!;
255255
}
256256

257+
/// <summary>
258+
/// Parent in the parent/child pair used to exercise EF Core's correlated
259+
/// subquery emission. EF Core's <c>Any</c>/<c>Contains</c> over another
260+
/// DbSet inside a WHERE predicate translates to <c>EXISTS</c>/<c>IN
261+
/// (SELECT)</c>; the simulator needs to handle both.
262+
/// </summary>
263+
internal class Customer
264+
{
265+
public int Id { get; set; }
266+
267+
[Column(TypeName = "nvarchar(50)")]
268+
public string Name { get; set; } = null!;
269+
}
270+
271+
/// <summary>
272+
/// Child entity referencing <see cref="Customer"/> by id without an explicit
273+
/// navigation property — EF Core still emits the correlated subquery pattern
274+
/// when the test query uses an explicit comparison
275+
/// (<c>o.CustomerId == c.Id</c>) inside <c>Any</c>/<c>Contains</c>.
276+
/// </summary>
277+
internal class CustomerOrder
278+
{
279+
public int Id { get; set; }
280+
281+
public int CustomerId { get; set; }
282+
283+
[Column(TypeName = "decimal(10, 2)")]
284+
public decimal Amount { get; set; }
285+
}
286+
257287
internal class TestDbContext(Simulation simulation) : DbContext
258288
{
259289
public Simulation Simulation { get; set; } = simulation;
@@ -317,6 +347,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
317347

318348
public DbSet<StockItem> StockItems => Set<StockItem>();
319349

350+
public DbSet<Customer> Customers => Set<Customer>();
351+
352+
public DbSet<CustomerOrder> CustomerOrders => Set<CustomerOrder>();
353+
320354
public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values)
321355
{
322356
var simulation = new Simulation();
@@ -489,6 +523,26 @@ Status nvarchar(20) null
489523
return simulation;
490524
}
491525

526+
public static Simulation CreateCustomersSimulation()
527+
{
528+
var simulation = new Simulation();
529+
_ = simulation
530+
.CreateOpenConnection()
531+
.CreateCommand("""
532+
create table Customers (
533+
Id int identity(1, 1) not null primary key,
534+
Name nvarchar(50) not null
535+
);
536+
create table CustomerOrders (
537+
Id int identity(1, 1) not null primary key,
538+
CustomerId int not null,
539+
Amount decimal(10, 2) not null
540+
)
541+
""")
542+
.ExecuteNonQuery();
543+
return simulation;
544+
}
545+
492546
public static Simulation CreateStockItemsSimulation()
493547
{
494548
var simulation = new Simulation();

0 commit comments

Comments
 (0)