Skip to content

Commit 73a57a6

Browse files
committed
Added variable-length column types (varchar, nvarchar, varbinary) with declared-length enforcement, plus bit packing, an 8060-byte row-size limit, CP1252 default-collation encoding, and database-compatibility and trace-flag state controlling verbose vs. legacy truncation behavior.
1 parent c935704 commit 73a57a6

23 files changed

Lines changed: 1580 additions & 96 deletions

.gitignore

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,5 @@ obj/
1010
# Don't want to be prescriptive about the user's development environment.
1111
.devcontainer/
1212

13-
# The simplicity of this project structure enables AI tools to figure it out instantly.
14-
.claude/
15-
CLAUDE.md
16-
.codex/
17-
AGENTS.md
18-
.github/copilot-instructions.md
19-
.gemini/
20-
GEMINI.md
21-
.cursor/
22-
.cursorrules
23-
.aider*
24-
.windsurf/
25-
.windsurfrules
26-
2713
# Suppress temporary files that might otherwise leak into a commit.
2814
tmp/

CLAUDE.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Claude Working Notes
2+
3+
Canonical orientation for working in this repo. Claude Code reads this file automatically; `README.md` is user-facing and not auto-loaded.
4+
5+
## What this is
6+
7+
SqlServerSimulator is a .NET library that emulates a SQL Server instance in-process. Code written against `Microsoft.Data.SqlClient` or `Microsoft.EntityFrameworkCore.SqlServer` can use a `Simulation` instance instead of a real database — useful for fast deterministic tests, for reproducing pathological SQL Server behaviors that are hard to set up on a real server, and for any scenario where embedding a database avoids the operational weight of a real one.
8+
9+
## Long-term direction
10+
11+
High-fidelity SQL Server simulation, eventually including transactions, locks, MVCC, real allocation tracking (IAM/PFS), and overflow pages — the things that make SQL Server *SQL Server*, not just a SQL parser with a hash table behind it. The fidelity bar is set by the EF Core regression oracle in the `*.Tests.EFCore` project: if EF Core trusts the simulator end-to-end, the simulator is earning its keep. That project must stay green.
12+
13+
A long arc replacing in-memory boxed-object row storage with page-format-aligned encoding completed before this file was written; the simulator now has a single backend with real 8KB pages. Transactions / locks / MVCC are the next phase.
14+
15+
## Public API surface
16+
17+
`Simulation` and `CreateDbConnection()`. That's it. `QualityTests.PublicApiWhitelist` fails the build if anything else becomes public. Resist expanding the surface.
18+
19+
## Architecture shape
20+
21+
One backend: page-format storage. User tables hold a heap of 8KB pages; rows are encoded as bytes via a row encoder/decoder and navigated column-by-column without rehydrating whole rows. The type system has its own `SqlType`/`SqlValue` pair. Expression evaluation has separate paths for runtime evaluation and static type-of resolution (for projection planning).
22+
23+
Top-level subsystems (in `SqlServerSimulator/`):
24+
- `Storage/` — pages, row encoder/decoder, types, values
25+
- `Parser/` — tokenizer, expression tree, query planner
26+
- root — `Simulated*` implementations of `DbCommand`/`DbConnection`/`DbDataReader`/etc., plus `Simulation` itself
27+
28+
Test projects: main feature tests, internal-access tests for storage/parser internals, EF Core integration (the oracle), and analyzer tests.
29+
30+
## Build, test, format
31+
32+
```
33+
dotnet build
34+
dotnet test
35+
dotnet format --verify-no-changes
36+
```
37+
38+
`dotnet build` runs most analyzers via `EnforceCodeStyleInBuild=true`, but a few formatting rules (notably IDE0055) only fire under `dotnet format` or in Visual Studio. CI runs both — keep both green locally before pushing.
39+
40+
## Project-specific rules
41+
42+
### SSS001 (custom analyzer)
43+
A repo-local analyzer flags any property in a non-public type that's an auto-property or a trivial wrapper over a same-type field. Rationale: non-public types gain no API-stability benefit from property metadata; the wrapper just adds JIT/IDE overhead. Expose the field directly (`public readonly T Foo = expr;`). Overrides, abstracts, statics, and explicit interface implementations are exempt. The analyzer lives in its own project with its own test project.
44+
45+
### MSTEST0049 (error severity)
46+
Configured in `.editorconfig` to fail the build. Async tests must thread `TestContext.CancellationToken` into framework calls. Pattern: `public TestContext TestContext { get; set; } = null!;` on the test class plus a helper that uses `this.TestContext.CancellationToken`.
47+
48+
### AssemblyHooks
49+
Each test project has an `AssemblyHooks.cs` with a `static [TestClass]` hosting `[AssemblyInitialize]`. Use this for assembly-scoped warm-up or sanity checks rather than embedding the hook in a regular test class. The analyzer-tests warm-up specifically prevents Roslyn cache contention under parallel test execution (~3x slowdown without it).
50+
51+
### Test parallelism
52+
Every test project uses `[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]`. New test projects should follow.
53+
54+
### Exception types
55+
`SimulatedSqlException` mirrors a real SQL Server error — its number, class, state, and message format. Use it when the simulator's behavior matches SQL Server's: invalid SQL, type mismatches, constraint violations, oversize column declarations, truncation. `NotSupportedException` is for valid SQL Server features the simulator hasn't built yet (row-overflow pages, `varchar(MAX)`, etc.); name the unmodeled feature in the message. The distinction matters to users debugging tests against the simulator: "this is invalid SQL" should look different from "this works against real SQL Server but not yet here."
56+
57+
### Commit messages
58+
Single sentence with period. Concrete artifacts named where useful. Squashes capture the intended end state, not the working steps.
59+
60+
## Test organization
61+
62+
The main test project is split topically — one file per feature (SELECT, WHERE, TOP, INSERT, etc.). When adding tests, prefer extending an existing topical file over creating a new one with overlapping scope. Storage internals are tested separately because they require `internal` access.
63+
64+
## Live limitations
65+
66+
Live state is best read from the code itself and recent commit history. Architectural-level gaps as of the page-storage milestone:
67+
- Transactions / locks / MVCC: not implemented. The page foundation is in place; the next phase hasn't started.
68+
- Row-overflow / LOB pages: not modeled. Rows whose encoded size exceeds SQL Server's 8060-byte in-row limit throw at insert time; `varchar(MAX)` is parsed but rejected with a clear "not yet supported" error rather than silently fall through.
69+
- Heap allocation: flat page list. No IAM/PFS allocation tracking.
70+
- Cross-category type promotion (string ↔ numeric): not implemented.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Exercises the simulator's <c>varchar</c> / <c>nvarchar</c> column support
7+
/// through EF Core's idiomatic surface: typed entities mapped via attributes,
8+
/// LINQ projections, and SaveChanges for the write path. Truncation surfaces
9+
/// as DbUpdateException with the simulator's SimulatedSqlException as the
10+
/// inner exception, matching real SQL Server's failure shape under EF Core.
11+
/// </summary>
12+
[TestClass]
13+
public class EFCoreStrings
14+
{
15+
public TestContext TestContext { get; set; } = null!;
16+
17+
[TestMethod]
18+
public void Insert_NameRoundTripsViaProjection()
19+
{
20+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
21+
22+
_ = context.People.Add(new Person { Id = 1, Name = "Alice" });
23+
_ = context.SaveChanges();
24+
25+
Assert.AreEqual("Alice", context.People.Select(p => p.Name).FirstOrDefault());
26+
}
27+
28+
[TestMethod]
29+
public async Task InsertAsync_NameRoundTrips()
30+
{
31+
await using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
32+
33+
_ = context.People.Add(new Person { Id = 1, Name = "Alice" });
34+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
35+
36+
Assert.AreEqual("Alice", await context.People.Select(p => p.Name).FirstOrDefaultAsync(this.TestContext.CancellationToken));
37+
}
38+
39+
[TestMethod]
40+
public void Insert_VarcharCodeRoundTrips()
41+
{
42+
// Code is varchar(10) — the UTF-8 storage path. ASCII fits 1:1 with bytes.
43+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
44+
45+
_ = context.People.Add(new Person { Id = 1, Name = "Bob", Code = "ABC123" });
46+
_ = context.SaveChanges();
47+
48+
Assert.AreEqual("ABC123", context.People.Select(p => p.Code).FirstOrDefault());
49+
}
50+
51+
[TestMethod]
52+
public void Insert_NullableCodeAcceptsNull()
53+
{
54+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
55+
56+
_ = context.People.Add(new Person { Id = 1, Name = "Carol", Code = null });
57+
_ = context.SaveChanges();
58+
59+
Assert.IsNull(context.People.Select(p => p.Code).FirstOrDefault());
60+
}
61+
62+
[TestMethod]
63+
public void Insert_NameAtMaxLengthSucceeds()
64+
{
65+
// nvarchar(50) — 50 UCS-2 code units exactly fits.
66+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
67+
68+
var atLimit = new string('x', 50);
69+
_ = context.People.Add(new Person { Id = 1, Name = atLimit });
70+
_ = context.SaveChanges();
71+
72+
Assert.AreEqual(atLimit, context.People.Select(p => p.Name).FirstOrDefault());
73+
}
74+
75+
[TestMethod]
76+
public void Insert_NameOverMaxLengthRaisesUpdateException()
77+
{
78+
// EF Core wraps the simulator's SimulatedSqlException in DbUpdateException,
79+
// matching real SQL Server's failure shape — the Msg 2628 truncation error
80+
// surfaces on InnerException.
81+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
82+
83+
_ = context.People.Add(new Person { Id = 1, Name = new string('x', 51) });
84+
85+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
86+
Assert.IsNotNull(ex.InnerException);
87+
StringAssert.Contains(ex.InnerException.Message, "would be truncated");
88+
StringAssert.Contains(ex.InnerException.Message, "People");
89+
StringAssert.Contains(ex.InnerException.Message, "Name");
90+
}
91+
92+
[TestMethod]
93+
public void Insert_VarcharCodeOutOfCp1252_RoundTripsAsReplacement()
94+
{
95+
// varchar uses Windows-1252; characters outside CP1252 (CJK, emoji,
96+
// non-Latin scripts) silently round-trip as '?', matching SQL Server's
97+
// default collation. EF Core surfaces this without intervention.
98+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
99+
100+
_ = context.People.Add(new Person { Id = 1, Name = "Eve", Code = "日本" });
101+
_ = context.SaveChanges();
102+
103+
Assert.AreEqual("??", context.People.Select(p => p.Code).FirstOrDefault());
104+
}
105+
106+
[TestMethod]
107+
public void Insert_NVarcharAcceptsSupplementaryCharacter()
108+
{
109+
// 🎉 is one Unicode code point but two UTF-16 code units (surrogate pair).
110+
// It fits in nvarchar(50) — the simulator's check is on UTF-16 code units,
111+
// matching SQL Server's nvarchar semantics.
112+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
113+
114+
_ = context.People.Add(new Person { Id = 1, Name = "🎉 party 🎉" });
115+
_ = context.SaveChanges();
116+
117+
Assert.AreEqual("🎉 party 🎉", context.People.Select(p => p.Name).FirstOrDefault());
118+
}
119+
120+
[TestMethod]
121+
public void Insert_VarbinaryAvatarRoundTrips()
122+
{
123+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
124+
125+
var bytes = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF };
126+
_ = context.People.Add(new Person { Id = 1, Name = "Frank", Avatar = bytes });
127+
_ = context.SaveChanges();
128+
129+
var read = context.People.Select(p => p.Avatar).FirstOrDefault();
130+
CollectionAssert.AreEqual(bytes, read);
131+
}
132+
133+
[TestMethod]
134+
public void Insert_VarbinaryAvatarOverMax_RaisesTruncationWithHexValue()
135+
{
136+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
137+
138+
var oversize = new byte[65]; // Avatar is varbinary(64)
139+
for (var i = 0; i < oversize.Length; i++)
140+
oversize[i] = (byte)i;
141+
142+
_ = context.People.Add(new Person { Id = 1, Name = "Greta", Avatar = oversize });
143+
144+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
145+
Assert.IsNotNull(ex.InnerException);
146+
StringAssert.Contains(ex.InnerException.Message, "would be truncated");
147+
StringAssert.Contains(ex.InnerException.Message, "Avatar");
148+
StringAssert.Contains(ex.InnerException.Message, "0x"); // hex prefix, not string
149+
}
150+
151+
[TestMethod]
152+
public void Insert_NullableAvatarAcceptsNull()
153+
{
154+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
155+
156+
_ = context.People.Add(new Person { Id = 1, Name = "Hank", Avatar = null });
157+
_ = context.SaveChanges();
158+
159+
Assert.IsNull(context.People.Select(p => p.Avatar).FirstOrDefault());
160+
}
161+
162+
[TestMethod]
163+
public void Insert_MultipleRows_RoundTripsBothColumns()
164+
{
165+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
166+
167+
context.People.AddRange(
168+
new Person { Id = 1, Name = "Alice", Code = "A" },
169+
new Person { Id = 2, Name = "Bob", Code = "B" },
170+
new Person { Id = 3, Name = "Carol", Code = null });
171+
_ = context.SaveChanges();
172+
173+
var names = context.People.Select(p => p.Name).ToArray();
174+
var codes = context.People.Select(p => p.Code).ToArray();
175+
176+
CollectionAssert.AreEquivalent(new[] { "Alice", "Bob", "Carol" }, names);
177+
CollectionAssert.AreEquivalent(new[] { "A", "B", null }, codes);
178+
}
179+
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.EntityFrameworkCore;
2+
using System.ComponentModel.DataAnnotations.Schema;
23

