Skip to content

Commit 5a1ff4e

Browse files
committed
Implemented CHECK constraints, more Boolean expression types, enforce NOT NULL at INSERT/MERGE.
1 parent 18b2195 commit 5a1ff4e

18 files changed

Lines changed: 1247 additions & 92 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7575
- `text` / `ntext` / `image` operation restrictions: comparison (Msg 402) and ORDER BY / DISTINCT (Msg 306) are enforced; function-level restrictions (e.g. `LEN(ntext)` raising Msg 8116) and the legacy `READTEXT`/`WRITETEXT`/`UPDATETEXT` family aren't modeled.
7676
- `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.
7777
- `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.
78-
- Boolean combinators in WHERE / MERGE-ON predicates: `AND` chains work, but `OR` / `NOT` (at the predicate-combinator level — `NOT LIKE` is fine inside a single predicate) and parenthesized predicate groupings aren't parsed yet. `OR` raises `NotSupportedException`.
78+
- 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. `IN` accepts only an expression list, not a subquery (`IN (SELECT ...)`). `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).
7979
- `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.
8080
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
8181
- EF Core compatibility: the `SqlServerSimulator.EFCore` adapter (`UseSqlServerSimulator(...)`) covers the seven `SqlParameter`-downcast pairs — `DateOnly → date`, `DateTime → date`, `DateTime → smalldatetime`, `TimeOnly → time(N)`, `TimeSpan → time(N)`, `decimal → money`, `decimal → smallmoney`. Without the adapter (plain `UseSqlServer`), those mappings still throw at SaveChanges as before. The MAX-string family (default-mapped `string``nvarchar(max)`, `[Column(TypeName = "varchar(max)")]`, `[Column(TypeName = "varbinary(max)")]`) flows through plain `UseSqlServer` without needing the adapter. Other type pairs the SqlServer provider supports but aren't modeled here (e.g. `hierarchyid`, `geography`, `geometry`, `rowversion`) are not bridged.
8282
- `OUTPUT` and `MERGE` are scoped to the EF Core SaveChanges shape: `INSERT ... OUTPUT INSERTED.<col>` (single-row) and `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch). `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, OUTPUT on UPDATE/DELETE, `INSERTED.*` star expansion, MERGE source subqueries, MERGE target with column refs in `ON`, the `WHEN MATCHED` UPDATE/DELETE branches, and `$action` aren't supported. The `WHEN MATCHED` branch parses syntactically but throws `NotSupportedException` if the per-row predicate ever evaluates true.
8383
- Session-scoped state. `SCOPE_IDENTITY()` / `@@IDENTITY`, `SET IDENTITY_INSERT`'s active table, and `DBCC TRACEON(N)` flags all live on `Simulation` rather than per-connection. The simulator collapses session vs global scope until a real connection-scoped state model exists; revisit when transactions/locks/MVCC bring the per-session shape with them.
8484
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
8585
- `PRIMARY KEY` / `UNIQUE` on a computed column. SQL Server allows it (silently persisting the values for the underlying index); the simulator throws `NotSupportedException` because the enforcement loop would need to evaluate the computed expression against every existing row at insert time. Auto-generated constraint names use a deterministic FNV-1a hash for the 16-hex suffix, not SQL Server's object-id-derived hex — same shape, stable across runs but won't byte-match a real-server reproduction. PK/UNIQUE enforcement is a linear scan over the heap (O(N) per insert); no B-tree backing yet.
86-
- `NOT NULL` enforcement at INSERT. The column-level flag exists and CREATE TABLE rejects misuse (e.g. `IDENTITY` on a nullable column, `PRIMARY KEY` on an explicitly nullable column), but a plain `insert into t (notnullcol) values (null)` doesn't raise Msg 515 today.
86+
- `CHECK` constraints: parsed and enforced for inline single-column and table-level forms; Msg 547 fires per row on a definitely-false predicate. Msg 8141 (an inline CHECK that references a peer column) isn't enforced — SQL Server rejects at CREATE TABLE; the simulator accepts and lets the CHECK reference any column. Auto-generated names use the same FNV-1a hash convention as PK/UNIQUE; structurally identical to SQL Server's `CK__<table>__[col__]<hex>` shape but won't byte-match a real-server reproduction.
8787
- Heap allocation tracking (IAM/PFS): the page list is flat.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Exercises the simulator's CHECK constraint enforcement through EF
7+
/// Core's SaveChanges path. Violations surface as
8+
/// <see cref="DbUpdateException"/> with the simulator's Msg 547 in the
9+
/// inner exception — the same shape EF Core's SqlServer provider produces
10+
/// against a real database, so calling code that catches
11+
/// <see cref="DbUpdateException"/> works against either.
12+
/// </summary>
13+
[TestClass]
14+
public class EFCoreCheckConstraint
15+
{
16+
public TestContext TestContext { get; set; } = null!;
17+
18+
[TestMethod]
19+
public void SaveChanges_StockItemPositiveQuantity_RoundTrips()
20+
{
21+
using var context = new TestDbContext(TestDbContext.CreateStockItemsSimulation());
22+
_ = context.StockItems.Add(new StockItem { Sku = "BOLT-7", Quantity = 12 });
23+
_ = context.SaveChanges();
24+
25+
var fetched = context.StockItems.AsNoTracking().Single();
26+
Assert.AreEqual("BOLT-7", fetched.Sku);
27+
Assert.AreEqual(12, fetched.Quantity);
28+
}
29+
30+
[TestMethod]
31+
public void SaveChanges_StockItemNegativeQuantity_RaisesDbUpdateException()
32+
{
33+
// The CHECK constraint `Quantity > 0` rejects the row at the
34+
// simulator level; EF Core wraps Msg 547 in DbUpdateException.
35+
using var context = new TestDbContext(TestDbContext.CreateStockItemsSimulation());
36+
_ = context.StockItems.Add(new StockItem { Sku = "BOLT-7", Quantity = -5 });
37+
38+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
39+
Assert.IsNotNull(ex.InnerException);
40+
Assert.Contains("CHECK constraint", ex.InnerException.Message);
41+
Assert.Contains("ck_stockitem_qty", ex.InnerException.Message);
42+
}
43+
44+
[TestMethod]
45+
public async Task SaveChangesAsync_StockItemNegativeQuantity_RaisesDbUpdateException()
46+
{
47+
await using var context = new TestDbContext(TestDbContext.CreateStockItemsSimulation());
48+
_ = context.StockItems.Add(new StockItem { Sku = "WIDGET-2", Quantity = -1 });
49+
50+
var ex = await Assert.ThrowsAsync<DbUpdateException>(() => context.SaveChangesAsync(this.TestContext.CancellationToken));
51+
Assert.IsNotNull(ex.InnerException);
52+
Assert.Contains("CHECK constraint", ex.InnerException.Message);
53+
}
54+
55+
[TestMethod]
56+
public void SaveChanges_StockItemZeroQuantity_AlsoRejected()
57+
{
58+
// Predicate is strict `> 0`; zero falls outside.
59+
using var context = new TestDbContext(TestDbContext.CreateStockItemsSimulation());
60+
_ = context.StockItems.Add(new StockItem { Sku = "ZERO", Quantity = 0 });
61+
62+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
63+
Assert.IsNotNull(ex.InnerException);
64+
Assert.AreEqual("547", ex.InnerException.Data["HelpLink.EvtID"]);
65+
}
66+
67+
[TestMethod]
68+
public void SaveChanges_StockItemMultiRowBatch_FailingRowSurfacesAsDbUpdateException()
69+
{
70+
// EF Core's SqlServer provider sends multi-row INSERTs through a
71+
// single MERGE statement; the simulator's MERGE implementation
72+
// enforces CHECK per-row at insert time. The first failing row
73+
// raises Msg 547, EF Core wraps it in DbUpdateException. Whether
74+
// earlier rows in the batch are visible depends on transaction
75+
// semantics — the simulator doesn't model transactions, so we only
76+
// assert the exception, not roll-back atomicity.
77+
using var context = new TestDbContext(TestDbContext.CreateStockItemsSimulation());
78+
context.StockItems.AddRange(
79+
new StockItem { Sku = "OK-1", Quantity = 5 },
80+
new StockItem { Sku = "BAD", Quantity = -1 },
81+
new StockItem { Sku = "OK-2", Quantity = 7 });
82+
83+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
84+
Assert.IsNotNull(ex.InnerException);
85+
Assert.AreEqual("547", ex.InnerException.Data["HelpLink.EvtID"]);
86+
}
87+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end tests for the LINQ predicate shapes EF Core emits via its
5+
/// SqlServer provider, exercising the simulator's full boolean-expression
6+
/// grammar (AND, OR, NOT, parens) and three-valued evaluator. These pin the
7+
/// "real-world LINQ Where queries round-trip" guarantee — the headline
8+
/// unlock from adding tri-state Run plus OR/NOT/parens to BooleanExpression.
9+
/// </summary>
10+
[TestClass]
11+
public class EFCorePredicates
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
private static TestDbContext SeededContext()
16+
{
17+
var context = new TestDbContext(TestDbContext.CreateFiltersSimulation());
18+
context.Filters.AddRange(
19+
new Filter { A = 1, B = 1, NullableC = 10, IsActive = true, Status = "active" },
20+
new Filter { A = 1, B = 2, NullableC = null, IsActive = false, Status = "pending" },
21+
new Filter { A = 2, B = 2, NullableC = 20, IsActive = true, Status = "active" },
22+
new Filter { A = 2, B = 3, NullableC = null, IsActive = false, Status = null },
23+
new Filter { A = 3, B = 1, NullableC = 30, IsActive = true, Status = "archived" });
24+
_ = context.SaveChanges();
25+
return context;
26+
}
27+
28+
[TestMethod]
29+
public void Where_OrAcrossTwoColumns_FlatPredicate()
30+
{
31+
// EF Core emits `WHERE [A] = 1 OR [B] = 3`. Pre-fix the simulator
32+
// dropped the OR clause; now it returns rows matching either side.
33+
using var context = SeededContext();
34+
var ids = context.Filters.Where(f => f.A == 1 || f.B == 3).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
35+
CollectionAssert.AreEqual(new[] { 1, 2, 4 }, ids);
36+
}
37+
38+
[TestMethod]
39+
public void Where_OrChain_StatusValueSet()
40+
{
41+
// EF Core consolidates a same-column OR-chain into `[Status] IN
42+
// (N'a', N'b')`. The simulator now parses that path.
43+
using var context = SeededContext();
44+
var ids = context.Filters.Where(f => f.Status == "active" || f.Status == "archived").OrderBy(f => f.Id).Select(f => f.Id).ToArray();
45+
CollectionAssert.AreEqual(new[] { 1, 3, 5 }, ids);
46+
}
47+
48+
[TestMethod]
49+
public void Where_ContainsArray_EmitsInList()
50+
{
51+
// EF Core's idiomatic shape for "value in a set": LINQ Contains
52+
// against an in-memory array. SqlServer provider emits `IN (...)`.
53+
using var context = SeededContext();
54+
var wanted = new[] { "active", "archived" };
55+
var ids = context.Filters.Where(f => wanted.Contains(f.Status)).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
56+
CollectionAssert.AreEqual(new[] { 1, 3, 5 }, ids);
57+
}
58+
59+
[TestMethod]
60+
public void Where_AndOrPrecedence_AndBindsTighter()
61+
{
62+
// `a == 1 || (b == 2 && active)` is what EF Core's expression
63+
// visitor will emit unparenthesized: `WHERE [A] = 1 OR [B] = 2 AND
64+
// [IsActive] = CAST(1 AS bit)`. Standard SQL precedence.
65+
using var context = SeededContext();
66+
var ids = context.Filters.Where(f => f.A == 1 || (f.B == 2 && f.IsActive)).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
67+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, ids);
68+
}
69+
70+
[TestMethod]
71+
public void Where_ParenthesizedCompound_ExplicitGrouping()
72+
{
73+
// (A=1 OR B=3) AND IsActive — explicit parens force AND across the
74+
// whole OR group rather than only the right side.
75+
using var context = SeededContext();
76+
var ids = context.Filters.Where(f => (f.A == 1 || f.B == 3) && f.IsActive).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
77+
CollectionAssert.AreEqual(new[] { 1 }, ids);
78+
}
79+
80+
[TestMethod]
81+
public void Where_NotEqualOverNullableColumn_ExcludesNull()
82+
{
83+
// EF Core emits `WHERE [NullableC] <> 20` plus a null-compensation
84+
// branch (`OR [NullableC] IS NULL` or similar). Now both halves
85+
// parse; tri-state Run gives the right answer (rows 1 and 5).
86+
using var context = SeededContext();
87+
var ids = context.Filters.Where(f => f.NullableC != 20).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
88+
CollectionAssert.AreEqual(new[] { 1, 2, 4, 5 }, ids);
89+
}
90+
91+
[TestMethod]
92+
public void Where_NullableColumnIsNull()
93+
{
94+
// `f.NullableC == null` translates to `WHERE [NullableC] IS NULL`.
95+
using var context = SeededContext();
96+
var ids = context.Filters.Where(f => f.NullableC == null).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
97+
CollectionAssert.AreEqual(new[] { 2, 4 }, ids);
98+
}
99+
100+
[TestMethod]
101+
public void Where_NullableColumnIsNotNull()
102+
{
103+
using var context = SeededContext();
104+
var ids = context.Filters.Where(f => f.NullableC != null).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
105+
CollectionAssert.AreEqual(new[] { 1, 3, 5 }, ids);
106+
}
107+
108+
[TestMethod]
109+
public void Where_NotInCompoundPredicate()
110+
{
111+
// !IsActive combined via AND with another comparison; EF Core emits
112+
// `WHERE [IsActive] = CAST(0 AS bit) AND [A] > 0` (or NOT-flavor
113+
// depending on version). Either lands on the new boolean grammar.
114+
using var context = SeededContext();
115+
var ids = context.Filters.Where(f => !f.IsActive && f.A > 1).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
116+
CollectionAssert.AreEqual(new[] { 4 }, ids);
117+
}
118+
119+
[TestMethod]
120+
public void Where_KeysetPagination_OrShape()
121+
{
122+
// The keyset-pagination shape EF Core users frequently write:
123+
// `(A > val) OR (A == val AND B > val2)` — gets the next page after
124+
// a known `(A, B)` cursor. Multi-clause OR with an inner AND group.
125+
using var context = SeededContext();
126+
var ids = context.Filters
127+
.Where(f => f.A > 1 || (f.A == 1 && f.B > 1))
128+
.OrderBy(f => f.A).ThenBy(f => f.B)
129+
.Select(f => f.Id)
130+
.ToArray();
131+
CollectionAssert.AreEqual(new[] { 2, 3, 4, 5 }, ids);
132+
}
133+
134+
[TestMethod]
135+
public void Where_BoolColumnDirect_IncludesActiveOnly()
136+
{
137+
using var context = SeededContext();
138+
var ids = context.Filters.Where(f => f.IsActive).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
139+
CollectionAssert.AreEqual(new[] { 1, 3, 5 }, ids);
140+
}
141+
142+
[TestMethod]
143+
public void Where_NotBoolColumn_OnlyInactive()
144+
{
145+
using var context = SeededContext();
146+
var ids = context.Filters.Where(f => !f.IsActive).OrderBy(f => f.Id).Select(f => f.Id).ToArray();
147+
CollectionAssert.AreEqual(new[] { 2, 4 }, ids);
148+
}
149+
150+
[TestMethod]
151+
public void Where_NullableEqualsValue_AutoExcludesNulls()
152+
{
153+
// `f.NullableC == 30` — SQL `=` on NULL returns UNKNOWN → excluded.
154+
using var context = SeededContext();
155+
var ids = context.Filters.Where(f => f.NullableC == 30).Select(f => f.Id).ToArray();
156+
CollectionAssert.AreEqual(new[] { 5 }, ids);
157+
}
158+
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,46 @@ internal class Inventory
198198
public int Quantity { get; set; }
199199
}
200200

