Skip to content

Commit 49e6f37

Browse files
committed
IS [NOT] DISTINCT FROM (SQL Server 2022 NULL-safe equality): new DistinctFromExpression : BooleanExpression — tuple-switch on (l.IsNull, r.IsNull) returns false when both NULL (not distinct), true when exactly one NULL (distinct), and routes through CompareValuesPromoted for the two-non-null case. Result XOR'd with the negated flag; always definitive (never UNKNOWN) since the per-side null check absorbs what would otherwise be the UNKNOWN-producing comparison. Parser refactor: ParseIsNullSuffix → ParseIsSuffix dispatches on the post-IS-[NOT] token — Null routes to the existing IsNullExpression (no behavior change), Distinct consumes the required From keyword (Msg 102 if missing) and reads the RHS via Expression.Parse. Probe-confirmed semantics: predicate-only at the grammar level (bare-SELECT-list select 5 is distinct from nullraises Msg 156 in real SQL Server because IS isn't a value operator); reachable in any boolean context (WHERE / HAVING / ON / CASE-WHEN / CHECK). Type promotion follows the regular comparison path — numerically-equal int vs decimal collapses to "not distinct"; genuinely-uncoercible operand pairs (e.g. 'hello' is distinct from 5) surface Msg 245 from the comparator unchanged. 18 new DistinctFromTests via DataRow + multi-row scenarios cover every NULL × value combination × negation, type promotion across int/decimal and varchar/nvarchar, CASE-WHEN context, multi-row WHERE for both DISTINCT and NOT DISTINCT (validating the (NULL, NULL) match and the (NULL, value) distinction), NULL-safe INNER JOIN ON (the canonical app-side use — regular= excludes NULL=NULL pairs, NOT DISTINCT FROM includes them), Msg 245 type-mismatch surfacing, Msg 102 missing-From, and an IS NULL / IS NOT NULL regression smoke test against the parser refactor. docs/claude/query.md Boolean section extended with the IS-family always-definitive note plus a dedicated paragraph on the type-promotion + bare-SELECT-list rejection.
1 parent 31f20c6 commit 49e6f37

3 files changed

