Skip to content

Commit ccb2a60

Browse files
committed
Added CROSS APPLY / OUTER APPLY support: lateral derived tables re-execute per outer row through a new FromSource.LateralPlan, unlocking EF Core 10's SelectMany-over-filtered-navigation shape.
1 parent a5c0eda commit ccb2a60

8 files changed

Lines changed: 456 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ Precedence: INTERSECT > UNION/EXCEPT (which are co-equal, left-to-right). Mismat
154154
ORDER BY: a non-set-op SELECT keeps branch-internal ORDER BY (which can reference non-projected source columns); when a set-op follows the first branch, per-branch ORDER BY → Msg 156. Top-level ORDER BY (after the chain) wraps via `ApplyTopLevelOrderBy` and references first-branch column names only — no source-column fallback. `Selection.HasOrderBy` is the parse-time signal that gates Msg 156 in `CombineSetOps`.
155155

156156
### JOINs
157-
`INNER JOIN ... ON`, bare `JOIN` (= INNER), `LEFT [OUTER] JOIN ... ON`, `CROSS JOIN`. Multi-table chains compose left-to-right. Self-joins via alias work. ON-predicate UNKNOWN excludes. Aliases parse with or without `AS`.
157+
`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`.
158+
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; older EF Core's top-N projection shapes that used `OUTER APPLY` now go through `ROW_NUMBER() OVER` + `LEFT JOIN` instead.
158160

159161
### CASE
160162
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.
@@ -240,7 +242,7 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
240242
- Transactions / locks / MVCC.
241243
- `RIGHT JOIN` (rewrite as LEFT with sources swapped); `FULL OUTER JOIN`. Both raise `NotSupportedException` at parse.
242244
- Comma-separated FROM (legacy ANSI-89 join syntax).
243-
- `CROSS APPLY` / `OUTER APPLY` (lateral). Derived tables in FROM don't see outer scope.
245+
- 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.
244246
- `ANY` / `SOME` / `ALL` quantifiers.
245247
- `UNION` / `UNION ALL` inside a subquery body.
246248
- Row-constructor `IN ((1,2), (3,4))`.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end tests for the LINQ shapes EF Core 10 translates to
5+
/// <c>CROSS APPLY</c> / <c>OUTER APPLY</c>: <c>SelectMany</c> over a filtered
6+
/// collection navigation, and the same shape with <c>DefaultIfEmpty</c> for
7+
/// the OUTER variant. EF Core 10's emission for top-N projections moved to
8+
/// <c>ROW_NUMBER() OVER</c> + <c>LEFT JOIN</c>, so APPLY only emerges
9+
/// here — when the inner correlates by something more than top-level
10+
/// equality.
11+
/// </summary>
12+
[TestClass]
13+
public class EFCoreApply
14+
{
15+
public TestContext TestContext { get; set; } = null!;
16+
17+
private static TestDbContext SeededContext()
18+
{
19+
var context = new TestDbContext(TestDbContext.CreateAuthorsSimulation());
20+
context.Authors.AddRange(
21+
new Author { Name = "alice" },
22+
new Author { Name = "bob" },
23+
new Author { Name = "carol" });
24+
_ = context.SaveChanges();
25+
// alice=1: 3 books with scores 10/20/30. bob=2: 1 book with score 5. carol=3: no books.
26+
context.Books.AddRange(
27+
new Book { AuthorId = 1, Title = "B1", Score = 10 },
28+
new Book { AuthorId = 1, Title = "B2", Score = 20 },
29+
new Book { AuthorId = 1, Title = "B3", Score = 30 },
30+
new Book { AuthorId = 2, Title = "B4", Score = 5 });
31+
_ = context.SaveChanges();
32+
return context;
33+
}
34+
35+
[TestMethod]
36+
public void SelectMany_FilteredNavigation_EmitsCrossApply()
37+
{
38+
// EF Core 10 emits CROSS APPLY for SelectMany over a filtered
39+
// collection navigation. Verifies end-to-end correctness against
40+
// the shape probe-confirmed against real SQL Server 2025.
41+
using var context = SeededContext();
42+
var pairs = context.Authors
43+
.SelectMany(a => a.Books.Where(b => b.Score > 10),
44+
(a, b) => new { Author = a.Name, b.Title, b.Score })
45+
.OrderBy(x => x.Author).ThenBy(x => x.Score)
46+
.ToArray();
47+
Assert.HasCount(2, pairs);
48+
Assert.AreEqual(("alice", "B2", 20), (pairs[0].Author, pairs[0].Title, pairs[0].Score));
49+
Assert.AreEqual(("alice", "B3", 30), (pairs[1].Author, pairs[1].Title, pairs[1].Score));
50+
}
51+
52+
[TestMethod]
53+
public void SelectMany_FilterReferencesOuter_EmitsCrossApply()
54+
{
55+
// Inner WHERE references both outer and inner columns —
56+
// can't lift to a JOIN, must be APPLY.
57+
using var context = SeededContext();
58+
var pairs = context.Authors
59+
.SelectMany(a => a.Books.Where(b => b.Score >= a.Id * 5),
60+
(a, b) => new { Author = a.Name, b.Score })
61+
.OrderBy(x => x.Author).ThenBy(x => x.Score)
62+
.ToArray();
63+
// alice (id=1, threshold=5): all 3 books match. bob (id=2, threshold=10): 5 fails.
64+
Assert.HasCount(3, pairs);
65+
Assert.AreEqual("alice", pairs[0].Author);
66+
Assert.AreEqual("alice", pairs[1].Author);
67+
Assert.AreEqual("alice", pairs[2].Author);
68+
}
69+
70+
[TestMethod]
71+
public void SelectMany_NoMatches_DropsOuterRow()
72+
{
73+
// carol has no books → CROSS APPLY drops the row entirely.
74+
using var context = SeededContext();
75+
var names = context.Authors
76+
.SelectMany(a => a.Books, (a, b) => a.Name)
77+
.Distinct()
78+
.OrderBy(n => n)
79+
.ToArray();
80+
CollectionAssert.AreEqual(new[] { "alice", "bob" }, names);
81+
}
82+
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,34 @@ internal class CustomerOrder
284284
public decimal Amount { get; set; }
285285
}
286286

