Skip to content

Commit b2ec9c7

Browse files
committed
Derived tables in FROM correlate to outer scope: always-defer execution into FromSource.LateralPlan, JoinDriver lateral branch applies ON predicates and null-fills for LEFT, fixes EF Core's Distinct().Count() shape.
1 parent d026d96 commit b2ec9c7

5 files changed

Lines changed: 255 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ ORDER BY: a non-set-op SELECT keeps branch-internal ORDER BY (which can referenc
156156
### JOINs
157157
`INNER JOIN ... ON`, bare `JOIN` (= INNER), `LEFT [OUTER] JOIN ... ON`, `CROSS JOIN`, `CROSS APPLY (...) [AS alias]`, `OUTER APPLY (...) [AS alias]`. Multi-table chains compose left-to-right. Self-joins via alias work. ON-predicate UNKNOWN excludes. Aliases parse with or without `AS`.
158158

159-
APPLY is the lateral form: the right side is a derived-table SELECT re-executed per outer row, with the outer tuple's columns visible inside its WHERE / projection. CROSS APPLY drops outer rows whose plan yields zero rows (INNER-style); OUTER APPLY null-fills the right side (LEFT-style). No `ON` clause — correlation lives inside the inner WHERE. The lateral plan stays deferred (`FromSource.LateralPlan`) and re-executes via `Selection.Execute(currentTupleResolver)` per outer tuple in `JoinDriver`. EF Core 10 emits `CROSS APPLY` for `SelectMany(a => a.Books.Where(...))` over a collection navigation.
159+
APPLY is the lateral form: the right side is a derived-table SELECT re-executed per outer row, with the outer tuple's columns visible inside its WHERE / projection. CROSS APPLY drops outer rows whose plan yields zero rows (INNER-style); OUTER APPLY null-fills the right side (LEFT-style). No `ON` clause — correlation lives inside the inner WHERE. EF Core 10 emits `CROSS APPLY` for `SelectMany(a => a.Books.Where(...))` over a collection navigation.
160+
161+
**Derived tables in FROM are always deferred** — `ParseSingleFromSource` stores every `(SELECT ...) AS alias` source on `FromSource.LateralPlan` and the executor re-runs it through `Selection.Execute(outerResolver)` per surrounding row. This matches SQL Server's "any FROM derived table can correlate" rule (not just APPLY), and is required for shapes like `(SELECT COUNT(*) FROM (SELECT DISTINCT col FROM t WHERE t.k = outer.k) AS sub)` that EF Core 10 emits for `Distinct().Count()` over correlated sets. Static parse-time correlation detection isn't sufficient because outer references in WHERE / ON predicates are resolved through `Run`, not `GetSqlType`. The always-defer cost is one inner-plan execution per outer Execute call regardless — the same one the eager path used to do at parse time. The chained outer-type-resolver (preferring `ParserContext.OuterTypeResolver` when set, falling back to the explicit chain parameter) is threaded into the inner Parse so projection / GROUP BY references against outer columns also resolve at parse time.
162+
163+
`JoinDriver`'s lateral branch handles both APPLY (CrossApply / OuterApply, no ON predicate) and ordinary derived tables in INNER / LEFT / CROSS join slots — the latter apply their `ON` predicate after the inner row materializes, and LEFT joins null-fill on no-match the same way OUTER APPLY does. Leftmost-source lateral plans run from the surrounding `outerResolver` directly (no per-tuple feedback at level 0).
160164

161165
### CASE
162166
Searched (`CASE WHEN cond THEN ... [ELSE ...] END`) and simple (`CASE input WHEN val ...`). Branches evaluate in order; first true predicate wins. UNKNOWN excludes (matches WHERE); simple-form `CASE NULL WHEN NULL` falls through. Result type computed via `SqlType.Promote` across all THEN/ELSE, cached on first `GetSqlType`; `Run` coerces matched values to the common type. No-match-no-ELSE → typed NULL.
@@ -270,7 +274,6 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
270274
- Locks / MVCC / isolation levels — the simulator has no isolation; uncommitted writes are immediately visible to all readers (single-Simulation, single-thread-at-a-time assumption). `BEGIN DISTRIBUTED TRANSACTION` and `BEGIN TRANSACTION ... WITH MARK '...'` aren't parsed. `XACT_ABORT` / `SET TRANSACTION ISOLATION LEVEL`.
271275
- `RIGHT JOIN` (rewrite as LEFT with sources swapped); `FULL OUTER JOIN`. Both raise `NotSupportedException` at parse.
272276
- Comma-separated FROM (legacy ANSI-89 join syntax).
273-
- Plain derived tables in FROM (without APPLY) don't see outer scope — they execute eagerly at parse time. Lateral access requires `CROSS APPLY` / `OUTER APPLY`. SQL Server actually allows derived-table-sees-outer in any FROM subquery; the gap shows up in compound shapes like `(SELECT … FROM (SELECT TOP(N) … WHERE outer.col = …) AS t)` where the inner correlation isn't expressed via APPLY.
274277
- `ANY` / `SOME` / `ALL` quantifiers.
275278
- `UNION` / `UNION ALL` inside a subquery body.
276279
- Row-constructor `IN ((1,2), (3,4))`.

SqlServerSimulator.Tests.EFCore/EFCoreSubquery.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,35 @@ public void Where_ScalarComparisonAgainstSubquery_FiltersByMaxAmount()
121121
.ToArray();
122122
CollectionAssert.AreEqual(new[] { 2 }, customerIds);
123123
}
124+
125+
[TestMethod]
126+
public void Projection_DistinctCorrelatedCount_EmitsCorrelatedDerivedTable()
127+
{
128+
// EF Core 10 emits `(SELECT COUNT(*) FROM (SELECT DISTINCT col FROM t
129+
// WHERE t.k = outer.k) AS sub)` for `Distinct().Count()` over a
130+
// correlated subset — the inner derived table references the outer
131+
// scope via its WHERE. Before the always-defer derived-table fix
132+
// this raised "Invalid column name" because plain derived tables
133+
// didn't see outer scope.
134+
using var context = SeededContext();
135+
var rows = context.Customers
136+
.OrderBy(c => c.Id)
137+
.Select(c => new
138+
{
139+
c.Id,
140+
DistinctAmounts = context.CustomerOrders
141+
.Where(o => o.CustomerId == c.Id)
142+
.Select(o => o.Amount)
143+
.Distinct()
144+
.Count(),
145+
})
146+
.ToArray();
147+
Assert.HasCount(3, rows);
148+
// c.Id=1 has orders [10, 20] → 2 distinct amounts.
149+
// c.Id=2 has order [30] → 1.
150+
// c.Id=3 has none → 0.
151+
Assert.AreEqual(2, rows[0].DistinctAmounts);
152+
Assert.AreEqual(1, rows[1].DistinctAmounts);
153+
Assert.AreEqual(0, rows[2].DistinctAmounts);
154+
}
124155
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for derived tables in FROM that reference outer-scope columns —
8+
/// SQL Server's "any FROM derived table can correlate" rule, not just the
9+
/// CROSS APPLY / OUTER APPLY shape. The simulator threads the outer-type
10+
/// resolver into the inner Parse and defers execution per Selection
11+
/// instance so the inner plan re-runs with each call's outer resolver.
12+
/// </summary>
13+
[TestClass]
14+
public sealed class CorrelatedDerivedTableTests
15+
{
16+
private static DbConnection SeededSales()
17+
{
18+
var connection = new Simulation().CreateOpenConnection();
19+
_ = connection.CreateCommand(
20+
"create table sales (region varchar(10), salesperson varchar(20), amount decimal(10,2))").ExecuteNonQuery();
21+
_ = connection.CreateCommand(
22+
"insert into sales values " +
23+
"('east', 'alice', 100), " +
24+
"('east', 'alice', 200), " +
25+
"('east', 'bob', 150), " +
26+
"('west', 'carol', 300), " +
27+
"('west', 'carol', 500), " +
28+
"('west', 'dan', 50)").ExecuteNonQuery();
29+
return connection;
30+
}
31+
32+
[TestMethod]
33+
public void DistinctCorrelatedCount_TheEFShape_Works()
34+
{
35+
// The exact shape EF Core 10 emits for `db.X.Select(s => new {
36+
// DistinctCount = db.Y.Where(...).Distinct().Count() })`. Before
37+
// this fix, the simulator threw "Invalid column name 's.Region'"
38+
// because the inner derived table couldn't see the outer scope.
39+
using var connection = SeededSales();
40+
using var reader = connection.CreateCommand(
41+
"select s.region, (select count(*) from (select distinct s0.salesperson from sales as s0 where s0.region = s.region) as s1) from sales as s").ExecuteReader();
42+
var counts = new Dictionary<string, int>();
43+
while (reader.Read())
44+
counts[reader.GetString(0)] = reader.GetInt32(1);
45+
AreEqual(2, counts["east"]); // alice, bob
46+
AreEqual(2, counts["west"]); // carol, dan
47+
}
48+
49+
[TestMethod]
50+
public void CorrelatedDerivedTableInWhereExists()
51+
{
52+
// `where exists (select 1 from (correlated derived) as sub where ...)`
53+
using var connection = SeededSales();
54+
using var reader = connection.CreateCommand(
55+
"select distinct s.region from sales as s where exists (" +
56+
" select 1 from (select distinct x.salesperson from sales as x where x.region = s.region) as sub" +
57+
" where sub.salesperson = 'alice')").ExecuteReader();
58+
var regions = new List<string>();
59+
while (reader.Read())
60+
regions.Add(reader.GetString(0));
61+
// Only east has 'alice'; west doesn't.
62+
HasCount(1, regions);
63+
AreEqual("east", regions[0]);
64+
}
65+
66+
[TestMethod]
67+
public void NonCorrelatedDerivedTable_StillWorks()
68+
{
69+
// Regression — an uncorrelated derived table (no outer reference)
70+
// should still produce its own row stream. Always-defer changes
71+
// the runtime path but the result must be identical.
72+
using var connection = SeededSales();
73+
using var reader = connection.CreateCommand(
74+
"select region, total from (select region, sum(amount) as total from sales group by region) as g order by region").ExecuteReader();
75+
var pairs = new List<(string Region, decimal Total)>();
76+
while (reader.Read())
77+
pairs.Add((reader.GetString(0), reader.GetDecimal(1)));
78+
HasCount(2, pairs);
79+
AreEqual(("east", 450.00m), pairs[0]);
80+
AreEqual(("west", 850.00m), pairs[1]);
81+
}
82+
83+
[TestMethod]
84+
public void NonCorrelatedDerivedTable_JoinedToHeapTable()
85+
{
86+
// Pre-existing JOIN-with-derived-table shape. After the always-defer
87+
// change the JoinDriver lateral branch handles the ON predicate
88+
// and INNER semantics for these too.
89+
using var connection = SeededSales();
90+
using var reader = connection.CreateCommand(
91+
"select s.region, s.salesperson, t.total " +
92+
"from sales as s inner join (select region, sum(amount) as total from sales group by region) as t " +
93+
" on s.region = t.region " +
94+
"order by s.region, s.salesperson, s.amount").ExecuteReader();
95+
var rows = new List<(string Region, string Person, decimal Total)>();
96+
while (reader.Read())
97+
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetDecimal(2)));
98+
HasCount(6, rows);
99+
// Each detail row carries its region's broadcast total.
100+
IsTrue(rows.Where(r => r.Region == "east").All(r => r.Total == 450.00m));
101+
IsTrue(rows.Where(r => r.Region == "west").All(r => r.Total == 850.00m));
102+
}
103+
104+
[TestMethod]
105+
public void LeftJoin_DerivedTable_NullFillsNoMatch()
106+
{
107+
// LEFT JOIN to a derived table with an ON predicate that fails
108+
// for some outer rows must null-fill, matching real SQL Server.
109+
using var connection = SeededSales();
110+
_ = connection.CreateCommand(
111+
"create table regions (region varchar(10), label varchar(20))").ExecuteNonQuery();
112+
_ = connection.CreateCommand(
113+
"insert into regions values ('east', 'East'), ('north', 'North')").ExecuteNonQuery();
114+
using var reader = connection.CreateCommand(
115+
"select r.region, r.label, t.total " +
116+
"from regions as r left join (select region, sum(amount) as total from sales group by region) as t " +
117+
" on r.region = t.region " +
118+
"order by r.region").ExecuteReader();
119+
var rows = new List<(string Region, string Label, decimal? Total)>();
120+
while (reader.Read())
121+
rows.Add((reader.GetString(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetDecimal(2)));
122+
HasCount(2, rows);
123+
AreEqual("east", rows[0].Region);
124+
AreEqual(450.00m, rows[0].Total);
125+
// 'north' has no matching sales rows → null-filled total.
126+
AreEqual("north", rows[1].Region);
127+
IsNull(rows[1].Total);
128+
}
129+
130+
[TestMethod]
131+
public void CorrelatedDerivedTable_AsScalarSubqueryFromSource()
132+
{
133+
// Scalar subquery whose own FROM is a correlated derived table —
134+
// the EF distinct-count shape with TOP rather than COUNT.
135+
using var connection = SeededSales();
136+
using var reader = connection.CreateCommand(
137+
"select s.region, (select top 1 sub.salesperson from (select distinct x.salesperson from sales as x where x.region = s.region) as sub order by sub.salesperson) " +
138+
"from sales as s where s.salesperson = 'alice'").ExecuteReader();
139+
// Both alice rows are in 'east'; the inner returns the first
140+
// distinct salesperson alphabetically in 'east' = 'alice'.
141+
var rows = new List<(string Region, string FirstSp)>();
142+
while (reader.Read())
143+
rows.Add((reader.GetString(0), reader.GetString(1)));
144+
HasCount(2, rows);
145+
IsTrue(rows.All(r => r.Region == "east" && r.FirstSp == "alice"));
146+
}
147+
}