201+
/// <summary>
202+
/// Exercises EF Core's full LINQ predicate surface (<c>||</c>, <c>!</c>,
203+
/// parenthesized compounds, <c>!=</c> over nullable columns) against the
204+
/// simulator's predicate parser and three-valued evaluator. Mix of nullable
205+
/// and non-nullable columns to surface SQL Server's NULL-exclusion semantics
206+
/// in NOT and inequality predicates.
207+
/// </summary>
208+
internal class Filter
209+
{
210+
public int Id { get; set; }
211+
212+
public int A { get; set; }
213+
214+
public int B { get; set; }
215+
216+
public int? NullableC { get; set; }
217+
218+
public bool IsActive { get; set; }
219+
220+
[Column(TypeName = "nvarchar(20)")]
221+
public string? Status { get; set; }
222+
}
223+
224+
/// <summary>
225+
/// Exercises the simulator's CHECK constraint enforcement through EF Core.
226+
/// <see cref="Quantity"/> carries a positivity invariant the schema enforces
227+
/// at insert time; a violation surfaces as
228+
/// <see cref="DbUpdateException"/> whose inner exception is the simulator's
229+
/// Msg 547.
230+
/// </summary>
231+
internal class StockItem
232+
{
233+
public int Id { get; set; }
234+
235+
[Column(TypeName = "nvarchar(20)")]
236+
public string Sku { get; set; } = null!;
237+
238+
public int Quantity { get; set; }
239+
}
240+
201241
/// <summary>
202242
/// Counterpart to <see cref="Widget"/> with <see cref="DatabaseGeneratedOption.None"/>
203243
/// — EF Core treats <see cref="Id"/> as caller-supplied, so SaveChanges
@@ -273,6 +313,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
273313