34
namespace SqlServerSimulator;
45

@@ -7,6 +8,26 @@ internal class TestRow
78
public int Id { get; set; }
89
}
910

11+
/// <summary>
12+
/// Exercises the simulator's variable-length string and binary columns
13+
/// through EF Core: <see cref="Name"/> maps to <c>nvarchar(50)</c>,
14+
/// <see cref="Code"/> to <c>varchar(10)</c>, and <see cref="Avatar"/> to
15+
/// <c>varbinary(64)</c> — covering UTF-16, CP1252, and raw-bytes paths.
16+
/// </summary>
17+
internal class Person
18+
{
19+
public int Id { get; set; }
20+
21+
[Column(TypeName = "nvarchar(50)")]
22+
public string Name { get; set; } = null!;
23+
24+
[Column(TypeName = "varchar(10)")]
25+
public string? Code { get; set; }
26+
27+
[Column(TypeName = "varbinary(64)")]
28+
public byte[]? Avatar { get; set; }
29+
}
30+
1031
internal class TestDbContext(Simulation simulation) : DbContext
1132
{
1233
public Simulation Simulation { get; set; } = simulation;
@@ -23,6 +44,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
2344

2445
public DbSet<TestRow> Rows => Set<TestRow>();
2546

47+
public DbSet<Person> People => Set<Person>();
48+
2649
public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values)
2750
{
2851
var simulation = new Simulation();
@@ -44,4 +67,14 @@ public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values
4467

4568
return simulation;
4669
}
70+
71+
public static Simulation CreatePeopleSimulation()
72+
{
73+
var simulation = new Simulation();
74+
_ = simulation
75+
.CreateOpenConnection()
76+
.CreateCommand("create table People ( Id int, Name nvarchar(50) not null, Code varchar(10) null, Avatar varbinary(64) null )")
77+
.ExecuteNonQuery();
78+
return simulation;
79+
}
4780
}

