Skip to content

Commit e1e2b29

Browse files
committed
Add Msg 8133 enforcement for all-bare-NULL CASE / IIF — compile-time check on every THEN body + ELSE (absent ELSE = implicit NULL); typed NULL (CAST(NULL AS T)) satisfies the rule; (NULL) paren-wrap still trips via Parenthesized unwrap helper Expression.IsBareNullLiteral.
1 parent 9865049 commit e1e2b29

8 files changed

Lines changed: 181 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ The dispatch loop drains optional `;`s at the top of each iteration and trusts e
104104
- Boolean combinators (WHERE / MERGE-ON / CHECK): `AND` / `OR` / `NOT`, parens, `IS [NOT] NULL`, `[NOT] IN (literal,...)`. Tri-valued.
105105
- 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.
106106
- `SELECT *`: bare and qualified `<source>.*`. Multi-source `*` keeps duplicate names. Unbound `<qualifier>.*` → Msg 4104.
107-
- CASE: searched + simple. UNKNOWN excludes (matches WHERE); simple-form `CASE NULL WHEN NULL` falls through. Result type from `SqlType.Promote` over THEN/ELSE.
107+
- CASE: searched + simple. UNKNOWN excludes (matches WHERE); simple-form `CASE NULL WHEN NULL` falls through. Result type from `SqlType.Promote` over THEN/ELSE. **Msg 8133** fires at parse when every result expression (every THEN body + the explicit ELSE if present; an absent ELSE counts as implicit bare NULL) is a bare `NULL` literal — `Expression.IsBareNullLiteral` unwraps `Parenthesized` so `(NULL)` still trips. A single typed branch (e.g. `CAST(NULL AS int)`) satisfies the rule. `IIF` enforces the same check on its two value arms (real SQL Server desugars IIF to CASE).
108108
- `ISNULL` truncates fallback to first arg's type. `IIF` = sugar for searched CASE. `NULLIF(a, b)` = `CASE WHEN a = b THEN NULL ELSE a END`. EF emits `ISNULL` only for `??` with a CAST; bare `??` emits `COALESCE`. Neither IIF nor NULLIF is EF-emitted (LINQ ternary → CASE) — load-bearing for `FromSqlInterpolated`.
109109

