Skip to content

Commit 2db9d03

Browse files
committed
Implemented CONVERT and TRY_CONVERT.
1 parent d4106d7 commit 2db9d03

9 files changed

Lines changed: 480 additions & 34 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 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-
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.
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, 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

@@ -73,7 +73,7 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
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.
7575
- `NEWSEQUENTIALID()` (deferred until `DEFAULT`-clause support exists in `CREATE TABLE`; the function is only valid in that context).
76-
- `CONVERT` (cousin to `CAST`, with a separate style-code surface).
76+
- `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.
7777
- `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.
7878
- 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.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
[TestClass]
8+
public sealed class ConvertTests
9+
{
10+
[TestMethod]
11+
[DataRow("convert(int, '42')", 42)]
12+
[DataRow("convert(int, 1)", 1)]
13+
[DataRow("convert(bigint, 1)", 1L)]
14+
[DataRow("convert(varchar(10), 42)", "42")]
15+
[DataRow("convert(varchar, 12345)", "12345")]
16+
public void Convert_Basics(string expression, object expected) =>
17+
AreEqual(expected, ExecuteScalar($"select {expression}"));
18+
19+
[TestMethod]
20+
public void Convert_DecimalRoundsToTargetScale() =>
21+
AreEqual(3.14m, ExecuteScalar("select convert(decimal(10,2), 3.14)"));
22+
23+
[TestMethod]
24+
public void Convert_NullSourcePassesThroughTypedNull() =>
25+
IsInstanceOfType<DBNull>(ExecuteScalar("select convert(int, null)"));
26+
27+
/// <summary>
28+
/// Any NULL style argument propagates the entire result to NULL, even on non-applicable source types.
29+
/// </summary>
30+
[TestMethod]
31+
public void Convert_NullStyleReturnsNull() =>
32+
IsInstanceOfType<DBNull>(ExecuteScalar("select convert(int, '42', null)"));
33+
34+
[TestMethod]
35+
[DataRow("'abc'")]
36+
[DataRow("'42.5'")]
37+
public void Convert_StringParseFailure_StillThrows(string source)
38+
{
39+
var ex = Throws<DbException>(() => ExecuteScalar($"select convert(int, {source})"));
40+
Contains("Conversion failed", ex.Message);
41+
}
42+
43+
[TestMethod]
44+
public void Convert_NarrowingOverflow_StillThrows()
45+
{
46+
var ex = Throws<DbException>(() => ExecuteScalar("select convert(tinyint, 300)"));
47+
Contains("Arithmetic overflow", ex.Message);
48+
}
49+
50+
// --- Date/time × style 0/120/121 -----------------------------------
51+
52+
[TestMethod]
53+
[DataRow(0, "Mar 15 2024 10:20AM")]
54+
[DataRow(120, "2024-03-15 10:20:30")]
55+
[DataRow(121, "2024-03-15 10:20:30.000")]
56+
public void Convert_DateTime_StyleOutputs(int style, string expected) =>
57+
AreEqual(expected, ExecuteScalar(
58+
$"select convert(varchar(30), cast('2024-03-15 10:20:30' as datetime), {style})"));
59+
60+
[TestMethod]
61+
[DataRow(0, "Mar 15 2024 10:21AM")]
62+
[DataRow(120, "2024-03-15 10:21:00")]
63+
[DataRow(121, "2024-03-15 10:21:00.000")]
64+
public void Convert_SmallDateTime_StyleOutputs(int style, string expected) =>
65+
AreEqual(expected, ExecuteScalar(
66+
$"select convert(varchar(30), cast('2024-03-15 10:21:00' as smalldatetime), {style})"));
67+
68+
[TestMethod]
69+
[DataRow(0, "2024-03-15")]
70+
[DataRow(120, "2024-03-15")]
71+
[DataRow(121, "2024-03-15")]
72+
public void Convert_Date_StyleOutputs(int style, string expected) =>
73+
AreEqual(expected, ExecuteScalar(
74+
$"select convert(varchar(30), cast('2024-03-15' as date), {style})"));
75+
76+
[TestMethod]
77+
[DataRow(0, "Mar 15 2024 10:20AM")] // legacy form for explicit style 0
78+
[DataRow(120, "2024-03-15 10:20:30")] // ISO without fractional
79+
[DataRow(121, "2024-03-15 10:20:30.1234567")] // precision-respecting
80+
public void Convert_DateTime2_StyleOutputs(int style, string expected) =>
81+
AreEqual(expected, ExecuteScalar(
82+
$"select convert(varchar(40), cast('2024-03-15 10:20:30.1234567' as datetime2(7)), {style})"));
83+
84+
[TestMethod]
85+
public void Convert_DateTime2_Precision0_Style121_OmitsFractional() =>
86+
AreEqual("2024-03-15 10:20:30", ExecuteScalar(
87+
"select convert(varchar(40), cast('2024-03-15 10:20:30' as datetime2(0)), 121)"));
88+
89+
[TestMethod]
90+
[DataRow(0, "10:20AM")]
91+
[DataRow(120, "10:20:30")]
92+
[DataRow(121, "10:20:30.123")]
93+
public void Convert_Time_StyleOutputs(int style, string expected) =>
94+
AreEqual(expected, ExecuteScalar(
95+
$"select convert(varchar(40), cast('10:20:30.123' as time(3)), {style})"));
96+
97+
[TestMethod]
98+
[DataRow(0, "Mar 15 2024 10:20AM -05:00")]
99+
[DataRow(120, "2024-03-15 10:20:30 -05:00")]
100+
[DataRow(121, "2024-03-15 10:20:30.123 -05:00")]
101+
public void Convert_DateTimeOffset_StyleOutputs(int style, string expected) =>
102+
AreEqual(expected, ExecuteScalar(
103+
$"select convert(varchar(40), cast('2024-03-15 10:20:30.123 -05:00' as datetimeoffset(3)), {style})"));
104+
105+
[TestMethod]
106+
public void Convert_NoStyle_MatchesCastDefault()
107+
{
108+
// No-style CONVERT mirrors CAST: datetime2 stays in ISO form, even
109+
// though explicit style 0 would switch it to legacy.
110+
AreEqual("2024-03-15 10:20:30.1234567", ExecuteScalar(
111+
"select convert(varchar(40), cast('2024-03-15 10:20:30.1234567' as datetime2(7)))"));
112+
AreEqual("Mar 15 2024 10:20AM", ExecuteScalar(
113+
"select convert(varchar(40), cast('2024-03-15 10:20:30' as datetime))"));
114+
}
115+
116+
[TestMethod]
117+
public void Convert_NVarcharTarget_StyleStillApplies() =>
118+
AreEqual("2024-03-15 10:20:30.000", ExecuteScalar(
119+
"select convert(nvarchar(30), cast('2024-03-15 10:20:30' as datetime), 121)"));
120+
121+
// --- Style validation ----------------------------------------------
122+
123+
[TestMethod]
124+
[DataRow("datetime", "'2024-03-15 10:00:00'", "999")]
125+
[DataRow("datetime2", "'2024-03-15 10:00:00'", "999")]
126+
[DataRow("time", "'10:00:00'", "999")]
127+
[DataRow("datetimeoffset", "'2024-03-15 10:00:00 +00:00'", "999")]
128+
[DataRow("date", "'2024-03-15'", "999")]
129+
[DataRow("smalldatetime", "'2024-03-15 10:00:00'", "999")]
130+
public void Convert_BadStyle_RaisesMsg281(string typeName, string sourceLiteral, string style)
131+
{
132+
var ex = Throws<DbException>(() => ExecuteScalar(
133+
$"select convert(varchar(30), cast({sourceLiteral} as {typeName}), {style})"));
134+
AreEqual($"{style} is not a valid style number when converting from {typeName} to a character string.", ex.Message);
135+
}
136+
137+
[TestMethod]
138+
public void Convert_StyleOnNonDateSource_SilentlyIgnored()
139+
{
140+
// Probe-confirmed: real SQL Server happily accepts a style argument
141+
// on int → varchar conversions and just ignores it.
142+
AreEqual("12345", ExecuteScalar("select convert(varchar(30), 12345, 120)"));
143+
AreEqual(42, ExecuteScalar("select convert(int, '42', 1)"));
144+
}
145+
146+
[TestMethod]
147+
public void Convert_StringStyle_RaisesMsg8116()
148+
{
149+
var ex = Throws<DbException>(() => ExecuteScalar(
150+
"select convert(varchar(30), cast('2024-03-15' as datetime), '120')"));
151+
AreEqual("Argument data type varchar is invalid for argument 3 of convert function.", ex.Message);
152+
}
153+
154+
// --- TRY_CONVERT ---------------------------------------------------
155+
156+
[TestMethod]
157+
public void TryConvert_GoodValue_ReturnsValue() =>
158+
AreEqual(42, ExecuteScalar("select try_convert(int, '42')"));
159+
160+
[TestMethod]
161+
public void TryConvert_NullSource_ReturnsNull() =>
162+
IsInstanceOfType<DBNull>(ExecuteScalar("select try_convert(int, null)"));
163+
164+
[TestMethod]
165+
[DataRow("try_convert(int, 'abc')")] // Msg 245
166+
[DataRow("try_convert(int, '42.5')")] // Msg 245
167+
[DataRow("try_convert(tinyint, '300')")] // Msg 244
168+
[DataRow("try_convert(int, '99999999999')")] // Msg 248
169+
[DataRow("try_convert(uniqueidentifier, 'bad')")] // Msg 8169
170+
[DataRow("try_convert(tinyint, 300)")] // Msg 8115 (overflow)
171+
[DataRow("try_convert(int, 'x', 1)")] // probe-confirmed: ignored style + failure → NULL
172+
public void TryConvert_ConversionFailure_ReturnsNull(string expression) =>
173+
IsInstanceOfType<DBNull>(ExecuteScalar($"select {expression}"));
174+
175+
[TestMethod]
176+
public void TryConvert_TruncatesDecimalToInt() =>
177+
AreEqual(42, ExecuteScalar("select try_convert(int, 42.7)"));
178+
179+
[TestMethod]
180+
public void TryConvert_ExplicitConversionNotAllowed_StillThrows()
181+
{
182+
// Msg 529: SQL Server still raises for type pairs where coercion
183+
// is fundamentally disallowed, even under TRY_CONVERT.
184+
var ex = Throws<DbException>(() => ExecuteScalar("select try_convert(date, 0)"));
185+
Contains("Explicit conversion", ex.Message);
186+
}
187+
188+
[TestMethod]
189+
public void TryConvert_NullStyle_ReturnsNull() =>
190+
IsInstanceOfType<DBNull>(ExecuteScalar("select try_convert(int, '42', null)"));
191+
192+
// --- Syntax errors -------------------------------------------------
193+
194+
[TestMethod]
195+
public void Convert_MissingArgs_RaisesSyntaxError()
196+
{
197+
var ex = Throws<DbException>(() => ExecuteScalar("select convert(int)"));
198+
Contains("syntax", ex.Message);
199+
}
200+
201+
[TestMethod]
202+
public void Convert_BadTargetType_RaisesMsg243()
203+
{
204+
var ex = Throws<DbException>(() => ExecuteScalar("select convert(notatype, 1)"));
205+
Contains("notatype", ex.Message);
206+
}
207+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ public static Expression Parse(ParserContext context)
6464
? new LastIdentityExpression(context.Simulation)
6565
: new Value(doubleAtPrefixedString),
6666
ReservedKeyword { Keyword: Keyword.Null } => new Value(),
67-
// LEFT and RIGHT are reserved (for future JOIN support) but
68-
// dispatch as function calls when followed by '('.
69-
ReservedKeyword { Keyword: Keyword.Left or Keyword.Right } reserved => new Reference(reserved.ToString()),
67+
// LEFT, RIGHT, CONVERT, and TRY_CONVERT are reserved keywords
68+
// but dispatch as function calls when followed by '(' — the
69+
// surrounding loop hands the call shape off to ResolveBuiltIn.
70+
ReservedKeyword { Keyword: Keyword.Left or Keyword.Right or Keyword.Convert or Keyword.Try_Convert } reserved => new Reference(reserved.ToString()),
7071
Name name => new Reference(name),
7172
Operator { Character: '(' } => new Parenthesized(context),
7273
_ => throw SimulatedSqlException.SyntaxErrorNear(context)
@@ -186,6 +187,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
186187
},
187188
7 => uppercaseName switch
188189
{
190+
"CONVERT" => new ConvertExpression(context, tryMode: false),
189191
"REPLACE" => new Replace(context),
190192
"REVERSE" => new Reverse(context),
191193
_ => null
@@ -201,6 +203,11 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
201203
"DATALENGTH" => new DataLength(context),
202204
_ => null
203205
},
206+
11 => uppercaseName switch
207+
{
208+
"TRY_CONVERT" => new ConvertExpression(context, tryMode: true),
209+
_ => null
210+
},
204211
13 => uppercaseName switch
205212
{
206213
"IDENT_CURRENT" => new IdentCurrent(context),

SqlServerSimulator/Parser/Expressions/Cast.cs

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,31 @@ public Cast(ParserContext context)
3333
throw SimulatedSqlException.SyntaxErrorNear(context);
3434

3535
var typeName = context.GetNextRequired<Name>();
36+
(this.targetType, this.targetMaxLength) = ParseTargetTypeSpec(context, typeName);
3637

37-
// Optional (N) or (P, S) length/precision specifier — accepted for
38-
// parity with SQL Server syntax. Length is generally not enforced as
39-
// a value-level cap; precision/scale are interpreted by the type.
38+
if (context.Token is not Operator { Character: ')' })
39+
throw SimulatedSqlException.SyntaxErrorNear(context);
40+
}
41+
42+
public override SqlValue Run(Func<List<string>, SqlValue> getColumnValue) =>
43+
ApplyCoercion(source.Run(getColumnValue), this.targetType, this.targetMaxLength);
44+
45+
public override SqlType GetSqlType(Func<List<string>, SqlType> resolveColumnType) => targetType;
46+
47+
#if DEBUG
48+
public override string ToString() => $"CAST({source} AS {targetType})";
49+
#endif
50+
51+
/// <summary>
52+
/// Parses the optional <c>(length)</c> or <c>(precision, scale)</c> spec
53+
/// after a CAST/CONVERT target type name and resolves the type. The caller
54+
/// supplies the already-consumed type-name token; the helper advances past
55+
/// the spec (if any) and leaves <see cref="ParserContext.Token"/> on the
56+
/// first un-consumed token, ready for the wrapping function's closing
57+
/// paren. Errors use Msg 243 / 291 with the CAST-context wording.
58+
/// </summary>
59+
internal static (SqlType targetType, int? targetMaxLength) ParseTargetTypeSpec(ParserContext context, Name typeName)
60+
{
4061
int? declaredMaxLength = null;
4162
int? declaredScale = null;
4263
context.MoveNextRequired();
@@ -45,8 +66,7 @@ public Cast(ParserContext context)
4566
if (context.GetNextRequired() is not Numeric { Value: { IsNull: false } numericValue })
4667
throw SimulatedSqlException.SyntaxErrorNear(context);
4768
declaredMaxLength = numericValue.AsInt32;
48-
var next = context.GetNextRequired();
49-
switch (next)
69+
switch (context.GetNextRequired())
5070
{
5171
case Operator { Character: ',' }:
5272
if (context.GetNextRequired() is not Numeric { Value: { IsNull: false } scaleValue })
@@ -63,21 +83,20 @@ public Cast(ParserContext context)
6383
context.MoveNextRequired();
6484
}
6585

66-
if (context.Token is not Operator { Character: ')' })
67-
throw SimulatedSqlException.SyntaxErrorNear(context);
68-
69-
// Null columnName signals CAST context: errors use Msg 243 (unknown
70-
// type), 291 (length on fixed type), and the "type"/"convert
71-
// specification" wording for Msg 131 size errors.
72-
var (resolved, max) = SqlType.GetByName(typeName, declaredMaxLength, declaredScale, 1, columnName: null);
73-
this.targetType = resolved;
74-
this.targetMaxLength = max;
86+
// Null columnName signals CAST/CONVERT context: errors use Msg 243
87+
// (unknown type), 291 (length on fixed type), and the
88+
// "type"/"convert specification" wording for Msg 131 size errors.
89+
return SqlType.GetByName(typeName, declaredMaxLength, declaredScale, 1, columnName: null);
7590
}
7691

