Skip to content

Commit cd51a45

Browse files
committed
CONVERT style codes — common subset, bidirectional, plus money formatting. Datetime → string surface gains 11 new styles (1/101 US, 10/110 USA, 12/112 ISO compact, 102 ANSI, 103 UK, 23 date-only ISO, 126/127 ISO 8601) on top of the existing 0/120/121; the 9 date-only-emit styles share a new FormatDateOnlyStyle helper that every date/datetime/smalldatetime/datetime2/datetimeoffset routes through (so CONVERT(varchar, dt, 112) returns '20260513' regardless of source precision, matching probe). Styles 126/127 emit T-separated ISO 8601 with source-precision fractional seconds; 127 on datetimeoffset projects to UTC and emits a trailing Z via FormatDateTimeOffsetIso + a +00:00Z rewrite. String → datetime surface gains a new CoerceStringToDateLikeWithStyle path: per-style DateTime.TryParseExact format-string lists (each with optional trailing-time variants so CONVERT(date, '20260513 14:25:36', 112) works), success re-encodes through datetime2(7) then narrows to target. Parse-failure dispositions are probe-faithful: input that's not a date at all → Msg 241 (general parse fail), input that's a valid date but wrong format for the style → new Msg 9807 ("The input character string does not follow style N…") via new InputCharacterStringStyleMismatch factory. Msg 9807 added to Cast.IsConversionFailure so TRY_CONVERT swallows it. Money → string surface adds CoerceMoneyToStringWithStyle with styles 0 (no commas, 2 decimals — F2), 1 (comma thousands, 2 decimals — N2), 2 (no commas, 4 decimals — F4); smallmoney shares the formatter. ConvertExpression.Run routes by (source category × target category × style present) into the three new paths (date→string, string→date, money→string); all other source-target combinations keep silently ignoring style, matching real SQL Server. Unknown styles within each path raise Msg 281 with the source-family wording (e.g. 99 is not a valid style number when converting from datetime to a character string.). 36 new ConvertTests (datarow-parametrized) cover the new datetime/date/datetime2/datetimeoffset → string styles, the string → date round-trips for 101/112/102/103/23/126/127, the Msg 9807 vs Msg 241 distinction, TRY_CONVERT swallow, money 0/1/2 + smallmoney + negative + Msg 281. CLAUDE.md "Not modeled" entry shrunk to list the residual unsupported style numbers; docs/claude/casting.md gains a full CONVERT style-code section with per-direction tables.
1 parent 5fda360 commit cd51a45

7 files changed

