Skip to content

Commit a003d9c

Browse files
committed
Expanded SQL type and operation coverage; reorganized the storage/parser layers to support the new types.
Types added: `varchar`, `nvarchar`, `varbinary` (with declared-length enforcement, an 8060-byte row-size limit, and CP1252 default-collation encoding); `date`, `datetime2(N)`, `time(N)`, `datetimeoffset(N)`, legacy `datetime`, and `smalldatetime`. Operations added: string and binary literals (`'foo'`, `N'foo'`, `0xAB`); twelve common string functions (LEN, LEFT/RIGHT, SUBSTRING, UPPER/LOWER, LTRIM/RTRIM/TRIM, CHARINDEX, REPLACE, REVERSE); CAST to integer-family targets; case-insensitive equality and ordering with ANSI trailing-space padding; cross-family date/time promotion; implicit string↔date/time and integer↔legacy-datetime coercion; `+` / `-` arithmetic over date types; `ORDER BY` (with ordinal positions, alias resolution, and NULL-first-ASC ordering); and `DISTINCT`. Refactorings: split the type system into `IntegerTypes` / `StringTypes` / `DateTimeTypes`; split `SqlValue` into `Coerce` and `Parse` partials; extracted built-in functions into per-expression files; added `CompatibilityLevel` with trace-flag overrides driving verbose-vs-legacy error messages.
1 parent c935704 commit a003d9c

72 files changed