SqlServerSimulator/Parser/Selection.Execution.cs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -912,7 +912,16 @@ private static SqlValue ResolveAcrossTuple(
912912

913913
if (joins.Length == 0)
914914
{
915-
foreach (var row in sources[0].Rows)
915+
// Single FROM source. A correlated derived table here carries a
916+
// LateralPlan whose execution depends on the enclosing scope's
917+
// resolver (no per-row tuple to feed yet — we're at the outermost
918+
// level of this Selection). Run it once with the outer resolver
919+
// and stream its rows; eager (non-correlated) sources iterate
920+
// their pre-evaluated row bytes the same way as before.
921+
var rows = sources[0].LateralPlan is { } leftmostLateralPlan
922+
? leftmostLateralPlan.Execute(outerResolver).RowBytes
923+
: sources[0].Rows;
924+
foreach (var row in rows)
916925
{
917926
tuple[0] = row;
918927
yield return tuple;
@@ -923,6 +932,22 @@ private static SqlValue ResolveAcrossTuple(
923932
SqlValue Resolve(byte[]?[] currentTuple, MultiPartName name) =>
924933
ResolveAcrossTuple(sources, currentTuple, name, outerResolver, n => Resolve(currentTuple, n));
925934

935+
// Leftmost-source lateral plan in a multi-source FROM: same as the
936+
// joins.Length==0 case — drive it from the outer resolver, then let
937+
// the join driver chain through the remaining sources. The level==0
938+
// branch in JoinDriver handles non-lateral leftmost sources; we
939+
// pre-feed the lateral row stream via a temporary FromSource swap.
940+
if (sources[0].LateralPlan is { } leadLateralPlan)
941+
{
942+
foreach (var row in leadLateralPlan.Execute(outerResolver).RowBytes)
943+
{
944+
tuple[0] = row;
945+
foreach (var t in JoinDriver(sources, joins, tuple, Resolve, level: 1))
946+
yield return t;
947+
}
948+
yield break;
949+
}
950+
926951
foreach (var t in JoinDriver(sources, joins, tuple, Resolve, level: 0))
927952
yield return t;
928953
}
@@ -965,21 +990,27 @@ SqlValue Resolve(byte[]?[] currentTuple, MultiPartName name) =>
965990
var join = joins[level - 1];
966991
var matched = false;
967992

968-
// Lateral source (right side of CROSS APPLY / OUTER APPLY): the
969-
// plan re-executes per outer tuple, and its result rows are this
970-
// level's contribution to the join. No ON predicate — correlation
971-
// lives inside the lateral plan's own WHERE clause.
993+
// Lateral source: any source backed by a deferred plan. APPLY brings
994+
// its own join kind (CrossApply / OuterApply, no ON predicate) and
995+
// correlates via the inner WHERE; ordinary derived tables in INNER /
996+
// LEFT / CROSS JOIN slots also flow here since
997+
// <see cref="ParseSingleFromSource"/> always defers (correlation isn't
998+
// statically detectable). Apply the ON predicate when the join has
999+
// one, and null-fill for both LEFT and OUTER APPLY when nothing
1000+
// matched.
9721001
if (sources[level].LateralPlan is { } lateralPlan)
9731002
{
9741003
foreach (var row in lateralPlan.Execute(name => resolve(tuple, name)).RowBytes)
9751004
{
9761005
tuple[level] = row;
1006+
if (join.OnPredicate is not null && join.OnPredicate.Run(name => resolve(tuple, name)) != true)
1007+
continue;
9771008
matched = true;
9781009
foreach (var t in JoinDriver(sources, joins, tuple, resolve, level + 1))
9791010
yield return t;
9801011
}
9811012
tuple[level] = null;
982-
if (!matched && join.Kind == JoinKind.OuterApply)
1013+
if (!matched && join.Kind is JoinKind.OuterApply or JoinKind.Left)
9831014
{
9841015
foreach (var t in JoinDriver(sources, joins, tuple, resolve, level + 1))
9851016
yield return t;

0 commit comments

Comments
 (0)