110110
### JOINs / APPLY
@@ -418,7 +418,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
418418
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*` / `INSERTED.*` star expansion.
419419
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
420420
- Msg 8141 (inline CHECK referencing a peer column — SQL Server rejects at CREATE TABLE; simulator allows).
421-
- Msg 8133 (CASE where every branch is bare `NULL`; simulator returns NULL of `int`).
422421
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
423422
- Heap allocation tracking (flat page list, no IAM/PFS).
424423
- Compound assignment (`SET @v += expr` / `-=` / `*=` etc.) — rewrite as `SET @v = @v + expr`. The arithmetic-operator runtime is locked behind `protected` instance methods on `TwoSidedExpression`; exposing them as static helpers is the prerequisite refactor.

SqlServerSimulator.Tests/CaseExpressionTests.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,91 @@ public void Case_MissingEnd_RaisesSyntaxError()
174174
var ex = Throws<DbException>(() => _ = new Simulation().ExecuteScalar("select case when 1=1 then 'a'"));
175175
AreEqual("102", ex.Data["HelpLink.EvtID"]);
176176
}
177+
178+
// === Msg 8133: every result expression is a bare NULL ===
179+
// Probed against SQL Server 2025 (2026-05-11). Class 16 State 1 verbatim:
180+
// "At least one of the result expressions in a CASE specification must
181+
// be an expression other than the NULL constant."
182+
183+
[TestMethod]
184+
public void Case_AllBareNull_NoElse_Searched_Msg8133()
185+
=> new Simulation().AssertSqlError(
186+
"select case when 1=1 then null end",
187+
8133,
188+
"At least one of the result expressions in a CASE specification must be an expression other than the NULL constant.");
189+
190+
[TestMethod]
191+
public void Case_AllBareNull_WithElseNull_Searched_Msg8133()
192+
=> new Simulation().AssertSqlError(
193+
"select case when 1=1 then null else null end",
194+
8133);
195+
196+
[TestMethod]
197+
public void Case_AllBareNull_MultipleWhens_NoElse_Msg8133()
198+
=> new Simulation().AssertSqlError(
199+
"select case when 1=1 then null when 1=0 then null end",
200+
8133);
201+
202+
[TestMethod]
203+
public void Case_AllBareNull_Simple_Msg8133()
204+
=> new Simulation().AssertSqlError(
205+
"select case 1 when 1 then null else null end",
206+
8133);
207+
208+
[TestMethod]
209+
public void Case_AllBareNull_SimpleNoElse_Msg8133()
210+
=> new Simulation().AssertSqlError(
211+
"select case 1 when 1 then null end",
212+
8133);
213+
214+
[TestMethod]
215+
public void Case_AllBareNull_ParenWrapped_Msg8133()
216+
=> new Simulation().AssertSqlError(
217+
"select case when 1=1 then (null) else (null) end",
218+
8133);
219+
220+
[TestMethod]
221+
public void Case_AllBareNull_DoubleParenWrapped_Msg8133()
222+
=> new Simulation().AssertSqlError(
223+
"select case when 1=1 then ((null)) else null end",
224+
8133);
225+
226+
[TestMethod]
227+
public void Case_TypedNullElse_Accepted()
228+
{
229+
// A typed NULL (`cast(null as int)`) on any branch satisfies the
230+
// rule because the result type can be inferred.
231+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
232+
"select case when 1=1 then null else cast(null as int) end"));
233+
}
234+
235+
[TestMethod]
236+
public void Case_TypedNullThen_Accepted()
237+
{
238+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
239+
"select case when 1=1 then cast(null as varchar(10)) else null end"));
240+
}
241+
242+
[TestMethod]
243+
public void Case_OneBranchTyped_OneBareNull_Accepted()
244+
{
245+
// One typed branch satisfies Msg 8133. Use int branches because the
246+
// simulator currently types bare NULL as int and a varchar typed
247+
// branch would collide on Promote (pre-existing fidelity gap that's
248+
// orthogonal to Msg 8133).
249+
AreEqual(7, new Simulation().ExecuteScalar(
250+
"select case when 1=1 then 7 when 1=0 then null else null end"));
251+
}
252+
253+
[TestMethod]
254+
public void Case_AllBareNull_InWhere_Msg8133()
255+
=> new Simulation().AssertSqlError(
256+
"select 1 where case when 1=1 then null else null end is null",
257+
8133);
258+
259+
[TestMethod]
260+
public void Case_AllBareNull_Nested_OuterTrips_Msg8133()
261+
=> new Simulation().AssertSqlError(
262+
"select case when 1=1 then case when 1=1 then null else null end else null end",
263+
8133);
177264
}

SqlServerSimulator.Tests/IsNullIifNullIfTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,35 @@ public void NullIf_ThreeArgs_RaisesSyntax()
196196
_ = Throws<DbException>(() => ExecuteScalar("select nullif(1, 2, 3)"));
197197
}
198198

199+
// === Msg 8133 via IIF: IIF desugars to CASE so all-bare-NULL arms ===
200+
// share the CASE wording. Probed against SQL Server 2025 (2026-05-11).
201+
202+
[TestMethod]
203+
public void Iif_BothArmsBareNull_Msg8133()
204+
=> AssertSqlError("select iif(1=1, null, null)", 8133,
205+
"At least one of the result expressions in a CASE specification must be an expression other than the NULL constant.");
206+
207+
[TestMethod]
208+
public void Iif_BothArmsBareNullParenWrapped_Msg8133()
209+
=> AssertSqlError("select iif(1=1, (null), (null))", 8133);
210+
211+
[TestMethod]
212+
public void Iif_OneArmTypedNull_Accepted()
213+
{
214+
// A typed NULL on one arm satisfies the rule.
215+
AreEqual(DBNull.Value, ExecuteScalar("select iif(1=1, null, cast(null as int))"));
216+
}
217+
218+
[TestMethod]
219+
public void Iif_OneArmTyped_OneArmBareNull_Accepted()
220+
{
221+
// One typed arm satisfies Msg 8133. Int both sides because the
222+
// simulator currently types bare NULL as int (cross-family
223+
// varchar+null promotion is a pre-existing fidelity gap orthogonal
224+
// to Msg 8133).
225+
AreEqual(7, ExecuteScalar("select iif(1=1, 7, null)"));
226+
}
227+
199228
// EF Core emits NULLIF for safe-divide: a / NULLIF(b, 0).
200229
[TestMethod]
201230
public void NullIf_FromTableRow_EfCoreSafeDividePattern()

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,19 @@ internal static SimulatedSqlException FunctionMayNotHaveWithinGroup(string funct
311311
/// </summary>
312312
internal static SimulatedSqlException IntegerIndexNotAllowedInOrderedAggregate() =>
313313
new("Windowed functions, aggregates and NEXT VALUE FOR functions do not support integer indices as ORDER BY clause expressions.", 5308, 15, 1);
314+
315+
/// <summary>
316+
/// Mimics SQL Server's Msg 8133 — every result expression in a
317+
/// <c>CASE</c> specification is a bare <c>NULL</c> literal. SQL Server
318+
/// requires at least one branch (THEN body or explicit ELSE) to be a
319+
/// typed expression so the result type can be inferred. An absent ELSE
320+
/// is treated as implicit NULL — so a single <c>WHEN cond THEN NULL</c>
321+
/// with no ELSE also fires Msg 8133. Probe-confirmed against SQL Server
322+
/// 2025 (2026-05-11): Class 16, State 1, verbatim wording. Also fires
323+
/// for <c>IIF(cond, NULL, NULL)</c> (real SQL Server desugars IIF to
324+
/// CASE); <c>COALESCE(NULL, NULL)</c> takes a different path (Msg 4127)
325+
/// and <c>NULLIF</c> a third (Msg 4151).
326+
/// </summary>
327+
internal static SimulatedSqlException AllResultsInCaseAreNull() =>
328+
new("At least one of the result expressions in a CASE specification must be an expression other than the NULL constant.", 8133, 16, 1);
314329
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,24 @@ private static void ParseWithinGroupOrderBy(AggregateExpression aggregate, Parse
322322
/// </summary>
323323
internal virtual bool ResultIsNullable(Func<MultiPartName, bool> resolveColumnNullable) => true;
324324

325+
/// <summary>
326+
/// Returns true when <paramref name="expression"/> is a bare <c>NULL</c>
327+
/// literal at the syntactic level — that is, the keyword <c>NULL</c>
328+
/// optionally wrapped in any number of parentheses. A typed NULL like
329+
/// <c>CAST(NULL AS int)</c> is NOT a bare NULL (it's a <see cref="Cast"/>
330+
/// expression carrying a typed value). Used by <see cref="CaseExpression"/>
331+
/// and <see cref="Iif"/> to enforce SQL Server's Msg 8133 rule: every
332+
/// result expression in a CASE specification being a bare NULL is a
333+
/// compile-time error, but a single explicitly-typed NULL among the
334+
/// branches satisfies the rule.
335+
/// </summary>
336+
internal static bool IsBareNullLiteral(Expression expression) => expression switch
337+
{
338+
Parenthesized p => IsBareNullLiteral(p.Wrapped),
339+
Value v => v.Constant.IsNull,
340+
_ => false,
341+
};
342+
325343
/// <summary>
326344
/// Parses a grouped expression starting at the opening <c>(</c>. Two
327345
/// shapes share the leading paren: a parenthesized expression

SqlServerSimulator/Parser/Expressions/CaseExpression.cs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,24 @@ public static CaseExpression ParseCase(ParserContext context)
161161
elseBranch = Expression.Parse(context);
162162
}
163163

164+
// Real SQL Server fires Msg 8133 at compile time when every result
165+
// expression — every THEN body, plus the explicit ELSE if present
166+
// (absent ELSE = implicit NULL) — is a bare NULL literal. A typed
167+
// NULL (e.g. `CAST(NULL AS int)`) satisfies the rule because its
168+
// type isn't ambiguous. Probe-confirmed against SQL Server 2025.
169+
var anyTypedBranch = elseBranch is not null && !IsBareNullLiteral(elseBranch);
170+
for (var i = 0; !anyTypedBranch && i < thens.Count; i++)
171+
anyTypedBranch = !IsBareNullLiteral(thens[i]);
172+
164173
return context.Token is not ReservedKeyword { Keyword: Keyword.End }
165174
? throw SimulatedSqlException.SyntaxErrorNear(context)
166-
: new CaseExpression(
167-
input,
168-
input is null ? [.. searchedWhensList!] : null,
169-
input is not null ? [.. compareValuesList!] : null,
170-
[.. thens],
171-
elseBranch);
175+
: !anyTypedBranch
176+
? throw SimulatedSqlException.AllResultsInCaseAreNull()
177+
: new CaseExpression(
178+
input,
179+
input is null ? [.. searchedWhensList!] : null,
180+
input is not null ? [.. compareValuesList!] : null,
181+
[.. thens],
182+
elseBranch);
172183
}
173184
}

SqlServerSimulator/Parser/Expressions/Iif.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ public Iif(ParserContext context)
2626
if (context.Token is not Tokens.Operator { Character: ',' })
2727
throw SimulatedSqlException.SyntaxErrorNear(context);
2828
this.falseValue = Parse(context.MoveNextRequiredReturnSelf());
29+
30+
// IIF desugars to a searched CASE in SQL Server, so Msg 8133 fires
31+
// when both value arms are bare NULL literals — probe-confirmed
32+
// against SQL Server 2025 (verbatim CASE wording, not an IIF-specific
33+
// message). A single typed NULL (`CAST(NULL AS int)`) on either arm
34+
// satisfies the rule.
35+
if (IsBareNullLiteral(this.trueValue) && IsBareNullLiteral(this.falseValue))
36+
throw SimulatedSqlException.AllResultsInCaseAreNull();
2937
}
3038

3139
public override SqlValue Run(RuntimeContext runtime)

SqlServerSimulator/Parser/Expressions/Parenthesized.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ namespace SqlServerSimulator.Parser.Expressions;
99
/// </summary>
1010
internal sealed class Parenthesized(Expression wrapped) : Expression
1111
{
12-
public override Storage.SqlValue Run(RuntimeContext runtime) => wrapped.Run(runtime);
12+
/// <summary>The expression nested inside the parentheses. Exposed so callers (notably <see cref="Expression.IsBareNullLiteral"/>) can peer through paren wrappers.</summary>
13+
public readonly Expression Wrapped = wrapped;
1314

14-
public override Storage.SqlType GetSqlType(Func<MultiPartName, Storage.SqlType> resolveColumnType) => wrapped.GetSqlType(resolveColumnType);
15+
public override Storage.SqlValue Run(RuntimeContext runtime) => this.Wrapped.Run(runtime);
1516

16-
internal override string DebugDisplay() => $"( {wrapped.DebugDisplay()} )";
17+
public override Storage.SqlType GetSqlType(Func<MultiPartName, Storage.SqlType> resolveColumnType) => this.Wrapped.GetSqlType(resolveColumnType);
18+
19+
internal override string DebugDisplay() => $"( {this.Wrapped.DebugDisplay()} )";
1720
}

0 commit comments

Comments
 (0)