Skip to content

Commit 0598c24

Browse files
committed
Implemented text/ntext/image. Minor test clean-up and quality improvement.
1 parent 2cf61f2 commit 0598c24

48 files changed

Lines changed: 1418 additions & 195 deletions

Some content is hidden

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

.editorconfig

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,15 @@ dotnet_diagnostic.IDE0040.severity = none # Add accessibility modifiers
1717
dotnet_diagnostic.IDE0061.severity = none # Use block body for local function
1818
dotnet_diagnostic.IDE0072.severity = none # Add missing cases
1919

20+
dotnet_diagnostic.MSTEST0017.severity = error # Assertion arguments should be passed in the correct order (expected then actual)
21+
dotnet_diagnostic.MSTEST0024.severity = error # Do not store TestContext in a static or readonly field
22+
dotnet_diagnostic.MSTEST0025.severity = error # Use 'Assert.Fail' instead of always-failing constructs
23+
dotnet_diagnostic.MSTEST0026.severity = error # Avoid conditional access in assertions
24+
dotnet_diagnostic.MSTEST0032.severity = error # Review or remove the assertion as its condition is known to be always true
25+
dotnet_diagnostic.MSTEST0036.severity = error # Don't compare strings using '==' operator in tests
26+
dotnet_diagnostic.MSTEST0037.severity = error # Use proper Assert methods (avoid generic Assert.AreEqual on known types)
27+
dotnet_diagnostic.MSTEST0038.severity = error # Don't use 'Assert.AreSame' / 'Assert.AreNotSame' with value types
28+
dotnet_diagnostic.MSTEST0039.severity = error # Use 'Assert.Throws<T>' over the legacy 'Assert.ThrowsException<T>'
29+
dotnet_diagnostic.MSTEST0042.severity = error # Duplicate attribute
30+
dotnet_diagnostic.MSTEST0046.severity = error # Use 'Assert.Contains' instead of 'StringAssert.Contains'
2031
dotnet_diagnostic.MSTEST0049.severity = error # Flow TestContext.CancellationToken to async operations

CLAUDE.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ High-fidelity SQL Server simulation, eventually including transactions, locks, M
1212

1313
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.
1414

15-
The overall plan is incremental and non-specific: pick the lowest-effort path that unlocks the most useful application compatibility next. Transactions / locks / MVCC, the `varchar(MAX)` family — both are eventual targets, but the order is opportunistic, driven by what's blocking the user's actual application stand-ins. Over time, this accretion gets the simulator to "most apps just work"; near term, work flows to the smallest unlock for the biggest cohort of pain points.
15+
The overall plan is incremental and non-specific: pick the lowest-effort path that unlocks the most useful application compatibility next. Transactions / locks / MVCC are the next obvious eventual target; order is opportunistic, driven by what's blocking the user's actual application stand-ins. Over time, this accretion gets the simulator to "most apps just work"; near term, work flows to the smallest unlock for the biggest cohort of pain points.
1616

1717
## Public API surface
1818

1919
`Simulation` and `CreateDbConnection()`. That's it. `QualityTests.PublicApiWhitelist` fails the build if anything else becomes public. Resist expanding the surface.
2020

2121
## Architecture shape
2222

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).
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. Oversize values for the LOB-eligible types (`varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` / `text` / `ntext` / `image`) flow through a parallel chain of 8KB LOB pages on the same heap; the row stores an inline marker plus a chain-head pointer. 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).
2424

2525
Top-level subsystems (in `SqlServerSimulator/`):
2626
- `Storage/` — pages, row encoder/decoder, types, values
@@ -56,7 +56,7 @@ Each test project has an `AssemblyHooks.cs` with a `static [TestClass]` hosting
5656
Every test project uses `[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]`. New test projects should follow.
5757

5858
### Exception types
59-
`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."
59+
`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; 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."
6060

6161
### Commit messages
6262
Single sentence with period. Concrete artifacts named where useful. Squashes capture the intended end state, not the working steps.
@@ -72,13 +72,14 @@ Prefer public-API tests when the behavior is reachable from SQL — they exercis
7272
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.
7373

