Skip to content

Commit 2a1dae6

Browse files
committed
Improved coverage of CONVERT date/time styles.
1 parent 3a5302a commit 2a1dae6

4 files changed

Lines changed: 120 additions & 53 deletions

File tree

SqlServerSimulator.Tests/ConvertTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,44 @@ public void Convert_DateTimeOffsetStyle127_ProjectsToUtcWithZ() =>
221221
public void Convert_StringToDateWithStyle(int style, string input) =>
222222
AreEqual(new DateTime(2026, 5, 13), ExecuteScalar($"select convert(date, '{input}', {style})"));
223223

224+
// General styles run SQL Server's flexible parser: separators (/ - .) are
225+
// interchangeable and ISO year-first is accepted, with the trailing pair
226+
// ordered by the style family. Each input below resolves to 2026-05-13.
227+
[TestMethod]
228+
[DataRow(101, "2026-05-13")] // mdy + ISO year-first (the AdventureWorks shape)
229+
[DataRow(101, "2026/05/13")]
230+
[DataRow(101, "05-13-2026")] // mdy, dash where the style documents slash
231+
[DataRow(0, "2026-05-13")] // default style, ISO
232+
[DataRow(120, "2026-05-13")] // ODBC canonical
233+
[DataRow(102, "2026/05/13")] // ymd, slash where the style documents dot
234+
[DataRow(110, "05/13/2026")] // mdy, slash where the style documents dash
235+
public void Convert_StringToDate_GeneralStyle_SeparatorAndIsoFlexible(int style, string input) =>
236+
AreEqual(new DateTime(2026, 5, 13), ExecuteScalar($"select convert(date, '{input}', {style})"));
237+
238+
[TestMethod]
239+
[DataRow(103, "2003-04-05")] // dmy + year-first → trailing pair is day-month → May 4
240+
[DataRow(104, "2003.04.05")]
241+
public void Convert_StringToDate_DmyYearFirst_OrdersTrailingDayMonth(int style, string input) =>
242+
AreEqual(new DateTime(2003, 5, 4), ExecuteScalar($"select convert(date, '{input}', {style})"));
243+
244+
[TestMethod]
245+
[DataRow("Apr 5 2003")]
246+
[DataRow("April 5, 2003")]
247+
[DataRow("5 Apr 2003")]
248+
public void Convert_StringToDate_MonthNameForms_StyleIndependent(string input) =>
249+
AreEqual(new DateTime(2003, 4, 5), ExecuteScalar($"select convert(date, '{input}', 101)"));
250+
251+
[TestMethod]
252+
public void Convert_StringToDateTime_GeneralStyle_AmPmAndSpaceTime()
253+
{
254+
AreEqual(new DateTime(2003, 1, 1, 23, 59, 0), ExecuteScalar("select convert(datetime, 'Jan 1 2003 11:59PM', 101)"));
255+
AreEqual(new DateTime(2003, 4, 5, 10, 20, 30), ExecuteScalar("select convert(datetime, '2003-04-05 10:20:30', 101)"));
256+
}
257+
258+
[TestMethod]
259+
public void Convert_StringToDateTime_GeneralStyle_BareTimeAnchorsTo1900() =>
260+
AreEqual(new DateTime(1900, 1, 1, 10, 20, 30), ExecuteScalar("select convert(datetime, '10:20:30', 101)"));
261+
224262
[TestMethod]
225263
public void Convert_StringToDateTime2_Style126() =>
226264
AreEqual(new DateTime(2026, 5, 13, 14, 25, 36, 789), ExecuteScalar(

SqlServerSimulator/Storage/SqlValue.Coerce.cs

Lines changed: 75 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -434,78 +434,102 @@ internal SqlValue CoerceMoneyToStringWithStyle(SqlType target, int style)
434434
}
435435

436436
/// <summary>
437-
/// String → date-like coercion with a CONVERT style hint. Each style
438-
/// pins the acceptable input format(s); a string that matches the
439-
/// style's format parses; a string that's a valid date by some OTHER
440-
/// format raises Msg 9807 (style mismatch); a string that's not a
441-
/// valid date by any format raises Msg 241 (general parse failure).
442-
/// Probe-confirmed against SQL Server 2025 (2026-05-13): all three
443-
/// disposition paths land on the matching Msg numbers / wording.
437+
/// String → date-like coercion with a CONVERT style hint, mirroring SQL
438+
/// Server's flexible string-to-datetime parser (probed against SQL Server
439+
/// 2025, 2026-05-27).
444440
/// </summary>
445441
/// <remarks>
446-
/// Styles supported here: <c>1</c>/<c>101</c>, <c>10</c>/<c>110</c>,
447-
/// <c>12</c>/<c>112</c>, <c>102</c>, <c>103</c>, <c>23</c>,
448-
/// <c>126</c>/<c>127</c> (ISO 8601 with optional fractional seconds).
449-
/// Other styles fall through to the default style-less parser via
450-
/// caller dispatch.
442+
/// <para>
443+
/// <strong>Strict styles</strong> (<c>12</c> <c>yymmdd</c>, <c>112</c>
444+
/// <c>yyyymmdd</c>, <c>23</c> <c>yyyy-mm-dd</c>, <c>126</c>/<c>127</c>
445+
/// ISO 8601) pin an exact format; a string that's a valid date by some
446+
/// OTHER format raises Msg 9807, a non-date raises Msg 241.
447+
/// </para>
448+
/// <para>
449+
/// <strong>General styles</strong> route through .NET's flexible parser:
450+
/// separators (<c>/ - .</c>) are interchangeable, and numeric / ISO
451+
/// year-first / month-name forms plus an optional trailing time all parse.
452+
/// The only family distinction is date-part order for ambiguous numeric
453+
/// dates — the dmy set (<see cref="IsDayMonthYearStyle"/>) reads day-first
454+
/// (<c>en-GB</c>), every other style month-first (<c>en-US</c>); a leading
455+
/// 4-digit token is the year, with the trailing pair following the family
456+
/// order. Known leniency divergences: the 2-digit-vs-4-digit-year
457+
/// with/without-century restriction isn't enforced, and a <c>T</c>-separated
458+
/// time is accepted under general styles (real SQL Server reserves it for
459+
/// 126/127). See [`docs/claude/casting.md`].
460+
/// </para>
451461
/// </remarks>
452462
internal SqlValue CoerceStringToDateLikeWithStyle(SqlType target, int style)
453463
{
454464
var input = this.AsString;
455-
var formats = StyleSpecificDateFormats(style);
456-
if (formats is null)
465+
466+
var strictFormats = StrictStyleDateFormats(style);
467+
if (strictFormats is not null)
468+
{
469+
// AssumeUniversal + AdjustToUniversal keeps the wall-clock reading
470+
// for style 127's `Z` UTC suffix regardless of host timezone.
471+
const DateTimeStyles StrictParseStyles = DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
472+
if (DateTime.TryParseExact(input, strictFormats, CultureInfo.InvariantCulture, StrictParseStyles, out var exact))
473+
return FromDateTime2(SqlType.GetDateTime2(7), exact).CoerceTo(target);
474+
if (DateTime.TryParse(input, CultureInfo.InvariantCulture, StrictParseStyles, out _))
475+
throw SimulatedSqlException.InputCharacterStringStyleMismatch(style);
476+
throw SimulatedSqlException.ConversionFailedDateTimeFromString();
477+
}
478+
479+
var dayMonthYear = IsDayMonthYearStyle(style);
480+
var culture = dayMonthYear ? GbCulture : UsCulture;
481+
const DateTimeStyles ParseStyles = DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.NoCurrentDateDefault;
482+
483+
// Separatorless yyyyMMdd is accepted under every general style but
484+
// isn't recognized by the flexible parser.
485+
if (DateTime.TryParseExact(input.Trim(), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var basic))
486+
return FromDateTime2(SqlType.GetDateTime2(7), basic).CoerceTo(target);
487+
488+
// dmy + year-first: the flexible en-GB parse reads a leading 4-digit
489+
// token then month-first, so force day-first on the trailing pair to
490+
// match SQL Server (e.g. style 103 reads '2003-04-05' as 2003-05-04).
491+
if (dayMonthYear
492+
&& DateTime.TryParseExact(input.Trim(), DayMonthYearFirstFormats, CultureInfo.InvariantCulture, ParseStyles, out var dmyYearFirst))
457493
{
458-
// Style isn't in the parser's known set; fall back to default
459-
// style-less parsing. Matches SQL Server's "silently ignore
460-
// unrecognized style on string source" behavior for the styles
461-
// that lack a dedicated input parser (e.g. styles only meaningful
462-
// on output, like style 0 with mixed-type sources).
463-
return this.CoerceTo(target);
494+
return FromDateTime2(SqlType.GetDateTime2(7), dmyYearFirst).CoerceTo(target);
464495
}
465-
// AssumeUniversal + AdjustToUniversal keeps the wall-clock reading
466-
// when the input carries a `Z` suffix (style 127's UTC form): the
467-
// parser would otherwise reinterpret `Z` as "convert to local
468-
// timezone", which on a non-UTC host (e.g. Windows in PT) returns a
469-
// value offset from the input. Real SQL Server's datetime2 target
470-
// has no offset surface, so it preserves the literal wall-clock
471-
// time verbatim — those flags get us the same result deterministically
472-
// regardless of host timezone.
473-
const DateTimeStyles ParseStyles = DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
474-
if (DateTime.TryParseExact(input, formats, CultureInfo.InvariantCulture, ParseStyles, out var parsed))
496+
497+
if (DateTime.TryParse(input, culture, ParseStyles, out var parsed))
475498
{
476-
// Re-encode via the simulator's own datetime2(7) path so target-
477-
// specific narrowing (smalldatetime range, datetime2 precision,
478-
// date-only truncation) goes through one route.
499+
// A bare time (no date component) anchors to 1900-01-01 — NoCurrentDateDefault
500+
// leaves the date at 0001-01-01, which is below the datetime range anyway.
501+
if (parsed is { Year: 1, Month: 1, Day: 1 })
502+
parsed = DateTimeSqlType.BaseDate.Add(parsed.TimeOfDay);
479503
return FromDateTime2(SqlType.GetDateTime2(7), parsed).CoerceTo(target);
480504
}
481-
// Style didn't match. Distinguish "parseable as some date" (Msg 9807)
482-
// from "not a date at all" (Msg 241) by trying the default parser.
483505
if (DateTime.TryParse(input, CultureInfo.InvariantCulture, ParseStyles, out _))
484506
throw SimulatedSqlException.InputCharacterStringStyleMismatch(style);
485507
throw SimulatedSqlException.ConversionFailedDateTimeFromString();
486508
}
487509

510+
private static readonly CultureInfo UsCulture = CultureInfo.GetCultureInfo("en-US");
511+
512+
private static readonly CultureInfo GbCulture = CultureInfo.GetCultureInfo("en-GB");
513+
514+
private static readonly string[] DayMonthYearFirstFormats = ["yyyy/d/M", "yyyy-d-M", "yyyy.d.M"];
515+
488516
/// <summary>
489-
/// Returns the input-format strings accepted by <see cref="CoerceStringToDateLikeWithStyle"/>
490-
/// for the given style code, or <see langword="null"/> when the style
491-
/// has no dedicated input parser (caller falls back to the default
492-
/// style-less parser). Each style allows the trailing-time portion as
493-
/// an optional extra: real SQL Server accepts <c>'20260513 14:25:36'</c>
494-
/// under style 112 by parsing the date portion against style and the
495-
/// time portion as free-form — the alternative formats here capture
496-
/// that "date + optional time" shape.
517+
/// The day-month-year CONVERT styles. Every other (general) style orders
518+
/// ambiguous numeric dates month-first.
497519
/// </summary>
498-
private static string[]? StyleSpecificDateFormats(int style) => style switch
520+
private static bool IsDayMonthYearStyle(int style) =>
521+
style is 3 or 4 or 5 or 13 or 14 or 103 or 104 or 105 or 113 or 114 or 130 or 131;
522+
523+
/// <summary>
524+
/// Exact format(s) for the strict CONVERT styles (basic <c>yymmdd</c> /
525+
/// <c>yyyymmdd</c> and the ISO 8601 forms, each with an optional trailing
526+
/// time), or null for the general flexible styles.
527+
/// </summary>
528+
private static string[]? StrictStyleDateFormats(int style) => style switch
499529
{
500-
1 => ["MM/dd/yy", "MM/dd/yy HH:mm:ss", "MM/dd/yy HH:mm:ss.fff"],
501-
101 => ["MM/dd/yyyy", "MM/dd/yyyy HH:mm:ss", "MM/dd/yyyy HH:mm:ss.fff"],
502-
10 => ["MM-dd-yy", "MM-dd-yy HH:mm:ss", "MM-dd-yy HH:mm:ss.fff"],
503-
110 => ["MM-dd-yyyy", "MM-dd-yyyy HH:mm:ss", "MM-dd-yyyy HH:mm:ss.fff"],
504530
12 => ["yyMMdd", "yyMMdd HH:mm:ss", "yyMMdd HH:mm:ss.fff"],
505-
112 => ["yyyyMMdd", "yyyyMMdd HH:mm:ss", "yyyyMMdd HH:mm:ss.fff"],
506-
102 => ["yyyy.MM.dd", "yyyy.MM.dd HH:mm:ss", "yyyy.MM.dd HH:mm:ss.fff"],
507-
103 => ["dd/MM/yyyy", "dd/MM/yyyy HH:mm:ss", "dd/MM/yyyy HH:mm:ss.fff"],
508531
23 => ["yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss.fff", "yyyy-MM-dd HH:mm:ss.fffffff"],
532+
112 => ["yyyyMMdd", "yyyyMMdd HH:mm:ss", "yyyyMMdd HH:mm:ss.fff"],
509533
126 or 127 =>
510534
[
511535
"yyyy-MM-ddTHH:mm:ss",

docs/claude/casting.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,12 @@ Fractional-second separator follows the **source family**: legacy `datetime` / `
8181

8282
**Time-only source hour-padding quirk**: styles 0/9/100/109 emit single-digit hour WITHOUT leading-space padding (`2:25PM`), but styles 22/130/131 DO pad (` 2:25:36 PM`) — verified against SQL Server 2025. The rationale isn't documented; the simulator mirrors it.
8383

84-
**String → date-like** (`SqlValue.CoerceStringToDateLikeWithStyle`): style-aware parser hosts a list of `DateTime.TryParseExact` format strings per style. On parse success, re-encodes through `datetime2(7)` and narrows to the target. On parse failure: if the same input parses under the default style-less parser, raises **Msg 9807** (`"The input character string does not follow style N, …"`); otherwise raises **Msg 241** (`"Conversion failed when converting date and/or time from character string."`). `TRY_CONVERT` swallows both. Currently supports the same 1/10/12/23/101/102/103/110/112/126/127 input forms as the inverse direction; the wider style table from the output direction (styles 2/3/4/5/6/7/11/etc.) doesn't have input-direction parsers and falls through to default parsing — `CONVERT(date, '13.05.2024', 104)` parses via the default ISO parser instead of the German-style format. Minor pre-existing gap; expand on demand.
84+
**String → date-like** (`SqlValue.CoerceStringToDateLikeWithStyle`): mirrors SQL Server's flexible string-to-datetime parser (probed against SQL Server 2025, 2026-05-27). On success re-encodes through `datetime2(7)` and narrows to the target; on failure, an input that's a valid date by some other format raises **Msg 9807** (`"The input character string does not follow style N, …"`), a non-date raises **Msg 241**. `TRY_CONVERT` swallows both. Two style classes:
85+
86+
- **Strict styles**`12` (`yymmdd`), `112` (`yyyymmdd`), `23` (`yyyy-mm-dd`), `126`/`127` (ISO 8601 with `T`): exact `TryParseExact` format match, no separator flexibility. `CONVERT(date, '05/13/2026', 112)` → Msg 9807.
87+
- **General styles** (everything else) — route through `DateTime.TryParse` with a family culture. Separators (`/ - .`) are interchangeable; numeric, ISO year-first, and English month-name forms (`Apr 5 2003`, `April 5, 2003`, `5 Apr 2003`) plus optional trailing time / AM-PM / bare time (anchored to 1900-01-01) all parse. The **only** family distinction is date-part order for ambiguous numeric dates: the dmy set (`3`/`4`/`5`/`13`/`14`/`103`/`104`/`105`/`113`/`114`/`130`/`131``en-GB`) reads day-first, every other style (→ `en-US`) month-first. A leading 4-digit token is the year, trailing pair in family order — so `2003-04-05` is Apr-5 under style 101 but May-4 under 103 (a 3-format `yyyy{sep}d{sep}M` pre-check supplies the dmy year-first ordering `TryParse` won't). Separatorless `yyyyMMdd` is accepted under every general style.
88+
89+
**Known leniency divergences** (simulator accepts more than the live server, low-value edges): the 2-digit-vs-4-digit-year with/without-century restriction isn't enforced (`CONVERT(datetime, '04/05/03', 101)` succeeds; live rejects), and a `T`-separated time is accepted under general styles (live reserves `T` for 126/127, raising out-of-range otherwise).
8590

8691
**Money → string** (`SqlValue.CoerceMoneyToStringWithStyle`):
8792

docs/claude/xml.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Backs `.value()` / `.nodes()` / `.query()` / `.exist()`. Covers the subset SQL S
5858
### Divergences
5959

6060
- Only the path subset above is modeled. FLWOR, arithmetic / comparison / boolean XQuery operators, `local-name()`-style functions in the source text, and constructors are not — they'd surface as malformed XPath or wrong results rather than a clean error.
61-
- `.value()` casts go through the standard string→type coercion, which inherits the simulator's CONVERT limitations — notably **`CONVERT(datetime, '<yyyy-mm-dd>', 101)` rejects year-first ISO strings the live server accepts under mdy-family styles** (Msg 9807). This blocks the AdventureWorks `vJobCandidateEducation` / `vJobCandidateEmployment` / `vPersonDemographics` views at the downstream `CONVERT`, not at the XML extraction. Tracked as a CONVERT date-style gap, separate from XML.
61+
- `.value()` casts go through the standard string→type coercion (`casting.md`'s flexible string→date-like parser), so the AdventureWorks `vJobCandidateEducation` / `vJobCandidateEmployment` / `vPersonDemographics` views — which wrap `.value()` date strings in `CONVERT(datetime, …, 101)` — now resolve.
6262

6363
## Catalog views in `BuiltInResources.cs`
6464

0 commit comments

Comments
 (0)