Lines changed: 207 additions & 12 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for <c>a IS [NOT] DISTINCT FROM b</c> — SQL Server 2022 NULL-safe
8+
/// comparison. Unlike <c>=</c>/<c>&lt;&gt;</c> (which return UNKNOWN when
9+
/// either operand is NULL), the DISTINCT-FROM family treats NULL as a
10+
/// comparable value: two NULLs are not distinct, one NULL plus a non-NULL is
11+
/// distinct. Probed against SQL Server 2025 (2026-05-13).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class DistinctFromTests
15+
{
16+
[TestMethod]
17+
[DataRow("5 is distinct from 5", 0)]
18+
[DataRow("5 is distinct from 6", 1)]
19+
[DataRow("cast(null as int) is distinct from 5", 1)]
20+
[DataRow("5 is distinct from cast(null as int)", 1)]
21+
[DataRow("cast(null as int) is distinct from cast(null as int)", 0)]
22+
[DataRow("5 is not distinct from 5", 1)]
23+
[DataRow("5 is not distinct from 6", 0)]
24+
[DataRow("cast(null as int) is not distinct from 5", 0)]
25+
[DataRow("cast(null as int) is not distinct from cast(null as int)", 1)]
26+
public void Basic(string predicate, int expectedCount) =>
27+
AreEqual(expectedCount,
28+
new Simulation().ExecuteReader($"select 1 where {predicate}").EnumerateRecords().Count());
29+
30+
[TestMethod]
31+
public void TypePromotion_IntVsDecimal_NumericallyEqual()
32+
{
33+
// 5 (int) vs 5.0 (decimal) — values numerically equal; not distinct.
34+
AreEqual(0,
35+
new Simulation().ExecuteReader(
36+
"select 1 where 5 is distinct from cast(5.0 as decimal(5,2))").EnumerateRecords().Count());
37+
AreEqual(1,
38+
new Simulation().ExecuteReader(
39+
"select 1 where 5 is distinct from cast(5.5 as decimal(5,2))").EnumerateRecords().Count());
40+
}
41+
42+
[TestMethod]
43+
public void TypePromotion_VarcharVsNvarchar_Equal()
44+
{
45+
AreEqual(0,
46+
new Simulation().ExecuteReader(
47+
"select 1 where 'hello' is distinct from cast('hello' as nvarchar(10))").EnumerateRecords().Count());
48+
}
49+
50+
[TestMethod]
51+
public void CaseWhenContext()
52+
{
53+
// Probe-confirmed: works in CASE-WHEN predicate position.
54+
AreEqual("yes",
55+
new Simulation().ExecuteScalar(
56+
"select case when 5 is distinct from null then 'yes' else 'no' end"));
57+
}
58+
59+
[TestMethod]
60+
public void MultiRowWhere_DistinctFromPicksUpNullEdges()
61+
{
62+
using var conn = new Simulation().CreateOpenConnection();
63+
_ = conn.CreateCommand("""
64+
create table dt (id int, a int, b int);
65+
insert dt values (1, 5, 5), (2, 5, null), (3, null, 5), (4, null, null), (5, 5, 6)
66+
""").ExecuteNonQuery();
67+
using var reader = conn.CreateCommand(
68+
"select id from dt where a is distinct from b order by id").ExecuteReader();
69+
var ids = new List<int>();
70+
while (reader.Read())
71+
ids.Add(reader.GetInt32(0));
72+
// Distinct rows: (5,null), (null,5), (5,6). Not-distinct: (5,5), (null,null).
73+
CollectionAssert.AreEqual(new int[] { 2, 3, 5 }, ids);
74+
}
75+
76+
[TestMethod]
77+
public void MultiRowWhere_NotDistinctFromIncludesBothNullRow()
78+
{
79+
using var conn = new Simulation().CreateOpenConnection();
80+
_ = conn.CreateCommand("""
81+
create table dt (id int, a int, b int);
82+
insert dt values (1, 5, 5), (2, 5, null), (3, null, 5), (4, null, null), (5, 5, 6)
83+
""").ExecuteNonQuery();
84+
using var reader = conn.CreateCommand(
85+
"select id from dt where a is not distinct from b order by id").ExecuteReader();
86+
var ids = new List<int>();
87+
while (reader.Read())
88+
ids.Add(reader.GetInt32(0));
89+
// Probe-confirmed: NULL=NULL counts as a match here (the key
90+
// difference from regular `=`).
91+
CollectionAssert.AreEqual(new int[] { 1, 4 }, ids);
92+
}
93+
94+
[TestMethod]
95+
public void JoinOn_NullSafeJoin()
96+
{
97+
using var conn = new Simulation().CreateOpenConnection();
98+
_ = conn.CreateCommand("""
99+
create table j1 (id int, v int);
100+
create table j2 (id int, v int);
101+
insert j1 values (1, 5), (2, null), (3, 10);
102+
insert j2 values (1, 5), (2, null), (3, 11)
103+
""").ExecuteNonQuery();
104+
using var reader = conn.CreateCommand(
105+
"select j1.id from j1 inner join j2 on j1.v is not distinct from j2.v order by j1.id").ExecuteReader();
106+
var ids = new List<int>();
107+
while (reader.Read())
108+
ids.Add(reader.GetInt32(0));
109+
// Probe-confirmed: NULL-safe join matches rows where both v are NULL;
110+
// regular `=` would exclude that pair.
111+
CollectionAssert.AreEqual(new int[] { 1, 2 }, ids);
112+
}
113+
114+
[TestMethod]
115+
public void TypeMismatch_SurfacesUnderlyingConversionError()
116+
{
117+
// Promotion routes through CompareValuesPromoted; string→int conversion
118+
// fails with Msg 245 (same as regular `=` would).
119+
var ex = Throws<DbException>(() =>
120+
new Simulation().ExecuteScalar("select 1 where 'hello' is distinct from 5"));
121+
AreEqual("245", ex.Data["HelpLink.EvtID"]);
122+
}
123+
124+
[TestMethod]
125+
public void MissingFromKeyword_SyntaxError()
126+
{
127+
// `IS DISTINCT rhs` without FROM → syntax error at rhs.
128+
var ex = Throws<DbException>(() =>
129+
new Simulation().ExecuteScalar("select 1 where 5 is distinct 5"));
130+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
131+
}
132+
133+
[TestMethod]
134+
public void RegressionIsNullStillWorks()
135+
{
136+
// ParseIsSuffix took over from ParseIsNullSuffix; verify the IS NULL /
137+
// IS NOT NULL branches still route correctly. Existing IsNullExpression
138+
// tests in other suites cover broader semantics — this is a smoke
139+
// test for the refactor.
140+
AreEqual(1, new Simulation().ExecuteReader(
141+
"select 1 where cast(null as int) is null").EnumerateRecords().Count());
142+
AreEqual(0, new Simulation().ExecuteReader(
143+
"select 1 where 5 is null").EnumerateRecords().Count());
144+
AreEqual(1, new Simulation().ExecuteReader(
145+
"select 1 where 5 is not null").EnumerateRecords().Count());
146+
AreEqual(0, new Simulation().ExecuteReader(
147+
"select 1 where cast(null as int) is not null").EnumerateRecords().Count());
148+
}
149+
}

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ private static BooleanExpression ParseComparison(Expression left, ParserContext
134134
case ReservedKeyword { Keyword: Keyword.Like }:
135135
return ParseLike(left, context, negated: false);
136136
case ReservedKeyword { Keyword: Keyword.Is }:
137-
return ParseIsNullSuffix(left, context);
137+
return ParseIsSuffix(left, context);
138138
case ReservedKeyword { Keyword: Keyword.In }:
139139
return ParseInList(left, context, negated: false);
140140
case ReservedKeyword { Keyword: Keyword.Between }:
@@ -285,13 +285,14 @@ private static LikeExpression ParseLike(Expression left, ParserContext context,
285285
}
286286

287287
/// <summary>
288-
/// Parses the <c>IS [NOT] NULL</c> suffix after an expression. Entered
289-
/// with <see cref="ParserContext.Token"/> on the <c>IS</c> keyword;
290-
/// consumes <c>IS</c>, optional <c>NOT</c>, and the required <c>NULL</c>.
291-
/// Leaves the token on the next un-consumed token (typically a boolean
292-
/// combinator, comma, or end-of-input).
288+
/// Parses an <c>IS</c> suffix after an expression — either <c>IS [NOT] NULL</c>
289+
/// (returns <see cref="IsNullExpression"/>) or <c>IS [NOT] DISTINCT FROM rhs</c>
290+
/// (returns <see cref="DistinctFromExpression"/>, SQL Server 2022+ NULL-safe
291+
/// equality). Entered with <see cref="ParserContext.Token"/> on the <c>IS</c>
292+
/// keyword; consumes <c>IS</c>, optional <c>NOT</c>, then dispatches by the
293+
/// next token. Leaves the token on the next un-consumed token.
293294
/// </summary>
294-
private static IsNullExpression ParseIsNullSuffix(Expression left, ParserContext context)
295+
private static BooleanExpression ParseIsSuffix(Expression left, ParserContext context)
295296
{
296297
var negated = false;
297298
var next = context.GetNextRequired();
@@ -300,10 +301,19 @@ private static IsNullExpression ParseIsNullSuffix(Expression left, ParserContext
300301
negated = true;
301302
next = context.GetNextRequired();
302303
}
303-
if (next is not ReservedKeyword { Keyword: Keyword.Null })
304-
throw SimulatedSqlException.SyntaxErrorNear(context);
305-
context.MoveNextOptional();
306-
return new IsNullExpression(left, negated);
304+
switch (next)
305+
{
306+
case ReservedKeyword { Keyword: Keyword.Null }:
307+
context.MoveNextOptional();
308+
return new IsNullExpression(left, negated);
309+
case ReservedKeyword { Keyword: Keyword.Distinct }:
310+
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.From })
311+
throw SimulatedSqlException.SyntaxErrorNear(context);
312+
context.MoveNextRequired();
313+
return new DistinctFromExpression(left, Expression.Parse(context), negated);
314+
default:
315+
throw SimulatedSqlException.SyntaxErrorNear(context);
316+
}
307317
}
308318