7474
- Transactions / locks / MVCC.
75-
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
75+
- Row-overflow pages for ordinary `varchar(N)`/`nvarchar(N)`/`varbinary(N)` columns aren't modeled — rows whose declared bounded var values together exceed 8060 bytes still fail at insert. The MAX siblings (`varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)`) and the always-LOB types (`text` / `ntext` / `image`) bypass the row-size limit through their own LOB-chain pages.
76+
- `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.
7677
- `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.
7778
- `NEWSEQUENTIALID()` (deferred until `DEFAULT`-clause support exists in `CREATE TABLE`; the function is only valid in that context).
7879
- `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.
7980
- `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.
8081
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
81-
- 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. Other type pairs the SqlServer provider supports but aren't modeled here (e.g. `hierarchyid`, `geography`, `geometry`, `rowversion`) are not bridged.
82+
- 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.
8283
- `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.
8384
- 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.
8485
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.

SqlServerSimulator.Tests.EFCore/EFCoreDateTime.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public void MultipleRows_RoundTripBothColumns()
107107
_ = context.SaveChanges();
108108

109109
var rows = context.Events.OrderBy(e => e.Id).Select(e => new { e.CreatedAt, e.Updated }).ToArray();
110-
Assert.AreEqual(2, rows.Length);
110+
Assert.HasCount(2, rows);
111111
Assert.AreEqual(a, rows[0].CreatedAt);
112112
Assert.AreEqual(b, rows[0].Updated);
113113
Assert.AreEqual(b, rows[1].CreatedAt);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// Exercises the simulator's <c>varchar(MAX)</c> / <c>nvarchar(MAX)</c> /
5+
/// <c>varbinary(MAX)</c> support through EF Core. EF Core's default mapping
6+
/// for an unannotated <c>string</c> property is <c>nvarchar(max)</c>, so any
7+
/// model with such a property hits this path; the explicit MAX siblings
8+
/// behave identically.
9+
/// </summary>
10+
[TestClass]
11+
public class EFCoreMaxTypes
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
[TestMethod]
16+
public void Insert_NVarcharMax_DefaultStringMapping_RoundTrips()
17+
{
18+
// Body has no [Column] annotation; EF picks nvarchar(max) by default.
19+
using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
20+
_ = context.Articles.Add(new Article { Id = 1, Body = "hello world" });
21+
_ = context.SaveChanges();
22+
23+
Assert.AreEqual("hello world", context.Articles.Select(a => a.Body).First());
24+
}
25+
26+
[TestMethod]
27+
public void Insert_NVarcharMax_LargeValue_RoundTripsThroughLobChain()
28+
{
29+
using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
30+
31+
var big = new string('§', 12_000); // > inline 8060-byte cap, forces off-row LOB
32+
_ = context.Articles.Add(new Article { Id = 1, Body = big });
33+
_ = context.SaveChanges();
34+
35+
Assert.AreEqual(big, context.Articles.Select(a => a.Body).First());
36+
}
37+
38+
[TestMethod]
39+
public void Insert_VarcharMax_RoundTrips()
40+
{
41+
using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
42+
43+
var big = new string('A', 20_000);
44+
_ = context.Articles.Add(new Article { Id = 1, Body = "irrelevant", Summary = big });
45+
_ = context.SaveChanges();
46+
47+
Assert.AreEqual(big, context.Articles.Select(a => a.Summary).First());
48+
}
49+
50+
[TestMethod]
51+
public void Insert_VarbinaryMax_LargeValue_RoundTrips()
52+
{
53+
using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
54+
55+
var bytes = new byte[18_000];
56+
for (var i = 0; i < bytes.Length; i++)
57+
bytes[i] = (byte)(i & 0xFF);
58+
59+
_ = context.Articles.Add(new Article { Id = 1, Body = "irrelevant", Attachment = bytes });
60+
_ = context.SaveChanges();
61+
62+
var roundtrip = context.Articles.Select(a => a.Attachment).First();
63+
Assert.IsNotNull(roundtrip);
64+
CollectionAssert.AreEqual(bytes, roundtrip);
65+
}
66+
67+
[TestMethod]
68+
public async Task InsertAsync_NVarcharMax_RoundTrips()
69+
{
70+
await using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
71+
const string body = "async path through nvarchar(max)";
72+
_ = context.Articles.Add(new Article { Id = 1, Body = body });
73+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
74+
75+
Assert.AreEqual(body, context.Articles.Select(a => a.Body).First());
76+
}
77+
78+
[TestMethod]
79+
public void Where_FiltersByNVarcharMaxEquality()
80+
{
81+
using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
82+
context.Articles.AddRange(
83+
new Article { Id = 1, Body = "first" },
84+
new Article { Id = 2, Body = "second" },
85+
new Article { Id = 3, Body = "third" });
86+
_ = context.SaveChanges();
87+
88+
var match = context.Articles.Where(a => a.Body == "second").Select(a => a.Id).Single();
89+
Assert.AreEqual(2, match);
90+
}
91+
92+
[TestMethod]
93+
public void Insert_NullableVarcharMax_AcceptsNull()
94+
{
95+
using var context = new TestDbContext(TestDbContext.CreateArticlesSimulation());
96+
_ = context.Articles.Add(new Article { Id = 1, Body = "body" });
97+
_ = context.SaveChanges();
98+
99+
Assert.IsNull(context.Articles.Select(a => a.Summary).First());
100+
}
101+
}

SqlServerSimulator.Tests.EFCore/EFCoreStrings.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ public void Insert_NameOverMaxLengthRaisesUpdateException()
8484

8585
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
8686
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");
87+
Assert.Contains("would be truncated", ex.InnerException.Message);
88+
Assert.Contains("People", ex.InnerException.Message);
89+
Assert.Contains("Name", ex.InnerException.Message);
9090
}
9191

9292
[TestMethod]
@@ -143,9 +143,9 @@ public void Insert_VarbinaryAvatarOverMax_RaisesTruncationWithHexValue()
143143

144144
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
145145
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
146+
Assert.Contains("would be truncated", ex.InnerException.Message);
147+
Assert.Contains("Avatar", ex.InnerException.Message);
148+
Assert.Contains("0x", ex.InnerException.Message); // hex prefix, not string
149149
}
150150

151151
[TestMethod]

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,28 @@ internal class Product
109109
public decimal? Discount { get; set; }
110110
}
111111

112+
/// <summary>
113+
/// Exercises the simulator's <c>nvarchar(MAX)</c> / <c>varchar(MAX)</c> /
114+
/// <c>varbinary(MAX)</c> support through EF Core — the LOB-eligible MAX
115+
/// siblings of the bounded var types. EF Core's default mapping for an
116+
/// unannotated <c>string</c> property is <c>nvarchar(max)</c>, so this
117+
/// entity is also a regression check for the simulator handling that
118+
/// default without a length annotation.
119+
/// </summary>
120+
internal class Article
121+
{
122+
public int Id { get; set; }
123+
124+
// Default EF mapping for string is nvarchar(max).
125+
public string Body { get; set; } = null!;
126+
127+
[Column(TypeName = "varchar(max)")]
128+
public string? Summary { get; set; }
129+
130+
[Column(TypeName = "varbinary(max)")]
131+
public byte[]? Attachment { get; set; }
132+
}
133+
112134
/// <summary>
113135
/// Exercises the simulator's <c>IDENTITY</c> column support through EF Core.
114136
/// EF Core defaults int primary keys to <c>ValueGeneratedOnAdd</c>; with the
@@ -163,6 +185,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
163185

164186
public DbSet<Product> Products => Set<Product>();
165187

188+
public DbSet<Article> Articles => Set<Article>();
189+
166190
public DbSet<Widget> Widgets => Set<Widget>();
167191

168192
public DbSet<Sticker> Stickers => Set<Sticker>();
@@ -225,6 +249,23 @@ OptionalKey uniqueidentifier null
225249
return simulation;
226250
}
227251

252+
public static Simulation CreateArticlesSimulation()
253+
{
254+
var simulation = new Simulation();
255+
_ = simulation
256+
.CreateOpenConnection()
257+
.CreateCommand("""
258+
create table Articles (
259+
Id int,
260+
Body nvarchar(max) not null,
261+
Summary varchar(max) null,
262+
Attachment varbinary(max) null
263+
)
264+
""")
265+
.ExecuteNonQuery();
266+
return simulation;
267+
}
268+
228269
public static Simulation CreateProductsSimulation()
229270
{
230271
var simulation = new Simulation();

SqlServerSimulator.Tests.Internal/Storage/DecodeColumnTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ namespace SqlServerSimulator.Storage;
88
/// catch regressions the way users will.
99
/// </summary>
1010
/// <remarks>
11-
/// Covers <see cref="RowDecoder.DecodeColumn"/>, which the data reader uses to
12-
/// navigate row bytes one column at a time without materializing the whole row.
11+
/// Covers <see cref="RowDecoder"/>, which the data reader uses to navigate
12+
/// row bytes one column at a time without materializing the whole row.
1313
/// </remarks>
1414
[TestClass]
1515
public sealed class DecodeColumnTests

SqlServerSimulator.Tests.Internal/Storage/HeapPageTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public void NewPage_HasHeapTypeAndEmptySlotDir()
2020
AreEqual((ushort)HeapPage.HeaderSize, page.FreeSpacePointer);
2121
AreEqual(-1, page.NextPageIndex);
2222
AreEqual(-1, page.PrevPageIndex);
23-
AreEqual(HeapPage.PageSize, page.Bytes.Length);
23+
HasCount(HeapPage.PageSize, page.Bytes);
2424
}
2525

2626
[TestMethod]
@@ -37,7 +37,7 @@ public void TryInsert_SingleRow_AppendsRowAndSlot()
3737
AreEqual((ushort)HeapPage.HeaderSize, slot0);
3838

3939
var rows = page.EnumerateRows().ToList();
40-
AreEqual(1, rows.Count);
40+
HasCount(1, rows);
4141
CollectionAssert.AreEqual(new byte[] { 0x80, 0x81, 0x82 }, rows[0]);
4242
}
4343

@@ -52,7 +52,7 @@ public void TryInsert_MultipleRows_PreservesOrderAndPacksContiguously()
5252
AreEqual((ushort)3, page.SlotCount);
5353

5454
var rows = page.EnumerateRows().ToList();
55-
AreEqual(3, rows.Count);
55+
HasCount(3, rows);
5656
CollectionAssert.AreEqual(new byte[] { 0x80 }, rows[0]);
5757
CollectionAssert.AreEqual(new byte[] { 0x90, 0x91 }, rows[1]);
5858
CollectionAssert.AreEqual(new byte[] { 0xA0, 0xA1, 0xA2 }, rows[2]);
@@ -83,7 +83,7 @@ public void TryInsert_RowPayloadAtMaximum_FitsExactly()
8383
AreEqual(0, page.FreeSpace);
8484

8585
var rows = page.EnumerateRows().ToList();
86-
AreEqual(1, rows.Count);
86+
HasCount(1, rows);
8787
CollectionAssert.AreEqual(big, rows[0]);
8888
}
8989

0 commit comments

Comments
 (0)