274314
public DbSet<Inventory> Inventory => Set<Inventory>();
275315

316+
public DbSet<Filter> Filters => Set<Filter>();
317+
318+
public DbSet<StockItem> StockItems => Set<StockItem>();
319+
276320
public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values)
277321
{
278322
var simulation = new Simulation();
@@ -426,6 +470,41 @@ Quantity int not null
426470
return simulation;
427471
}
428472

473+
public static Simulation CreateFiltersSimulation()
474+
{
475+
var simulation = new Simulation();
476+
_ = simulation
477+
.CreateOpenConnection()
478+
.CreateCommand("""
479+
create table Filters (
480+
Id int identity(1, 1) not null primary key,
481+
A int not null,
482+
B int not null,
483+
NullableC int null,
484+
IsActive bit not null,
485+
Status nvarchar(20) null
486+
)
487+
""")
488+
.ExecuteNonQuery();
489+
return simulation;
490+
}
491+
492+
public static Simulation CreateStockItemsSimulation()
493+
{
494+
var simulation = new Simulation();
495+
_ = simulation
496+
.CreateOpenConnection()
497+
.CreateCommand("""
498+
create table StockItems (
499+
Id int identity(1, 1) not null primary key,
500+
Sku nvarchar(20) not null,
501+
Quantity int not null constraint ck_stockitem_qty check (Quantity > 0)
502+
)
503+
""")
504+
.ExecuteNonQuery();
505+
return simulation;
506+
}
507+
429508
public static Simulation CreateStickersSimulation()
430509
{
431510
var simulation = new Simulation();

0 commit comments

Comments
 (0)