Skip to content

Commit bf6cc3a

Browse files
committed
Null/conditional shorthand: ISNULL / IIF / NULLIF — three EF-Core-frequent expressions added in Parser/Expressions/. ISNULL fixes its result type to the first argument's type and coerces the fallback (distinct from 2-arg COALESCE's joint promotion); wrong arity → Msg 174. IIF is a searched-CASE shorthand with joint-promoted arms; NULLIF is CASE WHEN a = b THEN NULL ELSE a END with first-arg-typed result. NULLIF joins COALESCE / CONVERT / TRY_CONVERT in the reserved-keyword passthrough lists; ISNULL and IIF dispatch through the normal Reference-name path. Stale "rowversion" entry dropped from CLAUDE.md's "Not modeled" list (the dedicated rowversion subsection has described it as fully modeled since the rowversion bundle).
1 parent 8d115fd commit bf6cc3a

9 files changed

Lines changed: 454 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ Bare `*` and qualified `<source>.*` both work. `*` expands to every column of ev
171171
### CASE
172172
Searched (`CASE WHEN cond THEN ... [ELSE ...] END`) and simple (`CASE input WHEN val ...`). Branches evaluate in order; first true predicate wins. UNKNOWN excludes (matches WHERE); simple-form `CASE NULL WHEN NULL` falls through. Result type computed via `SqlType.Promote` across all THEN/ELSE, cached on first `GetSqlType`; `Run` coerces matched values to the common type. No-match-no-ELSE → typed NULL.
173173

