Skip to content

Commit ced7d54

Browse files
committed
ANY / SOME / ALL quantified comparison subqueries: new QuantifiedComparisonExpression folds three-valued semantics over the inner SELECT (empty inner → ALL vacuously true / ANY vacuously false regardless of LHS NULL; non-empty with any NULL-tainted compare → UNKNOWN unless short-circuited by a definitive opposite); all six operators (= <> < <= > >=) plus T-SQL synonyms (!= !< !>) supported through a refactored ParseComparison that lifts operator detection into a ComparisonOp enum before reading RHS (so the six CompareExpression subclasses migrated from (left, ParserContext) to (left, Expression) ctors); SOME aliases ANY at parse time. Predicate-only restriction inherited naturally from parsing in BooleanExpression.ParseAtom — SELECT-list usage surfaces Msg 102 at the operator, matching real SQL Server's grammar. Inner must be single-column (Msg 116 via existing IN-subquery factory). 27 new QuantifiedComparisonTests cover every operator × ANY/ALL, SOME synonym, the three T-SQL synonyms, empty/null-only inner vacuity, NULL LHS interactions, multi-col rejection, correlated re-evaluation, HAVING/CASE-WHEN contexts, predicate-only rejection, and type promotion. CLAUDE.md "Subqueries" extended; "Not modeled" entry removed; docs/claude/query.md gained a quantified-comparison bullet covering fold semantics and the predicate-only restriction.
1 parent cd51a45 commit ced7d54

4 files changed

