Skip to content

Commit 0493c06

Browse files
committed
Improved simulation accuracy.
1 parent 489e420 commit 0493c06

18 files changed

Lines changed: 789 additions & 118 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,5 @@ Live state is best read from the code itself and recent commit history. Architec
7171
- Transactions / locks / MVCC: not implemented. The page foundation is in place; the next phase hasn't started.
7272
- 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.
7373
- Heap allocation: flat page list. No IAM/PFS allocation tracking.
74-
- Cross-category and cross-string-type coercion (string ↔ numeric, varchar ↔ nvarchar): not implemented. `CAST` and the binary-expression `Promote` path both error on these pairs.
74+
- Cross-string-type coercion (`varchar``nvarchar`): not modeled. The binary-expression `Promote` path's cross-category handling is also not modeled (string ↔ integer in expressions still errors). String → integer-family `CAST` is implemented, with the SQL-Server-matched Msg 244 / 245 / 248 / 8115 error variants.
7575
- Pattern matching (`LIKE`), `CONVERT`, fixed-length `char(N)` / `nchar(N)` / `binary(N)`, and identity columns: not modeled.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using SqlServerSimulator.Parser.Tokens;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator.Parser;
5+
6+
/// <summary>
7+
/// Internal-only tests. If a behavior is reachable through SQL, write it in
8+
/// SqlServerSimulator.Tests instead — public-API tests survive refactors and
9+
/// catch regressions the way users will.
10+
/// </summary>
11+
/// <remarks>
12+
/// <see cref="Token.LineNumber"/> backs the <c>"Line N"</c> prefix in
13+
/// SQL-Server-mimicking error messages (e.g. Msg 1002 invalid scale). Pin
14+
/// the byte-counting math directly so the line attributed to an error is
15+
/// not silently miscounted by an off-by-one against newline conventions.
16+
/// </remarks>
17+
[TestClass]
18+
public sealed class TokenLineNumberTests
19+
{
20+
[TestMethod]
21+
public void SingleLineCommand_AllTokensReportLine1()
22+
{
23+
foreach (var token in TokenizeMeaningful("select 1 + 2"))
24+
AreEqual(1, token.LineNumber, $"token '{token}' expected line 1");
25+
}
26+
27+
[TestMethod]
28+
public void TokenAfterLfNewline_ReportsLine2()
29+
{
30+
var tokens = TokenizeMeaningful("select 1\n+ 2").ToArray();
31+
AreEqual(1, tokens[0].LineNumber); // 'select'
32+
var plus = tokens.First(t => t.ToString() == "+");
33+
AreEqual(2, plus.LineNumber);
34+
}
35+
36+
[TestMethod]
37+
public void TokenAfterCrlfNewline_AlsoReportsLine2()
38+
{
39+
// Only \n increments the line. \r before it is folded into the same
40+
// line so CRLF and LF both count once. (SQL Server matches this.)
41+
var tokens = TokenizeMeaningful("select 1\r\n+ 2").ToArray();
42+
var plus = tokens.First(t => t.ToString() == "+");
43+
AreEqual(2, plus.LineNumber);
44+
}
45+
46+
[TestMethod]
47+
public void LeadingBlankLines_PushTokenLineDown()
48+
{
49+
var tokens = TokenizeMeaningful("\n\n\nselect 1").ToArray();
50+
AreEqual(4, tokens[0].LineNumber);
51+
}
52+
53+
[TestMethod]
54+
public void MultipleLineBreaks_AccumulateInOrder()
55+
{
56+
var tokens = TokenizeMeaningful("a\nb\nc\nd").ToArray();
57+
AreEqual(1, tokens[0].LineNumber);
58+
AreEqual(2, tokens[1].LineNumber);
59+
AreEqual(3, tokens[2].LineNumber);
60+
AreEqual(4, tokens[3].LineNumber);
61+
}
62+
63+
[TestMethod]
64+
public void NewlineWithinACommentToken_PushesLaterTokensDown()
65+
{
66+
// Block comments are skipped at the parser level, but the lines they
67+
// contain still count toward what comes after them.
68+
var tokens = TokenizeMeaningful("/* a\nb */ select 1").ToArray();
69+
var select = tokens.First(t => t.ToString().Equals("select", StringComparison.OrdinalIgnoreCase));
70+
AreEqual(2, select.LineNumber);
71+
}
72+
73+
/// <summary>
74+
/// Mirrors <see cref="ParserContext.MoveNext"/>: skips whitespace and
75+
/// comment tokens so the assertions match what error-reporting code sees.
76+
/// </summary>
77+
private static IEnumerable<Token> TokenizeMeaningful(string command)
78+
{
79+
var index = -1;
80+
while (Tokenizer.NextToken(command, ref index) is Token t)
81+
{
82+
if (t is Whitespace or Comment)
83+
continue;
84+
yield return t;
85+
}
86+
}
87+
}

