Skip to content

Commit 8b04dd0

Browse files
committed
Added DATEDIFF / DATEDIFF_BIG, with the per-caller Msg 155 wording fix and the latent NANOSECOND-bucket bug surfaced during probing folded in.
1 parent de30725 commit 8b04dd0

8 files changed

Lines changed: 448 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,16 @@ Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDE
185185

186186
EF Core 10 always wraps ROW_NUMBER in a derived-table subquery: `SELECT ... FROM (SELECT cols, ROW_NUMBER() OVER(...) AS row FROM T) AS sub WHERE sub.row <= N` (Take) or `WHERE 1 < sub.row AND sub.row <= K` (Skip+Take). The simulator's plain-derived-table-doesn't-see-outer-scope limitation doesn't bite here because the ROW_NUMBER subquery has no outer correlation — its OVER refers only to the inner FROM.
187187

188-
### Date scalar functions: `DATEPART` / `DATEADD`
189-
Both take a bare datepart keyword as the first argument (parse-time `Name` token, not an expression). Canonical keywords + common aliases: `year`/`yy`/`yyyy`, `quarter`/`qq`/`q`, `month`/`mm`/`m`, `dayofyear`/`dy`/`y`, `day`/`dd`/`d`, `week`/`wk`/`ww`, `iso_week`/`isowk`/`isoww`, `weekday`/`dw`, `hour`/`hh`, `minute`/`mi`/`n`, `second`/`ss`/`s`, `millisecond`/`ms`, `microsecond`/`mcs`, `nanosecond`/`ns`, `tzoffset`/`tz`. `DATEPART` always returns `int`; `DATEADD` preserves the input's SQL type (`date` stays `date`, `time(N)` stays `time(N)`, etc.).
188+
### Date scalar functions: `DATEPART` / `DATEADD` / `DATEDIFF` / `DATEDIFF_BIG`
189+
All four take a bare datepart keyword as the first argument (parse-time `Name` token, not an expression). Canonical keywords + common aliases: `year`/`yy`/`yyyy`, `quarter`/`qq`/`q`, `month`/`mm`/`m`, `dayofyear`/`dy`/`y`, `day`/`dd`/`d`, `week`/`wk`/`ww`, `iso_week`/`isowk`/`isoww`, `weekday`/`dw`, `hour`/`hh`, `minute`/`mi`/`n`, `second`/`ss`/`s`, `millisecond`/`ms`, `microsecond`/`mcs`, `nanosecond`/`ns`, `tzoffset`/`tz`. Result types: `DATEPART` `int`; `DATEADD` preserves the input's SQL type (`date` stays `date`, `time(N)` stays `time(N)`, etc.); `DATEDIFF``int`; `DATEDIFF_BIG``bigint`.
190190

191-
Per-type keyword compatibility mirrors SQL Server (probed against real SQL Server 2025, 2026-05-07): `date` accepts only date parts; `time(N)` accepts only time parts; `datetime` / `smalldatetime` / `datetime2(N)` accept date and time parts; `datetimeoffset(N)` adds `tzoffset`. Wrong combinations raise **Msg 9810** `"The datepart {part} is not supported by date function {function} for data type {type}."`. Unknown keyword → **Msg 155** `"'{X}' is not a recognized datepart option."`. `DATEADD` overflow (e.g. `dateadd(year, 100000, dateCol)`) → **Msg 517** `"Adding a value to a '{type}' column caused an overflow."`. NULL-value input → typed-NULL output (DATEPART returns NULL int). DATEPART(weekday) uses default `DATEFIRST 7` (Sunday=1, Saturday=7); changing `DATEFIRST` isn't modeled. The week / iso_week algorithm pins the default us_english behavior — week 1 is the week containing January 1, rolling on Sundays.
191+
**`DATEPART` / `DATEADD`** enforce per-type keyword compatibility: `date` accepts only date parts; `time(N)` accepts only time parts; `datetime` / `smalldatetime` / `datetime2(N)` accept date and time parts; `datetimeoffset(N)` adds `tzoffset`. Wrong combinations raise **Msg 9810** `"The datepart {part} is not supported by date function {function} for data type {type}."`. `DATEADD` overflow (e.g. `dateadd(year, 100000, dateCol)`) → **Msg 517** `"Adding a value to a '{type}' column caused an overflow."`. DATEPART(weekday) uses default `DATEFIRST 7` (Sunday=1, Saturday=7); changing `DATEFIRST` isn't modeled. The week / iso_week algorithm pins the default us_english behavior — week 1 is the week containing January 1, rolling on Sundays.
192192