309319
/// <summary>
@@ -472,6 +482,41 @@ private sealed class IsNullExpression(Expression source, bool negated) : Boolean
472482
internal override void VisitOperandExpressions(Action<Expression> visitor) => visitor(source);
473483
}
474484

485+
/// <summary>
486+
/// <c>expr IS [NOT] DISTINCT FROM rhs</c> — SQL Server 2022 NULL-safe
487+
/// comparison. Like <see cref="IsNullExpression"/>, never returns UNKNOWN:
488+
/// <c>both NULL → not distinct</c>, <c>exactly one NULL → distinct</c>,
489+
/// <c>both non-null → distinct iff unequal</c>. Type promotion follows the
490+
/// regular comparison path via <see cref="CompareValuesPromoted"/>; a
491+
/// genuinely uncoercible operand pair (e.g. <c>'hello' is distinct from 5</c>)
492+
/// still surfaces the underlying Msg 245 / Msg 402 from the comparator,
493+
/// matching real SQL Server's behavior.
494+
/// </summary>
495+
private sealed class DistinctFromExpression(Expression left, Expression right, bool negated) : BooleanExpression
496+
{
497+
public override bool? Run(RuntimeContext runtime)
498+
{
499+
var l = left.Run(runtime);
500+
var r = right.Run(runtime);
501+
var distinct = (l.IsNull, r.IsNull) switch
502+
{
503+
(true, true) => false,
504+
(true, false) or (false, true) => true,
505+
_ => CompareValuesPromoted(l, r, "not equal to", static (a, b) => !a.Equals(b)) == true,
506+
};
507+
return distinct ^ negated;
508+
}
509+
510+
internal override string DebugDisplay() =>
511+
$"{left.DebugDisplay()} IS {(negated ? "NOT " : "")}DISTINCT FROM {right.DebugDisplay()}";
512+
513+
internal override void VisitOperandExpressions(Action<Expression> visitor)
514+
{
515+
visitor(left);
516+
visitor(right);
517+
}
518+
}
519+
475520
/// <summary>
476521
/// <c>expr [NOT] IN (v1, v2, ...)</c>: equivalent to a chain of
477522
/// promote-and-equal comparisons OR-combined (or AND-of-not for