SqlServerSimulator.Tests.Internal/Storage/SqlValueTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,12 @@ public void CoerceTo_BigIntToInt32_OutOfRangeOverflows() =>
171171
Throws<OverflowException>(() => SqlValue.FromInt64(int.MaxValue + 1L).CoerceTo(SqlType.Int32));
172172

173173
[TestMethod]
174-
public void CoerceTo_BetweenStringAndInt_NotSupported() =>
175-
Throws<NotSupportedException>(() => SqlValue.FromVarchar("42").CoerceTo(SqlType.Int32));
174+
public void CoerceTo_VarcharToInt32_Parses()
175+
{
176+
var coerced = SqlValue.FromVarchar("42").CoerceTo(SqlType.Int32);
177+
AreSame(SqlType.Int32, coerced.Type);
178+
AreEqual(42, coerced.AsInt32);
179+
}
176180

177181
[TestMethod]
178182
public void Date_AsDate_OnNull_Throws() =>

SqlServerSimulator.Tests/CastTests.cs

Lines changed: 199 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public sealed class CastTests
1414
[DataRow("cast(255 as tinyint)", (byte)255)]
1515
[DataRow("cast(1 as bit)", true)]
1616
[DataRow("cast(0 as bit)", false)]
17+
[DataRow("cast(-1 as int)", -1)] // regression: unary minus inside CAST
18+
[DataRow("cast(-(2 + 3) as int)", -5)] // unary minus + parenthesized
1719
public void Cast(string expression, object expected) =>
1820
AreEqual(expected, ExecuteScalar($"select {expression}"));
1921

@@ -25,15 +27,118 @@ public void Cast_NullPassesThroughWithRetargetedType() =>
2527
public void Cast_NarrowingOverflow_RaisesArithmeticOverflow()
2628
{
2729
var ex = Throws<DbException>(() => ExecuteScalar("select cast(300 as tinyint)"));
28-
StringAssert.Contains(ex.Message, "Arithmetic overflow");
30+
Contains("Arithmetic overflow", ex.Message);
2931
}
3032

