Skip to content

Commit 3fbfd81

Browse files
committed
Added JOIN support (INNER / LEFT / CROSS, multi-table chains, qualifier-aware resolution); refactored Selection into partial-class parser/executor split.
1 parent 191c9f2 commit 3fbfd81

7 files changed

Lines changed: 1306 additions & 707 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +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+
- JOINs: `INNER JOIN ... ON`, bare `JOIN ... ON` (treated as INNER), `LEFT [OUTER] JOIN ... ON`, and `CROSS JOIN` (no ON) all parse and execute. Multi-table chains compose left-to-right; self-joins via alias work; ON-predicate three-valued logic excludes UNKNOWN matches (matching SQL Server). Column resolution is qualifier-aware across all sources — a multi-part name like `t1.id` matches only the source whose qualifier (alias-or-table-name) equals `t1`; an unqualified name that resolves in more than one source raises **Msg 209** (`"Ambiguous column name 'X'."`). Aliases parse with or without the `AS` keyword. Row enumeration is a recursive cross-product over `FromSource[]` with each tuple represented as `byte[]?[]` (one byte[] per source, null for the unmatched right side of a LEFT JOIN). **Not modeled**: `RIGHT JOIN` (rewrite as LEFT with sources swapped) and `FULL OUTER JOIN` raise `NotSupportedException` at parse time; comma-separated FROM (legacy ANSI-89 join syntax) is not parsed; `CROSS APPLY` / `OUTER APPLY` (lateral) aren't supported.
8081
- `CASE` expressions: both searched (`CASE WHEN cond THEN ... [ELSE ...] END`) and simple (`CASE input WHEN val THEN ... [ELSE ...] END`) forms parse anywhere an expression is allowed. Branches evaluate in source order; first true predicate wins. UNKNOWN is treated as exclude (matching WHERE), so the simple form's NULL-vs-NULL `WHEN` falls through (`CASE NULL WHEN NULL` → no match). Result type is computed via `SqlType.Promote` across all THEN / ELSE branches and cached on the first `GetSqlType` call; `Run` then coerces matched values to that common type so projection schema stays consistent. No-match-no-ELSE → typed NULL. **Not enforced**: Msg 8133 (real SQL Server raises this when every branch is a bare `NULL` literal — the simulator returns NULL of `int`).
8182
- 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).
8283
- `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.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// 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.
9+
/// </summary>
10+
[TestClass]
11+
public class EFCoreJoin
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
private static TestDbContext SeededContext()
16+
{
17+
var context = new TestDbContext(TestDbContext.CreateCustomersSimulation());
18+
context.Customers.AddRange(
19+
new Customer { Name = "alpha" },
20+
new Customer { Name = "beta" },
21+
new Customer { Name = "gamma" });
22+
_ = context.SaveChanges();
23+
// alpha=1 has 2 orders, beta=2 has 1, gamma=3 has none.
24+
context.CustomerOrders.AddRange(
25+
new CustomerOrder { CustomerId = 1, Amount = 10m },
26+
new CustomerOrder { CustomerId = 1, Amount = 20m },
27+
new CustomerOrder { CustomerId = 2, Amount = 30m });
28+
_ = context.SaveChanges();
29+
return context;
30+
}
31+
32+
[TestMethod]
33+
public void Join_ExplicitInnerJoin_PairsMatching()
34+
{
35+
// EF Core's `Join` LINQ method emits `INNER JOIN`.
36+
using var context = SeededContext();
37+
var pairs = context.Customers
38+
.Join(context.CustomerOrders,
39+
c => c.Id,
40+
o => o.CustomerId,
41+
(c, o) => new { c.Name, o.Amount })
42+
.OrderBy(x => x.Name).ThenBy(x => x.Amount)
43+
.ToArray();
44+
Assert.HasCount(3, pairs);
45+
Assert.AreEqual("alpha", pairs[0].Name); Assert.AreEqual(10m, pairs[0].Amount);
46+
Assert.AreEqual("alpha", pairs[1].Name); Assert.AreEqual(20m, pairs[1].Amount);
47+
Assert.AreEqual("beta", pairs[2].Name); Assert.AreEqual(30m, pairs[2].Amount);
48+
}
49+
50+
[TestMethod]
51+
public void GroupJoin_DefaultIfEmpty_EmitsLeftJoin()
52+
{
53+
// The classic EF Core pattern for "left outer join via LINQ":
54+
// GroupJoin + SelectMany + DefaultIfEmpty. Translates to LEFT JOIN.
55+
using var context = SeededContext();
56+
var pairs = context.Customers
57+
.GroupJoin(context.CustomerOrders,
58+
c => c.Id,
59+
o => o.CustomerId,
60+
(c, orders) => new { c.Name, Orders = orders })
61+
.SelectMany(x => x.Orders.DefaultIfEmpty(),
62+
(c, o) => new { c.Name, Amount = o == null ? (decimal?)null : o.Amount })
63+
.OrderBy(x => x.Name).ThenBy(x => x.Amount)
64+
.ToArray();
65+
// Expected: alpha (10), alpha (20), beta (30), gamma (NULL).
66+
Assert.HasCount(4, pairs);
67+
Assert.AreEqual("gamma", pairs[3].Name);
68+
Assert.IsNull(pairs[3].Amount);
69+
}
70+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for SQL Server's JOIN forms: <c>INNER JOIN</c>,
8+
/// <c>LEFT [OUTER] JOIN</c>, bare <c>JOIN</c> (treated as INNER),
9+
/// <c>CROSS JOIN</c>, multi-table chains, self-joins via alias, and the
10+
/// shared rules — qualifier-aware column resolution (Msg 209 on
11+
/// unqualified ambiguity), ON-predicate 3VL semantics (UNKNOWN doesn't
12+
/// match), and parser rejections (INNER JOIN without ON, CROSS JOIN
13+
/// with ON). RIGHT JOIN is intentionally not modeled — see
14+
/// <see cref="RightJoin_NotSupported"/>.
15+
/// </summary>
16+
[TestClass]
17+
public sealed class JoinTests
18+
{
19+
private static DbConnection SeededAB()
20+
{
21+
var connection = new Simulation().CreateOpenConnection();
22+
_ = connection.CreateCommand("create table a (id int, name varchar(20))").ExecuteNonQuery();
23+
_ = connection.CreateCommand("create table b (id int, a_id int, val int)").ExecuteNonQuery();
24+
_ = connection.CreateCommand("insert into a values (1, 'one'), (2, 'two'), (3, 'three')").ExecuteNonQuery();
25+
_ = connection.CreateCommand("insert into b values (10, 1, 100), (11, 1, 200), (12, 2, 300)").ExecuteNonQuery();
26+
return connection;
27+
}
28+
29+
private static List<(int, int)> ReadIntPairs(DbCommand command)
30+
{
31+
using var reader = command.ExecuteReader();
32+
var rows = new List<(int, int)>();
33+
while (reader.Read())
34+
rows.Add((reader.GetInt32(0), reader.IsDBNull(1) ? -1 : reader.GetInt32(1)));
35+
return rows;
36+
}
37+
38+
// === INNER JOIN ===
39+
40+
[TestMethod]
41+
public void InnerJoin_BasicMatch()
42+
{
43+
using var connection = SeededAB();
44+
var rows = ReadIntPairs(connection.CreateCommand(
45+
"select a.id, b.val from a inner join b on a.id = b.a_id"));
46+
CollectionAssert.AreEquivalent(new[] { (1, 100), (1, 200), (2, 300) }, rows);
47+
}
48+
49+
[TestMethod]
50+
public void InnerJoin_BareJoinKeyword_TreatedAsInner()
51+
{
52+
using var connection = SeededAB();
53+
var rows = ReadIntPairs(connection.CreateCommand(
54+
"select a.id, b.val from a join b on a.id = b.a_id"));
55+
CollectionAssert.AreEquivalent(new[] { (1, 100), (1, 200), (2, 300) }, rows);
56+
}
57+
58+
[TestMethod]
59+
public void InnerJoin_UnmatchedRowsExcluded()
60+
{
61+
// a.id=3 has no matching b row, so the row is excluded from INNER JOIN.
62+
using var connection = SeededAB();
63+
var matched = new List<int>();
64+
using var reader = connection.CreateCommand(
65+
"select a.id from a inner join b on a.id = b.a_id").ExecuteReader();
66+
while (reader.Read()) matched.Add(reader.GetInt32(0));
67+
// 1 (matches b twice), 1, 2.
68+
CollectionAssert.AreEquivalent(new[] { 1, 1, 2 }, matched);
69+
}
70+
71+
[TestMethod]
72+
public void InnerJoin_MissingOn_RaisesSyntaxError()
73+
{
74+
var simulation = new Simulation();
75+
_ = simulation.ExecuteNonQuery("create table a (id int)");
76+
_ = simulation.ExecuteNonQuery("create table b (id int)");
77+
_ = Throws<DbException>(() => _ = simulation.ExecuteScalar("select 1 from a inner join b"));
78+
}
79+
80+
// === LEFT JOIN ===
81+
82+
[TestMethod]
83+
public void LeftJoin_NullFillsUnmatchedRight()
84+
{
85+
using var connection = SeededAB();
86+
var rows = ReadIntPairs(connection.CreateCommand(
87+
"select a.id, b.val from a left join b on a.id = b.a_id"));
88+
// a.id=3 has no match; b.val is NULL (we map to -1 for the test pair).
89+
CollectionAssert.AreEquivalent(new[] { (1, 100), (1, 200), (2, 300), (3, -1) }, rows);
90+
}
91+
92+
[TestMethod]
93+
public void LeftJoin_LeftOuterSpelling_Equivalent()
94+
{
95+
using var connection = SeededAB();
96+
var rows = ReadIntPairs(connection.CreateCommand(
97+
"select a.id, b.val from a left outer join b on a.id = b.a_id"));
98+
CollectionAssert.AreEquivalent(new[] { (1, 100), (1, 200), (2, 300), (3, -1) }, rows);
99+
}
100+
101+
[TestMethod]
102+
public void LeftJoin_IsNullPattern_FindsUnmatched()
103+
{
104+
// The classic "find rows in A with no match in B" pattern.
105+
using var connection = SeededAB();
106+
var matched = new List<int>();
107+
using var reader = connection.CreateCommand(
108+
"select a.id from a left join b on a.id = b.a_id where b.val is null").ExecuteReader();
109+
while (reader.Read()) matched.Add(reader.GetInt32(0));
110+
CollectionAssert.AreEqual(new[] { 3 }, matched);
111+
}
112+
113+
// === CROSS JOIN ===
114+
115+
[TestMethod]
116+
public void CrossJoin_CartesianProduct()
117+
{
118+
using var connection = SeededAB();
119+
// a has 3 rows, b has 3 rows → 9.
120+
AreEqual(9, connection.CreateCommand("select count(*) from a cross join b").ExecuteScalar());
121+
}
122+
123+
[TestMethod]
124+
public void CrossJoin_WithOn_RaisesSyntaxError()
125+
{
126+
var simulation = new Simulation();
127+
_ = simulation.ExecuteNonQuery("create table a (id int)");
128+
_ = simulation.ExecuteNonQuery("create table b (id int)");
129+
_ = Throws<DbException>(() => _ = simulation.ExecuteScalar("select 1 from a cross join b on 1=1"));
130+
}
131+
132+
// === Multi-table chain ===
133+
134+
[TestMethod]
135+
public void Chain_InnerThenLeft_Composes()
136+
{
137+
var simulation = new Simulation();
138+
_ = simulation.ExecuteNonQuery("create table a (id int, name varchar(20))");
139+
_ = simulation.ExecuteNonQuery("create table b (id int, a_id int, val int)");
140+
_ = simulation.ExecuteNonQuery("create table c (id int, b_id int, label varchar(20))");
141+
_ = simulation.ExecuteNonQuery("insert into a values (1, 'one'), (2, 'two')");
142+
_ = simulation.ExecuteNonQuery("insert into b values (10, 1, 100), (11, 1, 200), (12, 2, 300)");
143+
_ = simulation.ExecuteNonQuery("insert into c values (20, 10, 'first'), (21, 12, 'second')");
144+
145+
using var connection = simulation.CreateOpenConnection();
146+
using var reader = connection.CreateCommand(
147+
"select a.name, b.val, c.label from a inner join b on a.id = b.a_id left join c on b.id = c.b_id").ExecuteReader();
148+
var rows = new List<(string, int, string?)>();
149+
while (reader.Read())
150+
rows.Add((reader.GetString(0), reader.GetInt32(1), reader.IsDBNull(2) ? null : reader.GetString(2)));
151+
CollectionAssert.AreEquivalent(new (string, int, string?)[]
152+
{
153+
("one", 100, "first"),
154+
("one", 200, null),
155+
("two", 300, "second"),
156+
}, rows);
157+
}
158+
159+
// === Self-join via aliases ===
160+
161+
[TestMethod]
162+
public void SelfJoin_DifferentAliases_DistinguishCopies()
163+
{
164+
var simulation = new Simulation();
165+
_ = simulation.ExecuteNonQuery("create table a (id int, name varchar(20))");
166+
_ = simulation.ExecuteNonQuery("insert into a values (1, 'one'), (2, 'two')");
167+
168+
using var connection = simulation.CreateOpenConnection();
169+
using var reader = connection.CreateCommand(
170+
"select t1.name, t2.name from a t1 inner join a t2 on t1.id <> t2.id").ExecuteReader();
171+
var pairs = new List<(string, string)>();
172+
while (reader.Read()) pairs.Add((reader.GetString(0), reader.GetString(1)));
173+
CollectionAssert.AreEquivalent(new[] { ("one", "two"), ("two", "one") }, pairs);
174+
}
175+
176+
// === Column resolution ===
177+
178+
[TestMethod]
179+
public void Ambiguous_UnqualifiedColumn_RaisesMsg209()
180+
{
181+
using var connection = SeededAB();
182+
// Both a and b have an `id` column; bare `id` is ambiguous.
183+
var ex = Throws<DbException>(() => _ = connection.CreateCommand(
184+
"select id from a inner join b on a.id = b.a_id").ExecuteScalar());
185+
AreEqual("209", ex.Data["HelpLink.EvtID"]);
186+
AreEqual("Ambiguous column name 'id'.", ex.Message);
187+
}
188+
189+
[TestMethod]
190+
public void OnPredicate_NullEqualsNull_ExcludesRow()
191+
{
192+
// Two-table inner join where both columns can be NULL. ON `x.k = y.k`
193+
// with NULLs on both sides → UNKNOWN → excluded (3VL).
194+
var simulation = new Simulation();
195+
_ = simulation.ExecuteNonQuery("create table x (k int null)");
196+
_ = simulation.ExecuteNonQuery("create table y (k int null)");
197+
_ = simulation.ExecuteNonQuery("insert into x values (1), (null)");
198+
_ = simulation.ExecuteNonQuery("insert into y values (1), (null)");
199+
200+
using var connection = simulation.CreateOpenConnection();
201+
var rows = ReadIntPairs(connection.CreateCommand(
202+
"select x.k, y.k from x inner join y on x.k = y.k"));
203+
CollectionAssert.AreEqual(new[] { (1, 1) }, rows);
204+
}
205+
206+
// === RIGHT JOIN — explicitly not supported ===
207+
208+
[TestMethod]
209+
public void RightJoin_NotSupported()
210+
{
211+
var simulation = new Simulation();
212+
_ = simulation.ExecuteNonQuery("create table a (id int)");
213+
_ = simulation.ExecuteNonQuery("create table b (id int)");
214+
_ = Throws<NotSupportedException>(() =>
215+
_ = simulation.ExecuteScalar("select 1 from a right join b on a.id = b.id"));
216+
}
217+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser;
4+
5+
/// <summary>
6+
/// Per-source state captured during a SELECT's FROM clause parsing — one
7+
/// instance per table (or derived table) that participates in the row
8+
/// stream. Bundles the column metadata, the underlying byte stream, and
9+
/// any qualifier (alias or table name) used for column resolution.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// Pre-JOIN, every Selection had exactly one source; multi-table FROM
14+
/// (with <see cref="JoinSpec"/>) extends that to <c>FromSource[]</c>.
15+
/// Column lookup walks the array in source order; a qualified reference
16+
/// (<c>alias.col</c> / <c>tableName.col</c>) restricts to the matching
17+
/// source, an unqualified reference searches all and raises Msg 209 when
18+
/// the column name appears in more than one.
19+
/// </para>
20+
/// <para>
21+
/// <see cref="StoredSchema"/> equals <see cref="Columns"/> for ordinary
22+
/// heap rows but diverges when computed-column projections are stripped
23+
/// from storage; <see cref="StorageOrdinals"/> maps logical (Columns)
24+
/// indices to physical (StoredSchema) indices and is null for derived
25+
/// tables that don't have a separate stored layout.
26+
/// </para>
27+
/// </remarks>
28+
internal sealed class FromSource(
29+
string? qualifier,
30+
string[] columnNames,
31+
HeapColumn[] columns,
32+
HeapColumn[] storedSchema,
33+
int[]? storageOrdinals,
34+
Heap? lobStore,
35+
IEnumerable<byte[]> rows)
36+
{
37+
public readonly string? Qualifier = qualifier;
38+
public readonly string[] ColumnNames = columnNames;
39+
public readonly HeapColumn[] Columns = columns;
40+
public readonly HeapColumn[] StoredSchema = storedSchema;
41+
public readonly int[]? StorageOrdinals = storageOrdinals;
42+
public readonly Heap? LobStore = lobStore;
43+
public readonly IEnumerable<byte[]> Rows = rows;
44+
}
45+
46+
/// <summary>
47+
/// The variants of JOIN the simulator parses. <c>Inner</c> includes the
48+
/// bare <c>JOIN</c> keyword (which SQL Server treats as INNER) and the
49+
/// explicit <c>INNER JOIN</c>. <c>Left</c> covers <c>LEFT [OUTER] JOIN</c>.
50+
/// <c>Cross</c> is the unconditional Cartesian product (and rejects ON).
51+
/// <c>RIGHT JOIN</c> and <c>FULL OUTER JOIN</c> aren't modeled — the
52+
/// parser raises <see cref="NotSupportedException"/>; rewrite RIGHT as
53+
/// LEFT with sources reversed.
54+
/// </summary>
55+
internal enum JoinKind
56+
{
57+
Inner,
58+
Left,
59+
Cross,
60+
}
61+
62+
/// <summary>
63+
/// Describes how the next <see cref="FromSource"/> joins to the
64+
/// accumulated row tuple. <see cref="OnPredicate"/> is null only for
65+
/// <see cref="JoinKind.Cross"/>; the parser enforces the pairing.
66+
/// </summary>
67+
internal sealed class JoinSpec(JoinKind kind, BooleanExpression? onPredicate)
68+
{
69+
public readonly JoinKind Kind = kind;
70+
public readonly BooleanExpression? OnPredicate = onPredicate;
71+
}

0 commit comments

Comments
 (0)