Skip to content

Commit 191c9f2

Browse files
committed
Added CASE expression support (searched + simple forms, type-promoted result coercion); implemented SimulatedDbDataReader.GetBoolean.
1 parent 6d6562e commit 191c9f2

7 files changed

Lines changed: 396 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7777
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout.
7878
- `CONVERT` / `TRY_CONVERT` style-code coverage. Only styles `0`, `120`, and `121` are wired up for date-like sources targeting a character string (the EF Core code-generation defaults). Other style numbers raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date-parsing styles aren't modeled.
7979
- Boolean combinators in WHERE / MERGE-ON / CHECK predicates: `AND` / `OR` / `NOT`, parenthesized groupings, `IS [NOT] NULL`, and `[NOT] IN (literal, literal, ...)` all parse with standard SQL precedence (AND > OR; NOT highest). The pattern `where (arith_expr) cmp rhs` (parens-around-arithmetic as the left side of a comparison) is the one shape the simulator's parser doesn't accept; SQL Server does. `BooleanExpression.Run` returns `bool?` (three-valued: true / false / UNKNOWN); WHERE / MERGE-ON treat UNKNOWN as exclude, CHECK treats UNKNOWN as pass — matching SQL Server. `IS NULL` definitively resolves UNKNOWN to true/false (it's the canonical way to test nullability without falling through tri-state).
80+
- `CASE` expressions: both searched (`CASE WHEN cond THEN ... [ELSE ...] END`) and simple (`CASE input WHEN val THEN ... [ELSE ...] END`) forms parse anywhere an expression is allowed. Branches evaluate in source order; first true predicate wins. UNKNOWN is treated as exclude (matching WHERE), so the simple form's NULL-vs-NULL `WHEN` falls through (`CASE NULL WHEN NULL` → no match). Result type is computed via `SqlType.Promote` across all THEN / ELSE branches and cached on the first `GetSqlType` call; `Run` then coerces matched values to that common type so projection schema stays consistent. No-match-no-ELSE → typed NULL. **Not enforced**: Msg 8133 (real SQL Server raises this when every branch is a bare `NULL` literal — the simulator returns NULL of `int`).
8081
- Subqueries: `EXISTS (SELECT ...)` / `[NOT] EXISTS` and `expr [NOT] IN (SELECT ...)` parse as boolean atoms in WHERE / HAVING / CHECK; scalar subqueries `(SELECT col FROM ...)` parse anywhere an expression is allowed (projection, WHERE comparison, arithmetic operand). All forms work both correlated and non-correlated, with arbitrary outer-scope nesting depth. EXISTS counts rows only (multi-column inner allowed); `IN (SELECT ...)` and scalar subqueries require exactly one inner column (Msg 116). Scalar subqueries also enforce single-row cardinality at runtime (Msg 512, fired per outer row for correlated cases); empty result → NULL of the inner's projected type. NULL semantics in `IN (SELECT ...)` mirror the literal-list IN (NULL row → UNKNOWN unless a non-NULL match wins first). Column resolution honors qualifiers (`alias.col` / `tableName.col`) so a correlated reference to an outer column with the same name as an inner column works correctly. The inner plan re-executes per outer row (no result caching yet — fidelity over performance for now). `Selection.Parse` returns a deferred plan; `Selection.Execute(outerResolver)` materializes results, so the same plan re-runs against different outer rows. **Not modeled**: `ANY` / `SOME` / `ALL` quantifiers, `UNION` / `UNION ALL` inside a subquery body, row-constructor `IN ((1,2), (3,4))`. Derived tables in FROM (already supported) don't see outer scope (no APPLY / lateral).
8182
- `LIKE` `COLLATE` override. The default collation (case-insensitive, Latin1_General-shaped) is what every `LIKE` runs under; explicit `COLLATE` clauses on the predicate aren't parsed yet.
8283
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end tests for the LINQ projection / WHERE shapes EF Core's
5+
/// SqlServer provider emits as <c>CASE</c> expressions: ternary
6+
/// projections, <c>Any()</c> in projection (wraps EXISTS in
7+
/// <c>CASE WHEN ... THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END</c>), and
8+
/// boolean conditional projections.
9+
/// </summary>
10+
[TestClass]
11+
public class EFCoreCase
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
private static TestDbContext SeededContext()
16+
{
17+
var context = new TestDbContext(TestDbContext.CreateCustomersSimulation());
18+
context.Customers.AddRange(
19+
new Customer { Name = "alpha" },
20+
new Customer { Name = "beta" },
21+
new Customer { Name = "gamma" });
22+
_ = context.SaveChanges();
23+
context.CustomerOrders.AddRange(
24+
new CustomerOrder { CustomerId = 1, Amount = 10m },
25+
new CustomerOrder { CustomerId = 2, Amount = 30m });
26+
_ = context.SaveChanges();
27+
return context;
28+
}
29+
30+
[TestMethod]
31+
public void Projection_Ternary_EmitsCase()
32+
{
33+
// EF Core translates `c.Id > 1 ? "big" : "small"` to
34+
// `SELECT CASE WHEN [c].[Id] > 1 THEN N'big' ELSE N'small' END`.
35+
using var context = SeededContext();
36+
var labels = context.Customers
37+
.OrderBy(c => c.Id)
38+
.Select(c => c.Id > 1 ? "big" : "small")
39+
.ToArray();
40+
CollectionAssert.AreEqual(new[] { "small", "big", "big" }, labels);
41+
}
42+
43+
[TestMethod]
44+
public void Projection_AnyCorrelated_EmitsCaseWithExists()
45+
{
46+
// EF Core translates `Any` in a projection slot to
47+
// `SELECT CASE WHEN EXISTS (...) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END`.
48+
// Customers with at least one order: 1 and 2.
49+
using var context = SeededContext();
50+
var rows = context.Customers
51+
.OrderBy(c => c.Id)
52+
.Select(c => new
53+
{
54+
c.Id,
55+
HasOrder = context.CustomerOrders.Any(o => o.CustomerId == c.Id)
56+
})
57+
.ToArray();
58+
Assert.HasCount(3, rows);
59+
Assert.IsTrue(rows[0].HasOrder);
60+
Assert.IsTrue(rows[1].HasOrder);
61+
Assert.IsFalse(rows[2].HasOrder);
62+
}
63+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for SQL Server's <c>CASE</c> expression in both forms:
8+
/// searched (<c>CASE WHEN cond THEN ... [ELSE ...] END</c>) and simple
9+
/// (<c>CASE input WHEN val THEN ... [ELSE ...] END</c>). NULL handling,
10+
/// type promotion across branches, and composability with other expression
11+
/// contexts (WHERE, arithmetic, scalar subqueries) are sourced from probes
12+
/// against SQL Server 2025.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class CaseExpressionTests
16+
{
17+
// === Searched form ===
18+
19+
[TestMethod]
20+
public void Searched_FirstMatchingWhenWins()
21+
{
22+
AreEqual("a", new Simulation().ExecuteScalar("select case when 1=1 then 'a' when 1=1 then 'b' end"));
23+
}
24+
25+
[TestMethod]
26+
public void Searched_NoMatchNoElse_ReturnsNull()
27+
{
28+
// ExecuteScalar returns the value of the first column of the first row.
29+
// For a single row with a NULL column, that's DBNull.Value.
30+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select case when 1=0 then 'a' end"));
31+
}
32+
33+
[TestMethod]
34+
public void Searched_NoMatch_FallsBackToElse()
35+
{
36+
AreEqual("z", new Simulation().ExecuteScalar("select case when 1=0 then 'a' else 'z' end"));
37+
}
38+
39+
[TestMethod]
40+
public void Searched_UnknownPredicate_TreatedAsExclude()
41+
{
42+
// `null = 1` evaluates to UNKNOWN; UNKNOWN is not a match (same as WHERE).
43+
AreEqual("unmatched", new Simulation().ExecuteScalar("select case when null = 1 then 'matched' else 'unmatched' end"));
44+
}
45+
46+
[TestMethod]
47+
public void Searched_MultipleWhens_FirstTrueWins()
48+
{
49+
AreEqual("second", new Simulation().ExecuteScalar(
50+
"select case when 1=0 then 'first' when 2=2 then 'second' when 3=3 then 'third' end"));
51+
}
52+
53+
// === Simple form ===
54+
55+
[TestMethod]
56+
public void Simple_InputMatchesWhen_ReturnsThen()
57+
{
58+
AreEqual("two", new Simulation().ExecuteScalar("select case 2 when 1 then 'one' when 2 then 'two' else 'other' end"));
59+
}
60+
61+
[TestMethod]
62+
public void Simple_NullInputVsNullWhen_NotAMatch()
63+
{
64+
// `case null when null` follows `=` semantics: NULL = NULL is UNKNOWN, not a match.
65+
AreEqual("unmatched", new Simulation().ExecuteScalar(
66+
"select case cast(null as int) when null then 'matched' else 'unmatched' end"));
67+
}
68+
69+
[TestMethod]
70+
public void Simple_NoMatch_FallsBackToElse()
71+
{
72+
AreEqual("other", new Simulation().ExecuteScalar(
73+
"select case 99 when 1 then 'one' when 2 then 'two' else 'other' end"));
74+
}
75+
76+
[TestMethod]
77+
public void Simple_NoMatchNoElse_ReturnsNull()
78+
{
79+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select case 99 when 1 then 'one' when 2 then 'two' end"));
80+
}
81+
82+
// === Composition ===
83+
84+
[TestMethod]
85+
public void Case_InArithmetic_FlowsThroughOperator()
86+
{
87+
AreEqual(15, new Simulation().ExecuteScalar("select case when 1=1 then 10 else 20 end + 5"));
88+
}
89+
90+
[TestMethod]
91+
public void Case_InWhereClause_FiltersByDecision()
92+
{
93+
// Note: outer parens around the CASE are NOT used here — the
94+
// simulator's parser doesn't accept `(arith) cmp rhs` (a known
95+
// limitation). Bare `case ... end = 1` parses fine.
96+
var simulation = new Simulation();
97+
_ = simulation.ExecuteNonQuery("create table t (id int, name nvarchar(20))");
98+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'one'), (2, 'two'), (3, 'three')");
99+
100+
using var connection = simulation.CreateOpenConnection();
101+
using var reader = connection.CreateCommand(
102+
"select id from t where case when name='two' then 1 else 0 end = 1").ExecuteReader();
103+
var ids = new List<int>();
104+
while (reader.Read())
105+
ids.Add(reader.GetInt32(0));
106+
CollectionAssert.AreEqual(new[] { 2 }, ids);
107+
}
108+
109+
[TestMethod]
110+
public void Case_WithColumnReference_ResolvesPerRow()
111+
{
112+
var simulation = new Simulation();
113+
_ = simulation.ExecuteNonQuery("create table t (id int, name nvarchar(20))");
114+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'one'), (2, 'two'), (3, 'three')");
115+
116+
using var connection = simulation.CreateOpenConnection();
117+
using var reader = connection.CreateCommand(
118+
"select case when id = 2 then name else 'other' end from t").ExecuteReader();
119+
var labels = new List<string>();
120+
while (reader.Read())
121+
labels.Add(reader.GetString(0));
122+
CollectionAssert.AreEqual(new[] { "other", "two", "other" }, labels);
123+
}
124+
125+
[TestMethod]
126+
public void Case_Nested_OuterAndInnerEvaluateCorrectly()
127+
{
128+
AreEqual("inner-yes", new Simulation().ExecuteScalar(
129+
"select case when 1=1 then case when 2=2 then 'inner-yes' else 'inner-no' end else 'outer-no' end"));
130+
}
131+
132+
[TestMethod]
133+
public void Case_WithNullElse_ReturnsThenWhenMatched()
134+
{
135+
AreEqual(5, new Simulation().ExecuteScalar("select case when 1=1 then 5 else null end"));
136+
}
137+
138+
[TestMethod]
139+
public void Case_WithNullThen_ReturnsNullWhenMatched()
140+
{
141+
AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select case when 1=1 then null else 5 end"));
142+
}
143+
144+
[TestMethod]
145+
public void Case_InScalarSubquery_ProjectsValue()
146+
{
147+
var simulation = new Simulation();
148+
_ = simulation.ExecuteNonQuery("create table t (id int)");
149+
_ = simulation.ExecuteNonQuery("insert into t values (1)");
150+
151+
AreEqual("hit", simulation.ExecuteScalar(
152+
"select (select case when t.id = 1 then 'hit' else 'miss' end from t)"));
153+
}
154+
155+
// === Syntax errors ===
156+
157+
[TestMethod]
158+
public void Case_EmptyNoWhen_RaisesSyntaxError()
159+
{
160+
// `case end` has no WHEN clauses; SQL Server raises Msg 156 near `end`.
161+
var ex = Throws<DbException>(() => _ = new Simulation().ExecuteScalar("select case end"));
162+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
163+
}
164+
165+
[TestMethod]
166+
public void Case_MissingEnd_RaisesSyntaxError()
167+
{
168+
var ex = Throws<DbException>(() => _ = new Simulation().ExecuteScalar("select case when 1=1 then 'a'"));
169+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
170+
}
171+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public static Expression Parse(ParserContext context)
6666
? new LastIdentityExpression(context.Simulation)
6767
: new Value(doubleAtPrefixedString),
6868
ReservedKeyword { Keyword: Keyword.Null } => new Value(),
69+
ReservedKeyword { Keyword: Keyword.Case } => CaseExpression.ParseCase(context),
6970
// LEFT, RIGHT, CONVERT, and TRY_CONVERT are reserved keywords
7071
// but dispatch as function calls when followed by '(' — the
7172
// surrounding loop hands the call shape off to ResolveBuiltIn.

0 commit comments

Comments
 (0)