3133
[TestMethod]
32-
public void Cast_CrossCategory_NotSupported()
34+
[DataRow("cast('42' as int)", 42)]
35+
[DataRow("cast('-42' as int)", -42)]
36+
[DataRow("cast('+42' as int)", 42)]
37+
[DataRow("cast(' 42 ' as int)", 42)] // whitespace tolerated
38+
[DataRow("cast('007' as int)", 7)] // leading zeros fine
39+
[DataRow("cast('' as int)", 0)] // SQL Server quirk: empty → 0
40+
[DataRow("cast(' ' as int)", 0)] // whitespace-only → 0
41+
public void Cast_StringToInt32(string expression, int expected) =>
42+
AreEqual(expected, ExecuteScalar($"select {expression}"));
43+
44+
[TestMethod]
45+
[DataRow("'abc'", "Conversion failed when converting the varchar value 'abc' to data type int.")]
46+
[DataRow("'42.5'", "Conversion failed when converting the varchar value '42.5' to data type int.")]
47+
[DataRow("'0x42'", "Conversion failed when converting the varchar value '0x42' to data type int.")]
48+
public void Cast_BadStringToInt32_RaisesMsg245(string source, string expected)
49+
{
50+
var ex = Throws<DbException>(() => ExecuteScalar($"select cast({source} as int)"));
51+
AreEqual(expected, ex.Message);
52+
}
53+
54+
[TestMethod]
55+
public void Cast_NVarcharToInt32_UsesNVarcharInErrorMessage()
56+
{
57+
// Source-type wording in Msg 245 reflects the actual source type.
58+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(N'abc' as int)"));
59+
AreEqual("Conversion failed when converting the nvarchar value 'abc' to data type int.", ex.Message);
60+
}
61+
62+
[TestMethod]
63+
public void Cast_StringOverflow_TinyInt_RaisesMsg244WithINT1()
64+
{
65+
// Tinyint/smallint use Msg 244 with internal SQL names INT1/INT2.
66+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('300' as tinyint)"));
67+
AreEqual("The conversion of the varchar value '300' overflowed an INT1 column. Use a larger integer column.", ex.Message);
68+
}
69+
70+
[TestMethod]
71+
public void Cast_StringOverflow_SmallInt_RaisesMsg244WithINT2()
72+
{
73+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('99999' as smallint)"));
74+
AreEqual("The conversion of the varchar value '99999' overflowed an INT2 column. Use a larger integer column.", ex.Message);
75+
}
76+
77+
[TestMethod]
78+
public void Cast_StringOverflow_Int_RaisesMsg248()
79+
{
80+
// Int has its own overflow message (Msg 248) — distinct text and no
81+
// "Use a larger integer column" suffix.
82+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('99999999999' as int)"));
83+
AreEqual("The conversion of the varchar value '99999999999' overflowed an int column.", ex.Message);
84+
}
85+
86+
[TestMethod]
87+
public void Cast_StringOverflow_BigInt_RaisesMsg8115()
88+
{
89+
// Bigint overflow falls through to the generic arithmetic-overflow
90+
// message; no source-value detail.
91+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('99999999999999999999' as bigint)"));
92+
Contains("Arithmetic overflow", ex.Message);
93+
Contains("bigint", ex.Message);
94+
}
95+
96+
[TestMethod]
97+
[DataRow("'1'", true)]
98+
[DataRow("'0'", false)]
99+
[DataRow("'true'", true)]
100+
[DataRow("'TRUE'", true)]
101+
[DataRow("'false'", false)]
102+
[DataRow("'FALSE'", false)]
103+
[DataRow("' true '", true)] // surrounding whitespace tolerated
104+
[DataRow("'2'", true)] // any non-zero numeric → true
105+
[DataRow("'-1'", true)]
106+
[DataRow("'000'", false)] // all-zero digit string → false
107+
[DataRow("''", false)] // empty → false
108+
[DataRow("' '", false)] // whitespace-only → false
109+
[DataRow("'99999999999999999999'", true)] // exceeds long but bit ignores magnitude
110+
public void Cast_StringToBit(string source, bool expected) =>
111+
AreEqual(expected, ExecuteScalar($"select cast({source} as bit)"));
112+
113+
[TestMethod]
114+
[DataRow("'yes'")]
115+
[DataRow("'no'")]
116+
[DataRow("'t'")]
117+
[DataRow("'truex'")]
118+
[DataRow("'1.0'")] // decimal point not accepted even for bit
119+
public void Cast_BadStringToBit_RaisesMsg245(string source)
120+
{
121+
var ex = Throws<DbException>(() => ExecuteScalar($"select cast({source} as bit)"));
122+
Contains("Conversion failed", ex.Message);
123+
Contains("to data type bit", ex.Message);
124+
}
125+
126+
[TestMethod]
127+
[DataRow("cast(42 as varchar(10))", "42")]
128+
[DataRow("cast(-42 as varchar(10))", "-42")]
129+
[DataRow("cast(0 as varchar(10))", "0")]
130+
[DataRow("cast(cast(255 as tinyint) as varchar(5))", "255")]
131+
[DataRow("cast(cast(1 as bit) as varchar(5))", "1")]
132+
[DataRow("cast(cast(0 as bit) as varchar(5))", "0")]
133+
public void Cast_IntegerToString(string expression, string expected) =>
134+
AreEqual(expected, ExecuteScalar($"select {expression}"));
135+
136+
[TestMethod]
137+
public void Cast_BigIntToVarchar_PreservesFullRange()
33138
{
34-
// String ↔ numeric coercion isn't modeled yet; CAST surfaces the same
35-
// NotSupportedException SqlValue.CoerceTo throws.
36-
_ = Throws<NotSupportedException>(() => ExecuteScalar("select cast('42' as int)"));
139+
// Numeric literal tokens are int-bounded; the source bigint here is
140+
// constructed by parsing a string instead.
141+
AreEqual("9223372036854775807", ExecuteScalar("select cast(cast('9223372036854775807' as bigint) as varchar(30))"));
37142
}
38143