287+
/// <summary>
288+
/// Parent of <see cref="Book"/> with an explicit collection navigation
289+
/// (<see cref="Books"/>). EF Core emits <c>CROSS APPLY</c> for
290+
/// <c>SelectMany(a =&gt; a.Books.Where(...))</c> against this shape — the
291+
/// fidelity oracle for the simulator's APPLY support.
292+
/// </summary>
293+
internal class Author
294+
{
295+
public int Id { get; set; }
296+
297+
[Column(TypeName = "nvarchar(50)")]
298+
public string Name { get; set; } = "";
299+
300+
public List<Book> Books { get; set; } = [];
301+
}
302+
303+
internal class Book
304+
{
305+
public int Id { get; set; }
306+
307+
public int AuthorId { get; set; }
308+
309+
[Column(TypeName = "nvarchar(50)")]
310+
public string Title { get; set; } = "";
311+
312+
public int Score { get; set; }
313+
}
314+
287315
internal class TestDbContext(Simulation simulation) : DbContext
288316
{
289317
public Simulation Simulation { get; set; } = simulation;
@@ -351,6 +379,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
351379

352380
public DbSet<CustomerOrder> CustomerOrders => Set<CustomerOrder>();
353381

382+
public DbSet<Author> Authors => Set<Author>();
383+
384+
public DbSet<Book> Books => Set<Book>();
385+
354386
public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values)
355387
{
356388
var simulation = new Simulation();
@@ -523,6 +555,27 @@ Status nvarchar(20) null
523555
return simulation;
524556
}
525557