174+
### Null / conditional shorthand: `ISNULL` / `IIF` / `NULLIF`
175+
EF Core 10 emission (probe-confirmed 2026-05-09): `ISNULL` is emitted for `?? <fallback>` only when a CAST is involved — e.g. `(decimal?)nullableInt ?? 0m` translates to `ISNULL(CAST([f].[NullableC] AS decimal(18,2)), 0.0)`; bare `nullableInt ?? 0` emits `COALESCE` instead. `IIF` and `NULLIF` aren't emitted by any natural LINQ shape we found — EF Core picks `CASE WHEN ... THEN ... ELSE ... END` for ternary projections and safe-divide. Both reach app code through `FromSqlInterpolated` / `FromSqlRaw` rather than the LINQ translator, so support is still load-bearing for raw-SQL consumers. Probe-confirmed against SQL Server 2025 (2026-05-09):
176+
- `ISNULL(check, replacement)` — returns `check` if non-NULL, else `replacement` coerced to `check`'s type. **Result type and length are fixed to the first argument**`ISNULL(varchar(5), 'longerstring')` truncates the fallback to 5 characters; `ISNULL(int_null, 'abc')` raises Msg 245 at runtime through int's CAST path. Distinct from 2-arg `COALESCE` (joint promotion). Wrong arity (1 or 3+ arguments) → **Msg 174** `"The isnull function requires 2 argument(s)."`.
177+
- `IIF(condition, true_value, false_value)` — equivalent to `CASE WHEN condition THEN true_value ELSE false_value END`. Result type is the joint promotion of the two value arms (matching CASE's branch-unification rule); UNKNOWN condition routes to the false arm.
178+
- `NULLIF(a, b)` — equivalent to `CASE WHEN a = b THEN NULL ELSE a END`. Result type is fixed to `a`'s type. Equality uses simple-form CASE / `=` semantics (NULL on either side → UNKNOWN → ELSE arm returns `a`, which is itself NULL when the NULL is on the left).
179+
180+
`IsNullExpression`, `Iif`, and `NullIf` (`Parser/Expressions/`) each cache the result type from `GetSqlType` and coerce the matched value at `Run` time, the same pattern as `Coalesce` / `CaseExpression`. `NullIf` reuses `BooleanExpression.CompareValuesPromoted` for its equality check. `NULLIF` is a reserved keyword (per the SQL Server reserved-words list) so it appears alongside `Coalesce` / `Convert` / `Try_Convert` in `Expression.Parse`'s and `Selection.cs`'s function-keyword passthrough lists; `ISNULL` and `IIF` are not reserved and dispatch through the standard `Reference`-name path. The simulator deliberately does *not* raise Msg 8133 for `IIF(c, NULL, NULL)` or Msg 4127 for `NULLIF(NULL, NULL)` — both inherit the existing CASE-deviation that returns typed NULL when SQL Server would reject the bare-NULL form.
181+
174182
### Subqueries
175183
- `EXISTS (SELECT ...)` / `NOT EXISTS` — boolean atom in WHERE/HAVING/CHECK. Multi-column inner allowed.
176184
- `expr [NOT] IN (SELECT ...)` — boolean atom; exactly one inner column (Msg 116). NULL semantics mirror literal-list IN (NULL row → UNKNOWN unless a non-NULL match wins first).
@@ -321,7 +329,7 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
321329
- `PRIMARY KEY` / `UNIQUE` on a computed column (would need to evaluate the expression against every existing row at insert; `NotSupportedException`).
322330
- Heap allocation tracking: flat page list, no IAM/PFS.
323331
- Per-connection session state for some scopes: `SCOPE_IDENTITY()` / `@@IDENTITY`, `SET IDENTITY_INSERT`'s active table, `DBCC TRACEON(N)` flags all live on `Simulation` rather than the connection. (Transaction state already moved to per-connection in `SimulatedDbConnection.CurrentTransaction`.)
324-
- `hierarchyid`, `geography`, `geometry`, `rowversion`.
332+
- `hierarchyid`, `geography`, `geometry`.
325333

326334
---
327335

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end coverage for the LINQ shapes EF Core 10 translates to
5+
/// <c>ISNULL</c>. Probe-confirmed against EF Core 10 (2026-05-09):
6+
/// <c>ISNULL</c> emission is gated on a CAST being involved in the
7+
/// <c>??</c> operands — <c>(decimal?)nullableInt ?? 0m</c> translates to
8+
/// <c>ISNULL(CAST([f].[NullableC] AS decimal(18,2)), 0.0)</c>, while
9+
/// plain <c>nullableInt ?? 0</c> picks <c>COALESCE</c> instead. <c>IIF</c>
10+
/// and <c>NULLIF</c> aren't emitted by any natural LINQ shape we found
11+
/// (EF Core picks <c>CASE</c> for ternary projections and safe-divide),
12+
/// so they don't have an EF Core integration story — direct simulator
13+
/// coverage in <c>IsNullIifNullIfTests</c> is the right surface.
14+
/// </summary>
15+
[TestClass]
16+
public class EFCoreNullConditional
17+
{
18+
public TestContext TestContext { get; set; } = null!;
19+
20+
private static TestDbContext SeededFiltersContext()
21+
{
22+
var context = new TestDbContext(TestDbContext.CreateFiltersSimulation());
23+
context.Filters.AddRange(
24+
new Filter { A = 10, B = 2, NullableC = 5, IsActive = true, Status = "open" },
25+
new Filter { A = 10, B = 0, NullableC = null, IsActive = false, Status = null },
26+
new Filter { A = 20, B = 5, NullableC = 7, IsActive = true, Status = "open" });
27+
_ = context.SaveChanges();
28+
return context;
29+
}
30+
31+
[TestMethod]
32+
public void IsNull_DecimalCastNullCoalesce_ReplacesNullWithFallback()
33+
{
34+
// (decimal?)f.NullableC ?? 0m → ISNULL(CAST(... AS decimal(18,2)), 0.0)
35+
using var context = SeededFiltersContext();
36+
var values = context.Filters
37+
.OrderBy(f => f.Id)
38+
.Select(f => (decimal?)f.NullableC ?? 0m)
39+
.ToArray();
40+
CollectionAssert.AreEqual(new[] { 5m, 0m, 7m }, values);
41+
}
42+
43+
[TestMethod]
44+
public void IsNull_LongCastNullCoalesce_ReplacesNullWithFallback()
45+
{
46+
// (long?)f.NullableC ?? 0L → ISNULL(CAST(... AS bigint), CAST(0 AS bigint))
47+
using var context = SeededFiltersContext();
48+
var values = context.Filters
49+
.OrderBy(f => f.Id)
50+
.Select(f => (long?)f.NullableC ?? 0L)
51+
.ToArray();
52+
CollectionAssert.AreEqual(new[] { 5L, 0L, 7L }, values);
53+
}
54+
55+
[TestMethod]
56+
public void IsNull_ChainedCoalesce_FallsThroughToInnerCast()
57+
{
58+
// (decimal?)f.NullableC ?? (decimal?)f.A ?? 0m → ISNULL(CAST(NullableC), CAST(A))
59+
using var context = SeededFiltersContext();
60+
var values = context.Filters
61+
.OrderBy(f => f.Id)
62+
.Select(f => (decimal?)f.NullableC ?? (decimal?)f.A ?? 0m)
63+
.ToArray();
64+
CollectionAssert.AreEqual(new[] { 5m, 10m, 7m }, values);
65+
}
66+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
[TestClass]
8+
public sealed class IsNullIifNullIfTests
9+
{
10+
[TestMethod]
11+
public void IsNull_NonNullFirst_ReturnsFirst()
12+
{
13+
AreEqual(5, ExecuteScalar<int>("select isnull(5, 99)"));
14+
AreEqual("hello", ExecuteScalar("select isnull('hello', 'world')"));
15+
}
16+
17+
[TestMethod]
18+
public void IsNull_NullFirst_ReturnsSecondCoercedToFirstType()
19+
{
20+
AreEqual(42, ExecuteScalar<int>("select isnull(cast(null as int), '42')"));
21+
AreEqual(99, ExecuteScalar<int>("select isnull(cast(null as int), 99)"));
22+
}
23+
24+
[TestMethod]
25+
public void IsNull_NullFirst_NonNumericString_RaisesMsg245()
26+
{
27+
var ex = Throws<DbException>(() => ExecuteScalar("select isnull(cast(null as int), 'abc')"));
28+
StartsWith("Conversion failed when converting the varchar value 'abc'", ex.Message);
29+
}
30+
31+
[TestMethod]
32+
public void IsNull_BothNull_ReturnsTypedNull()
33+
{
34+
AreEqual(DBNull.Value, ExecuteScalar("select isnull(cast(null as int), cast(null as bigint))"));
35+
AreEqual(DBNull.Value, ExecuteScalar("select isnull(null, null)"));
36+
}
37+
38+
[TestMethod]
39+
public void IsNull_OneArg_RaisesMsg174()
40+
{
41+
var ex = Throws<DbException>(() => ExecuteScalar("select isnull(5)"));
42+
AreEqual("The isnull function requires 2 argument(s).", ex.Message);
43+
AreEqual("174", ex.Data["HelpLink.EvtID"]);
44+
}
45+
46+
[TestMethod]
47+
public void IsNull_ThreeArgs_RaisesMsg174()
48+
{
49+
var ex = Throws<DbException>(() => ExecuteScalar("select isnull(null, null, 1)"));
50+
AreEqual("The isnull function requires 2 argument(s).", ex.Message);
51+
AreEqual("174", ex.Data["HelpLink.EvtID"]);
52+
}
53+
54+
[TestMethod]
55+
public void IsNull_FirstWins_NoCoercionOfFallback()
56+
{
57+
// 5 is non-null, so the second arg's parse is skipped — even though
58+
// 'invalid' would fail to convert to int, it's never reached.
59+
AreEqual(5, ExecuteScalar<int>("select isnull(cast(5 as int), cast('invalid' as varchar(20)))"));
60+
}
61+
62+
[TestMethod]
63+
public void IsNull_AggregateFallback()
64+
{
65+
// EF Core emits this shape constantly: SUM over an empty filter
66+
// returns NULL; ISNULL substitutes a default.
67+
var sim = new Simulation();
68+
_ = sim.ExecuteNonQuery("create table t (v int)");
69+
AreEqual(0, sim.ExecuteScalar<int>("select isnull(sum(v), 0) from t where 1=0"));
70+
}
71+
72+
[TestMethod]
73+
public void IsNull_Nested()
74+
{
75+
AreEqual(5, ExecuteScalar<int>("select isnull(isnull(cast(null as int), cast(null as int)), 5)"));
76+
}
77+
78+
[TestMethod]
79+
public void Iif_TrueBranch()
80+
{
81+
AreEqual("yes", ExecuteScalar("select iif(1=1, 'yes', 'no')"));
82+
AreEqual(5, ExecuteScalar<int>("select iif(1=1, 5, 99)"));
83+
}
84+
85+
[TestMethod]
86+
public void Iif_FalseBranch()
87+
{
88+
AreEqual("no", ExecuteScalar("select iif(1=2, 'yes', 'no')"));
89+
AreEqual(99, ExecuteScalar<int>("select iif(1=2, 5, 99)"));
90+
}
91+
92+
[TestMethod]
93+
public void Iif_UnknownConditionRoutesToFalse()
94+
{
95+
// UNKNOWN-condition CASE-equivalent semantics: not-true → ELSE arm.
96+
AreEqual("else", ExecuteScalar("select iif(null=1, 'eq', 'else')"));
97+
AreEqual("b", ExecuteScalar("select iif(cast(null as bit) = 1, 'a', 'b')"));
98+
}
99+
100+
[TestMethod]
101+
public void Iif_TypePromotion()
102+
{
103+
// int + decimal arms → decimal result.
104+
var result = ExecuteScalar("select iif(1=1, cast(5 as int), cast(5.5 as decimal(10,2)))");
105+
AreEqual(5m, result);
106+
var resultFalse = ExecuteScalar("select iif(1=2, cast(5 as int), cast(5.5 as decimal(10,2)))");
107+
AreEqual(5.5m, resultFalse);
108+
}
109+
110+
[TestMethod]
111+
public void Iif_NullArm_PropagatesNull()
112+
{
113+
AreEqual(DBNull.Value, ExecuteScalar("select iif(1=1, null, 5)"));
114+
AreEqual(5, ExecuteScalar<int>("select iif(1=2, null, 5)"));
115+
}
116+
117+
[TestMethod]
118+
public void Iif_FromTableRow()
119+
{
120+
var sim = new Simulation();
121+
_ = sim.ExecuteNonQuery("create table t (id int, name varchar(20))");
122+
_ = sim.ExecuteNonQuery("insert into t (id, name) values (1, 'a'), (-1, 'b')");
123+
using var reader = sim.ExecuteReader("select iif(id > 0, name, 'none') from t order by id");
124+
IsTrue(reader.Read());
125+
AreEqual("none", reader.GetString(0));
126+
IsTrue(reader.Read());
127+
AreEqual("a", reader.GetString(0));
128+
IsFalse(reader.Read());
129+
}
130+
131+
[TestMethod]
132+
public void Iif_TwoArgs_RaisesSyntax()
133+
{
134+
_ = Throws<DbException>(() => ExecuteScalar("select iif(1=1, 'a')"));
135+
}
136+
137+
[TestMethod]
138+
public void Iif_FourArgs_RaisesSyntax()
139+
{
140+
_ = Throws<DbException>(() => ExecuteScalar("select iif(1=1, 'a', 'b', 'c')"));
141+
}
142+
143+
[TestMethod]
144+
public void NullIf_Equal_ReturnsNull()
145+
{
146+
AreEqual(DBNull.Value, ExecuteScalar("select nullif(5, 5)"));
147+
AreEqual(DBNull.Value, ExecuteScalar("select nullif('abc', 'abc')"));
148+
}
149+
150+
[TestMethod]
151+
public void NullIf_NotEqual_ReturnsFirst()
152+
{
153+
AreEqual(5, ExecuteScalar<int>("select nullif(5, 3)"));
154+
AreEqual("abc", ExecuteScalar("select nullif('abc', 'def')"));
155+
}
156+
157+
[TestMethod]
158+
public void NullIf_FirstNull_ReturnsNull()
159+
{
160+
// a is NULL → equality is UNKNOWN → ELSE branch returns a (NULL).
161+
AreEqual(DBNull.Value, ExecuteScalar("select nullif(cast(null as int), 5)"));
162+
}
163+
164+
[TestMethod]
165+
public void NullIf_SecondNull_ReturnsFirst()
166+
{
167+
// a non-null, b NULL → equality is UNKNOWN → ELSE branch returns a.
168+
AreEqual(5, ExecuteScalar<int>("select nullif(5, cast(null as int))"));
169+
}
170+
171+
[TestMethod]
172+
public void NullIf_TypeMix_PromotedEquality_ReturnsNull()
173+
{
174+
// 5 (int) compared against 5.0 (decimal) — equality holds via promote;
175+
// result is NULL, typed as int (first-arg type).
176+
AreEqual(DBNull.Value, ExecuteScalar("select nullif(cast(5 as int), cast(5.0 as decimal(10,2)))"));
177+
}
178+
179+
[TestMethod]
180+
public void NullIf_TypeMix_NotEqual_ReturnsFirstAtFirstType()
181+
{
182+
// 5 (int) != 5.5 (decimal) — returns 5 as int.
183+
AreEqual(5, ExecuteScalar<int>("select nullif(cast(5 as int), cast(5.5 as decimal(10,2)))"));
184+
}
185+
186+
[TestMethod]
187+
public void NullIf_OneArg_RaisesSyntax()
188+
{
189+
_ = Throws<DbException>(() => ExecuteScalar("select nullif(5)"));
190+
}
191+
192+
[TestMethod]
193+
public void NullIf_ThreeArgs_RaisesSyntax()
194+
{
195+
_ = Throws<DbException>(() => ExecuteScalar("select nullif(1, 2, 3)"));
196+
}
197+
198+
[TestMethod]
199+
public void NullIf_FromTableRow_EfCoreSafeDividePattern()
200+
{
201+
// EF Core emits NULLIF for safe-divide: a / NULLIF(b, 0).
202+
var sim = new Simulation();
203+
_ = sim.ExecuteNonQuery("create table t (a int, b int)");
204+
_ = sim.ExecuteNonQuery("insert into t (a, b) values (10, 2), (10, 0)");
205+
using var reader = sim.ExecuteReader("select a / nullif(b, 0) from t order by b");
206+
IsTrue(reader.Read());
207+
IsTrue(reader.IsDBNull(0));
208+
IsTrue(reader.Read());
209+
AreEqual(5, reader.GetInt32(0));
210+
IsFalse(reader.Read());
211+
}
212+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ internal static SimulatedSqlException FetchMustBeGreaterThanZero() =>
8686

8787
internal static SimulatedSqlException UnrecognizedBuiltInFunction(string name) => new($"'{name}' is not a recognized built-in function name.", 195, 15, 10);
8888

89+
/// <summary>
90+
/// Mimics SQL Server's Msg 174 — fired when a built-in function is called
91+
/// with the wrong number of arguments (e.g. <c>ISNULL(x)</c> or
92+
/// <c>ISNULL(a, b, c)</c>). The function name is rendered lowercase in
93+
/// the message regardless of source casing — probe-confirmed.
94+
/// </summary>
95+
internal static SimulatedSqlException FunctionRequiresNArguments(string functionLowerName, int argumentCount) =>
96+
new($"The {functionLowerName} function requires {argumentCount} argument(s).", 174, 15, 1);
97+
8998
/// <summary>
9099
/// Mimics SQL Server error 155: the first argument to <c>DATEPART</c> /
91100
/// <c>DATEADD</c> / <c>DATEDIFF</c> / etc. wasn't a recognized datepart

SqlServerSimulator/Parser/Expression.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ public static Expression Parse(ParserContext context)
7070
},
7171
ReservedKeyword { Keyword: Keyword.Null } => new Value(),
7272
ReservedKeyword { Keyword: Keyword.Case } => CaseExpression.ParseCase(context),
73-
// LEFT, RIGHT, CONVERT, and TRY_CONVERT are reserved keywords
74-
// but dispatch as function calls when followed by '(' — the
75-
// surrounding loop hands the call shape off to ResolveBuiltIn.
76-
ReservedKeyword { Keyword: Keyword.Left or Keyword.Right or Keyword.Convert or Keyword.Try_Convert or Keyword.Coalesce } reserved => new Reference(reserved.ToString()),
73+
// LEFT, RIGHT, CONVERT, TRY_CONVERT, COALESCE, and NULLIF are
74+
// reserved keywords but dispatch as function calls when followed
75+
// by '(' — the surrounding loop hands the call shape off to
76+
// ResolveBuiltIn.
77+
ReservedKeyword { Keyword: Keyword.Left or Keyword.Right or Keyword.Convert or Keyword.Try_Convert or Keyword.Coalesce or Keyword.NullIf } reserved => new Reference(reserved.ToString()),
7778
Name name => new Reference(name),
7879
Operator { Character: '(' } => ParseGroupedExpression(context),
7980
_ => throw SimulatedSqlException.SyntaxErrorNear(context)
@@ -237,6 +238,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
237238
{
238239
"ABS" => new AbsoluteValue(context),
239240
"AVG" => AggregateExpression.Parse(context, AggregateKind.Avg),
241+
"IIF" => new Iif(context),
240242
"LEN" => new Length(context),
241243
"MAX" => AggregateExpression.Parse(context, AggregateKind.Max),
242244
"MIN" => AggregateExpression.Parse(context, AggregateKind.Min),
@@ -266,6 +268,8 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
266268
},
267269
6 => uppercaseName switch
268270
{
271+
"ISNULL" => new IsNullExpression(context),
272+
"NULLIF" => new NullIf(context),
269273
"STDEVP" => AggregateExpression.Parse(context, AggregateKind.StdevP),
270274
_ => null
271275
},

0 commit comments

Comments
 (0)