docs/claude/query.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Query semantics — set ops, CASE, pagination, aggregates, windows
22

33
## Boolean / set ops / projection / CASE
4-
- Boolean combinators (WHERE / MERGE-ON / CHECK): `AND` / `OR` / `NOT`, parens, `IS [NOT] NULL`, `[NOT] IN (literal,...)`, `[NOT] IN (SELECT ...)`, `EXISTS (SELECT ...)`, `expr <op> {ANY|SOME|ALL} (SELECT col FROM ...)` quantified comparison, `value [NOT] BETWEEN lower AND upper`. Tri-valued.
4+
- Boolean combinators (WHERE / MERGE-ON / CHECK): `AND` / `OR` / `NOT`, parens, `IS [NOT] NULL`, `IS [NOT] DISTINCT FROM` (SQL Server 2022 NULL-safe equality — never UNKNOWN), `[NOT] IN (literal,...)`, `[NOT] IN (SELECT ...)`, `EXISTS (SELECT ...)`, `expr <op> {ANY|SOME|ALL} (SELECT col FROM ...)` quantified comparison, `value [NOT] BETWEEN lower AND upper`. Tri-valued except for the two `IS`-family forms (always definitive).
5+
- `IS [NOT] DISTINCT FROM` (`BooleanExpression.DistinctFromExpression`): two NULLs are *not* distinct (match), exactly one NULL *is* distinct (no match), two non-NULLs distinct iff unequal under the regular promote-and-compare. Type-mismatch operand pairs still surface the underlying Msg 245 / Msg 402 — the NULL-safety lives in the per-side null check, the value-side coerces normally. Reachable in any boolean context (WHERE / HAVING / ON / CASE-WHEN / CHECK); bare SELECT-list use raises Msg 156 in real SQL Server because `IS` isn't a value operator.
56
- `[NOT] BETWEEN` desugars to `value >= lower AND value <= upper` (inclusive on both ends, probe-confirmed). Reversed bounds (low > high) collapse to a definite false; NULL in any operand position propagates through three-valued AND. `value` is evaluated once per row by `BetweenExpression.Run` (the desugaring is per-row semantics, not duplicated evaluation). BETWEEN binds tighter than the surrounding AND/OR, so `value between a and b and other` parses as `(value between a and b) AND (other)`; bounds may be arbitrary value expressions (Expression.Parse stops at the trailing AND keyword).
67
- Quantified comparison (`Parser/BooleanExpression.cs` — `QuantifiedComparisonExpression`): all six operators (`=`, `<>`, `<`, `<=`, `>`, `>=`) plus T-SQL synonyms (`!=` → `<>`, `!<` → `>=`, `!>` → `<=`) fold at parse time into a `ComparisonOp` enum. `SOME` is a pure alias of `ANY`. Inner SELECT must project exactly one column (Msg 116, shared factory with IN-subquery). **Predicate-only**: probe-confirmed (2026-05-13) that real SQL Server raises Msg 102 when the form appears in a SELECT-list expression slot — the simulator inherits the same restriction because parsing lives in `BooleanExpression.ParseComparison`, reachable only from the boolean-atom path. Empty inner: `ALL` is vacuously true, `ANY` vacuously false (LHS NULL irrelevant — the inner is consumed first). Non-empty inner runs three-valued fold: any comparison evaluating to `true` short-circuits `ANY` to `true`; any `false` short-circuits `ALL` to `false`; otherwise `null`-tainted comparisons turn the overall result UNKNOWN, fall-through is `false` for `ANY` / `true` for `ALL`. `<op> ANY` cannot be negated directly (`NOT <op> ANY` isn't grammar — apps must flip the operator: `NOT (x > ALL y)` ≡ `x <= ANY y`).
78
- Set ops (UNION / UNION ALL / INTERSECT / EXCEPT): standard precedence (INTERSECT > UNION/EXCEPT). **NULLs are equal during set-op dedup/matching** (opposite of `=`'s tri-state). Per-branch ORDER BY in non-final branch → Msg 156. Top-level ORDER BY references first-branch column names only.

0 commit comments

Comments
 (0)