Skip to content

Commit 79a896b

Browse files
committed
Release prep: tightened NuGet package set, fixed some low-difficulty fidelity issues.
1 parent 92caf50 commit 79a896b

18 files changed

Lines changed: 250 additions & 46 deletions

Example/Example.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
88
<DebugType>embedded</DebugType>
9+
<!-- Demonstration project; not a shippable package. -->
10+
<IsPackable>false</IsPackable>
911
</PropertyGroup>
1012

1113
<ItemGroup>

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ sealed class SimulatedContext(Simulation simulation) : DbContext
5757
}
5858
```
5959

60+
<!-- Not shipping this until someone asks for it.
6061
The companion `SqlServerSimulator.EFCore` package adds `UseSqlServerSimulator(...)` for entities that use CLR/store-type pairs whose EF default mappings downcast to `SqlParameter` (`DateOnly`/`DateTime`→`date`/`smalldatetime`, `TimeOnly`/`TimeSpan`→`time(N)`, `decimal`→`money`/`smallmoney`). Without it, those mappings throw at SaveChanges. The base-ADO.NET types in the example above don't need it.
62+
-->
6163

6264
## Fidelity
6365

64-
Behavior was probed against a live SQL Server reference instance before being modeled. SQL Server's quirks, inconsistencies, and suprises are mostly preserved. Error messages usually match.
66+
Behavior was probed against a live SQL Server reference instance before being modeled. SQL Server's quirks, inconsistencies, and surprises are mostly preserved. Error messages usually match.
6567

6668
Entity Framework Core trusts the simulator end-to-end: LINQ queries, migrations, change tracking, and the SaveChanges pipeline all flow through unchanged.
6769

SqlServerSimulator.EFCore/SqlServerSimulator.EFCore.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
2727
<RootNamespace>SqlServerSimulator.EFCore</RootNamespace>
2828
<PackageReadmeFile>README.md</PackageReadmeFile>
29+
<!-- The simulator package ships on its own; the EF Core adapter is not
30+
published separately. Excluded from `dotnet pack` output. -->
31+
<IsPackable>false</IsPackable>
2932
</PropertyGroup>
3033

3134
<ItemGroup>

SqlServerSimulator.Tests/BuiltInFunctionTests.cs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,68 @@ public void MultiArgStringFunction_NullPropagates(string expression) =>
108108
[DataRow("left('abc', -1)")]
109109
[DataRow("right('abc', -1)")]
110110
[DataRow("substring('abc', 1, -1)")]
111-
public void NegativeLength_RaisesMsg537(string expression)
111+
public void NegativeLength_RaisesMsg536(string expression)
112112
{
113-
var ex = Throws<DbException>(() => ExecuteScalar($"select {expression}"));
113+
var ex = Throws<SimulatedSqlException>(() => ExecuteScalar($"select {expression}"));
114+
AreEqual(536, ex.Number);
114115
Assert.Contains("Invalid length parameter", ex.Message);
115116
}
116117

118+
[TestMethod]
119+
[DataRow("1/0")]
120+
[DataRow("1%0")]
121+
[DataRow("cast(1 as bigint) / 0")]
122+
[DataRow("10 % cast(0 as bigint)")]
123+
[DataRow("isnull(1/0, 0)")]
124+
public void IntegerDivideByZero_RaisesMsg8134(string expression)
125+
{
126+
var ex = Throws<SimulatedSqlException>(() => ExecuteScalar($"select {expression}"));
127+
AreEqual(8134, ex.Number);
128+
Assert.Contains("Divide by zero", ex.Message);
129+
}
130+
131+
[TestMethod]
132+
[DataRow("left('abc', 2147483648)")]
133+
[DataRow("right('abc', 2147483648)")]
134+
public void IntArgumentOverflow_RaisesMsg8115(string expression)
135+
{
136+
var ex = Throws<SimulatedSqlException>(() => ExecuteScalar($"select {expression}"));
137+
AreEqual(8115, ex.Number);
138+
Assert.Contains("Arithmetic overflow", ex.Message);
139+
}
140+
141+
[TestMethod]
142+
[DataRow("substring('abc', -2147483648, 2147483647)", "")]
143+
[DataRow("substring('abc', 2147483647, 2147483647)", "")]
144+
[DataRow("substring('abcdef', 0, 3)", "ab")]
145+
[DataRow("substring('abcdef', -2, 5)", "ab")]
146+
public void Substring_ClampsExtremesInsteadOfThrowing(string expression, string expected)
147+
=> AreEqual(expected, ExecuteScalar($"select {expression}"));
148+
149+
[TestMethod]
150+
[DataRow("switchoffset(sysdatetimeoffset(), 9999)")]
151+
[DataRow("switchoffset(sysdatetimeoffset(), '+20:00')")]
152+
[DataRow("todatetimeoffset(getdate(), 9999)")]
153+
[DataRow("todatetimeoffset(getdate(), -2000)")]
154+
public void OffsetOutOfRange_RaisesMsg9812(string expression)
155+
{
156+
var ex = Throws<SimulatedSqlException>(() => ExecuteScalar($"select {expression}"));
157+
AreEqual(9812, ex.Number);
158+
Assert.Contains("timezone provided to builtin function", ex.Message);
159+
}
160+
161+
[TestMethod]
162+
public void DecimalLiteralBeyondMaxPrecision_RaisesMsg1007()
163+
{
164+
var ex = Throws<SimulatedSqlException>(() => ExecuteScalar("select 1.23456789012345678901234567890123456789012"));
165+
AreEqual(1007, ex.Number);
166+
Assert.Contains("out of the range for numeric representation", ex.Message);
167+
}
168+
169+
[TestMethod]
170+
public void ReplaceWithEmptySearch_ReturnsInputUnchanged()
171+
=> AreEqual("abc", ExecuteScalar("select replace('abc', '', 'X')"));
172+
117173
[TestMethod]
118174
public void FunctionOfColumn_FromTable()
119175
{

SqlServerSimulator.Tests/CommandTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ public void DesignTimeVisibleBehavior()
6161
IsTrue(command.DesignTimeVisible);
6262
}
6363

64+
[TestMethod]
65+
public void CancelAndPrepareAreNoOps()
66+
{
67+
using var connection = CreateOpenConnection();
68+
using var command = connection.CreateCommand();
69+
command.CommandText = "select 1";
70+
command.Cancel();
71+
command.Prepare();
72+
AreEqual(1, command.ExecuteScalar());
73+
}
74+
6475
[TestMethod]
6576
public void TransactionPassThrough()
6677
{

SqlServerSimulator.Tests/ConnectionTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,37 @@ public async Task OpenAsyncCancellable()
4040
await connection.OpenAsync(this.TestContext.CancellationToken);
4141
Assert.AreEqual(ConnectionState.Open, connection.State);
4242
}
43+
44+
[TestMethod]
45+
public void DatabaseReflectsCurrentDatabaseAndRoundTripsThroughChangeDatabase()
46+
{
47+
using var connection = new Simulation().CreateDbConnection();
48+
var current = connection.Database;
49+
Assert.IsFalse(string.IsNullOrEmpty(current));
50+
51+
connection.ChangeDatabase(current);
52+
Assert.AreEqual(current, connection.Database);
53+
}
54+
55+
[TestMethod]
56+
public void ChangeDatabaseToMissingRaisesMsg911()
57+
{
58+
using var connection = new Simulation().CreateDbConnection();
59+
var exception = Assert.ThrowsExactly<SimulatedSqlException>(() => connection.ChangeDatabase("no_such_database"));
60+
Assert.AreEqual(911, exception.Number);
61+
}
62+
63+
[TestMethod]
64+
public void ChangeDatabaseToWhitespaceRaisesArgumentException()
65+
{
66+
using var connection = new Simulation().CreateDbConnection();
67+
_ = Assert.ThrowsExactly<ArgumentException>(() => connection.ChangeDatabase(" "));
68+
}
69+
70+
[TestMethod]
71+
public void ConnectionStringSetterIsNotSupported()
72+
{
73+
using var connection = new Simulation().CreateDbConnection();
74+
_ = Assert.ThrowsExactly<NotSupportedException>(() => connection.ConnectionString = "anything");
75+
}
4376
}

SqlServerSimulator.Tests/IfBlockTests.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -366,15 +366,16 @@ select 1
366366
"""));
367367

368368
/// <summary>
369-
/// Cond eval errors propagate when the IF is reached. Real SQL Server
370-
/// surfaces divide-by-zero in a cond as Msg 8134; the simulator's runtime
371-
/// surfaces a raw <see cref="DivideByZeroException"/> (pre-existing
372-
/// fidelity gap — same as the gap documented for TRY_CAST).
369+
/// Cond eval errors propagate when the IF is reached. Divide-by-zero in a
370+
/// cond surfaces as Msg 8134, matching SQL Server.
373371
/// </summary>
374372
[TestMethod]
375373
public void CondEvalError_PropagatesAsRuntimeError()
376-
=> Throws<DivideByZeroException>(() => new Simulation().ExecuteNonQuery(
374+
{
375+
var ex = Throws<SimulatedSqlException>(() => new Simulation().ExecuteNonQuery(
377376
"if 1/0 = 0 select 'ran'"));
377+
AreEqual(8134, ex.Number);
378+
}
378379

379380
/// <summary>
380381
/// A taken branch propagates errors normally — a CHECK violation in the

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,8 @@ internal static SimulatedSqlException CannotConvertCharToMoney() =>
245245
new("Cannot convert a char value to money. The char value has incorrect syntax.", 235, 16, 1);
246246

247247
/// <summary>
248-
/// Mimics SQL Server error 8134: division by zero in decimal / float /
249-
/// money arithmetic. Integer division currently falls through .NET's
250-
/// <see cref="DivideByZeroException"/> path; future integer-division
251-
/// alignment can reuse this factory.
248+
/// Mimics SQL Server error 8134: division by zero in integer, decimal,
249+
/// float, or money arithmetic.
252250
/// </summary>
253251
internal static SimulatedSqlException DivideByZero() =>
254252
new("Divide by zero error encountered.", 8134, 16, 1);
@@ -415,11 +413,29 @@ internal static SimulatedSqlException IncompatibleDataTypesInOperator(SqlType a,
415413
new($"The data types {FamilyRootName(a)} and {FamilyRootName(b)} are incompatible in the {operatorName} operator.", 402, 16, 1);
416414

417415
/// <summary>
418-
/// Mimics SQL Server error 537: a length / count argument to a string
419-
/// function (LEFT, RIGHT, SUBSTRING) was negative.
416+
/// Mimics SQL Server error 536: a length / count argument to a string
417+
/// function (left, right, substring) was negative. The function name is
418+
/// lowercase in the message and the state varies by function (6 for
419+
/// left / right, 8 for substring), verified against SQL Server 2025.
420420
/// </summary>
421-
internal static SimulatedSqlException NegativeLengthNotAllowed(string function) =>
422-
new($"Invalid length parameter passed to the {function} function.", 537, 16, 3);
421+
internal static SimulatedSqlException NegativeLengthNotAllowed(string function, byte state) =>
422+
new($"Invalid length parameter passed to the {function} function.", 536, 16, state);
423+
424+
/// <summary>
425+
/// Mimics SQL Server error 1007: a numeric literal carries more than 38
426+
/// significant digits, exceeding the maximum precision of the numeric
427+
/// representation. Class 15 — raised while reading the literal.
428+
/// </summary>
429+
internal static SimulatedSqlException NumberOutOfRangeForNumeric(string literal) =>
430+
new($"The number '{literal}' is out of the range for numeric representation (maximum precision 38).", 1007, 15, 1);
431+
432+
/// <summary>
433+
/// Mimics SQL Server error 9812: the offset passed to SWITCHOFFSET /
434+
/// TODATETIMEOFFSET falls outside the legal ±14:00 range. The builtin
435+
/// function name appears lowercase in the message.
436+
/// </summary>
437+
internal static SimulatedSqlException InvalidTimeZone(string function) =>
438+
new($"The timezone provided to builtin function {function} is invalid.", 9812, 16, 1);
423439

424440
/// <summary>
425441
/// Mimics SQL Server error 6522: an input to a hierarchyid method

SqlServerSimulator/Parser/Expressions/DateTimeAdjustments.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public override SqlValue Run(RuntimeContext runtime)
117117
var off = this.offsetArg.Run(runtime);
118118
if (off.IsNull)
119119
return SqlValue.Null(v.Type);
120-
var offsetMinutes = ParseOffsetMinutes(off);
120+
var offsetMinutes = ParseOffsetMinutes(off, "switchoffset");
121121
var adjusted = v.AsDateTimeOffset.ToOffset(TimeSpan.FromMinutes(offsetMinutes));
122122
return SqlValue.FromDateTimeOffset(v.Type, adjusted);
123123
}
@@ -146,6 +146,20 @@ internal static int ParseOffsetMinutes(SqlValue v)
146146
return v.CoerceTo(SqlType.Int32).AsInt32;
147147
}
148148

149+
/// <summary>
150+
/// Parses the offset and enforces SQL Server's legal ±14:00 range,
151+
/// raising Msg 9812 (named for <paramref name="functionName"/>) when it
152+
/// is exceeded — instead of letting <see cref="DateTimeOffset"/> throw an
153+
/// internal <see cref="ArgumentOutOfRangeException"/>.
154+
/// </summary>
155+
internal static int ParseOffsetMinutes(SqlValue v, string functionName)
156+
{
157+
var minutes = ParseOffsetMinutes(v);
158+
return minutes is < -840 or > 840
159+
? throw SimulatedSqlException.InvalidTimeZone(functionName)
160+
: minutes;
161+
}
162+
149163
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) =>
150164
this.dtoArg.GetSqlType(batch, resolveColumnType) is DateTimeOffsetSqlType t ? t : SqlType.GetDateTimeOffset(7);
151165

@@ -184,7 +198,7 @@ public override SqlValue Run(RuntimeContext runtime)
184198
var off = this.offsetArg.Run(runtime);
185199
if (off.IsNull)
186200
return SqlValue.Null(ResultType);
187-
var offsetMinutes = SwitchOffset.ParseOffsetMinutes(off);
201+
var offsetMinutes = SwitchOffset.ParseOffsetMinutes(off, "todatetimeoffset");
188202
var dt = v.Type == SqlType.DateTime ? v.AsDateTime
189203
: v.Type == SqlType.SmallDateTime ? v.AsSmallDateTime
190204
: v.Type is DateTime2SqlType ? v.AsDateTime2

SqlServerSimulator/Parser/Expressions/Left.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ public override SqlValue Run(RuntimeContext runtime)
2828
return SqlValue.Null(StringScalars.ResolveResultType(rawSource.Type, runtime.Batch));
2929
var s = StringScalars.CoerceToVarchar(rawSource, runtime.Batch, "left");
3030

31-
var len = n.CoerceTo(SqlType.Int32).AsInt32;
31+
var len = StringScalars.CoerceLengthArgument(n);
3232
if (len < 0)
33-
throw SimulatedSqlException.NegativeLengthNotAllowed("LEFT");
33+
throw SimulatedSqlException.NegativeLengthNotAllowed("left", 6);
3434

3535
var input = s.AsString;
3636
var result = s.Type.Collation?.IsSupplementaryCharacterAware == true

0 commit comments

Comments
 (0)