Lines changed: 543 additions & 49 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. `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.
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); `expr <op> {ANY|SOME|ALL} (SELECT col FROM ...)` quantified comparison with all six operators (`=` `<>` `<` `<=` `>` `>=`) plus T-SQL synonyms `!=` `!<` `!>`, predicate-only (SELECT-list usage raises Msg 102 at the operator, matching real SQL Server's grammar restriction); SOME aliases ANY. Empty inner: ALL vacuously true, ANY vacuously false (both independent of LHS NULL); non-empty inner with NULL on either side of any per-row compare taints to UNKNOWN per three-valued logic. 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, ANY/ALL inners, 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
- 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.
157157
- `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.
158158
- Comma-separated FROM (legacy ANSI-89 join syntax).
159-
- `ANY` / `SOME` / `ALL` quantifiers.
160159
- `RANGE BETWEEN <N> PRECEDING` / `<N> FOLLOWING` — real SQL Server gates the numeric-offset RANGE form behind a separately-licensed feature surface and the simulator matches that rejection (Msg 4194). `ROWS` numeric-offset frames ship; both modes support the canonical `UNBOUNDED` / `CURRENT ROW` bounds and the single-bound shorthand (`ROWS UNBOUNDED PRECEDING``ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). The default frame when ORDER BY is present in OVER is `RANGE UNBOUNDED PRECEDING TO CURRENT ROW` (running-total semantic with peer-tie grouping); without ORDER BY, default frame is whole partition. LAST_VALUE ships with the same semantics as real SQL Server (its default frame returns the current row's value or the peer-tie last under RANGE — the intuitive "partition last" form needs explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`).
161160
- 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.
162161
- `LIKE` with `COLLATE` override (default collation only).
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for <c>lhs op {ANY|SOME|ALL} (SELECT col FROM ...)</c> — quantified
8+
/// subquery comparisons. Six comparison operators (<c>=</c>, <c>&lt;&gt;</c>,
9+
/// <c>&lt;</c>, <c>&lt;=</c>, <c>&gt;</c>, <c>&gt;=</c>) plus the T-SQL
10+
/// synonyms <c>!=</c> / <c>!&lt;</c> / <c>!&gt;</c>; SOME is a pure synonym
11+
/// of ANY. Semantics probed against SQL Server 2025 (2026-05-13).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class QuantifiedComparisonTests
15+
{
16+
private static DbConnection SeededTwoTables()
17+
{
18+
var conn = new Simulation().CreateOpenConnection();
19+
_ = conn.CreateCommand("""
20+
create table q1 (id int not null primary key, v int null);
21+
create table q2 (id int not null primary key, x int null);
22+
insert q1 values (1, 10), (2, 20), (3, 30), (4, null);
23+
insert q2 values (1, 15), (2, 25), (3, null)
24+
""").ExecuteNonQuery();
25+
return conn;
26+
}
27+
28+
private static int[] Ids(DbDataReader reader)
29+
{
30+
var results = new List<int>();
31+
while (reader.Read())
32+
results.Add(reader.GetInt32(0));
33+
return [.. results];
34+
}
35+
36+
[TestMethod]
37+
public void GreaterThanAll_NonEmptyInner_OnlyStrictlyGreaterRowsMatch()
38+
{
39+
using var conn = SeededTwoTables();
40+
using var reader = conn.CreateCommand(
41+
"select id from q1 where v > all (select x from q2 where x is not null)").ExecuteReader();
42+
// 15 and 25 in inner; only v=30 strictly exceeds both.
43+
CollectionAssert.AreEqual(new int[] { 3 }, Ids(reader));
44+
}
45+
46+
[TestMethod]
47+
public void GreaterThanAny_NonEmptyInner_MatchesIfBeatsAnyOne()
48+
{
49+
using var conn = SeededTwoTables();
50+
using var reader = conn.CreateCommand(
51+
"select id from q1 where v > any (select x from q2 where x is not null)").ExecuteReader();
52+
// 15 is the min; v=20 beats 15, v=30 beats both.
53+
CollectionAssert.AreEqual(new int[] { 2, 3 }, Ids(reader));
54+
}
55+
56+
[TestMethod]
57+
public void SomeIsAliasOfAny()
58+
{
59+
using var conn = SeededTwoTables();
60+
using var reader = conn.CreateCommand(
61+
"select id from q1 where v > some (select x from q2 where x is not null)").ExecuteReader();
62+
CollectionAssert.AreEqual(new int[] { 2, 3 }, Ids(reader));
63+
}
64+
65+
[TestMethod]
66+
public void EmptyInner_AllIsVacuouslyTrue_EvenForNullLhs()
67+
{
68+
using var conn = SeededTwoTables();
69+
using var reader = conn.CreateCommand(
70+
"select id from q1 where v > all (select x from q2 where 1=0)").ExecuteReader();
71+
// Empty inner → ALL vacuously true. Probe-confirmed (2026-05-13)
72+
// that this also includes rows where LHS is NULL.
73+
CollectionAssert.AreEqual(new int[] { 1, 2, 3, 4 }, Ids(reader));
74+
}
75+
76+
[TestMethod]
77+
public void EmptyInner_AnyIsVacuouslyFalse()
78+
{
79+
using var conn = SeededTwoTables();
80+
using var reader = conn.CreateCommand(
81+
"select id from q1 where v > any (select x from q2 where 1=0)").ExecuteReader();
82+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
83+
}
84+
85+
[TestMethod]
86+
public void NullOnlyInner_AllReturnsUnknown_RowExcludedFromWhere()
87+
{
88+
using var conn = SeededTwoTables();
89+
using var reader = conn.CreateCommand(
90+
"select id from q1 where v > all (select x from q2 where x is null)").ExecuteReader();
91+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
92+
}
93+
94+
[TestMethod]
95+
public void NullOnlyInner_AnyReturnsUnknown_RowExcludedFromWhere()
96+
{
97+
using var conn = SeededTwoTables();
98+
using var reader = conn.CreateCommand(
99+
"select id from q1 where v > any (select x from q2 where x is null)").ExecuteReader();
100+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
101+
}
102+
103+
[TestMethod]
104+
public void NullLhs_NonEmptyInner_RowExcluded()
105+
{
106+
using var conn = SeededTwoTables();
107+
using var reader = conn.CreateCommand(
108+
"select id from q1 where v > all (select x from q2 where x is not null) and v is null").ExecuteReader();
109+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
110+
}
111+
112+
[TestMethod]
113+
public void EqualAny_BehavesLikeIn()
114+
{
115+
using var conn = SeededTwoTables();
116+
using var reader = conn.CreateCommand(
117+
"select id from q1 where v = any (select x from q2 where x is not null)").ExecuteReader();
118+
// q1.v values 10/20/30 against q2.x in (15, 25): no equal matches.
119+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
120+
}
121+
122+
[TestMethod]
123+
public void EqualAny_LiteralUnion_MatchesIn()
124+
{
125+
using var conn = SeededTwoTables();
126+
using var reader = conn.CreateCommand(
127+
"select id from q1 where v = any (select 10 union select 20)").ExecuteReader();
128+
CollectionAssert.AreEqual(new int[] { 1, 2 }, Ids(reader));
129+
}
130+
131+
[TestMethod]
132+
public void NotEqualAll_BehavesLikeNotIn()
133+
{
134+
using var conn = SeededTwoTables();
135+
using var reader = conn.CreateCommand(
136+
"select id from q1 where v <> all (select 10 union select 20)").ExecuteReader();
137+
// v in (10, 20, 30, NULL) — only 30 differs from both 10 and 20 with
138+
// no NULL siblings (the union has no NULLs); v=NULL → UNKNOWN excluded.
139+
CollectionAssert.AreEqual(new int[] { 3 }, Ids(reader));
140+
}
141+
142+
[TestMethod]
143+
public void NotEqualAll_NullInInner_PoisonsResult()
144+
{
145+
using var conn = SeededTwoTables();
146+
using var reader = conn.CreateCommand(
147+
"select id from q1 where v <> all (select x from q2)").ExecuteReader();
148+
// q2.x has (15, 25, NULL). For each q1.v: v=10 → 10<>15 true, 10<>25
149+
// true, 10<>NULL UNKNOWN → ALL = UNKNOWN. Same for v=20/30. v=NULL
150+
// → UNKNOWN. No row should match.
151+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
152+
}
153+
154+
[TestMethod]
155+
public void EqualAll_AllSameValue_True()
156+
{
157+
using var conn = SeededTwoTables();
158+
using var reader = conn.CreateCommand(
159+
"select id from q1 where v = all (select 10 union all select 10)").ExecuteReader();
160+
CollectionAssert.AreEqual(new int[] { 1 }, Ids(reader));
161+
}
162+
163+
[TestMethod]
164+
public void EqualAll_DifferentValues_NoneMatch()
165+
{
166+
using var conn = SeededTwoTables();
167+
using var reader = conn.CreateCommand(
168+
"select id from q1 where v = all (select 10 union select 20)").ExecuteReader();
169+
CollectionAssert.AreEqual(Array.Empty<int>(), Ids(reader));
170+
}
171+
172+
[TestMethod]
173+
public void LessThanOrEqualAll_RequiresLhsBelowMinimum()
174+
{
175+
using var conn = SeededTwoTables();
176+
using var reader = conn.CreateCommand(
177+
"select id from q1 where v <= all (select x from q2 where x is not null)").ExecuteReader();
178+
// min(q2.x) = 15; only v=10 is <= all.
179+
CollectionAssert.AreEqual(new int[] { 1 }, Ids(reader));
180+
}
181+
182+
[TestMethod]
183+
public void GreaterThanOrEqualAll_RequiresLhsAtOrAboveMax()
184+
{
185+
using var conn = SeededTwoTables();
186+
using var reader = conn.CreateCommand(
187+
"select id from q1 where v >= all (select x from q2 where x is not null)").ExecuteReader();
188+
CollectionAssert.AreEqual(new int[] { 3 }, Ids(reader));
189+
}
190+
191+
[TestMethod]
192+
public void LessThanAll_StrictlyBelowMin()
193+
{
194+
using var conn = SeededTwoTables();
195+
using var reader = conn.CreateCommand(
196+
"select id from q1 where v < all (select x from q2 where x is not null)").ExecuteReader();
197+
CollectionAssert.AreEqual(new int[] { 1 }, Ids(reader));
198+
}
199+
200+
[TestMethod]
201+
public void BangEqualAny_SynonymOfNotEqualAny()
202+
{
203+
using var conn = SeededTwoTables();
204+
using var reader = conn.CreateCommand(
205+
"select id from q1 where v != any (select 10 union select 20)").ExecuteReader();
206+
// v=10 vs (10,20): 10<>10 false, 10<>20 true → ANY true. v=20 same.
207+
// v=30 vs both <>: true. v=NULL excluded.
208+
CollectionAssert.AreEqual(new int[] { 1, 2, 3 }, Ids(reader));
209+
}
210+
211+
[TestMethod]
212+
public void BangLessAny_SynonymOfGreaterOrEqualAny()
213+
{
214+
using var conn = SeededTwoTables();
215+
using var reader = conn.CreateCommand(
216+
"select id from q1 where v !< any (select 20)").ExecuteReader();
217+
// !< means >=. v=20 → 20>=20 true. v=30 → true. v=10 → 10>=20 false.
218+
CollectionAssert.AreEqual(new int[] { 2, 3 }, Ids(reader));
219+
}
220+
221+
[TestMethod]
222+
public void BangGreaterAny_SynonymOfLessOrEqualAny()
223+
{
224+
using var conn = SeededTwoTables();
225+
using var reader = conn.CreateCommand(
226+
"select id from q1 where v !> any (select 20)").ExecuteReader();
227+
// !> means <=. v=10 → true. v=20 → true. v=30 → false.
228+
CollectionAssert.AreEqual(new int[] { 1, 2 }, Ids(reader));
229+
}
230+
231+
[TestMethod]
232+
public void MultiColumnInner_RaisesMsg116()
233+
{
234+
using var conn = SeededTwoTables();
235+
using var cmd = conn.CreateCommand("select id from q1 where v > all (select id, x from q2)");
236+
var ex = Throws<DbException>(cmd.ExecuteReader);
237+
AreEqual("116", ex.Data["HelpLink.EvtID"]);
238+
AreEqual(
239+
"Only one expression can be specified in the select list when the subquery is not introduced with EXISTS.",
240+
ex.Message);
241+
}
242+
243+
[TestMethod]
244+
public void CorrelatedSubquery_PerOuterRowReevaluation()
245+
{
246+
using var conn = SeededTwoTables();
247+
using var reader = conn.CreateCommand(
248+
"select id from q1 o where v > all (select v from q1 i where i.id < o.id and v is not null)").ExecuteReader();
249+
// For each outer id, inner is rows with id < outer.id. id=1: empty
250+
// inner → vacuously true. id=2: inner = {10}; 20>10 → true. id=3:
251+
// inner = {10,20}; 30 beats both. id=4: v=null → UNKNOWN.
252+
CollectionAssert.AreEqual(new int[] { 1, 2, 3 }, Ids(reader));
253+
}
254+
255+
[TestMethod]
256+
public void HavingContext_AnyWorks()
257+
{
258+
using var conn = SeededTwoTables();
259+
using var reader = conn.CreateCommand(
260+
"select v from q1 group by v having v > any (select x from q2 where x is not null)").ExecuteReader();
261+
var values = new List<int>();
262+
while (reader.Read())
263+
values.Add(reader.GetInt32(0));
264+
values.Sort();
265+
CollectionAssert.AreEqual(new int[] { 20, 30 }, values);
266+
}
267+
268+
[TestMethod]
269+
public void CaseWhenContext_AnyWorks()
270+
{
271+
using var conn = SeededTwoTables();
272+
using var cmd = conn.CreateCommand(
273+
"select case when 50 > any (select x from q2 where x is not null) then 'yes' else 'no' end");
274+
AreEqual("yes", cmd.ExecuteScalar());
275+
}
276+
277+
[TestMethod]
278+
public void SelectListUsage_PredicateOnly_Rejected()
279+
{
280+
// Probe-confirmed: real SQL Server raises Msg 102 at the comparison
281+
// operator when a quantified comparison appears as a SELECT-list
282+
// value expression. The simulator's predicate-only grammar reaches
283+
// the same result because quantified parsing lives in
284+
// BooleanExpression.ParseComparison.
285+
new Simulation().ValidateSyntaxError("select 50 > all (select 1)", ">");
286+
}
287+
288+
[TestMethod]
289+
public void TypePromotion_IntLhsAgainstDecimalInner()
290+
{
291+
using var conn = new Simulation().CreateOpenConnection();
292+
_ = conn.CreateCommand("""
293+
create table tp (v int);
294+
insert tp values (1), (5), (10)
295+
""").ExecuteNonQuery();
296+
using var reader = conn.CreateCommand(
297+
"select v from tp where v > all (select cast(0.5 as decimal(5,2)) union select cast(2.5 as decimal(5,2)))").ExecuteReader();
298+
var values = new List<int>();
299+
while (reader.Read())
300+
values.Add(reader.GetInt32(0));
301+
values.Sort();
302+
CollectionAssert.AreEqual(new int[] { 5, 10 }, values);
303+
}
304+
305+
[TestMethod]
306+
public void TypeMismatch_StringLhs_IntInner_ConversionError()
307+
{
308+
using var conn = SeededTwoTables();
309+
using var cmd = conn.CreateCommand("select id from q1 where 'x' = any (select x from q2 where x is not null)");
310+
var ex = Throws<DbException>(cmd.ExecuteReader);
311+
AreEqual("245", ex.Data["HelpLink.EvtID"]);
312+
}
313+
314+
}

0 commit comments

Comments
 (0)