39144
[TestMethod]
@@ -261,4 +366,93 @@ public void Cast_DateTimeOffsetToTime_DropsDateAndOffset()
261366
[TestMethod]
262367
public void Cast_DateTimeOffsetNullPassesThrough() =>
263368
IsInstanceOfType<DBNull>(ExecuteScalar("select cast(cast(null as datetimeoffset(7)) as varchar(40))"));
369+
370+
[TestMethod]
371+
public void Cast_UnknownTypeName_RaisesMsg243()
372+
{
373+
// CAST takes a different error path than CREATE TABLE: Msg 243
374+
// ("Type X is not a defined system type.") instead of Msg 2715.
375+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('x' as intz)"));
376+
AreEqual("Type intz is not a defined system type.", ex.Message);
377+
}
378+
379+
[TestMethod]
380+
public void Cast_LengthSpecifierOnFixedType_RaisesMsg291()
381+
{
382+
// CAST equivalent of CREATE TABLE's Msg 2716. Note the absence of a
383+
// trailing period — SQL Server's Msg 291 message lacks one.
384+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('x' as int(4))"));
385+
AreEqual("CAST or CONVERT: invalid attributes specified for type 'int'", ex.Message);
386+
}
387+
388+
[TestMethod]
389+
public void Cast_VarcharSizeExceedsMaximum_UsesTypeWording()
390+
{
391+
// CAST form of Msg 131 says "given to the type 'T'" rather than the
392+
// column-context "given to the column 'V'".
393+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('x' as varchar(8001))"));
394+
AreEqual("The size (8001) given to the type 'varchar' exceeds the maximum allowed for any data type (8000).", ex.Message);
395+
}
396+
397+
[TestMethod]
398+
public void Cast_VarbinarySizeExceedsMaximum_UsesTypeWording()
399+
{
400+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(0xAB as varbinary(8001))"));
401+
AreEqual("The size (8001) given to the type 'varbinary' exceeds the maximum allowed for any data type (8000).", ex.Message);
402+
}
403+
404+
[TestMethod]
405+
public void Cast_NVarcharSizeExceedsMaximum_UsesConvertSpecificationWording()
406+
{
407+
// nvarchar in CAST has its own phrasing distinct from varchar.
408+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(N'x' as nvarchar(4001))"));
409+
AreEqual("The size (4001) given to the convert specification 'nvarchar' exceeds the maximum allowed for any data type (4000).", ex.Message);
410+
}
411+
412+
[TestMethod]
413+
public void Cast_InvalidScale_OnLine1_PrefixesLine1()
414+
{
415+
// Msg 1002 is one of the few SQL Server errors that puts the line
416+
// number into the message text itself.
417+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('x' as datetime2(8))"));
418+
AreEqual("Line 1: Specified scale 8 is invalid.", ex.Message);
419+
}
420+
421+
[TestMethod]
422+
public void Cast_InvalidScale_OnLaterLine_PrefixesActualLine()
423+
{
424+
// The "Line N" tracks the line of the offending type token, not a
425+
// fixed line 1 — verified against real SQL Server. Single statement
426+
// with leading blank lines so ExecuteScalar surfaces the parse error
427+
// immediately (multi-statement batches parse lazily; see the
428+
// semicolon-batch variant below).
429+
var ex = Throws<DbException>(() => ExecuteScalar(
430+
"\n" +
431+
"\n" +
432+
"select cast('x' as datetime2(8))"));
433+
AreEqual("Line 3: Specified scale 8 is invalid.", ex.Message);
434+
}
435+
436+
[TestMethod]
437+
public void Cast_InvalidScale_InLaterStatementOfBatch_FiresWhenReaderReachesIt()
438+
{
439+
// Multi-statement batches are parsed lazily one statement at a time
440+
// (CreateResultSetsForCommand is an iterator). The third statement's
441+
// parse error doesn't fire until the consumer asks for its result
442+
// set via NextResult.
443+
using var connection = new Simulation().CreateOpenConnection();
444+
using var command = connection.CreateCommand(
445+
"select 1;\n" +
446+
"select 2;\n" +
447+
"select cast('x' as datetime2(8))");
448+
using var reader = command.ExecuteReader();
449+
450+
IsTrue(reader.NextResult()); // advance past statement 1
451+
var ex = Throws<DbException>(() =>
452+
{
453+
_ = reader.NextResult(); // forces parse of statement 3
454+
_ = reader.NextResult();
455+
});
456+
AreEqual("Line 3: Specified scale 8 is invalid.", ex.Message);
457+
}
264458
}

SqlServerSimulator.Tests/CompatibilityLevelTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,12 @@ public void ExplicitVerboseSetting_WinsOverTraceFlag()
108108
[TestMethod]
109109
public void InvalidCompatibilityLevel_RaisesMsg15048()
110110
{
111+
// SQL Server's Msg 15048 lists valid values but doesn't echo the
112+
// rejected value back — verified against real SQL Server 2025.
111113
using var connection = new Simulation().CreateOpenConnection();
112114
using var alter = connection.CreateCommand("alter database master set compatibility_level = 145");
113115
var ex = Assert.Throws<DbException>(() => alter.ExecuteNonQuery());
114-
StringAssert.Contains(ex.Message, "145");
115-
StringAssert.Contains(ex.Message, "compatibility level");
116+
Assert.AreEqual("Valid values of the database compatibility level are 100, 110, 120, 130, 140, 150, 160 or 170.", ex.Message);
116117
}
117118

118119
[TestMethod]

0 commit comments

Comments
 (0)