SqlServerSimulator.Tests.Internal/Storage/HeapTests.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ public void Insert_SingleRow_AllocatesFirstPage()
2424
}
2525

2626
[TestMethod]
27-
public void Insert_FillingPageThenOneMore_AllocatesSecondPageAndLinks()
27+
public void Insert_RowsExceedingPageCapacity_AllocatesSecondPageAndLinks()
2828
{
2929
var heap = new Heap();
30-
var big = new byte[HeapPage.MaxRowPayload];
31-
heap.Insert(big); // page 0 is now exactly full
32-
heap.Insert([42]); // forces a new page
30+
var big = new byte[Heap.MaxRowSize];
31+
heap.Insert(big); // page 0 holds one max-sized row
32+
heap.Insert(big); // forces a new page
3333

3434
AreEqual(2, heap.Pages.Count);
3535
AreEqual(1, heap.Pages[0].NextPageIndex);
@@ -64,16 +64,25 @@ public void EnumerateRows_WalksAllPagesInOrder()
6464
}
6565

6666
[TestMethod]
67-
public void Insert_RowExceedingMaxPayload_Throws()
67+
public void Insert_RowExceedingMaxRowSize_Throws()
6868
{
6969
var heap = new Heap();
70-
var oversize = new byte[HeapPage.MaxRowPayload + 1];
70+
var oversize = new byte[Heap.MaxRowSize + 1];
7171
_ = Throws<NotSupportedException>(() => heap.Insert(oversize));
7272

7373
// No page should have been allocated.
7474
AreEqual(0, heap.Pages.Count);
7575
}
7676

77+
[TestMethod]
78+
public void Insert_RowAtMaxRowSize_Succeeds()
79+
{
80+
var heap = new Heap();
81+
var row = new byte[Heap.MaxRowSize];
82+
heap.Insert(row);
83+
AreEqual(1, heap.RowCount);
84+
}
85+
7786
[TestMethod]
7887
public void RowCount_AggregatesAcrossPages()
7988
{

0 commit comments

Comments
 (0)