193-
`DatePartKind` (`Parser/Expressions/DatePartKind.cs`) is the shared enum + helpers; both `DatePart.cs` and `DateAdd.cs` route through it for keyword resolution, type-compatibility validation, extraction, and addition.
193+
**`DATEDIFF` / `DATEDIFF_BIG`** count the number of `datepart`-unit boundaries crossed going from `start` to `end` — not elapsed time. So `datediff(year, '2023-12-31', '2024-01-01')` = 1; `datediff(month, '2024-01-31', '2024-02-01')` = 1; `datediff(week, ...)` is Sunday-anchored. Negative results are valid (start > end). Type compatibility is intentionally more permissive than DATEPART/DATEADD — every datepart works against every date/time-family operand combination, including mixed pairs (e.g. `datediff(hour, dateCol, timeCol)` where `time` is anchored to 1900-01-01 and `date` to midnight). Only `tzoffset` and `iso_week` are rejected unconditionally with **Msg 9806** `"The datepart {part} is not supported by date function {fn}."` (no "for data type X" suffix). String-literal arguments are implicitly cast to `datetime2(7)` to match SQL Server's implicit-conversion behavior. `datetimeoffset` operands compare via UTC instant. Result-width overflow → **Msg 535** `"The {fn} function resulted in an overflow. ..."` — DATEDIFF hits this on millisecond ranges past ~25 days; DATEDIFF_BIG only on extreme nanosecond ranges (centuries). NULL on either operand → typed NULL.
194+
195+
NULL keyword/value handling and Msg 155 apply to all four. Unknown keyword → **Msg 155** `"'{X}' is not a recognized {fn} option."` — the message embeds the calling function's name verbatim (`datepart` / `dateadd` / `datediff` / `datediff_big`); the same Msg-155 factory threads `functionLowerName` through.
196+
197+
`DatePartKind` (`Parser/Expressions/DatePartKind.cs`) is the shared enum + helpers; `DatePart.cs`, `DateAdd.cs`, and `DateDiff.cs` all route through it. `DateDiff` handles both DATEDIFF and DATEDIFF_BIG via a single class with a `bool isBig` field — same boundary arithmetic, only the result-width / overflow cast differs. The diff math hoists year/month bucket-index computation (`MonthIndex`, `QuarterIndex`, `SundayWeekIndex`) into helpers so the per-part dispatch reads as plain bucket subtraction.
194198

