Skip to content

Commit f3463f8

Browse files
committed
Added UNION / UNION ALL / INTERSECT / EXCEPT support with INTERSECT-precedence chaining and post-chain top-level ORDER BY.
1 parent 3fbfd81 commit f3463f8

7 files changed

Lines changed: 701 additions & 29 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+
- Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `EXCEPT` all parse and execute. UNION dedupes; UNION ALL preserves duplicates; INTERSECT and EXCEPT both dedupe their inputs. NULLs are equal during set-op dedup / matching (opposite of `=`'s tri-state behavior — matching SQL Server's documented behavior). Result column names come from the first branch; result types are promoted across branches via `SqlType.Promote` and each branch's values are coerced to the combined schema. **Precedence**: INTERSECT binds tighter than UNION/EXCEPT (which are at the same level, left-to-right). Mismatched column count → Msg 205. **ORDER BY rules**: a non-set-op SELECT keeps the existing branch-internal ORDER BY (which can reference non-projected source columns); when a set-op follows the first branch, per-branch ORDER BY is rejected via Msg 156 (matching SQL Server's wording). Top-level ORDER BY (after the chain) wraps the combined result via `ApplyTopLevelOrderBy` and references first-branch column names only — no source-column fallback. `Selection.HasOrderBy` is the parse-time signal that gates per-branch-ORDER-BY-then-set-op rejection in `CombineSetOps`.
8081
- 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.
8182
- `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`).
8283
- 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).
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end tests for the set-operation shapes EF Core's SqlServer
5+
/// provider emits — LINQ <c>Union</c>/<c>Concat</c>/<c>Intersect</c>/
6+
/// <c>Except</c> against another query translate to <c>UNION</c>/
7+
/// <c>UNION ALL</c>/<c>INTERSECT</c>/<c>EXCEPT</c>. Validates that the
8+
/// simulator's set-op pipeline handles EF Core's concrete emit shapes
9+
/// end-to-end.
10+
/// </summary>
11+
[TestClass]
12+
public class EFCoreSetOperations
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+
context.CustomerOrders.AddRange(
25+
new CustomerOrder { CustomerId = 1, Amount = 10m },
26+
new CustomerOrder { CustomerId = 2, Amount = 30m });
27+
_ = context.SaveChanges();
28+
return context;
29+
}
30+
31+
[TestMethod]
32+
public void Concat_EmitsUnionAll()
33+
{
34+
// LINQ Concat preserves duplicates → UNION ALL.
35+
using var context = SeededContext();
36+
var ids = context.Customers.Select(c => c.Id)
37+
.Concat(context.Customers.Select(c => c.Id))
38+
.OrderBy(x => x)
39+
.ToArray();
40+
// Each id appears twice.
41+
CollectionAssert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, ids);
42+
}
43+
44+
[TestMethod]
45+
public void Union_EmitsUnionWithDedup()
46+
{
47+
// LINQ Union dedupes → UNION.
48+
using var context = SeededContext();
49+
var ids = context.Customers.Select(c => c.Id)
50+
.Union(context.Customers.Select(c => c.Id))
51+
.OrderBy(x => x)
52+
.ToArray();
53+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, ids);
54+
}
55+
56+
[TestMethod]
57+
public void Intersect_EmitsIntersect()
58+
{
59+
// Customers ids 1,2,3 INTERSECT Customer ids of those with orders (1,2) → {1,2}.
60+
using var context = SeededContext();
61+
var ids = context.Customers.Select(c => c.Id)
62+
.Intersect(context.CustomerOrders.Select(o => o.CustomerId))
63+
.OrderBy(x => x)
64+
.ToArray();
65+
CollectionAssert.AreEqual(new[] { 1, 2 }, ids);
66+
}
67+
68+
[TestMethod]
69+
public void Except_EmitsExcept()
70+
{
71+
// Customer ids minus customer ids with orders → {3}.
72+
using var context = SeededContext();
73+
var ids = context.Customers.Select(c => c.Id)
74+
.Except(context.CustomerOrders.Select(o => o.CustomerId))
75+
.ToArray();
76+
CollectionAssert.AreEqual(new[] { 3 }, ids);
77+
}
78+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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 set operators: <c>UNION</c> /
8+
/// <c>UNION ALL</c> / <c>INTERSECT</c> / <c>EXCEPT</c>. Covers dedup
9+
/// semantics (NULL-equals-NULL, opposite of <c>=</c>'s tri-state
10+
/// behavior), type promotion across branches, the precedence rule
11+
/// (INTERSECT binds tighter than UNION/EXCEPT), Msg 205 on column-count
12+
/// mismatch, Msg 156 on per-branch ORDER BY, and the top-level ORDER BY
13+
/// that applies post-chain. Sourced from probes against SQL Server 2025.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class SetOperationTests
17+
{
18+
private static List<int> ReadInts(DbCommand command)
19+
{
20+
using var reader = command.ExecuteReader();
21+
var values = new List<int>();
22+
while (reader.Read())
23+
values.Add(reader.GetInt32(0));
24+
return values;
25+
}
26+
27+
// === UNION / UNION ALL ===
28+
29+
[TestMethod]
30+
public void Union_Dedupes()
31+
{
32+
var values = ReadInts(new Simulation().CreateCommand("select 1 union select 2 union select 1"));
33+
CollectionAssert.AreEquivalent(new[] { 1, 2 }, values);
34+
}
35+
36+
[TestMethod]
37+
public void UnionAll_PreservesDuplicates()
38+
{
39+
var values = ReadInts(new Simulation().CreateCommand("select 1 union all select 2 union all select 1"));
40+
CollectionAssert.AreEqual(new[] { 1, 2, 1 }, values);
41+
}
42+
43+
[TestMethod]
44+
public void Union_NullsCompareEqual_DedupedToSingleRow()
45+
{
46+
// SET ops treat NULLs as equal — opposite of `=` operator's UNKNOWN.
47+
using var connection = new Simulation().CreateOpenConnection();
48+
using var reader = connection.CreateCommand(
49+
"select cast(null as int) union select cast(null as int)").ExecuteReader();
50+
var rows = 0;
51+
while (reader.Read())
52+
rows++;
53+
AreEqual(1, rows);
54+
}
55+
56+
// === INTERSECT ===
57+
58+
[TestMethod]
59+
public void Intersect_KeepsCommonRows()
60+
{
61+
var values = ReadInts(new Simulation().CreateCommand("select 1 intersect select 1"));
62+
CollectionAssert.AreEqual(new[] { 1 }, values);
63+
}
64+
65+
[TestMethod]
66+
public void Intersect_NoOverlap_Empty()
67+
{
68+
var values = ReadInts(new Simulation().CreateCommand("select 1 intersect select 2"));
69+
IsEmpty(values);
70+
}
71+
72+
[TestMethod]
73+
public void Intersect_NullsMatch()
74+
{
75+
using var connection = new Simulation().CreateOpenConnection();
76+
using var reader = connection.CreateCommand(
77+
"select cast(null as int) intersect select cast(null as int)").ExecuteReader();
78+
var rows = 0;
79+
while (reader.Read()) rows++;
80+
AreEqual(1, rows);
81+
}
82+
83+
// === EXCEPT ===
84+
85+
[TestMethod]
86+
public void Except_RemovesRightSide()
87+
{
88+
var values = ReadInts(new Simulation().CreateCommand("select 1 except select 2"));
89+
CollectionAssert.AreEqual(new[] { 1 }, values);
90+
}
91+
92+
[TestMethod]
93+
public void Except_AllRemoved_Empty()
94+
{
95+
var values = ReadInts(new Simulation().CreateCommand("select 1 except select 1"));
96+
IsEmpty(values);
97+
}
98+
99+
[TestMethod]
100+
public void Except_DedupesLeftBeforeFiltering()
101+
{
102+
var simulation = new Simulation();
103+
_ = simulation.ExecuteNonQuery("create table t (v int)");
104+
_ = simulation.ExecuteNonQuery("insert into t values (1), (1), (2)");
105+
106+
using var connection = simulation.CreateOpenConnection();
107+
var values = ReadInts(connection.CreateCommand("select v from t except select 99"));
108+
// INTERSECT/EXCEPT both dedupe their left side (probe-confirmed).
109+
CollectionAssert.AreEquivalent(new[] { 1, 2 }, values);
110+
}
111+
112+
// === Type promotion / column count ===
113+
114+
[TestMethod]
115+
public void TypePromotion_IntPlusDecimal_ProducesDecimal()
116+
{
117+
using var connection = new Simulation().CreateOpenConnection();
118+
using var reader = connection.CreateCommand("select 1 union select 2.5").ExecuteReader();
119+
var values = new List<decimal>();
120+
while (reader.Read())
121+
values.Add(reader.GetDecimal(0));
122+
CollectionAssert.AreEquivalent(new[] { 1m, 2.5m }, values);
123+
}
124+
125+
[TestMethod]
126+
public void MismatchedColumnCount_RaisesMsg205()
127+
{
128+
var ex = Throws<DbException>(() =>
129+
_ = new Simulation().ExecuteScalar("select 1, 2 union select 3"));
130+
AreEqual("205", ex.Data["HelpLink.EvtID"]);
131+
AreEqual("All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists.", ex.Message);
132+
}
133+
134+
// === Precedence / chaining ===
135+
136+
[TestMethod]
137+
public void Intersect_BindsTighterThanUnion()
138+
{
139+
// `1 union 2 intersect 2` should parse as `1 union (2 intersect 2)` = {1, 2}.
140+
var values = ReadInts(new Simulation().CreateCommand("select 1 union select 2 intersect select 2"));
141+
CollectionAssert.AreEquivalent(new[] { 1, 2 }, values);
142+
}
143+
144+
[TestMethod]
145+
public void ThreeBranchUnion_LeftAssociative()
146+
{
147+
var values = ReadInts(new Simulation().CreateCommand("select 1 union select 2 union select 3"));
148+
CollectionAssert.AreEquivalent(new[] { 1, 2, 3 }, values);
149+
}
150+
151+
[TestMethod]
152+
public void UnionAllAfterUnion_PreservesDupAtEnd()
153+
{
154+
// `(1 union 2) union all 1` = {1, 2} ++ {1} = {1, 2, 1}.
155+
var values = ReadInts(new Simulation().CreateCommand("select 1 union select 2 union all select 1"));
156+
CollectionAssert.AreEqual(new[] { 1, 2, 1 }, values);
157+
}
158+
159+
// === ORDER BY interaction ===
160+
161+
[TestMethod]
162+
public void TopLevelOrderBy_AppliesToCombinedResult()
163+
{
164+
// ORDER BY at the very end applies to the combined result and
165+
// can reference the first branch's column name.
166+
using var connection = new Simulation().CreateOpenConnection();
167+
using var reader = connection.CreateCommand(
168+
"select 1 as v union select 2 union select 3 order by v desc").ExecuteReader();
169+
var values = new List<int>();
170+
while (reader.Read())
171+
values.Add(reader.GetInt32(0));
172+
CollectionAssert.AreEqual(new[] { 3, 2, 1 }, values);
173+
}
174+
175+
[TestMethod]
176+
public void PerBranchOrderBy_RaisesMsg156()
177+
{
178+
var ex = Throws<DbException>(() =>
179+
_ = new Simulation().ExecuteScalar("select 1 order by 1 union select 2"));
180+
AreEqual("156", ex.Data["HelpLink.EvtID"]);
181+
}
182+
183+
[TestMethod]
184+
public void SingleSelect_OrderByNonProjectedSource_StillWorks()
185+
{
186+
// The set-op refactor must not break the existing branch-internal
187+
// ORDER BY path: a non-set-op SELECT can still ORDER BY a source
188+
// column that's not in the projection list.
189+
var simulation = new Simulation();
190+
_ = simulation.ExecuteNonQuery("create table t (a int, b int)");
191+
_ = simulation.ExecuteNonQuery("insert into t values (3, 30), (1, 10), (2, 20)");
192+
193+
using var connection = simulation.CreateOpenConnection();
194+
using var reader = connection.CreateCommand("select b from t order by a").ExecuteReader();
195+
var values = new List<int>();
196+
while (reader.Read())
197+
values.Add(reader.GetInt32(0));
198+
// a=1,2,3 → b=10,20,30 in that order.
199+
CollectionAssert.AreEqual(new[] { 10, 20, 30 }, values);
200+
}
201+
202+
// === Tabled-source set ops ===
203+
204+
[TestMethod]
205+
public void Union_AcrossTwoTables_Dedupes()
206+
{
207+
var simulation = new Simulation();
208+
_ = simulation.ExecuteNonQuery("create table left_t (v int)");
209+
_ = simulation.ExecuteNonQuery("create table right_t (v int)");
210+
_ = simulation.ExecuteNonQuery("insert into left_t values (1), (2), (3)");
211+
_ = simulation.ExecuteNonQuery("insert into right_t values (3), (4), (5)");
212+
213+
using var connection = simulation.CreateOpenConnection();
214+
var values = ReadInts(connection.CreateCommand(
215+
"select v from left_t union select v from right_t"));
216+
CollectionAssert.AreEquivalent(new[] { 1, 2, 3, 4, 5 }, values);
217+
}
218+
219+
[TestMethod]
220+
public void Intersect_AcrossTwoTables_Common()
221+
{
222+
var simulation = new Simulation();
223+
_ = simulation.ExecuteNonQuery("create table left_t (v int)");
224+
_ = simulation.ExecuteNonQuery("create table right_t (v int)");
225+
_ = simulation.ExecuteNonQuery("insert into left_t values (1), (2), (3)");
226+
_ = simulation.ExecuteNonQuery("insert into right_t values (3), (4), (5)");
227+
228+
using var connection = simulation.CreateOpenConnection();
229+
var values = ReadInts(connection.CreateCommand(
230+
"select v from left_t intersect select v from right_t"));
231+
CollectionAssert.AreEqual(new[] { 3 }, values);
232+
}
233+
234+
[TestMethod]
235+
public void Except_AcrossTwoTables_LeftMinusRight()
236+
{
237+
var simulation = new Simulation();
238+
_ = simulation.ExecuteNonQuery("create table left_t (v int)");
239+
_ = simulation.ExecuteNonQuery("create table right_t (v int)");
240+
_ = simulation.ExecuteNonQuery("insert into left_t values (1), (2), (3)");
241+
_ = simulation.ExecuteNonQuery("insert into right_t values (3), (4), (5)");
242+
243+
using var connection = simulation.CreateOpenConnection();
244+
var values = ReadInts(connection.CreateCommand(
245+
"select v from left_t except select v from right_t"));
246+
CollectionAssert.AreEquivalent(new[] { 1, 2 }, values);
247+
}
248+
}

SqlServerSimulator/Parser/FromSource.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ internal sealed class FromSource(
4343
public readonly IEnumerable<byte[]> Rows = rows;
4444
}
4545

46+
/// <summary>
47+
/// The four set-operation variants the simulator parses. <c>Union</c>
48+
/// dedupes; <c>UnionAll</c> preserves duplicates; <c>Intersect</c> keeps
49+
/// rows present in both branches (dedupes); <c>Except</c> keeps left-side
50+
/// rows not in the right (dedupes). NULLs are equal during dedup /
51+
/// matching — opposite of the <c>=</c> operator's three-valued behavior,
52+
/// matching SQL Server's documented set-op semantics.
53+
/// </summary>
54+
internal enum SetOpKind
55+
{
56+
Union,
57+
UnionAll,
58+
Intersect,
59+
Except,
60+
}
61+
4662
/// <summary>
4763
/// The variants of JOIN the simulator parses. <c>Inner</c> includes the
4864
/// bare <c>JOIN</c> keyword (which SQL Server treats as INNER) and the

0 commit comments

Comments
 (0)