Skip to content

Commit 10d7acd

Browse files
committed
Implemented char/nchar/binary.
1 parent 3b9e287 commit 10d7acd

11 files changed

Lines changed: 643 additions & 81 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ 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-
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.
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, `LIKE` and `CONVERT`, identity columns, the `varchar(MAX)` family, the EF Core adapter — all of these 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.
1616

1717
## Public API surface
1818

@@ -72,10 +72,10 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7272
- Transactions / locks / MVCC.
7373
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
7474
- `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. `money` / `smallmoney` are wired in raw SQL but unreachable through EF Core for the same `SqlParameter`-downcast reason as the date-only / time-only mappings — adding a money column to an EF entity breaks that entity's whole save path.
75-
- Fixed-length `char(N)` / `nchar(N)` / `binary(N)`, and identity columns.
75+
- Identity columns.
7676
- `NEWSEQUENTIALID()` (deferred until `DEFAULT`-clause support exists in `CREATE TABLE`; the function is only valid in that context).
7777
- Pattern matching (`LIKE`) and `CONVERT`.
78-
- Cross-category `Promote` for `varchar``nvarchar` and integer ↔ string. Only CAST works for those pairs.
78+
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
7979
- EF Core compatibility: the SqlServer provider downcasts `DbParameter` to `SqlParameter` for some mappings — `DateTime → date`, `DateTime → smalldatetime`, `DateOnly`, `TimeOnly`, `TimeSpan`, and `decimal → money` / `decimal → smallmoney` (via `SqlServerDecimalTypeMapping`) all break at SaveChanges. See `SimulatedDbParameter` for the matrix; a `SqlServerSimulator.EFCore` adapter package is planned to close the gap.
8080
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
8181
- Heap allocation tracking (IAM/PFS): the page list is flat.

SqlServerSimulator.Tests.EFCore/EFCoreStrings.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,43 @@ public void StringFunction_Replace()
244244
Assert.AreEqual("heLLo", context.People.Select(p => p.Name.Replace("l", "L")).FirstOrDefault());
245245
}
246246

247+
[TestMethod]
248+
public void Insert_FixedLengthChar_RoundTripsWithPadding()
249+
{
250+
// char(5) is the simulator's fixed-length CP1252 type. EF Core maps
251+
// strings to it via Column(TypeName="char(5)"); SaveChanges goes through
252+
// SqlServerStringTypeMapping which sets SqlDbType.Char on the parameter.
253+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
254+
255+
_ = context.People.Add(new Person { Id = 1, Name = "Iris", Tag = "hi" });
256+
_ = context.SaveChanges();
257+
258+
Assert.AreEqual("hi ", context.People.Select(p => p.Tag).FirstOrDefault());
259+
}
260+
261+
[TestMethod]
262+
public void Insert_FixedLengthNChar_RoundTripsWithPadding()
263+
{
264+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
265+
266+
_ = context.People.Add(new Person { Id = 1, Name = "Jane", Initials = "JD" });
267+
_ = context.SaveChanges();
268+
269+
Assert.AreEqual("JD ", context.People.Select(p => p.Initials).FirstOrDefault());
270+
}
271+
272+
[TestMethod]
273+
public void Insert_FixedLengthBinary_RoundTripsWithZeroPadding()
274+
{
275+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
276+
277+
_ = context.People.Add(new Person { Id = 1, Name = "Karl", Stamp = [0xCA, 0xFE] });
278+
_ = context.SaveChanges();
279+
280+
var read = context.People.Select(p => p.Stamp).FirstOrDefault();
281+
CollectionAssert.AreEqual(new byte[] { 0xCA, 0xFE, 0, 0 }, read);
282+
}
283+
247284
[TestMethod]
248285
public void Insert_MultipleRows_RoundTripsBothColumns()
249286
{

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ internal class Person
2626

2727
[Column(TypeName = "varbinary(64)")]
2828
public byte[]? Avatar { get; set; }
29+
30+
[Column(TypeName = "char(5)")]
31+
public string? Tag { get; set; }
32+
33+
[Column(TypeName = "nchar(3)")]
34+
public string? Initials { get; set; }
35+
36+
[Column(TypeName = "binary(4)")]
37+
public byte[]? Stamp { get; set; }
2938
}
3039

3140
/// <summary>
@@ -158,7 +167,10 @@ create table People (
158167
Id int,
159168
Name nvarchar(50) not null,
160169
Code varchar(10) null,
161-
Avatar varbinary(64) null
170+
Avatar varbinary(64) null,
171+
Tag char(5) null,
172+
Initials nchar(3) null,
173+
Stamp binary(4) null
162174
)
163175
""")
164176
.ExecuteNonQuery();
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// Behavioral tests for the fixed-length string and binary types
9+
/// (<c>char(N)</c>, <c>nchar(N)</c>, <c>binary(N)</c>): right-padding semantics,
10+
/// silent CAST truncation, INSERT-time truncation errors (Msg 2628 / 8152),
11+
/// trailing-space-aware comparison, and width-validation error variants.
12+
/// </summary>
13+
[TestClass]
14+
public sealed class FixedLengthStringTests
15+
{
16+
[TestMethod]
17+
public void Cast_String_PadsToDeclaredLength()
18+
{
19+
AreEqual("abc ", ExecuteScalar("select cast('abc' as char(5))"));
20+
}
21+
22+
[TestMethod]
23+
public void Cast_NString_PadsNcharToDeclaredCodeUnits()
24+
{
25+
AreEqual("abc ", ExecuteScalar("select cast(N'abc' as nchar(5))"));
26+
}
27+
28+
[TestMethod]
29+
public void Cast_Varbinary_PadsBinaryWithZeros()
30+
{
31+
var value = ExecuteScalar("select cast(0xCAFE as binary(5))") as byte[];
32+
IsNotNull(value);
33+
CollectionAssert.AreEqual(new byte[] { 0xCA, 0xFE, 0, 0, 0 }, value);
34+
}
35+
36+
[TestMethod]
37+
public void Cast_OversizeString_SilentlyTruncatesInCast()
38+
{
39+
// Verified against SQL Server 2025: CAST silently truncates without
40+
// raising — the truncation error is reserved for INSERT/UPDATE.
41+
AreEqual("hello", ExecuteScalar("select cast('hello world' as char(5))"));
42+
}
43+
44+
[TestMethod]
45+
public void Cast_OversizeBinary_SilentlyTruncatesInCast()
46+
{
47+
var value = ExecuteScalar("select cast(0x0102030405060708 as binary(5))") as byte[];
48+
IsNotNull(value);
49+
CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4, 5 }, value);
50+
}
51+
52+
[TestMethod]
53+
public void Cast_EmptyString_PadsToFullWidth()
54+
{
55+
AreEqual(" ", ExecuteScalar("select cast('' as char(3))"));
56+
}
57+
58+
[TestMethod]
59+
public void Comparison_CharWithVarchar_StripsTrailingSpaces()
60+
{
61+
// ANSI trailing-space padding is part of the equality semantics shared
62+
// by every string-family type, so char(5) 'abc ' equals varchar 'abc'.
63+
AreEqual(1, ExecuteScalar("select 1 where cast('abc' as char(5)) = 'abc'"));
64+
AreEqual(1, ExecuteScalar("select 1 where cast('abc' as char(5)) = 'abc '"));
65+
}
66+
67+
[TestMethod]
68+
public void Comparison_DifferentDeclaredLengths_StillEqualByContent()
69+
{
70+
AreEqual(1, ExecuteScalar("select 1 where cast('abc' as char(5)) = cast('abc' as char(10))"));
71+
}
72+
73+
[TestMethod]
74+
public void CreateTable_InsertSelect_RoundTripsPaddedValue()
75+
{
76+
var simulation = new Simulation();
77+
_ = simulation.ExecuteNonQuery("create table t (c char(5))");
78+
_ = simulation.ExecuteNonQuery("insert into t values ('hi')");
79+
AreEqual("hi ", simulation.ExecuteScalar("select c from t"));
80+
}
81+
82+
[TestMethod]
83+
public void CreateTable_NCharRoundTrip_PadsToDeclaredCodeUnits()
84+
{
85+
var simulation = new Simulation();
86+
_ = simulation.ExecuteNonQuery("create table t (c nchar(5))");
87+
_ = simulation.ExecuteNonQuery("insert into t values (N'hi')");
88+
AreEqual("hi ", simulation.ExecuteScalar("select c from t"));
89+
}
90+
91+
[TestMethod]
92+
public void CreateTable_BinaryRoundTrip_PadsWithZeros()
93+
{
94+
var simulation = new Simulation();
95+
_ = simulation.ExecuteNonQuery("create table t (b binary(5))");
96+
_ = simulation.ExecuteNonQuery("insert into t values (0xCAFE)");
97+
var value = simulation.ExecuteScalar("select b from t") as byte[];
98+
IsNotNull(value);
99+
CollectionAssert.AreEqual(new byte[] { 0xCA, 0xFE, 0, 0, 0 }, value);
100+
}
101+
102+
[TestMethod]
103+
public void Insert_OversizeString_RaisesTruncationError()
104+
{
105+
// Default compatibility level is 170 (SQL Server 2025), so the verbose
106+
// Msg 2628 fires with the column-name and truncated-prefix detail.
107+
var simulation = new Simulation();
108+
_ = simulation.ExecuteNonQuery("create table t (c char(5))");
109+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("insert into t values ('toolong')"));
110+
AreEqual("String or binary data would be truncated in table 't', column 'c'. Truncated value: 'toolo'.", ex.Message);
111+
}
112+
113+
[TestMethod]
114+
public void Insert_OversizeNChar_RaisesTruncationError()
115+
{
116+
var simulation = new Simulation();
117+
_ = simulation.ExecuteNonQuery("create table t (c nchar(5))");
118+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("insert into t values (N'toolong')"));
119+
AreEqual("String or binary data would be truncated in table 't', column 'c'. Truncated value: 'toolo'.", ex.Message);
120+
}
121+
122+
[TestMethod]
123+
public void Insert_OversizeBinary_RaisesTruncationError()
124+
{
125+
var simulation = new Simulation();
126+
_ = simulation.ExecuteNonQuery("create table t (b binary(5))");
127+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("insert into t values (0x010203040506)"));
128+
StringAssert.StartsWith(ex.Message, "String or binary data would be truncated in table 't', column 'b'.");
129+
}
130+
131+
[TestMethod]
132+
public void Cast_CharZero_RaisesMsg1001()
133+
{
134+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('a' as char(0))"));
135+
AreEqual("Line 1: Length or precision specification 0 is invalid.", ex.Message);
136+
}
137+
138+
[TestMethod]
139+
public void Cast_CharOversize_RaisesMsg131WithTypeWording()
140+
{
141+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('a' as char(8001))"));
142+
AreEqual("The size (8001) given to the type 'char' exceeds the maximum allowed for any data type (8000).", ex.Message);
143+
}
144+
145+
[TestMethod]
146+
public void Cast_NCharOversize_RaisesMsg131WithConvertSpecificationWording()
147+
{
148+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(N'a' as nchar(4001))"));
149+
AreEqual("The size (4001) given to the convert specification 'nchar' exceeds the maximum allowed for any data type (4000).", ex.Message);
150+
}
151+
152+
[TestMethod]
153+
public void Cast_BinaryOversize_RaisesMsg131WithBinaryWording()
154+
{
155+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(0xab as binary(8001))"));
156+
AreEqual("The size (8001) given to the type 'binary' exceeds the maximum allowed for any data type (8000).", ex.Message);
157+
}
158+
159+
[TestMethod]
160+
public void CreateTable_CharOversize_RaisesMsg131WithColumnWording()
161+
{
162+
var simulation = new Simulation();
163+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("create table t (c char(8001))"));
164+
AreEqual("The size (8001) given to the column 'c' exceeds the maximum allowed for any data type (8000).", ex.Message);
165+
}
166+
167+
[TestMethod]
168+
public void CreateTable_NCharOversize_RaisesMsg2717ParameterWording()
169+
{
170+
var simulation = new Simulation();
171+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("create table t (c nchar(4001))"));
172+
AreEqual("The size (4001) given to the parameter 'c' exceeds the maximum allowed (4000).", ex.Message);
173+
}
174+
175+
[TestMethod]
176+
public void Cast_Default_NoParensInCastIs30()
177+
{
178+
// Verified against SQL Server 2025: CAST without parens defaults to 30,
179+
// matching SQL Server's documented "30 in CAST, 1 in column declaration"
180+
// split. Confirm via DATALENGTH.
181+
AreEqual(30, ExecuteScalar("select datalength(cast('abc' as char))"));
182+
}
183+
184+
[TestMethod]
185+
public void CreateTable_DefaultIsOne()
186+
{
187+
// Column declaration default is 1 — SQL Server's documented split from
188+
// CAST's default of 30. INSERT a 1-char value to verify it round trips
189+
// without truncation; 'hello' would truncate.
190+
var simulation = new Simulation();
191+
_ = simulation.ExecuteNonQuery("create table t (c char)");
192+
_ = simulation.ExecuteNonQuery("insert into t values ('a')");
193+
AreEqual("a", simulation.ExecuteScalar("select c from t"));
194+
// 'hello' wouldn't fit char(1).
195+
_ = Throws<DbException>(() => simulation.ExecuteNonQuery("insert into t values ('hello')"));
196+
}
197+
198+
[TestMethod]
199+
public void Cast_UidToCharBelow36_RaisesMsg8170()
200+
{
201+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(NEWID() as char(35))"));
202+
AreEqual("Insufficient result space to convert uniqueidentifier value to char.", ex.Message);
203+
}
204+
205+
[TestMethod]
206+
public void Cast_UidToNCharBelow36_RaisesMsg8115WithNvarcharText()
207+
{
208+
// Verified against SQL Server 2025: nchar's overflow message names
209+
// "nvarchar" rather than "nchar" — same shared text path as nvarchar.
210+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(NEWID() as nchar(35))"));
211+
AreEqual("Arithmetic overflow error converting expression to data type nvarchar.", ex.Message);
212+
}
213+
214+
[TestMethod]
215+
public void Cast_VarcharToNvarchar_PreservesValue()
216+
{
217+
// String → string crossings now work in CAST (varchar ↔ nvarchar ↔
218+
// char(N) ↔ nchar(N)). This was a known gap before fixed-length types
219+
// were added; fixing it fell out of the same string→string code path.
220+
AreEqual("abc", ExecuteScalar("select cast('abc' as nvarchar(10))"));
221+
}
222+
223+
[TestMethod]
224+
public void Insert_WithLegacyCompatibility_RaisesMsg8152NoColumnDetail()
225+
{
226+
var simulation = new Simulation();
227+
_ = simulation.ExecuteNonQuery("alter database current set compatibility_level = 150");
228+
_ = simulation.ExecuteNonQuery("create table t (c char(5))");
229+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("insert into t values ('toolong')"));
230+
AreEqual("String or binary data would be truncated.", ex.Message);
231+
}
232+
233+
[TestMethod]
234+
public void Cast_CharToVarchar_TrailingSpacesPreservedInString()
235+
{
236+
// The padded form is part of the value, so casting to varchar carries
237+
// the trailing spaces through (verified: varchar comparison still
238+
// strips them via ANSI padding).
239+
AreEqual(1, ExecuteScalar("select 1 where cast(cast('abc' as char(5)) as varchar(10)) = 'abc'"));
240+
}
241+
}

SqlServerSimulator/Parser/Expressions/Cast.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,17 @@ public override SqlValue Run(Func<List<string>, SqlValue> getColumnValue)
8181
// uniqueidentifier → too-narrow string: SQL Server raises a target-
8282
// specific error rather than silently truncating. char/varchar use
8383
// Msg 8170 with its dedicated text; nchar/nvarchar use the generic
84-
// arithmetic-overflow Msg 8115. NULLs pass through silently.
84+
// arithmetic-overflow Msg 8115 (verified against SQL Server 2025: the
85+
// message names "nvarchar" for both nchar and nvarchar targets).
86+
// NULLs pass through silently.
8587
if (!value.IsNull
8688
&& value.Type == SqlType.UniqueIdentifier
8789
&& this.targetMaxLength is int max
8890
&& max < 36)
8991
{
90-
if (this.targetType == SqlType.Varchar)
92+
if (this.targetType == SqlType.Varchar || this.targetType is CharSqlType)
9193
throw SimulatedSqlException.InsufficientResultSpaceForUniqueIdentifier();
92-
if (this.targetType == SqlType.NVarchar)
94+
if (this.targetType == SqlType.NVarchar || this.targetType is NCharSqlType)
9395
throw SimulatedSqlException.ArithmeticOverflow("nvarchar");
9496
}
9597

SqlServerSimulator/SimulatedSqlException.cs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,15 @@ internal static SimulatedSqlException SizeExceedsMaximumColumn(string columnName
143143
new($"The size ({requested}) given to the column '{columnName}' exceeds the maximum allowed for any data type ({max}).", 131, 15, 2);
144144

145145
/// <summary>
146-
/// Mimics SQL Server error 131 in CAST form for
147-
/// <c>varchar</c> / <c>varbinary</c>. Class 15 / State 3.
146+
/// Mimics SQL Server error 131 in CAST form for <c>varchar</c> /
147+
/// <c>varbinary</c> / <c>char</c> / <c>binary</c>. Class 15 / State 3.
148+
/// The message always names the family root (no <c>(N)</c> suffix); callers
149+
/// of parameterized types must pass the bare name explicitly because the
150+
/// resolved <see cref="SqlType"/>'s <see cref="object.ToString"/> renders
151+
/// the suffix for debug contexts.
148152
/// </summary>
149-
internal static SimulatedSqlException SizeExceedsMaximumCast(SqlType type, int requested, int max) =>
150-
new($"The size ({requested}) given to the type '{type}' exceeds the maximum allowed for any data type ({max}).", 131, 15, 3);
153+
internal static SimulatedSqlException SizeExceedsMaximumCast(string typeName, int requested, int max) =>
154+
new($"The size ({requested}) given to the type '{typeName}' exceeds the maximum allowed for any data type ({max}).", 131, 15, 3);
151155

152156
/// <summary>
153157
/// Mimics SQL Server error 2717: an <c>nvarchar</c> column exceeds the
@@ -159,11 +163,13 @@ internal static SimulatedSqlException NVarcharSizeExceedsMaximumColumn(string co
159163
new($"The size ({requested}) given to the parameter '{columnName}' exceeds the maximum allowed (4000).", 2717, 16, 2);
160164

161165
/// <summary>
162-
/// Mimics SQL Server error 131 in CAST form for <c>nvarchar</c>. Class 16
163-
/// / State 1; uses "convert specification" wording.
166+
/// Mimics SQL Server error 131 in CAST form for <c>nvarchar</c> /
167+
/// <c>nchar</c>. Class 16 / State 1; uses "convert specification" wording.
168+
/// The type name is parameterized so <c>nchar(N)</c> casts produce the
169+
/// matching <c>'nchar'</c> wording (verified against SQL Server 2025).
164170
/// </summary>
165-
internal static SimulatedSqlException NVarcharSizeExceedsMaximumCast(int requested) =>
166-
new($"The size ({requested}) given to the convert specification 'nvarchar' exceeds the maximum allowed for any data type (4000).", 131, 16, 1);
171+
internal static SimulatedSqlException NVarcharSizeExceedsMaximumCast(string typeName, int requested) =>
172+
new($"The size ({requested}) given to the convert specification '{typeName}' exceeds the maximum allowed for any data type (4000).", 131, 16, 1);
167173

168174
/// <summary>
169175
/// Mimics SQL Server's verbose truncation error (Msg 2628): a string value

0 commit comments

Comments
 (0)