195199
### Constraints
196200
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// Direct simulator coverage for the <c>DATEDIFF</c> / <c>DATEDIFF_BIG</c>
9+
/// scalar functions. Probe-confirmed against SQL Server 2025 (2026-05-08):
10+
/// boundary-crossing semantics (year boundary <c>2023-12-31 → 2024-01-01 = 1</c>,
11+
/// not elapsed time); accept-everything type compatibility (only
12+
/// <c>tzoffset</c> and <c>iso_week</c> rejected, function-level Msg 9806);
13+
/// <c>datetimeoffset</c> compared via UTC instant; result widths (int vs
14+
/// bigint) and Msg 535 overflow on result-width violation. The two
15+
/// functions share an implementation; only the result type / overflow
16+
/// threshold differ.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class DateDiffTests
20+
{
21+
[TestMethod]
22+
[DataRow("year", "2020-01-01", "2024-01-01", 4)]
23+
[DataRow("year", "2023-12-31", "2024-01-01", 1)]
24+
[DataRow("quarter", "2024-03-31", "2024-04-01", 1)]
25+
[DataRow("quarter", "2023-10-15", "2024-02-15", 1)]
26+
[DataRow("month", "2024-01-31", "2024-02-01", 1)]
27+
[DataRow("month", "2024-06-01", "2024-06-30", 0)]
28+
[DataRow("day", "2024-01-01 23:59:59", "2024-01-02 00:00:01", 1)]
29+
[DataRow("hour", "2024-01-01 12:59:59", "2024-01-01 13:00:01", 1)]
30+
public void DateDiff_BoundaryCrossing(string part, string startStr, string endStr, int expected) =>
31+
AreEqual(expected, ExecuteScalar($"select datediff({part}, '{startStr}', '{endStr}')"));
32+
33+
[TestMethod]
34+
public void DateDiff_NegativeWhenStartAfterEnd() =>
35+
AreEqual(-4, ExecuteScalar("select datediff(year, '2024-01-01', '2020-01-01')"));
36+
37+
[TestMethod]
38+
public void DateDiff_WeekIsSundayAnchored()
39+
{
40+
// 2024-06-15 is Saturday; 2024-06-16 is Sunday — boundary crossed.
41+
AreEqual(1, ExecuteScalar("select datediff(week, '2024-06-15', '2024-06-16')"));
42+
// 2024-06-16 (Sun) → 2024-06-22 (Sat): same week, no boundary.
43+
AreEqual(0, ExecuteScalar("select datediff(week, '2024-06-16', '2024-06-22')"));
44+
// Weekday is treated as plain day count (probe: 7 for 2024-06-15→2024-06-22).
45+
AreEqual(7, ExecuteScalar("select datediff(weekday, '2024-06-15', '2024-06-22')"));
46+
}
47+
48+
[TestMethod]
49+
[DataRow("yy", 4)]
50+
[DataRow("yyyy", 4)]
51+
[DataRow("ww", 1)]
52+
[DataRow("wk", 1)]
53+
[DataRow("dw", 7)]
54+
[DataRow("dy", 7)]
55+
[DataRow("hh", 0)]
56+
public void DateDiff_AcceptsKeywordAliases(string alias, int expected)
57+
{
58+
var sql = alias is "ww" or "wk"
59+
? $"select datediff({alias}, '2024-06-15', '2024-06-22')"
60+
: alias is "dw" or "dy"
61+
? $"select datediff({alias}, '2024-06-15', '2024-06-22')"
62+
: alias is "hh"
63+
? $"select datediff({alias}, '2024-01-01 13:00', '2024-01-01 13:30')"
64+
: $"select datediff({alias}, '2020-01-01', '2024-01-01')";
65+
AreEqual(expected, ExecuteScalar(sql));
66+
}
67+
68+
[TestMethod]
69+
public void DateDiff_NullStartOrEnd_ReturnsTypedNullInt()
70+
{
71+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select datediff(day, cast(null as date), '2024-01-01')"));
72+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select datediff(day, '2024-01-01', cast(null as date))"));
73+
}
74+
75+
[TestMethod]
76+
public void DateDiffBig_ReturnsBigint()
77+
{
78+
AreEqual(86400000000000L, ExecuteScalar("select datediff_big(nanosecond, '2024-01-01', '2024-01-02')"));
79+
AreEqual(4L, ExecuteScalar("select datediff_big(year, '2020-01-01', '2024-01-01')"));
80+
}
81+
82+
[TestMethod]
83+
public void DateDiffBig_NullStartOrEnd_ReturnsTypedNullBigint() =>
84+
IsInstanceOfType<DBNull>(ExecuteScalar("select datediff_big(day, cast(null as date), '2024-01-01')"));
85+
86+
[TestMethod]
87+
public void DateDiff_AcceptsAllTypeCombinations()
88+
{
89+
// date - time mix: time anchored to 1900-01-01 (probe-confirmed).
90+
AreEqual(5, ExecuteScalar("select datediff(hour, cast('1900-01-01' as date), cast('05:00' as time))"));
91+
// year/day on time-only inputs returns 0 (no error like DATEPART would raise Msg 9810).
92+
AreEqual(0, ExecuteScalar("select datediff(year, cast('10:00' as time), cast('11:00' as time))"));
93+
AreEqual(0, ExecuteScalar("select datediff(day, cast('10:00' as time), cast('11:00' as time))"));
94+
// hour on date-only: always 24 × day diff.
95+
AreEqual(3984, ExecuteScalar("select datediff(hour, cast('2024-01-01' as date), cast('2024-06-15' as date))"));
96+
}
97+
98+
[TestMethod]
99+
public void DateDiff_DateTimeOffset_UsesUtcInstant()
100+
{
101+
// Same wall-clock, different offsets → identical UTC instants → 0.
102+
AreEqual(0, ExecuteScalar("select datediff(hour, cast('2024-01-01 12:00:00 +00:00' as datetimeoffset), cast('2024-01-01 17:00:00 +05:00' as datetimeoffset))"));
103+
// Same wall-clock literal but +5 offset shifts UTC backward by 5h.
104+
AreEqual(-5, ExecuteScalar("select datediff(hour, cast('2024-01-01 12:00:00 +00:00' as datetimeoffset), cast('2024-01-01 12:00:00 +05:00' as datetimeoffset))"));
105+
}
106+
107+
[TestMethod]
108+
public void DateDiff_TzOffsetPart_RaisesMsg9806()
109+
{
110+
// tzoffset is rejected at the function level, regardless of operand type.
111+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff(tzoffset, cast('2024-01-01 00:00:00 +00:00' as datetimeoffset), cast('2024-06-15 00:00:00 +00:00' as datetimeoffset))"));
112+
AreEqual("The datepart tzoffset is not supported by date function datediff.", ex.Message);
113+
AreEqual("9806", ex.Data["HelpLink.EvtID"]);
114+
}
115+
116+
[TestMethod]
117+
public void DateDiff_IsoWeekPart_RaisesMsg9806()
118+
{
119+
// iso_week works in DATEPART/DATEADD but is unconditionally rejected by DATEDIFF.
120+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff(iso_week, '2024-06-15', '2024-06-22')"));
121+
AreEqual("The datepart iso_week is not supported by date function datediff.", ex.Message);
122+
AreEqual("9806", ex.Data["HelpLink.EvtID"]);
123+
}
124+
125+
[TestMethod]
126+
public void DateDiffBig_TzOffset_MessageEmbedsFunctionName()
127+
{
128+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff_big(iso_week, '2024-06-15', '2024-06-22')"));
129+
AreEqual("The datepart iso_week is not supported by date function datediff_big.", ex.Message);
130+
}
131+
132+
[TestMethod]
133+
public void DateDiff_UnknownDatepart_RaisesMsg155()
134+
{
135+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff(badpart, '2024-01-01', '2024-06-15')"));
136+
AreEqual("'badpart' is not a recognized datediff option.", ex.Message);
137+
AreEqual("155", ex.Data["HelpLink.EvtID"]);
138+
}
139+
140+
[TestMethod]
141+
public void DateDiffBig_UnknownDatepart_MessageSaysDatediffBig()
142+
{
143+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff_big(badpart, '2024-01-01', '2024-06-15')"));
144+
AreEqual("'badpart' is not a recognized datediff_big option.", ex.Message);
145+
}
146+
147+
[TestMethod]
148+
public void DateAdd_UnknownDatepart_MessageSaysDateadd()
149+
{
150+
// Pre-DATEDIFF, this said "datepart option" — Msg 155 wording is per-caller.
151+
var ex = Throws<DbException>(() => ExecuteScalar("select dateadd(badpart, 1, '2024-01-01')"));
152+
AreEqual("'badpart' is not a recognized dateadd option.", ex.Message);
153+
}
154+
155+
[TestMethod]
156+
public void DateDiff_MillisecondOverflow_RaisesMsg535()
157+
{
158+
// ~25 days of ms is > int.MaxValue (probe boundary).
159+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff(millisecond, '2024-01-01', '2024-01-26')"));
160+
AreEqual("The datediff function resulted in an overflow. The number of dateparts separating two date/time instances is too large. Try to use datediff with a less precise datepart.", ex.Message);
161+
AreEqual("535", ex.Data["HelpLink.EvtID"]);
162+
}
163+
164+
[TestMethod]
165+
public void DateDiff_MillisecondJustUnderInt_DoesNotOverflow()
166+
{
167+
// 24 days × 86400000 ms = 2,073,600,000 — fits in int.MaxValue (2,147,483,647).
168+
AreEqual(2073600000, ExecuteScalar("select datediff(millisecond, '2024-01-01', '2024-01-25')"));
169+
}
170+
171+
[TestMethod]
172+
public void DateDiffBig_MillisecondLargeRange_StaysInBigint() =>
173+
AreEqual(3913056000000L, ExecuteScalar("select datediff_big(millisecond, '1900-01-01', '2024-01-01')"));
174+
175+
[TestMethod]
176+
public void DateDiffBig_NanosecondCenturies_OverflowsToMsg535()
177+
{
178+
// 0001 → 9999 in nanoseconds overflows even bigint; probe-confirmed Msg 535.
179+
var ex = Throws<DbException>(() => ExecuteScalar("select datediff_big(nanosecond, cast('0001-01-01' as datetime2), cast('9999-12-31' as datetime2))"));
180+
AreEqual("535", ex.Data["HelpLink.EvtID"]);
181+
AreEqual("The datediff_big function resulted in an overflow. The number of dateparts separating two date/time instances is too large. Try to use datediff_big with a less precise datepart.", ex.Message);
182+
}
183+
184+
[TestMethod]
185+
public void DateDiff_SubsecondParts()
186+
{
187+
// 100ms → 200ms = 100ms diff.
188+
AreEqual(100, ExecuteScalar("select datediff(millisecond, '2024-01-01 00:00:00.100', '2024-01-01 00:00:00.200')"));
189+
// datetime2(7) ticks: 0.0000001s = 1 tick; 0.0000005s = 5 ticks. nanosecond diff = (5-1)*100 = 400.
190+
AreEqual(400, ExecuteScalar("select datediff(nanosecond, cast('2024-01-01 00:00:00.0000001' as datetime2(7)), cast('2024-01-01 00:00:00.0000005' as datetime2(7)))"));
191+
// Same tick range in microseconds: both quantize to 0 μs → diff 0.
192+
AreEqual(0, ExecuteScalar("select datediff(microsecond, cast('2024-01-01 00:00:00.0000001' as datetime2(7)), cast('2024-01-01 00:00:00.0000005' as datetime2(7)))"));
193+
}
194+
195+
[TestMethod]
196+
public void DateDiff_OnColumn_PreservesIntType()
197+
{
198+
var sim = new Simulation();
199+
_ = sim.ExecuteNonQuery("create table t (a datetime2, b datetime2)");
200+
_ = sim.ExecuteNonQuery("insert t values ('2024-01-01', '2024-06-15')");
201+
AreEqual(166, sim.ExecuteScalar("select datediff(day, a, b) from t"));
202+
}
203+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
248248
8 => uppercaseName switch
249249
{
250250
"COALESCE" => new Coalesce(context),
251+
"DATEDIFF" => new DateDiff.Standard(context),
251252
"DATEPART" => new DatePart(context),
252253
_ => null
253254
},
@@ -273,6 +274,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
273274
12 => uppercaseName switch
274275
{
275276
"CHECKSUM_AGG" => AggregateExpression.Parse(context, AggregateKind.ChecksumAgg),
277+
"DATEDIFF_BIG" => new DateDiff.Big(context),
276278
_ => null
277279
},
278280
13 => uppercaseName switch

SqlServerSimulator/Parser/Expressions/DateAdd.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public DateAdd(ParserContext context)
2121
this.keywordText = context.Token is Name name
2222
? name.Value
2323
: throw SimulatedSqlException.SyntaxErrorNear(context);
24-
this.kind = DatePartKinds.ResolveOrThrow(this.keywordText);
24+
this.kind = DatePartKinds.ResolveOrThrow(this.keywordText, "dateadd");
2525
if (context.GetNextRequired() is not Operator { Character: ',' })
2626
throw SimulatedSqlException.SyntaxErrorNear(context);
2727
this.number = Parse(context.MoveNextRequiredReturnSelf());

0 commit comments

Comments
 (0)