Skip to content

Commit 432e241

Browse files
committed
Date-construction scalar functions: DATEFROMPARTS, DATETIMEFROMPARTS, DATETIME2FROMPARTS, DATETIMEOFFSETFROMPARTS, SMALLDATETIMEFROMPARTS, TIMEFROMPARTS, EOMONTH. Shared NULL-propagation + Msg 289 path with type-specific State numbers; precision arg constant-folded at parse time so '-1' raises Msg 1002 and NULL raises Msg 10760; EOMONTH always returns date and silently treats NULL month_offset as zero (probe-confirmed quirk).
1 parent ac1183c commit 432e241

6 files changed

Lines changed: 630 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ String concatenation via `+` is **NULL-propagating** (matches default `CONCAT_NU
258258

259259
`Subtract` / `Multiply` / etc. on string operands still raise `NotSupportedException` (real SQL Server: Msg 402 for `-`, Msg 8117 for `*` / `/` / `%`). Not a fidelity priority — apps don't use those shapes intentionally.
260260

261+
### Date-construction scalar functions: the `*FROMPARTS` family + `EOMONTH`
262+
All six builders — `DATEFROMPARTS`, `DATETIMEFROMPARTS`, `DATETIME2FROMPARTS`, `DATETIMEOFFSETFROMPARTS`, `SMALLDATETIMEFROMPARTS`, `TIMEFROMPARTS` — share one runtime path: NULL on any non-precision argument propagates to NULL; non-int operands coerce through the existing CAST machinery (decimal / string / bigint inputs all accepted, probe-confirmed against SQL Server 2025 on 2026-05-09); out-of-range values raise **Msg 289** with the type-specific State number (1=date, 2=time, 3=datetime, 5=datetime2, 6=datetimeoffset) and verbatim text `"Cannot construct data type {type}, some of the arguments have values which are not valid."`
263+
264+
The variable-precision builders (`datetime2` / `datetimeoffset` / `time`) accept the precision arg as the last position and require it to be an integer constant or constant expression. The simulator extracts it at parse time by evaluating the parsed sub-expression with a NULL-returning column resolver — so literal `1+2` folds to `3`, but a column reference degrades to NULL and surfaces as **Msg 10760** (`"Scale argument is not valid. Valid expressions for data type {type} scale argument are integer constants and integer constant expressions."`). Out-of-`[0, 7]` precision raises **Msg 1002** with the standard `"Specified scale {N} is invalid."` wording. Result type carries the captured precision: `DATETIME2FROMPARTS(..., 3)``datetime2(3)`.
265+
266+
`DATETIMEFROMPARTS` ms 999 with hour 23 / minute 59 / second 59 rolls to the next day via legacy `datetime`'s 1/300s tick rounding (probe-confirmed). `DATETIMEOFFSETFROMPARTS` enforces sign-consistency between `hour_offset` and `minute_offset` (mixed signs raise Msg 289 State 6) and a |offset| ≤ 14:00 cap.
267+
268+
`EOMONTH(start_date [, month_offset])` always returns `date` regardless of input type — `date` / `datetime` / `datetime2` / `datetimeoffset` / `smalldatetime` / string-literal inputs all surface as date in the output. **Quirk** worth knowing: a NULL `month_offset` is silently treated as zero (no shift), unlike NULL `start_date` which propagates. Probe-confirmed against SQL Server 2025.
269+
270+
`DatePartsBuilder` (`Parser/Expressions/`) is a single class with a `DatePartsBuilderKind` discriminator covering all six builders; `EOMonth` lives in its own file because its argument shape (date input + optional int offset) is structurally different from the parts-list pattern.
271+
261272
### Constraints
262273
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
263274
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// The six SQL Server <c>*FROMPARTS</c> date/time builders plus
8+
/// <c>EOMONTH</c>. Probe-anchored against SQL Server 2025 (2026-05-09).
9+
/// </summary>
10+
[TestClass]
11+
public sealed class DatePartsBuilderTests
12+
{
13+
[TestMethod]
14+
[DataRow("datefromparts(2026, 5, 9)", "2026-05-09")]
15+
[DataRow("datefromparts(2024, 2, 29)", "2024-02-29")]
16+
[DataRow("datefromparts(1, 1, 1)", "0001-01-01")]
17+
[DataRow("datefromparts(9999, 12, 31)", "9999-12-31")]
18+
public void DateFromParts_Valid(string expression, string expectedIso) =>
19+
AreEqual(DateTime.Parse(expectedIso), ExecuteScalar($"select {expression}"));
20+
21+
[TestMethod]
22+
[DataRow("datefromparts(null, 5, 9)")]
23+
[DataRow("datefromparts(2026, null, 9)")]
24+
[DataRow("datefromparts(2026, 5, null)")]
25+
public void DateFromParts_NullArgPropagates(string expression) =>
26+
IsInstanceOfType<DBNull>(ExecuteScalar($"select {expression}"));
27+
28+
[TestMethod]
29+
[DataRow("datefromparts(2026, 13, 1)")]
30+
[DataRow("datefromparts(2026, 0, 1)")]
31+
[DataRow("datefromparts(2026, 2, 30)")]
32+
[DataRow("datefromparts(2025, 2, 29)")]
33+
[DataRow("datefromparts(0, 1, 1)")]
34+
[DataRow("datefromparts(10000, 1, 1)")]
35+
[DataRow("datefromparts(2026, 1, -1)")]
36+
public void DateFromParts_Invalid_RaisesMsg289(string expression) =>
37+
AssertSqlError($"select {expression}", 289, "Cannot construct data type date, some of the arguments have values which are not valid.");
38+
39+
[TestMethod]
40+
[DataRow("datefromparts(2026.0, 5, 9)")]
41+
[DataRow("datefromparts('2026', '5', '9')")]
42+
[DataRow("datefromparts(cast(2026 as bigint), 5, 9)")]
43+
public void DateFromParts_ImplicitlyCoercesArgs(string expression) =>
44+
AreEqual(new DateTime(2026, 5, 9), ExecuteScalar($"select {expression}"));
45+
46+
/// <summary>ms=0 round-trips cleanly through datetime's 1/300s tick rounding.</summary>
47+
[TestMethod]
48+
public void DateTimeFromParts_Valid() =>
49+
AreEqual(new DateTime(2026, 5, 9, 12, 34, 56),
50+
ExecuteScalar("select datetimefromparts(2026, 5, 9, 12, 34, 56, 0)"));
51+
52+
[TestMethod]
53+
public void DateTimeFromParts_FractionalMs_RoundsTo1_300sTick()
54+
{
55+
// 789 ms → rounds to the nearest 1/300s tick. Test that the result
56+
// is within ~1 tick of the requested value to confirm the rounding
57+
// path is used (vs. straight-passthrough).
58+
var result = (DateTime)ExecuteScalar("select datetimefromparts(2026, 5, 9, 12, 34, 56, 789)")!;
59+
var diff = Math.Abs((result - new DateTime(2026, 5, 9, 12, 34, 56, 789)).Ticks);
60+
IsLessThan(TimeSpan.TicksPerMillisecond * 4, diff, $"datetime should round to 1/300s tick (within ~3.33 ms); got diff {diff} ticks");
61+
}
62+
63+
/// <summary>datetime's 1/300s rounding pushes ms 999 at 23:59:59 into the next day.</summary>
64+
[TestMethod]
65+
public void DateTimeFromParts_Ms999Rolls() =>
66+
AreEqual(new DateTime(2026, 5, 10), ExecuteScalar("select datetimefromparts(2026, 5, 9, 23, 59, 59, 999)"));
67+
68+
[TestMethod]
69+
[DataRow("datetimefromparts(2026, 5, 9, 24, 0, 0, 0)")]
70+
[DataRow("datetimefromparts(2026, 5, 9, 0, 60, 0, 0)")]
71+
[DataRow("datetimefromparts(2026, 5, 9, 0, 0, 0, 1000)")]
72+
public void DateTimeFromParts_Invalid_RaisesMsg289(string expression) =>
73+
AssertSqlError($"select {expression}", 289, "Cannot construct data type datetime, some of the arguments have values which are not valid.");
74+
75+
[TestMethod]
76+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 1234567, 7)", 7)]
77+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 123, 3)", 3)]
78+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 0, 0)", 0)]
79+
public void DateTime2FromParts_PrecisionRespected(string expression, int precision)
80+
{
81+
var result = (DateTime)ExecuteScalar($"select {expression}")!;
82+
AreEqual(2026, result.Year);
83+
AreEqual(5, result.Month);
84+
AreEqual(9, result.Day);
85+
AreEqual(12, result.Hour);
86+
AreEqual(34, result.Minute);
87+
AreEqual(56, result.Second);
88+
// Precision 7 means full 0.1234567 seconds; lower precisions store
89+
// proportionally fewer fractional digits (the value is the
90+
// precision-N integer multiplied to ticks).
91+
var expectedTicks = precision == 0 ? 0L
92+
: precision == 3 ? 123 * TimeSpan.TicksPerMillisecond
93+
: 1234567L;
94+
AreEqual(expectedTicks, result.Ticks % TimeSpan.TicksPerSecond);
95+
}
96+
97+
[TestMethod]
98+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 99999999, 7)")]
99+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 1000, 3)")]
100+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 1, 0)")]
101+
public void DateTime2FromParts_FractionsOverflow_RaisesMsg289(string expression) =>
102+
AssertSqlError($"select {expression}", 289, "Cannot construct data type datetime2, some of the arguments have values which are not valid.");
103+
104+
[TestMethod]
105+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 0, 8)")]
106+
[DataRow("datetime2fromparts(2026, 5, 9, 12, 34, 56, 0, -1)")]
107+
public void DateTime2FromParts_PrecisionOutOfRange_RaisesMsg1002(string expression) =>
108+
AssertSqlError($"select {expression}", 1002);
109+
110+
[TestMethod]
111+
public void DateTime2FromParts_NullPrecision_RaisesMsg10760() =>
112+
AssertSqlError("select datetime2fromparts(2026, 5, 9, 12, 34, 56, 0, null)", 10760,
113+
"Scale argument is not valid. Valid expressions for data type datetime2 scale argument are integer constants and integer constant expressions.");
114+
115+
[TestMethod]
116+
public void DateTime2FromParts_NullValueArg_PropagatesNull() =>
117+
IsInstanceOfType<DBNull>(ExecuteScalar("select datetime2fromparts(null, 5, 9, 12, 34, 56, 0, 7)"));
118+
119+
[TestMethod]
120+
public void DateTimeOffsetFromParts_Standard()
121+
{
122+
var result = (DateTimeOffset)ExecuteScalar("select datetimeoffsetfromparts(2026, 5, 9, 12, 34, 56, 1234567, 5, 30, 7)")!;
123+
AreEqual(new DateTime(2026, 5, 9, 12, 34, 56).AddTicks(1234567), result.DateTime);
124+
AreEqual(TimeSpan.FromMinutes((5 * 60) + 30), result.Offset);
125+
}
126+
127+
[TestMethod]
128+
public void DateTimeOffsetFromParts_NegativeOffset_PreservesSign()
129+
{
130+
var result = (DateTimeOffset)ExecuteScalar("select datetimeoffsetfromparts(2026, 5, 9, 12, 34, 56, 0, -8, 0, 7)")!;
131+
AreEqual(TimeSpan.FromHours(-8), result.Offset);
132+
}
133+
134+
[TestMethod]
135+
[DataRow("datetimeoffsetfromparts(2026, 5, 9, 12, 34, 56, 0, -5, 30, 7)")]
136+
[DataRow("datetimeoffsetfromparts(2026, 5, 9, 12, 34, 56, 0, 15, 0, 7)")]
137+
[DataRow("datetimeoffsetfromparts(2026, 5, 9, 12, 34, 56, 0, 14, 1, 7)")]
138+
public void DateTimeOffsetFromParts_OffsetInvalid_RaisesMsg289(string expression) =>
139+
AssertSqlError($"select {expression}", 289, "Cannot construct data type datetimeoffset, some of the arguments have values which are not valid.");
140+
141+
[TestMethod]
142+
public void DateTimeOffsetFromParts_NullPrecision_RaisesMsg10760() =>
143+
AssertSqlError("select datetimeoffsetfromparts(2026, 5, 9, 12, 34, 56, 0, 0, 0, null)", 10760,
144+
"Scale argument is not valid. Valid expressions for data type datetimeoffset scale argument are integer constants and integer constant expressions.");
145+
146+
[TestMethod]
147+
public void SmallDateTimeFromParts_Valid() =>
148+
AreEqual(new DateTime(2026, 5, 9, 12, 34, 0),
149+
ExecuteScalar("select smalldatetimefromparts(2026, 5, 9, 12, 34)"));
150+
151+
[TestMethod]
152+
public void SmallDateTimeFromParts_NullArgPropagates() =>
153+
IsInstanceOfType<DBNull>(ExecuteScalar("select smalldatetimefromparts(null, 5, 9, 12, 34)"));
154+
155+
[TestMethod]
156+
public void TimeFromParts_FullPrecision()
157+
{
158+
var span = (TimeSpan)ExecuteScalar("select timefromparts(12, 34, 56, 1234567, 7)")!;
159+
AreEqual(new TimeSpan(0, 12, 34, 56) + TimeSpan.FromTicks(1234567), span);
160+
}
161+
162+
[TestMethod]
163+
public void TimeFromParts_Hour24_RaisesMsg289() =>
164+
AssertSqlError("select timefromparts(24, 0, 0, 0, 0)", 289,
165+
"Cannot construct data type time, some of the arguments have values which are not valid.");
166+
167+
[TestMethod]
168+
public void TimeFromParts_NullPrecision_RaisesMsg10760() =>
169+
AssertSqlError("select timefromparts(12, 34, 56, 0, null)", 10760,
170+
"Scale argument is not valid. Valid expressions for data type time scale argument are integer constants and integer constant expressions.");
171+
172+
[TestMethod]
173+
[DataRow("eomonth(cast('2026-02-15' as date))", "2026-02-28")]
174+
[DataRow("eomonth(cast('2024-02-15' as date))", "2024-02-29")]
175+
[DataRow("eomonth(cast('2025-02-15' as date))", "2025-02-28")]
176+
[DataRow("eomonth(cast('2026-02-15' as datetime2))", "2026-02-28")]
177+
[DataRow("eomonth(cast('2026-02-15T12:00:00' as datetime))", "2026-02-28")]
178+
[DataRow("eomonth('2026-02-15')", "2026-02-28")]
179+
[DataRow("eomonth(cast('2026-02-15' as date), 1)", "2026-03-31")]
180+
[DataRow("eomonth(cast('2026-02-15' as date), -1)", "2026-01-31")]
181+
[DataRow("eomonth('2026-02-15', 1)", "2026-03-31")]
182+
public void EOMonth_Valid(string expression, string expectedIso) =>
183+
AreEqual(DateTime.Parse(expectedIso), ExecuteScalar($"select {expression}"));
184+
185+
[TestMethod]
186+
public void EOMonth_NullStartDate_PropagatesNull() =>
187+
IsInstanceOfType<DBNull>(ExecuteScalar("select eomonth(null)"));
188+
189+
/// <summary>Probe-confirmed quirk: NULL <c>month_offset</c> is silently treated as 0.</summary>
190+
[TestMethod]
191+
public void EOMonth_NullOffset_TreatedAsZero() =>
192+
AreEqual(new DateTime(2026, 2, 28),
193+
ExecuteScalar("select eomonth(cast('2026-02-15' as date), null)"));
194+
195+
[TestMethod]
196+
public void DatePartsBuilder_OfColumns_FromTable() =>
197+
AreEqual(new DateTime(2026, 5, 9), new Simulation().ExecuteScalar("""
198+
create table t (y int, m int, d int);
199+
insert t values (2026, 5, 9);
200+
select datefromparts(y, m, d) from t
201+
"""));
202+
203+
[TestMethod]
204+
public void EOMonth_OfColumn_FromTable() =>
205+
AreEqual(new DateTime(2026, 2, 28), new Simulation().ExecuteScalar("""
206+
create table t (d date);
207+
insert t values ('2026-02-15');
208+
select eomonth(d) from t
209+
"""));
210+
}

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,27 @@ internal static SimulatedSqlException LengthOrPrecisionSpecificationInvalid(int
117117
internal static SimulatedSqlException InvalidScale(int requested, int line) =>
118118
new($"Line {line}: Specified scale {requested} is invalid.", 1002, 15, 1);
119119

120+
/// <summary>
121+
/// Mimics SQL Server error 289: a <c>*FROMPARTS</c> builder received
122+
/// argument values outside the legal range for the constructed type
123+
/// (e.g. <c>DATEFROMPARTS(2025, 2, 30)</c>, <c>TIMEFROMPARTS(24, ...)</c>).
124+
/// State numbers vary by target type: 1=date, 2=time, 3=datetime,
125+
/// 5=datetime2, 6=datetimeoffset (probe-confirmed against SQL Server 2025,
126+
/// 2026-05-09).
127+
/// </summary>
128+
internal static SimulatedSqlException CannotConstructFromParts(string typeName, byte state) =>
129+
new($"Cannot construct data type {typeName}, some of the arguments have values which are not valid.", 289, 16, state);
130+
131+
/// <summary>
132+
/// Mimics SQL Server error 10760: the scale (precision) argument of a
133+
/// <c>*FROMPARTS</c> builder for <c>datetime2</c> / <c>time</c> /
134+
/// <c>datetimeoffset</c> isn't a valid integer constant — typically
135+
/// triggered by passing <c>NULL</c> in that slot. Probe-confirmed against
136+
/// SQL Server 2025 (2026-05-09).
137+
/// </summary>
138+
internal static SimulatedSqlException ScaleArgumentNotValid(string typeName) =>
139+
new($"Scale argument is not valid. Valid expressions for data type {typeName} scale argument are integer constants and integer constant expressions.", 10760, 16, 1);
140+
120141
/// <summary>
121142
/// Mimics SQL Server error 241: a string value could not be parsed as a
122143
/// date or time value (e.g. <c>CAST('not-a-date' AS date)</c>). Covers

SqlServerSimulator/Parser/Expression.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
370370
"CEILING" => new Ceiling(context),
371371
"CONVERT" => new ConvertExpression(context, tryMode: false),
372372
"DATEADD" => new DateAdd(context),
373+
"EOMONTH" => new EOMonth(context),
373374
"GETDATE" => new CurrentTimeFunction(context, CurrentTimeKind.GetDate),
374375
"REPLACE" => new Replace(context),
375376
"REVERSE" => new Reverse(context),
@@ -412,7 +413,9 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
412413
},
413414
13 => uppercaseName switch
414415
{
416+
"DATEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateFromParts),
415417
"IDENT_CURRENT" => new IdentCurrent(context),
418+
"TIMEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.TimeFromParts),
416419
_ => null
417420
},
418421
14 => uppercaseName switch
@@ -428,9 +431,25 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
428431
},
429432
17 => uppercaseName switch
430433
{
434+
"DATETIMEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTimeFromParts),
431435
"SYSDATETIMEOFFSET" => new CurrentTimeFunction(context, CurrentTimeKind.SysDateTimeOffset),
432436
_ => null
433437
},
438+
18 => uppercaseName switch
439+
{
440+
"DATETIME2FROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTime2FromParts),
441+
_ => null
442+
},
443+
22 => uppercaseName switch
444+
{
445+
"SMALLDATETIMEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.SmallDateTimeFromParts),
446+
_ => null
447+
},
448+
23 => uppercaseName switch
449+
{
450+
"DATETIMEOFFSETFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTimeOffsetFromParts),
451+
_ => null
452+
},
434453
21 => uppercaseName switch
435454
{
436455
"APPROX_COUNT_DISTINCT" => AggregateExpression.Parse(context, AggregateKind.ApproxCountDistinct),

0 commit comments

Comments
 (0)