Skip to content

Commit 3d008c5

Browse files
committed
Added DATEPART and DATEADD scalar functions covering the EF Core DateTime.Year/AddDays(N) translation patterns; both share a DatePartKind enum + helper for keyword resolution, per-input-type compatibility validation (Msg 9810), unknown-keyword rejection (Msg 155), and the DATEADD overflow path (Msg 517).
1 parent b8fcc93 commit 3d008c5

8 files changed

Lines changed: 602 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDE
175175
### Aggregates
176176
`COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT expr)` / `COUNT_BIG`, `SUM` / `AVG` (integer-truncating; `decimal(38, max(s, 6))` widening for AVG over decimals), `MAX` / `MIN`, statistical (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`. Standalone and inside `GROUP BY` / `HAVING`.
177177

178+
### Date scalar functions: `DATEPART` / `DATEADD`
179+
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.).
180+
181+
Per-type keyword compatibility mirrors SQL Server (verified against Kardax7 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.
182+
183+
`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. `DATEDIFF` is a downstream beneficiary if/when added — same dispatch surface.
184+
178185
### Constraints
179186
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
180187
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree backing.

SqlServerSimulator.Tests.EFCore/EFCoreDateTime.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,46 @@ public void Insert_LegacyDateTime_AtMin_RoundTrips()
242242

243243
Assert.AreEqual(min, context.Events.Select(e => e.Started).First());
244244
}
245+
246+
[TestMethod]
247+
public void Where_DateTimeYearExtraction_TranslatesToDatepart()
248+
{
249+
// EF Core translates .Year (and .Month / .Day / .Hour / etc.) to
250+
// DATEPART(year, col). Common in real apps for "events this year"
251+
// filters.
252+
var simulation = TestDbContext.CreateEventsSimulation();
253+
using (var seed = new TestDbContext(simulation))
254+
{
255+
seed.Events.AddRange(
256+
new Event { Id = 1, CreatedAt = new DateTime(2023, 6, 1), OccurredAt = DateTimeOffset.MinValue },
257+
new Event { Id = 2, CreatedAt = new DateTime(2024, 6, 1), OccurredAt = DateTimeOffset.MinValue },
258+
new Event { Id = 3, CreatedAt = new DateTime(2024, 12, 1), OccurredAt = DateTimeOffset.MinValue });
259+
_ = seed.SaveChanges();
260+
}
261+
262+
using var context = new TestDbContext(simulation);
263+
var ids = context.Events.Where(e => e.CreatedAt.Year == 2024).OrderBy(e => e.Id).Select(e => e.Id).ToArray();
264+
CollectionAssert.AreEqual(new[] { 2, 3 }, ids);
265+
}
266+
267+
[TestMethod]
268+
public void Projection_DateTimeAddDays_TranslatesToDateadd()
269+
{
270+
// EF Core translates .AddDays(N) to DATEADD(day, CAST(N AS int), col).
271+
var simulation = TestDbContext.CreateEventsSimulation();
272+
using (var seed = new TestDbContext(simulation))
273+
{
274+
_ = seed.Events.Add(new Event
275+
{
276+
Id = 1,
277+
CreatedAt = new DateTime(2024, 6, 1),
278+
OccurredAt = DateTimeOffset.MinValue,
279+
});
280+
_ = seed.SaveChanges();
281+
}
282+
283+
using var context = new TestDbContext(simulation);
284+
var rolled = context.Events.Select(e => e.CreatedAt.AddDays(7)).Single();
285+
Assert.AreEqual(new DateTime(2024, 6, 8), rolled);
286+
}
245287
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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>DATEPART</c> and <c>DATEADD</c>
9+
/// scalar functions: keyword resolution (canonical + aliases), per-input-type
10+
/// extraction / addition semantics, NULL propagation, the cross-type
11+
/// compatibility rejection (Msg 9810), and the overflow path on DATEADD
12+
/// (Msg 517). The EF Core wire path is exercised in <c>EFCoreDateTime.cs</c>;
13+
/// these tests cover SQL-only edges EF Core doesn't reach.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class DatePartTests
17+
{
18+
[TestMethod]
19+
[DataRow("year", 2024)]
20+
[DataRow("month", 6)]
21+
[DataRow("day", 15)]
22+
[DataRow("dayofyear", 167)]
23+
[DataRow("quarter", 2)]
24+
[DataRow("hour", 13)]
25+
[DataRow("minute", 45)]
26+
[DataRow("second", 30)]
27+
[DataRow("millisecond", 500)]
28+
public void DatePart_OnDateTime2_ReturnsExpectedComponent(string part, int expected) =>
29+
AreEqual(expected, ExecuteScalar($"select datepart({part}, cast('2024-06-15 13:45:30.5' as datetime2(7)))"));
30+
31+
[TestMethod]
32+
[DataRow("yy", 2024)]
33+
[DataRow("yyyy", 2024)]
34+
[DataRow("mm", 6)]
35+
[DataRow("dd", 15)]
36+
[DataRow("hh", 13)]
37+
[DataRow("mi", 45)]
38+
[DataRow("ss", 30)]
39+
public void DatePart_AcceptsCommonKeywordAliases(string alias, int expected) =>
40+
AreEqual(expected, ExecuteScalar($"select datepart({alias}, cast('2024-06-15 13:45:30' as datetime2(0)))"));
41+
42+
[TestMethod]
43+
public void DatePart_OnDate_AcceptsDateParts() =>
44+
AreEqual(2024, ExecuteScalar("select datepart(year, cast('2024-06-15' as date))"));
45+
46+
[TestMethod]
47+
public void DatePart_OnTime_AcceptsTimeParts() =>
48+
AreEqual(13, ExecuteScalar("select datepart(hour, cast('13:45:30' as time))"));
49+
50+
[TestMethod]
51+
public void DatePart_OnDateTimeOffset_AcceptsTzOffset() =>
52+
AreEqual(-420, ExecuteScalar("select datepart(tzoffset, cast('2024-06-15 13:45:30 -07:00' as datetimeoffset))"));
53+
54+
[TestMethod]
55+
public void DatePart_NullInput_ReturnsNullInt() =>
56+
IsInstanceOfType<DBNull>(ExecuteScalar("select datepart(year, cast(null as datetime2))"));
57+
58+
[TestMethod]
59+
public void DatePart_UnknownKeyword_RaisesMsg155()
60+
{
61+
var ex = Throws<DbException>(() => ExecuteScalar("select datepart(badpart, getdate())"));
62+
AreEqual("'badpart' is not a recognized datepart option.", ex.Message);
63+
AreEqual("155", ex.Data["HelpLink.EvtID"]);
64+
}
65+
66+
[TestMethod]
67+
public void DatePart_HourOnDate_RaisesMsg9810()
68+
{
69+
var ex = Throws<DbException>(() => ExecuteScalar("select datepart(hour, cast('2024-06-15' as date))"));
70+
AreEqual("The datepart hour is not supported by date function datepart for data type date.", ex.Message);
71+
AreEqual("9810", ex.Data["HelpLink.EvtID"]);
72+
}
73+
74+
[TestMethod]
75+
public void DatePart_YearOnTime_RaisesMsg9810()
76+
{
77+
var ex = Throws<DbException>(() => ExecuteScalar("select datepart(year, cast('13:45:30' as time))"));
78+
AreEqual("The datepart year is not supported by date function datepart for data type time.", ex.Message);
79+
}
80+
81+
[TestMethod]
82+
public void DateAdd_DayOnDate_PreservesDateType()
83+
{
84+
var simulation = new Simulation();
85+
_ = simulation.ExecuteNonQuery("create table t (d date)");
86+
_ = simulation.ExecuteNonQuery("insert t values ('2024-06-15')");
87+
AreEqual(new DateTime(2024, 6, 22), simulation.ExecuteScalar("select dateadd(day, 7, d) from t"));
88+
}
89+
90+
[TestMethod]
91+
public void DateAdd_HourOnTime_PreservesTimeType()
92+
{
93+
var simulation = new Simulation();
94+
_ = simulation.ExecuteNonQuery("create table t (h time(0))");
95+
_ = simulation.ExecuteNonQuery("insert t values ('13:45')");
96+
AreEqual(new TimeSpan(16, 45, 0), simulation.ExecuteScalar("select dateadd(hour, 3, h) from t"));
97+
}
98+
99+
[TestMethod]
100+
public void DateAdd_NegativeN_SubtractsFromValue() =>
101+
AreEqual(new DateTime(2023, 6, 15), ExecuteScalar("select dateadd(year, -1, cast('2024-06-15' as datetime2(0)))"));
102+
103+
[TestMethod]
104+
public void DateAdd_NullValue_ReturnsTypedNull() =>
105+
IsInstanceOfType<DBNull>(ExecuteScalar("select dateadd(day, 1, cast(null as datetime2))"));
106+
107+
[TestMethod]
108+
public void DateAdd_HourOnDate_RaisesMsg9810()
109+
{
110+
var ex = Throws<DbException>(() => ExecuteScalar("select dateadd(hour, 1, cast('2024-06-15' as date))"));
111+
AreEqual("The datepart hour is not supported by date function dateadd for data type date.", ex.Message);
112+
AreEqual("9810", ex.Data["HelpLink.EvtID"]);
113+
}
114+
115+
[TestMethod]
116+
public void DateAdd_DayOnTime_RaisesMsg9810()
117+
{
118+
var ex = Throws<DbException>(() => ExecuteScalar("select dateadd(day, 1, cast('13:45' as time))"));
119+
AreEqual("The datepart day is not supported by date function dateadd for data type time.", ex.Message);
120+
}
121+
122+
[TestMethod]
123+
public void DateAdd_YearOverflowOnDate_RaisesMsg517()
124+
{
125+
var ex = Throws<DbException>(() => ExecuteScalar("select dateadd(year, 100000, cast('2024-06-15' as date))"));
126+
AreEqual("Adding a value to a 'date' column caused an overflow.", ex.Message);
127+
AreEqual("517", ex.Data["HelpLink.EvtID"]);
128+
}
129+
130+
[TestMethod]
131+
public void DateAdd_TzOffsetOnDateTimeOffset_ShiftsWallClock()
132+
{
133+
// DATEADD(tzoffset, +60, ...) preserves the UTC instant but shifts
134+
// the rendered offset by 60 minutes.
135+
var v = (DateTimeOffset)ExecuteScalar("select dateadd(tzoffset, 60, cast('2024-06-15 13:00:00 +00:00' as datetimeoffset(0)))")!;
136+
AreEqual(TimeSpan.FromHours(1), v.Offset);
137+
AreEqual(new DateTime(2024, 6, 15, 14, 0, 0), v.DateTime);
138+
}
139+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,15 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
237237
7 => uppercaseName switch
238238
{
239239
"CONVERT" => new ConvertExpression(context, tryMode: false),
240+
"DATEADD" => new DateAdd(context),
240241
"REPLACE" => new Replace(context),
241242
"REVERSE" => new Reverse(context),
242243
_ => null
243244
},
244245
8 => uppercaseName switch
245246
{
246247
"COALESCE" => new Coalesce(context),
248+
"DATEPART" => new DatePart(context),
247249
_ => null
248250
},
249251
9 => uppercaseName switch
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using SqlServerSimulator.Parser.Tokens;
2+
using SqlServerSimulator.Storage;
3+
4+
namespace SqlServerSimulator.Parser.Expressions;
5+
6+
/// <summary>
7+
/// SQL <c>DATEADD(&lt;datepart&gt;, &lt;number&gt;, &lt;date-expr&gt;)</c>:
8+
/// returns the date/time value with the given count of datepart units added.
9+
/// The output type matches the input's (date stays date, time stays time,
10+
/// datetime2 stays datetime2, etc.) — matching SQL Server.
11+
/// </summary>
12+
internal sealed class DateAdd : Expression
13+
{
14+
private readonly DatePartKind kind;
15+
private readonly string keywordText;
16+
private readonly Expression number;
17+
private readonly Expression source;
18+
19+
public DateAdd(ParserContext context)
20+
{
21+
this.keywordText = context.Token is Name name
22+
? name.Value
23+
: throw SimulatedSqlException.SyntaxErrorNear(context);
24+
this.kind = DatePartKinds.ResolveOrThrow(this.keywordText);
25+
if (context.GetNextRequired() is not Operator { Character: ',' })
26+
throw SimulatedSqlException.SyntaxErrorNear(context);
27+
this.number = Parse(context.MoveNextRequiredReturnSelf());
28+
if (context.Token is not Operator { Character: ',' })
29+
throw SimulatedSqlException.SyntaxErrorNear(context);
30+
this.source = Parse(context.MoveNextRequiredReturnSelf());
31+
}
32+
33+
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue)
34+
{
35+
var value = source.Run(getColumnValue);
36+
var n = number.Run(getColumnValue);
37+
if (value.IsNull || n.IsNull)
38+
return SqlValue.Null(value.Type);
39+
DatePartKinds.RequireCompatible(this.kind, this.keywordText, value.Type, "dateadd");
40+
var nInt = n.CoerceTo(SqlType.Int32).AsInt32;
41+
return DatePartKinds.Add(this.kind, value, nInt);
42+
}
43+
44+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) =>
45+
source.GetSqlType(resolveColumnType);
46+
47+
internal override string DebugDisplay() => $"DATEADD({this.keywordText}, {number.DebugDisplay()}, {source.DebugDisplay()})";
48+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using SqlServerSimulator.Parser.Tokens;
2+
using SqlServerSimulator.Storage;
3+
4+
namespace SqlServerSimulator.Parser.Expressions;
5+
6+
/// <summary>
7+
/// SQL <c>DATEPART(&lt;datepart&gt;, &lt;date-expr&gt;)</c>: extracts an integer
8+
/// component of the date/time value identified by the bare <c>datepart</c>
9+
/// keyword (year, month, day, hour, minute, second, etc.). Always returns
10+
/// <c>int</c>, mirroring SQL Server.
11+
/// </summary>
12+
/// <remarks>
13+
/// The first argument is a bare unquoted identifier — distinct from a regular
14+
/// expression — so the function constructor reads it as a <see cref="Name"/>
15+
/// and resolves it to a <see cref="DatePartKind"/> at parse time. Aliases
16+
/// (<c>yy</c>/<c>yyyy</c>, <c>mm</c>/<c>m</c>, <c>dd</c>/<c>d</c>, …) all
17+
/// resolve to the canonical kind.
18+
/// </remarks>
19+
internal sealed class DatePart : Expression
20+
{
21+
private readonly DatePartKind kind;
22+
private readonly string keywordText;
23+
private readonly Expression source;
24+
25+
public DatePart(ParserContext context)
26+
{
27+
this.keywordText = context.Token is Name name
28+
? name.Value
29+
: throw SimulatedSqlException.SyntaxErrorNear(context);
30+
this.kind = DatePartKinds.ResolveOrThrow(this.keywordText);
31+
if (context.GetNextRequired() is not Operator { Character: ',' })
32+
throw SimulatedSqlException.SyntaxErrorNear(context);
33+
this.source = Parse(context.MoveNextRequiredReturnSelf());
34+
}
35+
36+
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue)
37+
{
38+
var value = source.Run(getColumnValue);
39+
if (value.IsNull)
40+
return SqlValue.Null(SqlType.Int32);
41+
DatePartKinds.RequireCompatible(this.kind, this.keywordText, value.Type, "datepart");
42+
return SqlValue.FromInt32(DatePartKinds.Extract(this.kind, value));
43+
}
44+
45+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
46+
47+
internal override string DebugDisplay() => $"DATEPART({this.keywordText}, {source.DebugDisplay()})";
48+
}

0 commit comments

Comments
 (0)