Skip to content

Commit dceec63

Browse files
committed
RIGHT [OUTER] JOIN and FULL [OUTER] JOIN against base / view / system-table right sides (parser cases for both keyword spellings; lateral / derived-table right raises NotSupportedException since real SQL Server's Msg 4104 rejection of correlated-right can't be statically distinguished from non-correlated). JoinDriver refactored from level-recursive to an operator-pipeline fold over joins[]: INNER/CROSS/LEFT/CROSS-APPLY/OUTER-APPLY stream, RIGHT/FULL materialize right rows + track a matched bitmap across the upstream iteration, then emit unmatched-right rows with NULL-filled left slots after upstream exhausts. EF Core 10 LINQ LeftJoin/RightJoin operators covered end-to-end; FullJoin has no .NET 10 LINQ counterpart so FULL OUTER stays raw-SQL-only.
1 parent 805a739 commit dceec63

6 files changed

Lines changed: 492 additions & 132 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Layout: `Storage/` (pages, types, row encoder/decoder, heap, constraints), `Pars
4646
`Selection.cs` + `Selection.Execution.cs` are a partial-class pair. `Parse → Selection`, `Execute → SimulatedSqlResultSet`. Correlated subqueries re-run the same plan per outer row via `outerResolver: Func<MultiPartName, SqlValue>?` (execute) and `outerTypeResolver: Func<MultiPartName, SqlType>?` (parse). Both walk arbitrary nesting depth via `ParserContext.OuterTypeResolver` + the runtime arg. **Derived tables in FROM are always deferred** (`FromSource.LateralPlan` is re-executed per outer row), matching SQL Server's "any FROM derived table can correlate" rule — required because outer references in WHERE/ON resolve through `Run`, not `GetSqlType`.
4747

4848
### Multi-source rows
49-
`FromSource[]`; rows during enumeration are `byte[]?[]`, one slot per source, null = unmatched LEFT JOIN right side. Column resolution is qualifier-aware via `FindSourceColumn` / `ResolveAcrossTuple`; ambiguous unqualified name → Msg 209.
49+
`FromSource[]`; rows during enumeration are `byte[]?[]`, one slot per source, null = NULL-filled outer-join side (LEFT/RIGHT/FULL/OUTER APPLY). Column resolution is qualifier-aware via `FindSourceColumn` / `ResolveAcrossTuple`; ambiguous unqualified name → Msg 209.
5050

5151
### `MultiPartName`
5252
Readonly struct, up to 4 inline slots (SQL Server's grammar limit). API: `Leaf`, `ImmediateQualifier` (null when unqualified — pair with `Collation.Default.Equals(name.ImmediateQualifier, "INSERTED")`, the equality folds null into `false`), `Count`, `ToString()`. 5th segment → Msg 4104.
@@ -95,7 +95,9 @@ Five scopes, one home each. **Add new state to whichever class matches its true
9595
The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contract. Notes below cover only probe-confirmed quirks, deviations from SQL Server, and non-obvious implementation rules. Per-feature deep-dives live under `docs/claude/` (see [Feature reference](#feature-reference) for the trigger-phrased index); short cross-cutting sections stay inline below.
9696

9797
### JOINs / APPLY
98-
INNER / bare JOIN / LEFT [OUTER] / CROSS / CROSS APPLY / OUTER APPLY. Multi-table chains compose left-to-right. ON-predicate UNKNOWN excludes. APPLY = lateral form (right side re-executed per outer row, no ON clause).
98+
INNER / bare JOIN / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS / CROSS APPLY / OUTER APPLY. Multi-table chains compose left-to-right. ON-predicate UNKNOWN excludes. APPLY = lateral form (right side re-executed per outer row, no ON clause).
99+
100+
`JoinDriver` is a fold over `joins[]`: leftmost rowset → wrap with each join's operator → final enumerator (`Selection.Execution.Joins.cs`). INNER / CROSS / LEFT / CROSS APPLY / OUTER APPLY stream one upstream tuple at a time; RIGHT / FULL materialize `sources[level].Rows` into a list and track a `matched[]` bitmap across the entire upstream iteration so unmatched right rows can be emitted (with all prior slots NULL-filled) after upstream is exhausted. **RIGHT / FULL with a derived-table or lateral right side raise `NotSupportedException`** — every derived table is deferred regardless of actual correlation, and real SQL Server rejects correlated subqueries on the right of RIGHT/FULL with Msg 4104. EF Core 10's LINQ `LeftJoin` / `RightJoin` operators translate to LEFT / RIGHT JOIN respectively and route through this pipeline; .NET 10 LINQ doesn't expose a `FullJoin` operator, so FULL OUTER JOIN is reachable only via raw SQL.
99101

100102
### Subqueries
101103
`EXISTS` / `NOT EXISTS` (multi-column inner allowed); `expr [NOT] IN (SELECT ...)` (single inner column, Msg 116); scalar `(SELECT col FROM ...)` (single column, single-row Msg 512 per outer row, empty → typed NULL). All forms work correlated and non-correlated, arbitrary nesting depth.
@@ -151,7 +153,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
151153
## Not modeled
152154

153155
- Locks / MVCC / isolation levels (single-Simulation, single-thread-at-a-time). `BEGIN DISTRIBUTED TRANSACTION`, `BEGIN TRANSACTION ... WITH MARK`, `XACT_ABORT`, `SET TRANSACTION ISOLATION LEVEL` not parsed.
154-
- `RIGHT JOIN` (rewrite as LEFT with sources swapped) and `FULL OUTER JOIN`both raise `NotSupportedException` at parse.
156+
- `RIGHT JOIN` / `FULL OUTER JOIN` with a derived-table or lateral right sidebase tables / views / system tables on the right ship (see JOINs / APPLY); a derived-table right (always-deferred in this simulator) raises `NotSupportedException` since real SQL Server's Msg 4104 rejection of correlated subqueries on the right of RIGHT/FULL can't be statically distinguished from non-correlated ones.
155157
- Comma-separated FROM (legacy ANSI-89 join syntax).
156158
- `ANY` / `SOME` / `ALL` quantifiers.
157159
- `UNION` / `UNION ALL` inside a subquery body.

SqlServerSimulator.Tests.EFCore/EFCoreJoin.cs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ namespace SqlServerSimulator;
22

33
/// <summary>
44
/// End-to-end tests for the JOIN shapes EF Core's SqlServer provider
5-
/// emits — explicit LINQ <c>Join</c> (translates to <c>INNER JOIN</c>)
6-
/// and the navigation / projection patterns that translate to
7-
/// <c>LEFT JOIN</c>. Validates that the simulator's multi-source row
8-
/// pipeline matches EF Core's expectations end-to-end.
5+
/// emits — explicit LINQ <c>Join</c> (translates to <c>INNER JOIN</c>),
6+
/// the navigation / projection patterns that translate to <c>LEFT JOIN</c>,
7+
/// and the .NET 10 / EF Core 10 <c>LeftJoin</c> / <c>RightJoin</c> LINQ
8+
/// operators that translate to <c>LEFT JOIN</c> / <c>RIGHT JOIN</c>.
9+
/// Validates that the simulator's multi-source row pipeline matches EF
10+
/// Core's expectations end-to-end. <c>FULL OUTER JOIN</c> isn't covered
11+
/// here because .NET 10 LINQ doesn't expose a <c>FullJoin</c> operator
12+
/// (the only way to reach it is raw SQL, which belongs in the parser
13+
/// tests, not <c>*.Tests.EFCore</c>).
914
/// </summary>
1015
[TestClass]
1116
public class EFCoreJoin
@@ -47,6 +52,50 @@ public void Join_ExplicitInnerJoin_PairsMatching()
4752
Assert.AreEqual("beta", pairs[2].Name); Assert.AreEqual(30m, pairs[2].Amount);
4853
}
4954

55+
[TestMethod]
56+
public void RightJoin_EmitsRightJoinAndIncludesUnmatchedRight()
57+
{
58+
// EF Core 10's RightJoin LINQ operator translates to RIGHT JOIN.
59+
// Seed an orphan order (CustomerId = 99 references no Customer) so the
60+
// unmatched-right path emits with a NULL customer name.
61+
using var context = SeededContext();
62+
_ = context.CustomerOrders.Add(new CustomerOrder { CustomerId = 99, Amount = 99m });
63+
_ = context.SaveChanges();
64+
65+
var pairs = context.Customers
66+
.RightJoin(context.CustomerOrders,
67+
c => c.Id,
68+
o => o.CustomerId,
69+
(c, o) => new { CustomerName = c == null ? null : c.Name, o.Amount })
70+
.OrderBy(x => x.Amount)
71+
.ToArray();
72+
Assert.HasCount(4, pairs);
73+
Assert.AreEqual("alpha", pairs[0].CustomerName); Assert.AreEqual(10m, pairs[0].Amount);
74+
Assert.AreEqual("alpha", pairs[1].CustomerName); Assert.AreEqual(20m, pairs[1].Amount);
75+
Assert.AreEqual("beta", pairs[2].CustomerName); Assert.AreEqual(30m, pairs[2].Amount);
76+
Assert.IsNull(pairs[3].CustomerName); Assert.AreEqual(99m, pairs[3].Amount);
77+
}
78+
79+
[TestMethod]
80+
public void LeftJoin_EmitsLeftJoinAndIncludesUnmatchedLeft()
81+
{
82+
// EF Core 10's LeftJoin LINQ operator translates to LEFT JOIN. The
83+
// GroupJoin/DefaultIfEmpty pattern below covers the same shape; this
84+
// exercises the explicit operator path through the join pipeline.
85+
using var context = SeededContext();
86+
var pairs = context.Customers
87+
.LeftJoin(context.CustomerOrders,
88+
c => c.Id,
89+
o => o.CustomerId,
90+
(c, o) => new { c.Name, Amount = o == null ? (decimal?)null : o.Amount })
91+
.OrderBy(x => x.Name).ThenBy(x => x.Amount)
92+
.ToArray();
93+
// gamma has no orders → null Amount.
94+
Assert.HasCount(4, pairs);
95+
Assert.AreEqual("gamma", pairs[3].Name);
96+
Assert.IsNull(pairs[3].Amount);
97+
}
98+
5099
[TestMethod]
51100
public void GroupJoin_DefaultIfEmpty_EmitsLeftJoin()
52101
{

SqlServerSimulator.Tests/JoinTests.cs

Lines changed: 171 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ namespace SqlServerSimulator;
55

66
/// <summary>
77
/// Behavioral tests for SQL Server's JOIN forms: INNER / bare JOIN / LEFT [OUTER] /
8-
/// CROSS, multi-table chains, self-joins via alias. Shared rules: qualifier-aware
9-
/// resolution (Msg 209 on ambiguity), ON-predicate 3VL semantics, parser rejections.
10-
/// RIGHT JOIN intentionally not modeled.
8+
/// RIGHT [OUTER] / FULL [OUTER] / CROSS, multi-table chains, self-joins via alias.
9+
/// Shared rules: qualifier-aware resolution (Msg 209 on ambiguity), ON-predicate 3VL
10+
/// semantics, parser rejections. RIGHT / FULL reject a derived-table right side.
1111
/// </summary>
1212
[TestClass]
1313
public sealed class JoinTests
@@ -29,7 +29,10 @@ insert b values (10, 1, 100), (11, 1, 200), (12, 2, 300)
2929
using var reader = command.ExecuteReader();
3030
var rows = new List<(int, int)>();
3131
while (reader.Read())
32-
rows.Add((reader.GetInt32(0), reader.IsDBNull(1) ? -1 : reader.GetInt32(1)));
32+
{
33+
rows.Add((reader.IsDBNull(0) ? -1 : reader.GetInt32(0),
34+
reader.IsDBNull(1) ? -1 : reader.GetInt32(1)));
35+
}
3336
return rows;
3437
}
3538

@@ -184,11 +187,173 @@ insert y values (1), (null)
184187
CollectionAssert.AreEqual(new[] { (1, 1) }, rows);
185188
}
186189

190+
/// <summary>
191+
/// Seed asymmetric x/y tables where left has an unmatched row (x=3 → no y) and
192+
/// right has an unmatched row (y=4 → no x). Shared by all RIGHT / FULL tests so the
193+
/// asymmetric paths actually exercise the unmatched-side NULL fill.
194+
/// </summary>
195+
private static DbConnection SeededXY()
196+
{
197+
var connection = new Simulation().CreateOpenConnection();
198+
_ = connection.CreateCommand("""
199+
create table x (id int);
200+
create table y (x_id int);
201+
insert x values (1), (2), (3);
202+
insert y values (1), (2), (4)
203+
""").ExecuteNonQuery();
204+
return connection;
205+
}
206+
207+
[TestMethod]
208+
public void RightJoin_BasicMatchAndOrphanRight()
209+
{
210+
using var connection = SeededXY();
211+
var rows = ReadIntPairs(connection.CreateCommand("select x.id, y.x_id from x right join y on x.id = y.x_id"));
212+
CollectionAssert.AreEquivalent(new[] { (1, 1), (2, 2), (-1, 4) }, rows);
213+
}
214+
215+
[TestMethod]
216+
public void RightOuterJoin_KeywordSpelling_Equivalent()
217+
{
218+
using var connection = SeededXY();
219+
var rows = ReadIntPairs(connection.CreateCommand("select x.id, y.x_id from x right outer join y on x.id = y.x_id"));
220+
CollectionAssert.AreEquivalent(new[] { (1, 1), (2, 2), (-1, 4) }, rows);
221+
}
222+
223+
[TestMethod]
224+
public void FullJoin_EmitsBothUnmatchedSides()
225+
{
226+
using var connection = SeededXY();
227+
var rows = ReadIntPairs(connection.CreateCommand("select x.id, y.x_id from x full join y on x.id = y.x_id"));
228+
CollectionAssert.AreEquivalent(new[] { (1, 1), (2, 2), (3, -1), (-1, 4) }, rows);
229+
}
230+
231+
[TestMethod]
232+
public void FullOuterJoin_KeywordSpelling_Equivalent()
233+
{
234+
using var connection = SeededXY();
235+
var rows = ReadIntPairs(connection.CreateCommand("select x.id, y.x_id from x full outer join y on x.id = y.x_id"));
236+
CollectionAssert.AreEquivalent(new[] { (1, 1), (2, 2), (3, -1), (-1, 4) }, rows);
237+
}
238+
239+
[TestMethod]
240+
public void RightJoin_NullKey_DoesNotMatch()
241+
{
242+
var connection = new Simulation().CreateOpenConnection();
243+
_ = connection.CreateCommand("""
244+
create table a (id int);
245+
create table b (a_id int);
246+
insert a values (1), (null);
247+
insert b values (1), (null)
248+
""").ExecuteNonQuery();
249+
250+
// ON uses three-valued equality: NULL = NULL is UNKNOWN, so the NULL rows
251+
// never match and both pair as their respective outer-side row.
252+
var rows = ReadIntPairs(connection.CreateCommand("select a.id, b.a_id from a right join b on a.id = b.a_id"));
253+
CollectionAssert.AreEquivalent(new[] { (1, 1), (-1, -1) }, rows);
254+
}
255+
256+
[TestMethod]
257+
public void RightJoin_NoLeftRows_AllRightEmittedNullLeft()
258+
{
259+
var connection = new Simulation().CreateOpenConnection();
260+
_ = connection.CreateCommand("""
261+
create table a (id int);
262+
create table b (id int);
263+
insert b values (10), (20)
264+
""").ExecuteNonQuery();
265+
266+
var rows = ReadIntPairs(connection.CreateCommand("select a.id, b.id from a right join b on a.id = b.id"));
267+
CollectionAssert.AreEquivalent(new[] { (-1, 10), (-1, 20) }, rows);
268+
}
269+
270+
[TestMethod]
271+
public void RightJoin_NoRightRows_EmitsNothing()
272+
{
273+
var connection = new Simulation().CreateOpenConnection();
274+
_ = connection.CreateCommand("""
275+
create table a (id int);
276+
create table b (id int);
277+
insert a values (1), (2)
278+
""").ExecuteNonQuery();
279+
280+
using var reader = connection.CreateCommand("select a.id, b.id from a right join b on a.id = b.id").ExecuteReader();
281+
IsFalse(reader.Read());
282+
}
283+
284+
[TestMethod]
285+
public void RightJoin_ChainedAfterInnerJoin()
286+
{
287+
// Three-table chain: (x INNER JOIN z) RIGHT JOIN y. The RIGHT JOIN's
288+
// left side is the materialized (x,z) rowset; unmatched y rows emit
289+
// with both x and z slots NULL-filled.
290+
var connection = new Simulation().CreateOpenConnection();
291+
_ = connection.CreateCommand("""
292+
create table x (id int);
293+
create table y (x_id int);
294+
create table z (id int);
295+
insert x values (1), (2);
296+
insert y values (1), (2), (4);
297+
insert z values (1), (2)
298+
""").ExecuteNonQuery();
299+
300+
var rows = new List<(int, int, int)>();
301+
using var reader = connection.CreateCommand(
302+
"select x.id, z.id, y.x_id from x inner join z on x.id = z.id right join y on x.id = y.x_id").ExecuteReader();
303+
while (reader.Read())
304+
{
305+
rows.Add((reader.IsDBNull(0) ? -1 : reader.GetInt32(0),
306+
reader.IsDBNull(1) ? -1 : reader.GetInt32(1),
307+
reader.GetInt32(2)));
308+
}
309+
CollectionAssert.AreEquivalent(new[] { (1, 1, 1), (2, 2, 2), (-1, -1, 4) }, rows);
310+
}
311+
312+
[TestMethod]
313+
public void FullJoin_ChainedAfterLeftJoin()
314+
{
315+
// (a LEFT JOIN b) FULL JOIN c: tests that left-side NULL slots from
316+
// the upstream LEFT JOIN don't poison the FULL JOIN's matched-bitmap
317+
// tracking, and that FULL's unmatched-right phase clears all prior slots.
318+
var connection = new Simulation().CreateOpenConnection();
319+
_ = connection.CreateCommand("""
320+
create table a (id int);
321+
create table b (a_id int);
322+
create table c (id int);
323+
insert a values (1), (2);
324+
insert b values (1);
325+
insert c values (1), (3)
326+
""").ExecuteNonQuery();
327+
328+
var rows = new List<(int, int, int)>();
329+
using var reader = connection.CreateCommand(
330+
"select a.id, b.a_id, c.id from a left join b on a.id = b.a_id full join c on a.id = c.id").ExecuteReader();
331+
while (reader.Read())
332+
{
333+
rows.Add((reader.IsDBNull(0) ? -1 : reader.GetInt32(0),
334+
reader.IsDBNull(1) ? -1 : reader.GetInt32(1),
335+
reader.IsDBNull(2) ? -1 : reader.GetInt32(2)));
336+
}
337+
// a=1 LEFT b → (1,1); a=2 LEFT b → (2,NULL). FULL c on a.id=c.id:
338+
// (1,1) matches c=1 → (1,1,1);
339+
// (2,NULL) no c match → (2,NULL,NULL);
340+
// c=3 unmatched → (NULL,NULL,3).
341+
CollectionAssert.AreEquivalent(new[] { (1, 1, 1), (2, -1, -1), (-1, -1, 3) }, rows);
342+
}
343+
344+
[TestMethod]
345+
public void RightJoin_DerivedTableRightSide_NotSupported()
346+
=> _ = Throws<NotSupportedException>(() => _ = new Simulation().ExecuteScalar("""
347+
create table a (id int);
348+
create table b (id int);
349+
select 1 from a right join (select id from b) bx on a.id = bx.id
350+
"""));
351+
187352
[TestMethod]
188-
public void RightJoin_NotSupported()
353+
public void FullJoin_DerivedTableRightSide_NotSupported()
189354
=> _ = Throws<NotSupportedException>(() => _ = new Simulation().ExecuteScalar("""
190355
create table a (id int);
191356
create table b (id int);
192-
select 1 from a right join b on a.id = b.id
357+
select 1 from a full join (select id from b) bx on a.id = bx.id
193358
"""));
194359
}

SqlServerSimulator/Parser/FromSource.cs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,34 @@ internal enum SetOpKind
9595
/// <summary>
9696
/// The variants of JOIN the simulator parses. <c>Inner</c> includes the
9797
/// bare <c>JOIN</c> keyword (which SQL Server treats as INNER) and the
98-
/// explicit <c>INNER JOIN</c>. <c>Left</c> covers <c>LEFT [OUTER] JOIN</c>.
99-
/// <c>Cross</c> is the unconditional Cartesian product (and rejects ON).
100-
/// <c>RIGHT JOIN</c> and <c>FULL OUTER JOIN</c> aren't modeled — the
101-
/// parser raises <see cref="NotSupportedException"/>; rewrite RIGHT as
102-
/// LEFT with sources reversed.
98+
/// explicit <c>INNER JOIN</c>. <c>Left</c> covers <c>LEFT [OUTER] JOIN</c>,
99+
/// <c>Right</c> covers <c>RIGHT [OUTER] JOIN</c>, <c>Full</c> covers
100+
/// <c>FULL [OUTER] JOIN</c>. <c>Cross</c> is the unconditional Cartesian
101+
/// product (and rejects ON).
103102
/// </summary>
104103
internal enum JoinKind
105104
{
106105
Inner,
107106
Left,
107+
108+
/// <summary>
109+
/// <c>RIGHT [OUTER] JOIN</c>: right rows missing a left match emit
110+
/// with the left side null-filled; left rows missing a right match
111+
/// are dropped. Executed by materializing the right source and
112+
/// tracking a matched bitmap across the entire left iteration —
113+
/// requires a non-lateral right side (probe-confirmed: real SQL
114+
/// Server rejects correlated derived tables on the right of
115+
/// RIGHT / FULL with Msg 4104).
116+
/// </summary>
117+
Right,
118+
119+
/// <summary>
120+
/// <c>FULL [OUTER] JOIN</c>: matched pairs emit normally; unmatched
121+
/// left rows emit with the right side null-filled; unmatched right
122+
/// rows emit with the left side null-filled. Same lateral-right
123+
/// restriction as <see cref="Right"/>.
124+
/// </summary>
125+
Full,
108126
Cross,
109127

110128
/// <summary>

0 commit comments

Comments
 (0)