77-
public override SqlValue Run(Func<List<string>, SqlValue> getColumnValue)
92+
/// <summary>
93+
/// Runs the value-level coercion shared by CAST and CONVERT: rejects
94+
/// uniqueidentifier-to-too-narrow-string with the target-specific Msg
95+
/// 8170 / 8115, then delegates to <see cref="SqlValue.CoerceTo"/> and
96+
/// rewraps <see cref="OverflowException"/> as the generic Msg 8115.
97+
/// </summary>
98+
internal static SqlValue ApplyCoercion(SqlValue value, SqlType targetType, int? targetMaxLength)
7899
{
79-
var value = source.Run(getColumnValue);
80-
81100
// uniqueidentifier → too-narrow string: SQL Server raises a target-
82101
// specific error rather than silently truncating. char/varchar use
83102
// Msg 8170 with its dedicated text; nchar/nvarchar use the generic
@@ -86,12 +105,12 @@ public override SqlValue Run(Func<List<string>, SqlValue> getColumnValue)
86105
// NULLs pass through silently.
87106
if (!value.IsNull
88107
&& value.Type == SqlType.UniqueIdentifier
89-
&& this.targetMaxLength is int max
108+
&& targetMaxLength is int max
90109
&& max < 36)
91110
{
92-
if (this.targetType == SqlType.Varchar || this.targetType is CharSqlType)
111+
if (targetType == SqlType.Varchar || targetType is CharSqlType)
93112
throw SimulatedSqlException.InsufficientResultSpaceForUniqueIdentifier();
94-
if (this.targetType == SqlType.NVarchar || this.targetType is NCharSqlType)
113+
if (targetType == SqlType.NVarchar || targetType is NCharSqlType)
95114
throw SimulatedSqlException.ArithmeticOverflow("nvarchar");
96115
}
97116

@@ -104,10 +123,4 @@ public override SqlValue Run(Func<List<string>, SqlValue> getColumnValue)
104123
throw SimulatedSqlException.ArithmeticOverflow(targetType.ToString()!);
105124
}
106125
}
107-
108-
public override SqlType GetSqlType(Func<List<string>, SqlType> resolveColumnType) => targetType;
109-
110-
#if DEBUG
111-
public override string ToString() => $"CAST({source} AS {targetType})";
112-
#endif
113126
}

0 commit comments

Comments
 (0)