Skip to content

Commit b689301

Browse files
committed
UNION / UNION ALL / INTERSECT / EXCEPT inside every subquery context (derived tables in FROM, EXISTS, IN, scalar, CTE bodies) already shipped via Selection.Parse → ParseQueryExpression; this commit adds the regression coverage that was missing: 12 SetOperationTests cases (TPC discriminator shape, UNION-joined-with-outer-table, nested UNION, scalar-subquery single-row vs Msg-512 multi-row) + 4 EFCoreInheritanceTpc cases exercising EF Core 10's UseTpcMappingStrategy end-to-end (base-set query, base-property filter, OfType<T>, Count across the union'd concrete tables). CLAUDE.md: removed the stale "Not modeled" line, expanded the Subqueries section to call out set-ops-anywhere + EF TPC coverage.
1 parent dceec63 commit b689301

3 files changed

Lines changed: 290 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ INNER / bare JOIN / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS / CROSS
100100
`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.
101101

102102
### Subqueries
103-
`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.
103+
`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. `UNION` / `UNION ALL` / `INTERSECT` / `EXCEPT` are legal inside every subquery context — derived tables in FROM, EXISTS, IN, scalar `(SELECT ...)`, CTE bodies — because subquery parsers route through `Selection.Parse``ParseQueryExpression`, which already drives the set-op chain. EF Core 7+'s TPC inheritance emit shape (UNION ALL of selects from each concrete table wrapped in a derived table) ships end-to-end through this path.
104104

105105
### Constraints
106106
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate. Inline column-level CHECK predicates may only reference their owning column — peer references raise **Msg 8141** at CREATE TABLE (probe-confirmed verbatim wording). The walker is structural via `Expression.VisitColumnReferences` + `BooleanExpression.VisitOperandExpressions`; coverage is currently limited to common container subclasses (`Reference`, `Parenthesized`, `TwoSidedExpression`, `Cast`, `Length`) — peer refs buried in less-common containers (`DATEPART`, `SUBSTRING`, nested `CASE`, etc.) silently escape the CREATE-TABLE check and surface at INSERT instead. Table-level CHECK has no peer restriction.
@@ -156,7 +156,6 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
156156
- `RIGHT JOIN` / `FULL OUTER JOIN` with a derived-table or lateral right side — base 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.
157157
- Comma-separated FROM (legacy ANSI-89 join syntax).
158158
- `ANY` / `SOME` / `ALL` quantifiers.
159-
- `UNION` / `UNION ALL` inside a subquery body.
160159
- Row-constructor `IN ((1,2), (3,4))`.
161160
- Window functions other than `ROW_NUMBER` and the aggregate-OVER family.
162161
- Recursive-part feature restrictions (Msg 460 DISTINCT / 461 TOP / 462 OUTER JOIN / 467 aggregate-or-GROUP-BY / 465 ref-in-subquery) — silently accepted with possibly-incorrect semantics rather than raising. Apps that exercise these in real SQL Server hit rejection there too.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// End-to-end tests for EF Core's Table-Per-Concrete-type (TPC) inheritance
8+
/// mapping. TPC stores each concrete entity in its own table and EF Core
9+
/// queries the base set by emitting <c>UNION ALL</c> across all concrete
10+
/// tables, wrapped in a derived table that's filtered / projected by the
11+
/// outer query. Exercises the simulator's UNION-inside-subquery support
12+
/// against the concrete shape EF Core 7+'s headline inheritance strategy
13+
/// emits.
14+
/// </summary>
15+
[TestClass]
16+
public class EFCoreInheritanceTpc
17+
{
18+
public TestContext TestContext { get; set; } = null!;
19+
20+
private abstract class Pet
21+
{
22+
public int Id { get; set; }
23+
24+
[Column(TypeName = "nvarchar(50)")]
25+
public string Name { get; set; } = "";
26+
}
27+
28+
private sealed class Dog : Pet
29+
{
30+
public int BarkVolume { get; set; }
31+
}
32+
33+
private sealed class Cat : Pet
34+
{
35+
public bool Purrs { get; set; }
36+
}
37+
38+
private sealed class PetContext(Simulation simulation) : DbContext
39+
{
40+
public Simulation Simulation { get; } = simulation;
41+
42+
public DbSet<Pet> Pets => Set<Pet>();
43+
public DbSet<Dog> Dogs => Set<Dog>();
44+
public DbSet<Cat> Cats => Set<Cat>();
45+
46+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
47+
{
48+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
49+
}
50+
51+
protected override void OnModelCreating(ModelBuilder modelBuilder)
52+
{
53+
// Caller-supplied PKs keep the test off the sequence path the
54+
// simulator doesn't model (EF Core's TPC default is HiLo via a
55+
// shared sequence, which would need a sequence object).
56+
_ = modelBuilder.Entity<Pet>()
57+
.UseTpcMappingStrategy()
58+
.Property(p => p.Id)
59+
.ValueGeneratedNever();
60+
_ = modelBuilder.Entity<Dog>().ToTable("Dogs");
61+
_ = modelBuilder.Entity<Cat>().ToTable("Cats");
62+
}
63+
}
64+
65+
private static Simulation CreatePetsSimulation()
66+
{
67+
var simulation = new Simulation();
68+
_ = simulation
69+
.CreateOpenConnection()
70+
.CreateCommand("""
71+
create table Dogs (
72+
Id int not null primary key,
73+
Name nvarchar(50) not null,
74+
BarkVolume int not null
75+
);
76+
create table Cats (
77+
Id int not null primary key,
78+
Name nvarchar(50) not null,
79+
Purrs bit not null
80+
)
81+
""")
82+
.ExecuteNonQuery();
83+
return simulation;
84+
}
85+
86+
private static PetContext SeededContext()
87+
{
88+
var context = new PetContext(CreatePetsSimulation());
89+
context.Dogs.AddRange(
90+
new Dog { Id = 1, Name = "Rex", BarkVolume = 5 },
91+
new Dog { Id = 2, Name = "Buddy", BarkVolume = 7 });
92+
_ = context.Cats.Add(new Cat { Id = 3, Name = "Whiskers", Purrs = true });
93+
_ = context.SaveChanges();
94+
return context;
95+
}
96+
97+
[TestMethod]
98+
public void Pets_QueryBaseSet_UnionsAllConcreteTables()
99+
{
100+
// Querying the base set under TPC translates to a UNION ALL of selects
101+
// from each concrete table, wrapped in a derived table for downstream
102+
// operations.
103+
using var context = SeededContext();
104+
var names = context.Pets.OrderBy(p => p.Id).Select(p => p.Name).ToArray();
105+
CollectionAssert.AreEqual(new[] { "Rex", "Buddy", "Whiskers" }, names);
106+
}
107+
108+
[TestMethod]
109+
public void Pets_FilterOnBaseProperty_PushesThroughUnion()
110+
{
111+
// Outer WHERE on a base property filters the union'd rowset.
112+
using var context = SeededContext();
113+
var names = context.Pets.Where(p => p.Name.StartsWith("R") || p.Name.StartsWith("W"))
114+
.OrderBy(p => p.Id)
115+
.Select(p => p.Name)
116+
.ToArray();
117+
CollectionAssert.AreEqual(new[] { "Rex", "Whiskers" }, names);
118+
}
119+
120+
[TestMethod]
121+
public void Pets_OfTypeDog_QueriesDogsTableOnly()
122+
{
123+
// OfType<Dog>() narrows the query to the concrete table without UNION.
124+
using var context = SeededContext();
125+
var dogs = context.Pets.OfType<Dog>().OrderBy(d => d.Id).ToArray();
126+
Assert.HasCount(2, dogs);
127+
Assert.AreEqual("Rex", dogs[0].Name); Assert.AreEqual(5, dogs[0].BarkVolume);
128+
Assert.AreEqual("Buddy", dogs[1].Name); Assert.AreEqual(7, dogs[1].BarkVolume);
129+
}
130+
131+
[TestMethod]
132+
public void Pets_CountAcrossUnion_ReturnsTotalAcrossAllConcretes()
133+
{
134+
using var context = SeededContext();
135+
Assert.AreEqual(3, context.Pets.Count());
136+
}
137+
}

SqlServerSimulator.Tests/SetOperationTests.cs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,156 @@ public void Intersect_AcrossTwoTables_Common()
182182
public void Except_AcrossTwoTables_LeftMinusRight()
183183
=> CollectionAssert.AreEquivalent(new[] { 1, 2 },
184184
ReadInts(SeededTwoTables().CreateCommand("select v from left_t except select v from right_t")));
185+
186+
// Set-ops inside a subquery body. The simulator's subquery parsers
187+
// (Expression.cs / BooleanExpression.cs) all route through Selection.Parse,
188+
// which already drives the set-op chain — exercised here as a regression so
189+
// refactors of that surface don't silently break TPC-shaped queries.
190+
191+
[TestMethod]
192+
public void Union_InsideFromDerivedTable()
193+
=> CollectionAssert.AreEquivalent(new[] { 1, 2, 3, 4, 5 },
194+
ReadInts(SeededTwoTables().CreateCommand(
195+
"select x.v from (select v from left_t union select v from right_t) x")));
196+
197+
[TestMethod]
198+
public void UnionAll_InsideFromDerivedTable_PreservesDuplicates()
199+
=> CollectionAssert.AreEquivalent(new[] { 1, 2, 3, 3, 4, 5 },
200+
ReadInts(SeededTwoTables().CreateCommand(
201+
"select x.v from (select v from left_t union all select v from right_t) x")));
202+
203+
[TestMethod]
204+
public void Union_InsideExistsSubquery_AnyBranchSatisfies()
205+
{
206+
using var connection = SeededTwoTables().CreateOpenConnection();
207+
using var reader = connection.CreateCommand(
208+
"select v from left_t where exists (select 1 from right_t where v = left_t.v union select 1 from right_t where v = left_t.v)").ExecuteReader();
209+
var values = new List<int>();
210+
while (reader.Read())
211+
values.Add(reader.GetInt32(0));
212+
CollectionAssert.AreEquivalent(new[] { 3 }, values);
213+
}
214+
215+
[TestMethod]
216+
public void Union_InsideInSubquery_MembershipMatchesEitherBranch()
217+
=> CollectionAssert.AreEquivalent(new[] { 1, 3 },
218+
ReadInts(SeededTwoTables().CreateCommand(
219+
"select v from left_t where v in (select 1 union select 3)")));
220+
221+
[TestMethod]
222+
public void Union_InsideScalarSubquery_SingleColumnSingleRowOk()
223+
{
224+
// Scalar subquery + UNION: both branches project a single column AND the
225+
// UNION dedups to a single row (max(v) = 3 in both branches), so the scalar
226+
// value resolves to 3.
227+
var simulation = new Simulation();
228+
_ = simulation.ExecuteNonQuery("create table t (v int); insert t values (1), (2), (3)");
229+
using var reader = simulation.CreateCommand(
230+
"select (select max(v) from t union select max(v) from t) as m").ExecuteReader();
231+
IsTrue(reader.Read());
232+
AreEqual(3, reader.GetInt32(0));
233+
IsFalse(reader.Read());
234+
}
235+
236+
[TestMethod]
237+
public void Union_InsideScalarSubquery_MultiRowResultRaises512()
238+
=> SeededTwoTables().AssertSqlError(
239+
"select (select max(v) from left_t union select max(v) from right_t) as m",
240+
512);
241+
242+
[TestMethod]
243+
public void Union_InsideCteBody_RecursiveAndAnchorBranches()
244+
{
245+
// Non-recursive CTE body that's itself a UNION: a common EF Core 7 TPC shape
246+
// wrapped inside WITH for downstream filtering.
247+
using var connection = SeededTwoTables().CreateOpenConnection();
248+
var values = ReadInts(connection.CreateCommand(
249+
"with combined as (select v from left_t union select v from right_t) select v from combined"));
250+
CollectionAssert.AreEquivalent(new[] { 1, 2, 3, 4, 5 }, values);
251+
}
252+
253+
[TestMethod]
254+
public void TpcDiscriminator_Shape_OuterFiltersRoundTrip()
255+
{
256+
// Mimics EF Core 7+'s TPC inheritance emit shape: each concrete table contributes
257+
// a SELECT with a constant discriminator column, the branches UNION ALL inside
258+
// a derived table, and the outer query filters / projects through that table.
259+
var simulation = new Simulation();
260+
_ = simulation.ExecuteNonQuery("""
261+
create table dogs (Id int, Name nvarchar(50), BarkVolume int);
262+
create table cats (Id int, Name nvarchar(50), Purrs bit);
263+
insert dogs values (1, 'Rex', 5), (2, 'Buddy', 7);
264+
insert cats values (3, 'Whiskers', 1)
265+
""");
266+
267+
using var reader = simulation.CreateCommand("""
268+
select t.Id, t.Name from (
269+
select Id, Name, 'Dog' as TypeTag from dogs
270+
union all
271+
select Id, Name, 'Cat' as TypeTag from cats
272+
) t
273+
where t.TypeTag = 'Cat'
274+
""").ExecuteReader();
275+
IsTrue(reader.Read());
276+
AreEqual(3, reader.GetInt32(0));
277+
AreEqual("Whiskers", reader.GetString(1));
278+
IsFalse(reader.Read());
279+
}
280+
281+
[TestMethod]
282+
public void Union_DerivedTable_LeftJoinedWithOuterTable()
283+
{
284+
// TPC variant: union'd derived table is joined to another table.
285+
var simulation = new Simulation();
286+
_ = simulation.ExecuteNonQuery("""
287+
create table dogs (Id int, Name nvarchar(50));
288+
create table cats (Id int, Name nvarchar(50));
289+
create table owners (PetId int, OwnerName nvarchar(50));
290+
insert dogs values (1, 'Rex');
291+
insert cats values (2, 'Whiskers');
292+
insert owners values (1, 'alice'), (2, 'bob')
293+
""");
294+
295+
using var reader = simulation.CreateCommand("""
296+
select t.Id, o.OwnerName from (
297+
select Id, Name from dogs
298+
union all
299+
select Id, Name from cats
300+
) t
301+
left join owners o on t.Id = o.PetId
302+
order by t.Id
303+
""").ExecuteReader();
304+
IsTrue(reader.Read());
305+
AreEqual(1, reader.GetInt32(0));
306+
AreEqual("alice", reader.GetString(1));
307+
IsTrue(reader.Read());
308+
AreEqual(2, reader.GetInt32(0));
309+
AreEqual("bob", reader.GetString(1));
310+
IsFalse(reader.Read());
311+
}
312+
313+
[TestMethod]
314+
public void NestedUnion_InsideUnionInsideFrom()
315+
{
316+
// Tests the parser's depth bookkeeping: a UNION-bearing derived table inside
317+
// another UNION-bearing derived table.
318+
using var reader = new Simulation().CreateCommand(
319+
"select y.a from (select a from (select 1 as a union select 2) x union select 3) y order by y.a").ExecuteReader();
320+
var values = new List<int>();
321+
while (reader.Read())
322+
values.Add(reader.GetInt32(0));
323+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, values);
324+
}
325+
326+
[TestMethod]
327+
public void Except_InsideFromDerivedTable()
328+
=> CollectionAssert.AreEquivalent(new[] { 1, 2 },
329+
ReadInts(SeededTwoTables().CreateCommand(
330+
"select x.v from (select v from left_t except select v from right_t) x")));
331+
332+
[TestMethod]
333+
public void Intersect_InsideFromDerivedTable()
334+
=> CollectionAssert.AreEquivalent(new[] { 3 },
335+
ReadInts(SeededTwoTables().CreateCommand(
336+
"select x.v from (select v from left_t intersect select v from right_t) x")));
185337
}

0 commit comments

Comments
 (0)