558+
public static Simulation CreateAuthorsSimulation()
559+
{
560+
var simulation = new Simulation();
561+
_ = simulation
562+
.CreateOpenConnection()
563+
.CreateCommand("""
564+
create table Authors (
565+
Id int identity(1, 1) not null primary key,
566+
Name nvarchar(50) not null
567+
);
568+
create table Books (
569+
Id int identity(1, 1) not null primary key,
570+
AuthorId int not null,
571+
Title nvarchar(50) not null,
572+
Score int not null
573+
)
574+
""")
575+
.ExecuteNonQuery();
576+
return simulation;
577+
}
578+
526579
public static Simulation CreateCustomersSimulation()
527580
{
528581
var simulation = new Simulation();
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for <c>CROSS APPLY</c> and <c>OUTER APPLY</c>: the right
8+
/// side is a correlated derived table re-executed per left-side row, with
9+
/// the outer row's columns visible inside the inner SELECT's WHERE /
10+
/// projection. CROSS APPLY drops outer rows whose lateral plan yields zero
11+
/// rows; OUTER APPLY null-fills the right side. The shape EF Core 10 emits
12+
/// for <c>SelectMany</c> over a filtered correlated child collection.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class ApplyTests
16+
{
17+
private static DbConnection SeededBlogsPosts()
18+
{
19+
var connection = new Simulation().CreateOpenConnection();
20+
_ = connection.CreateCommand("create table blogs (id int, title varchar(20))").ExecuteNonQuery();
21+
_ = connection.CreateCommand("create table posts (id int, blog_id int, title varchar(20), score int)").ExecuteNonQuery();
22+
_ = connection.CreateCommand("insert into blogs values (1, 'A'), (2, 'B'), (3, 'C')").ExecuteNonQuery();
23+
// Blog 1: 3 posts. Blog 2: 1 low-score post. Blog 3: no posts.
24+
_ = connection.CreateCommand(
25+
"insert into posts values " +
26+
"(1, 1, 'P1', 10), (2, 1, 'P2', 20), (3, 1, 'P3', 30), " +
27+
"(4, 2, 'P4', 5)").ExecuteNonQuery();
28+
return connection;
29+
}
30+
31+
// === CROSS APPLY ===
32+
33+
[TestMethod]
34+
public void CrossApply_FilteredChild_EmitsMatchingPairs()
35+
{
36+
// The shape EF Core 10 emits for SelectMany of a filtered nav.
37+
using var connection = SeededBlogsPosts();
38+
using var reader = connection.CreateCommand(
39+
"select p0.bt, p0.pt from blogs as b " +
40+
"cross apply (select b.title as bt, p.title as pt from posts as p " +
41+
" where b.id = p.blog_id and p.score > 10) as p0").ExecuteReader();
42+
var rows = new List<(string, string)>();
43+
while (reader.Read())
44+
rows.Add((reader.GetString(0), reader.GetString(1)));
45+
CollectionAssert.AreEquivalent(new[] { ("A", "P2"), ("A", "P3") }, rows);
46+
}
47+
48+
[TestMethod]
49+
public void CrossApply_ZeroMatches_DropsOuterRow()
50+
{
51+
// Blog 3 has no posts; CROSS APPLY drops it (INNER-style).
52+
using var connection = SeededBlogsPosts();
53+
var ids = new List<int>();
54+
using var reader = connection.CreateCommand(
55+
"select b.id from blogs as b " +
56+
"cross apply (select 1 as marker from posts as p where p.blog_id = b.id) as p0").ExecuteReader();
57+
while (reader.Read()) ids.Add(reader.GetInt32(0));
58+
CollectionAssert.AreEquivalent(new[] { 1, 1, 1, 2 }, ids);
59+
}
60+
61+
[TestMethod]
62+
public void CrossApply_ExpressionOverOuterColumn()
63+
{
64+
// Probe #9 shape: lateral WHERE references outer column in arithmetic.
65+
using var connection = SeededBlogsPosts();
66+
var rows = new List<(string, int)>();
67+
using var reader = connection.CreateCommand(
68+
"select b.title, p0.score from blogs as b " +
69+
"cross apply (select p.score from posts as p " +
70+
" where b.id = p.blog_id and p.score >= b.id * 5) as p0").ExecuteReader();
71+
while (reader.Read())
72+
rows.Add((reader.GetString(0), reader.GetInt32(1)));
73+
// Blog 1 (id=1, threshold=5): 10, 20, 30. Blog 2 (id=2, threshold=10): 5 fails.
74+
CollectionAssert.AreEquivalent(new[] { ("A", 10), ("A", 20), ("A", 30) }, rows);
75+
}
76+
77+
[TestMethod]
78+
public void CrossApply_TopN_PerOuterRow()
79+
{
80+
// Top-2 posts by score per blog.
81+
using var connection = SeededBlogsPosts();
82+
var rows = new List<(int, string)>();
83+
using var reader = connection.CreateCommand(
84+
"select b.id, p0.title from blogs as b " +
85+
"cross apply (select top(2) p.title, p.score from posts as p " +
86+
" where p.blog_id = b.id order by p.score desc) as p0").ExecuteReader();
87+
while (reader.Read())
88+
rows.Add((reader.GetInt32(0), reader.GetString(1)));
89+
CollectionAssert.AreEquivalent(new[] { (1, "P3"), (1, "P2"), (2, "P4") }, rows);
90+
}
91+
92+
[TestMethod]
93+
public void CrossApply_NoOnPredicateAccepted()
94+
{
95+
// APPLY explicitly forbids ON; the inner WHERE provides correlation.
96+
// This test just confirms that APPLY without ON parses and runs.
97+
using var connection = SeededBlogsPosts();
98+
using var reader = connection.CreateCommand(
99+
"select b.id, p0.score from blogs as b " +
100+
"cross apply (select p.score from posts as p where p.blog_id = b.id) as p0 " +
101+
"order by b.id, p0.score").ExecuteReader();
102+
var rows = new List<(int, int)>();
103+
while (reader.Read()) rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
104+
CollectionAssert.AreEqual(new[] { (1, 10), (1, 20), (1, 30), (2, 5) }, rows);
105+
}
106+
107+
// === OUTER APPLY ===
108+
109+
[TestMethod]
110+
public void OuterApply_ZeroMatches_NullFillsRight()
111+
{
112+
// Blog 3 has no posts; OUTER APPLY emits one NULL row for it.
113+
using var connection = SeededBlogsPosts();
114+
var rows = new List<(int Id, int? Score)>();
115+
using var reader = connection.CreateCommand(
116+
"select b.id, p0.score from blogs as b " +
117+
"outer apply (select p.score from posts as p where p.blog_id = b.id) as p0 " +
118+
"order by b.id, p0.score").ExecuteReader();
119+
while (reader.Read())
120+
rows.Add((reader.GetInt32(0), reader.IsDBNull(1) ? null : reader.GetInt32(1)));
121+
var expected = new List<(int Id, int? Score)> { (1, 10), (1, 20), (1, 30), (2, 5), (3, null) };
122+
CollectionAssert.AreEqual(expected, rows);
123+
}
124+
125+
[TestMethod]
126+
public void OuterApply_AllOuterRowsAppearAtLeastOnce()
127+
{
128+
using var connection = SeededBlogsPosts();
129+
var ids = new HashSet<int>();
130+
using var reader = connection.CreateCommand(
131+
"select b.id from blogs as b " +
132+
"outer apply (select p.id from posts as p where p.blog_id = b.id and p.score > 999) as p0").ExecuteReader();
133+
while (reader.Read()) _ = ids.Add(reader.GetInt32(0));
134+
CollectionAssert.AreEquivalent(new[] { 1, 2, 3 }, ids.ToArray());
135+
}
136+
137+
// === Chained APPLY ===
138+
139+
[TestMethod]
140+
public void CrossApply_MultipleChained_CorrelateLeftToRight()
141+
{
142+
// Two APPLYs in a row: the second references both b and p0.
143+
// Blog 1 (scores 10, 20, 30): p0=10 → p1∈{10,20,30}; p0=20 → p1∈{20,30}; p0=30 → p1∈{30}.
144+
// Blog 2 (score 5): p0=5 → p1∈{5}. Blog 3 has no posts → no rows.
145+
using var connection = SeededBlogsPosts();
146+
var triples = new List<(int, int, int)>();
147+
using var reader = connection.CreateCommand(
148+
"select b.id, p0.score, p1.score from blogs as b " +
149+
"cross apply (select p.score from posts as p where p.blog_id = b.id) as p0 " +
150+
"cross apply (select q.score from posts as q where q.blog_id = b.id and q.score >= p0.score) as p1").ExecuteReader();
151+
while (reader.Read())
152+
triples.Add((reader.GetInt32(0), reader.GetInt32(1), reader.GetInt32(2)));
153+
CollectionAssert.AreEquivalent(
154+
new[] { (1, 10, 10), (1, 10, 20), (1, 10, 30), (1, 20, 20), (1, 20, 30), (1, 30, 30), (2, 5, 5) },
155+
triples);
156+
}
157+
158+
// === Parser rejections ===
159+
160+
[TestMethod]
161+
public void Apply_RejectsOnClause()
162+
{
163+
// CROSS APPLY ... ON ... is not valid syntax (no ON for APPLY).
164+
using var connection = SeededBlogsPosts();
165+
_ = Throws<DbException>(() =>
166+
_ = connection.CreateCommand(
167+
"select 1 from blogs as b " +
168+
"cross apply (select 1 from posts as p where p.blog_id = b.id) as p0 " +
169+
"on b.id = p0.id").ExecuteScalar());
170+
}
171+
172+
[TestMethod]
173+
public void Apply_RequiresParenthesizedSelect()
174+
{
175+
using var connection = SeededBlogsPosts();
176+
_ = Throws<DbException>(() =>
177+
_ = connection.CreateCommand(
178+
"select 1 from blogs as b cross apply posts as p").ExecuteScalar());
179+
}
180+
181+
[TestMethod]
182+
public void OuterApply_AsLeadingKeyword_RequiresApply()
183+
{
184+
// OUTER alone (without LEFT/RIGHT/FULL preceding) only forms OUTER APPLY.
185+
using var connection = SeededBlogsPosts();
186+
_ = Throws<DbException>(() =>
187+
_ = connection.CreateCommand(
188+
"select 1 from blogs as b outer join posts as p on b.id = p.blog_id").ExecuteScalar());
189+
}
190+
}

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace SqlServerSimulator.Parser;
2020
enum ContextualKeyword
2121
{
2222
_ = 0, // Default — current token isn't a contextual keyword.
23+
Apply,
2324
Compatibility_Level,
2425
Configuration,
2526
First,

0 commit comments

Comments
 (0)