Lines changed: 8837 additions & 497 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
When SQL Server's actual behavior is quirky or lossy (CP1252's silent `?` replacement for out-of-codepage characters, ANSI trailing-space padding for `=`, `LEN` excluding trailing spaces), mirror it rather than "fixing" it — authenticity over desirability is what makes the simulator a faithful stand-in.
14+
15+
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.
16+
17+
## Public API surface
18+
19+
`Simulation` and `CreateDbConnection()`. That's it. `QualityTests.PublicApiWhitelist` fails the build if anything else becomes public. Resist expanding the surface.
20+
21+
## Architecture shape
22+
23+
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).
24+
25+
Top-level subsystems (in `SqlServerSimulator/`):
26+
- `Storage/` — pages, row encoder/decoder, types, values
27+
- `Parser/` — tokenizer, expression tree, query planner
28+
- root — `Simulated*` implementations of `DbCommand`/`DbConnection`/`DbDataReader`/etc., plus `Simulation` itself
29+
30+
Test projects: main feature tests, internal-access tests for storage/parser internals, EF Core integration (the oracle), and analyzer tests.
31+
32+
## Build, test, format
33+
34+
```
35+
dotnet build
36+
dotnet test
37+
dotnet format --verify-no-changes
38+
```
39+
40+
`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.
41+
42+
## Project-specific rules
43+
44+
### SSS001 (custom analyzer)
45+
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.
46+
47+
### MSTEST0049 (error severity)
48+
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`.
49+
50+
### AssemblyHooks
51+
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).
52+
53+
### Test parallelism
54+
Every test project uses `[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]`. New test projects should follow.
55+
56+
### Exception types
57+
`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."
58+
59+
### Commit messages
60+
Single sentence with period. Concrete artifacts named where useful. Squashes capture the intended end state, not the working steps.
61+
62+
## Test organization
63+
64+
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.
65+
66+
Prefer public-API tests when the behavior is reachable from SQL — they exercise the full parse/evaluate path and don't pin internal shapes that may refactor. Reserve `Tests.Internal` for things genuinely unreachable from public SQL: raw storage byte layouts, encoder/decoder invariants, and similar contracts that have no observable public surface.
67+
68+
## Live limitations
69+
70+
Heavy-hitters someone might assume work but don't. Source and `git log` are the truth for what's done; this list is for what isn't.
71+
72+
- Transactions / locks / MVCC.
73+
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
74+
- Decimal / numeric / float / real / money types. (Also blocks fractional-day `cast(0.5 as datetime)`.)
75+
- `uniqueidentifier`, fixed-length `char(N)` / `nchar(N)` / `binary(N)`, and identity columns.
76+
- Pattern matching (`LIKE`) and `CONVERT`.
77+
- Cross-category `Promote` for `varchar``nvarchar` and integer ↔ string. Only CAST works for those pairs.
78+
- EF Core compatibility: the SqlServer provider downcasts `DbParameter` to `SqlParameter` for some mappings — `DateTime → date`, `DateTime → smalldatetime`, `DateOnly`, `TimeOnly`, `TimeSpan` all break at SaveChanges. See `SimulatedDbParameter` for the matrix; a `SqlServerSimulator.EFCore` adapter package is planned to close the gap.
79+
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
80+
- Heap allocation tracking (IAM/PFS): the page list is flat.

SqlServerSimulator.Tests.EFCore/EFCoreBasics.cs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace SqlServerSimulator;
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
24

35
[TestClass]
46
public class EFCoreBasics
@@ -58,12 +60,88 @@ public void FirstOrDefault()
5860
[TestMethod]
5961
public void SingleOrDefault()
6062
{
61-
const int storedValue = 6; // Until `Where` is supported, this won't pass if multiple rows exist.
63+
const int storedValue = 6;
6264
using var context = new TestDbContext(storedValue);
6365
var receivedValue = context.Rows.Select(x => x.Id);
6466
Assert.AreEqual(storedValue, receivedValue.SingleOrDefault());
6567
}
6668

69+
[TestMethod]
70+
public void Single_WithWhereMatchingOneRow_Returns()
71+
{
72+
using var context = new TestDbContext(1, 2, 3);
73+
var id = context.Rows.Where(r => r.Id == 2).Select(r => r.Id).Single();
74+
Assert.AreEqual(2, id);
75+
}
76+
77+
[TestMethod]
78+
public void Single_WithPredicateMatchingOneRow_Returns()
79+
{
80+
using var context = new TestDbContext(1, 2, 3);
81+
var id = context.Rows.Select(r => r.Id).Single(x => x == 3);
82+
Assert.AreEqual(3, id);
83+
}
84+
85+
[TestMethod]
86+
public void Single_WithWhereMatchingMultipleRows_Throws()
87+
{
88+
// EF Core asks the simulator for `SELECT TOP 2 ...` so it can detect
89+
// the cardinality violation client-side and raise InvalidOperationException
90+
// — this verifies our TOP and WHERE composition land that response.
91+
// Bypass the EF entity tracker (which would reject the duplicate Id
92+
// primary key) by inserting via raw SQL.
93+
var simulation = new Simulation();
94+
_ = simulation.ExecuteNonQuery("create table Rows ( Id int )");
95+
_ = simulation.ExecuteNonQuery("insert Rows values (1),(2),(2),(3)");
96+
using var context = new TestDbContext(simulation);
97+
_ = Assert.Throws<InvalidOperationException>(() =>
98+
context.Rows.Where(r => r.Id == 2).Select(r => r.Id).Single());
99+
}
100+
101+
[TestMethod]
102+
public void Single_WithWhereMatchingZeroRows_Throws()
103+
{
104+
using var context = new TestDbContext(1, 2, 3);
105+
_ = Assert.Throws<InvalidOperationException>(() =>
106+
context.Rows.Where(r => r.Id == 99).Select(r => r.Id).Single());
107+
}
108+
109+
[TestMethod]
110+
public void SingleOrDefault_WithWhereMatchingZeroRows_ReturnsDefault()
111+
{
112+
using var context = new TestDbContext(1, 2, 3);
113+
var id = context.Rows.Where(r => r.Id == 99).Select(r => r.Id).SingleOrDefault();
114+
Assert.AreEqual(0, id);
115+
}
116+
117+
[TestMethod]
118+
public void SingleOrDefault_WithWhereMatchingMultipleRows_Throws()
119+
{
120+
// See Single_WithWhereMatchingMultipleRows_Throws for the raw-SQL bypass rationale.
121+
var simulation = new Simulation();
122+
_ = simulation.ExecuteNonQuery("create table Rows ( Id int )");
123+
_ = simulation.ExecuteNonQuery("insert Rows values (1),(2),(2),(3)");
124+
using var context = new TestDbContext(simulation);
125+
_ = Assert.Throws<InvalidOperationException>(() =>
126+
context.Rows.Where(r => r.Id == 2).Select(r => r.Id).SingleOrDefault());
127+
}
128+
129+
[TestMethod]
130+
public void SingleOrDefault_WithPredicateMatchingZeroRows_ReturnsDefault()
131+
{
132+
using var context = new TestDbContext(1, 2, 3);
133+
var id = context.Rows.Select(r => r.Id).SingleOrDefault(x => x == 99);
134+
Assert.AreEqual(0, id);
135+
}
136+
137+
[TestMethod]
138+
public async Task SingleAsync_WithWhereMatchingOneRow_Returns()
139+
{
140+
await using var context = new TestDbContext(1, 2, 3);
141+
var id = await context.Rows.Where(r => r.Id == 2).Select(r => r.Id).SingleAsync(this.TestContext.CancellationToken);
142+
Assert.AreEqual(2, id);
143+
}
144+
67145
[TestMethod]
68146
public void Take()
69147
{

0 commit comments

Comments
 (0)