Lines changed: 380 additions & 42 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
160160
- `RANGE BETWEEN <N> PRECEDING` / `<N> FOLLOWING` — real SQL Server gates the numeric-offset RANGE form behind a separately-licensed feature surface and the simulator matches that rejection (Msg 4194). `ROWS` numeric-offset frames ship; both modes support the canonical `UNBOUNDED` / `CURRENT ROW` bounds and the single-bound shorthand (`ROWS UNBOUNDED PRECEDING``ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). The default frame when ORDER BY is present in OVER is `RANGE UNBOUNDED PRECEDING TO CURRENT ROW` (running-total semantic with peer-tie grouping); without ORDER BY, default frame is whole partition. LAST_VALUE ships with the same semantics as real SQL Server (its default frame returns the current row's value or the peer-tie last under RANGE — the intuitive "partition last" form needs explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`).
161161
- Recursive-part feature restrictions (Msg 460 DISTINCT / 461 TOP / 462 OUTER JOIN / 467 aggregate-or-GROUP-BY / 465 ref-in-subquery) — silently accepted with possibly-incorrect semantics rather than raising. Apps that exercise these in real SQL Server hit rejection there too.
162162
- `LIKE` with `COLLATE` override (default collation only).
163-
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121`.
163+
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `1` / `10` / `12` / `23` / `101` / `102` / `103` / `110` / `112` / `120` / `121` / `126` / `127` (datetime ⇄ string, both directions; ISO 8601 + regional formats) and `0` / `1` / `2` (money → string). See [`docs/claude/casting.md`](docs/claude/casting.md).
164164
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
165165
- MERGE source as a CTE-headed SELECT (`USING (WITH cte AS … SELECT …)`) — Selection.Parse's CTE entry doesn't reach the USING-clause grammar; wrap the CTE inside a regular SELECT instead. MERGE inside a CTE body, multi-statement MERGE WHEN-clause bodies, and `MERGE INTO <view>` (real SQL Server's updatable-view MERGE) are also deferred.
166166
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).

SqlServerSimulator.Tests/ConvertTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,110 @@ public void Convert_BadTargetType_RaisesMsg243()
172172
var ex = Throws<DbException>(() => ExecuteScalar("select convert(notatype, 1)"));
173173
Contains("notatype", ex.Message);
174174
}
175+
176+
[TestMethod]
177+
[DataRow(1, "05/13/26")]
178+
[DataRow(101, "05/13/2026")]
179+
[DataRow(10, "05-13-26")]
180+
[DataRow(110, "05-13-2026")]
181+
[DataRow(12, "260513")]
182+
[DataRow(112, "20260513")]
183+
[DataRow(102, "2026.05.13")]
184+
[DataRow(103, "13/05/2026")]
185+
[DataRow(23, "2026-05-13")]
186+
[DataRow(126, "2026-05-13T14:25:36.790")]
187+
[DataRow(127, "2026-05-13T14:25:36.790")]
188+
public void Convert_DateTimeStyle(int style, string expected) =>
189+
AreEqual(expected, ExecuteScalar($"declare @d datetime = '2026-05-13T14:25:36.789'; select convert(varchar(40), @d, {style})"));
190+
191+
[TestMethod]
192+
[DataRow(1, "05/13/26")]
193+
[DataRow(101, "05/13/2026")]
194+
[DataRow(112, "20260513")]
195+
[DataRow(23, "2026-05-13")]
196+
[DataRow(126, "2026-05-13")]
197+
public void Convert_DateStyle(int style, string expected) =>
198+
AreEqual(expected, ExecuteScalar($"select convert(varchar(40), cast('2026-05-13' as date), {style})"));
199+
200+
[TestMethod]
201+
public void Convert_DateTime2Style126_FullPrecision() =>
202+
AreEqual("2026-05-13T14:25:36.1234567", ExecuteScalar(
203+
"select convert(varchar(40), cast('2026-05-13T14:25:36.1234567' as datetime2(7)), 126)"));
204+
205+
[TestMethod]
206+
public void Convert_DateTimeOffsetStyle126_PreservesOffset() =>
207+
AreEqual("2026-05-13T14:25:36.1234567+05:30", ExecuteScalar(
208+
"select convert(varchar(40), cast('2026-05-13T14:25:36.1234567+05:30' as datetimeoffset(7)), 126)"));
209+
210+
[TestMethod]
211+
public void Convert_DateTimeOffsetStyle127_ProjectsToUtcWithZ() =>
212+
AreEqual("2026-05-13T08:55:36.1234567Z", ExecuteScalar(
213+
"select convert(varchar(40), cast('2026-05-13T14:25:36.1234567+05:30' as datetimeoffset(7)), 127)"));
214+
215+
[TestMethod]
216+
[DataRow(101, "05/13/2026")]
217+
[DataRow(112, "20260513")]
218+
[DataRow(102, "2026.05.13")]
219+
[DataRow(103, "13/05/2026")]
220+
[DataRow(23, "2026-05-13")]
221+
public void Convert_StringToDateWithStyle(int style, string input) =>
222+
AreEqual(new DateTime(2026, 5, 13), ExecuteScalar($"select convert(date, '{input}', {style})"));
223+
224+
[TestMethod]
225+
public void Convert_StringToDateTime2_Style126() =>
226+
AreEqual(new DateTime(2026, 5, 13, 14, 25, 36, 789), ExecuteScalar(
227+
"select convert(datetime2(3), '2026-05-13T14:25:36.789', 126)"));
228+
229+
[TestMethod]
230+
public void Convert_StringToDateTime2_Style127WithZ() =>
231+
AreEqual(new DateTime(2026, 5, 13, 14, 25, 36, 789), ExecuteScalar(
232+
"select convert(datetime2(3), '2026-05-13T14:25:36.789Z', 127)"));
233+
234+
[TestMethod]
235+
public void Convert_StringToDate_StyleMismatchRaisesMsg9807()
236+
{
237+
var ex = Throws<DbException>(() => ExecuteScalar(
238+
"select convert(date, '05/13/2026', 112)"));
239+
AreEqual("9807", ex.Data["HelpLink.EvtID"]);
240+
Contains("style 112", ex.Message);
241+
}
242+
243+
[TestMethod]
244+
public void Convert_StringToDate_NotADate_RaisesMsg241()
245+
{
246+
var ex = Throws<DbException>(() => ExecuteScalar(
247+
"select convert(date, 'nope', 112)"));
248+
AreEqual("241", ex.Data["HelpLink.EvtID"]);
249+
}
250+
251+
[TestMethod]
252+
public void TryConvert_StringToDate_StyleMismatchReturnsNull() =>
253+
IsInstanceOfType<DBNull>(ExecuteScalar("select try_convert(date, '05/13/2026', 112)"));
254+
255+
[TestMethod]
256+
public void TryConvert_StringToDate_NotADateReturnsNull() =>
257+
IsInstanceOfType<DBNull>(ExecuteScalar("select try_convert(date, 'nope', 112)"));
258+
259+
[TestMethod]
260+
[DataRow(0, "1234567.89")]
261+
[DataRow(1, "1,234,567.89")]
262+
[DataRow(2, "1234567.8910")]
263+
public void Convert_MoneyStyle(int style, string expected) =>
264+
AreEqual(expected, ExecuteScalar($"select convert(varchar(40), cast(1234567.891 as money), {style})"));
265+
266+
[TestMethod]
267+
public void Convert_MoneyStyle_Negative()
268+
=> AreEqual("-12.50", ExecuteScalar("select convert(varchar(40), cast(-12.5 as money), 0)"));
269+
270+
[TestMethod]
271+
public void Convert_SmallMoneyStyle1()
272+
=> AreEqual("1,234.56", ExecuteScalar("select convert(varchar(40), cast(1234.56 as smallmoney), 1)"));
273+
274+
[TestMethod]
275+
public void Convert_MoneyStyle_UnknownStyle_RaisesMsg281()
276+
{
277+
var ex = Throws<DbException>(() => ExecuteScalar("select convert(varchar(40), cast(1.5 as money), 99)"));
278+
AreEqual("281", ex.Data["HelpLink.EvtID"]);
279+
Contains("money", ex.Message);
280+
}
175281
}

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ internal static SimulatedSqlException InvalidTimeZoneParameter(string name) =>
167167
internal static SimulatedSqlException ConversionFailedDateTimeFromString() =>
168168
new("Conversion failed when converting date and/or time from character string.", 241, 16, 1);
169169

170+
/// <summary>
171+
/// Mimics SQL Server error 9807: <c>CONVERT(date-like, '...', N)</c>
172+
/// received a string that parses as a valid date but doesn't follow the
173+
/// specific style number's format. Distinct from Msg 241 (general bad
174+
/// format) — apps that explicitly check for style-specific input shape
175+
/// can distinguish "wrong format" from "not a date at all". Probe-
176+
/// confirmed verbatim against SQL Server 2025 (2026-05-13).
177+
/// </summary>
178+
internal static SimulatedSqlException InputCharacterStringStyleMismatch(int style) =>
179+
new($"The input character string does not follow style {style}, either change the input character string or use a different style.", 9807, 16, 1);
180+
170181
/// <summary>
171182
/// Mimics SQL Server error 295: the <c>smalldatetime</c>-specific
172183
/// counterpart of <see cref="ConversionFailedDateTimeFromString"/>. SQL

SqlServerSimulator/Parser/Expressions/Cast.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,5 +248,6 @@ or 295 // ConversionFailedSmallDateTimeFromString
248248
or 8114 // ConvertingDataTypeError
249249
or 8115 // ArithmeticOverflow
250250
or 8169 // ConversionFailedFromStringToUniqueIdentifier
251-
or 8170; // InsufficientResultSpaceForUniqueIdentifier
251+
or 8170 // InsufficientResultSpaceForUniqueIdentifier
252+
or 9807; // InputCharacterStringStyleMismatch
252253
}

SqlServerSimulator/Parser/Expressions/Convert.cs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,29 @@ public override SqlValue Run(RuntimeContext runtime)
8383

8484
try
8585
{
86-
// Style is meaningful only for date-like sources targeting a
87-
// character string; everywhere else SQL Server silently
88-
// ignores it.
89-
return styleCode is int sc
90-
&& sourceValue.Type.Category == SqlTypeCategory.DateTime
91-
&& this.targetType.Category == SqlTypeCategory.String
92-
? sourceValue.CoerceDateTimeToStringWithStyle(this.targetType, sc)
93-
: Cast.ApplyCoercion(sourceValue, this.targetType, this.targetMaxLength);
86+
// Style is meaningful for: date-like → string (formatted output),
87+
// string → date-like (style-aware input parser), and money →
88+
// string (currency formatting). Everywhere else SQL Server
89+
// silently ignores it.
90+
if (styleCode is int sc)
91+
{
92+
if (sourceValue.Type.Category == SqlTypeCategory.DateTime
93+
&& this.targetType.Category == SqlTypeCategory.String)
94+
{
95+
return sourceValue.CoerceDateTimeToStringWithStyle(this.targetType, sc);
96+
}
97+
if (sourceValue.Type.Category == SqlTypeCategory.String
98+
&& this.targetType.Category == SqlTypeCategory.DateTime)
99+
{
100+
return sourceValue.CoerceStringToDateLikeWithStyle(this.targetType, sc);
101+
}
102+
if (sourceValue.Type.Category == SqlTypeCategory.Money
103+
&& this.targetType.Category == SqlTypeCategory.String)
104+
{
105+
return sourceValue.CoerceMoneyToStringWithStyle(this.targetType, sc);
106+
}
107+
}
108+
return Cast.ApplyCoercion(sourceValue, this.targetType, this.targetMaxLength);
94109
}
95110
catch (SimulatedSqlException ex) when (this.tryMode && Cast.IsConversionFailure(ex.Number))
96111
{

0 commit comments

Comments
 (0)