Skip to content

Commit 489e420

Browse files
committed
Support the "datetimeoffset" type.
1 parent 14bb1b8 commit 489e420

12 files changed

Lines changed: 627 additions & 35 deletions

File tree

SqlServerSimulator.Tests.EFCore/EFCoreDateTime.cs

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
namespace SqlServerSimulator;
22

33
/// <summary>
4-
/// Exercises the simulator's <c>datetime2(N)</c> column support through EF
5-
/// Core's idiomatic surface. Confirms the full-precision (precision 7) round
6-
/// trip and the lower-precision (precision 3) rounding-on-store behavior land
7-
/// correctly through EF's parameter binding and reader hydration.
4+
/// Exercises the simulator's <c>datetime2(N)</c> and <c>datetimeoffset(N)</c>
5+
/// column support through EF Core's idiomatic surface. Confirms full-precision
6+
/// (precision 7) round trips and lower-precision (precision 3 / 0) rounding-
7+
/// on-store behavior land correctly through EF's parameter binding and reader
8+
/// hydration. Datetimeoffset additionally pins offset preservation across the
9+
/// round trip and equality-by-UTC-instant in <c>WHERE</c>.
810
/// </summary>
911
/// <remarks>
10-
/// Only <see cref="DateTime"/> properties are covered today — see
11-
/// <see cref="SimulatedDbParameter"/> for the per-mapping compatibility table
12-
/// explaining why the other date/time configurations
12+
/// Only <see cref="DateTime"/> and <see cref="DateTimeOffset"/> properties are
13+
/// covered — see <see cref="SimulatedDbParameter"/> for the per-mapping
14+
/// compatibility table explaining why the other date/time configurations
1315
/// (<c>DateOnly → date</c>, <c>TimeOnly → time</c>, <c>TimeSpan → time</c>,
1416
/// <c>DateTime → date</c>) are unreachable through EF Core until a bridge
1517
/// adapter ships.
@@ -111,4 +113,63 @@ public void MultipleRows_RoundTripBothColumns()
111113
Assert.AreEqual(b, rows[1].CreatedAt);
112114
Assert.IsNull(rows[1].Updated);
113115
}
116+
117+
[TestMethod]
118+
public void Insert_DateTimeOffset_FullPrecisionAndOffsetRoundTrip()
119+
{
120+
using var context = new TestDbContext(TestDbContext.CreateEventsSimulation());
121+
122+
var dto = new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.FromHours(-7)).AddTicks(1234567);
123+
_ = context.Events.Add(new Event { Id = 1, CreatedAt = new DateTime(2026, 5, 4), OccurredAt = dto });
124+
_ = context.SaveChanges();
125+
126+
var read = context.Events.Select(e => e.OccurredAt).First();
127+
Assert.AreEqual(dto, read);
128+
Assert.AreEqual(TimeSpan.FromHours(-7), read.Offset);
129+
}
130+
131+
[TestMethod]
132+
public void Insert_NullableDateTimeOffset_AcceptsNullAndValue()
133+
{
134+
using var context = new TestDbContext(TestDbContext.CreateEventsSimulation());
135+
var cancelled = new DateTimeOffset(2026, 5, 4, 14, 0, 0, TimeSpan.FromHours(2));
136+
context.Events.AddRange(
137+
new Event { Id = 1, CreatedAt = new DateTime(2026, 5, 4), OccurredAt = DateTimeOffset.UnixEpoch },
138+
new Event { Id = 2, CreatedAt = new DateTime(2026, 5, 4), OccurredAt = DateTimeOffset.UnixEpoch, Cancelled = cancelled });
139+
_ = context.SaveChanges();
140+
141+
var rows = context.Events.OrderBy(e => e.Id).Select(e => e.Cancelled).ToArray();
142+
Assert.IsNull(rows[0]);
143+
Assert.AreEqual(cancelled, rows[1]);
144+
}
145+
146+
[TestMethod]
147+
public void Insert_DateTimeOffset_LowerPrecisionColumn_RoundsHalfUp()
148+
{
149+
// Cancelled is datetimeoffset(3); 0.5ms above a millisecond boundary rounds to next ms.
150+
using var context = new TestDbContext(TestDbContext.CreateEventsSimulation());
151+
var cancelled = new DateTimeOffset(2026, 5, 4, 13, 45, 30, 100, TimeSpan.FromHours(-7)).AddTicks(5_000);
152+
_ = context.Events.Add(new Event { Id = 1, CreatedAt = new DateTime(2026, 5, 4), OccurredAt = DateTimeOffset.UnixEpoch, Cancelled = cancelled });
153+
_ = context.SaveChanges();
154+
155+
var expected = new DateTimeOffset(2026, 5, 4, 13, 45, 30, 101, TimeSpan.FromHours(-7));
156+
Assert.AreEqual(expected, context.Events.Select(e => e.Cancelled).First());
157+
}
158+
159+
[TestMethod]
160+
public void Where_FiltersByDateTimeOffsetEquality_CrossOffset()
161+
{
162+
// The stored value and the parameter share a UTC instant but carry
163+
// different offsets; equality should still match.
164+
using var context = new TestDbContext(TestDbContext.CreateEventsSimulation());
165+
var east = new DateTimeOffset(2026, 5, 4, 20, 45, 30, TimeSpan.FromHours(7));
166+
context.Events.AddRange(
167+
new Event { Id = 1, CreatedAt = new DateTime(2026, 5, 4), OccurredAt = east },
168+
new Event { Id = 2, CreatedAt = new DateTime(2026, 5, 4), OccurredAt = new DateTimeOffset(2026, 5, 5, 0, 0, 0, TimeSpan.Zero) });
169+
_ = context.SaveChanges();
170+
171+
var west = new DateTimeOffset(2026, 5, 4, 6, 45, 30, TimeSpan.FromHours(-7));
172+
var match = context.Events.Where(e => e.OccurredAt == west).Select(e => e.Id).Single();
173+
Assert.AreEqual(1, match);
174+
}
114175
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ internal class Person
2929
}
3030

3131
/// <summary>
32-
/// Exercises the simulator's <c>datetime2</c> column support through EF Core.
33-
/// Properties are <see cref="DateTime"/> rather than <see cref="DateOnly"/> /
34-
/// <see cref="TimeOnly"/> / <see cref="TimeSpan"/> because EF Core's SqlServer
35-
/// provider downcasts <see cref="System.Data.Common.DbParameter"/> to
36-
/// <c>SqlParameter</c> in those mappings; <c>DateTime → datetime2</c> is the
37-
/// only date/time configuration whose mapping leaves the default
38-
/// <c>SqlDbType</c> in place and so doesn't trigger the downcast.
39-
/// See <see cref="SimulatedDbParameter"/> for the full compatibility matrix.
32+
/// Exercises the simulator's <c>datetime2</c> and <c>datetimeoffset</c>
33+
/// column support through EF Core. The CLR-side properties stay narrow:
34+
/// <see cref="DateTime"/> for datetime2 and <see cref="DateTimeOffset"/>
35+
/// for datetimeoffset, because EF Core's SqlServer provider downcasts
36+
/// <see cref="System.Data.Common.DbParameter"/> to <c>SqlParameter</c> in
37+
/// the mappings for <see cref="DateOnly"/>, <see cref="TimeOnly"/>,
38+
/// <see cref="TimeSpan"/>, and <c>DateTime → date</c>. The two configurations
39+
/// covered here are the ones that leave the default <c>SqlDbType</c> in
40+
/// place and so don't trigger the downcast. See
41+
/// <see cref="SimulatedDbParameter"/> for the full compatibility matrix.
4042
/// </summary>
4143
internal class Event
4244
{
@@ -47,6 +49,12 @@ internal class Event
4749

4850
[Column(TypeName = "datetime2(3)")]
4951
public DateTime? Updated { get; set; }
52+
53+
[Column(TypeName = "datetimeoffset(7)")]
54+
public DateTimeOffset OccurredAt { get; set; }
55+
56+
[Column(TypeName = "datetimeoffset(3)")]
57+
public DateTimeOffset? Cancelled { get; set; }
5058
}
5159

5260
internal class TestDbContext(Simulation simulation) : DbContext
@@ -106,7 +114,7 @@ public static Simulation CreateEventsSimulation()
106114
var simulation = new Simulation();
107115
_ = simulation
108116
.CreateOpenConnection()
109-
.CreateCommand("create table Events ( Id int, CreatedAt datetime2(7) not null, Updated datetime2(3) null )")
117+
.CreateCommand("create table Events ( Id int, CreatedAt datetime2(7) not null, Updated datetime2(3) null, OccurredAt datetimeoffset(7) not null, Cancelled datetimeoffset(3) null )")
110118
.ExecuteNonQuery();
111119
return simulation;
112120
}

SqlServerSimulator.Tests.Internal/Storage/RowRoundTripTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,25 @@ public void EncodedDateRow_StoresDayCountLittleEndian()
204204
AreEqual(7, BitConverter.ToUInt16(bytes, 2)); // fixed-length end offset (4 + 3)
205205
CollectionAssert.AreEqual(new byte[] { 0x9B, 0x49, 0x0B }, bytes[4..7]);
206206
}
207+
208+
[TestMethod]
209+
public void EncodedSingleColumnDateTimeOffsetRow_PrecisionZero_HasLength15()
210+
{
211+
// 4 (header) + 8 (datetimeoffset(0): 3 time + 3 date + 2 offset) + 2 (column count) + 1 (NULL bitmap byte) = 15.
212+
var type = SqlType.GetDateTimeOffset(0);
213+
var value = SqlValue.FromDateTimeOffset(type, new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.FromHours(-7)));
214+
AreEqual(15, RowEncoder.EncodeRow([type], [value]).Length);
215+
}
216+
217+
[TestMethod]
218+
public void EncodedDateTimeOffsetRow_StoresOffsetAsSignedLittleEndianMinutes()
219+
{
220+
// Layout at precision 0: 3-byte UTC time-of-day, 3-byte UTC day count, 2-byte signed offset minutes.
221+
var type = SqlType.GetDateTimeOffset(0);
222+
var value = SqlValue.FromDateTimeOffset(type, new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.FromHours(-7)));
223+
var bytes = RowEncoder.EncodeRow([type], [value]);
224+
225+
AreEqual(12, BitConverter.ToUInt16(bytes, 2)); // fixed-length end offset (4 + 8)
226+
AreEqual(-420, BitConverter.ToInt16(bytes, 10)); // -07:00 in minutes
227+
}
207228
}

SqlServerSimulator.Tests.Internal/Storage/SqlValueTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,31 @@ public void Time_DifferentPrecisions_AreDistinctTypes()
209209
AreNotSame(v3.Type, v7.Type);
210210
AreNotEqual(v3, v7);
211211
}
212+
213+
[TestMethod]
214+
public void FromDateTimeOffset_RejectsNonDateTimeOffsetType() =>
215+
Throws<ArgumentException>(() => SqlValue.FromDateTimeOffset(SqlType.Int32, DateTimeOffset.UtcNow));
216+
217+
[TestMethod]
218+
public void DateTimeOffset_DifferentPrecisions_AreDistinctTypes()
219+
{
220+
var v3 = SqlValue.FromDateTimeOffset(SqlType.GetDateTimeOffset(3), new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.Zero));
221+
var v7 = SqlValue.FromDateTimeOffset(SqlType.GetDateTimeOffset(7), new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.Zero));
222+
AreNotSame(v3.Type, v7.Type);
223+
AreNotEqual(v3, v7);
224+
}
225+
226+
[TestMethod]
227+
public void DateTimeOffset_EqualByUtc_DifferentOffsets_HaveEqualHashCodes()
228+
{
229+
// The Equals side of the by-UTC-instant contract is covered publicly
230+
// by FromTableWhere_FiltersByDateTimeOffsetEquality_AcrossOffsets.
231+
// GetHashCode has no public SQL trigger today (no DISTINCT/GROUP BY/
232+
// JOIN), so we pin the equal-objects-have-equal-hashes invariant
233+
// here directly until those features land.
234+
var type = SqlType.GetDateTimeOffset(7);
235+
var east = SqlValue.FromDateTimeOffset(type, new DateTimeOffset(2026, 5, 4, 20, 45, 30, TimeSpan.FromHours(7)));
236+
var west = SqlValue.FromDateTimeOffset(type, new DateTimeOffset(2026, 5, 4, 6, 45, 30, TimeSpan.FromHours(-7)));
237+
AreEqual(east.GetHashCode(), west.GetHashCode());
238+
}
212239
}

SqlServerSimulator.Tests/CastTests.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,98 @@ public void Cast_TimeToDateTime2_FillsLegacyDate()
167167
[TestMethod]
168168
public void Cast_TimeNullPassesThrough() =>
169169
IsInstanceOfType<DBNull>(ExecuteScalar("select cast(cast(null as time(7)) as varchar(20))"));
170+
171+
[TestMethod]
172+
[DataRow("cast('2026-05-04 13:45:30 -07:00' as datetimeoffset(0))", "2026-05-04T13:45:30-07:00")]
173+
[DataRow("cast('2026-05-04 13:45:30.123 +03:00' as datetimeoffset(3))", "2026-05-04T13:45:30.123+03:00")]
174+
[DataRow("cast('2026-05-04 13:45:30.1234567 +00:00' as datetimeoffset(7))", "2026-05-04T13:45:30.1234567+00:00")]
175+
public void Cast_StringToDateTimeOffset(string expression, string expectedIso)
176+
{
177+
var value = ExecuteScalar($"select {expression}");
178+
var dto = IsInstanceOfType<DateTimeOffset>(value);
179+
AreEqual(DateTimeOffset.Parse(expectedIso, System.Globalization.CultureInfo.InvariantCulture), dto);
180+
}
181+
182+
[TestMethod]
183+
public void Cast_StringWithoutOffsetToDateTimeOffset_AssumesUtc()
184+
{
185+
// SQL Server treats an offsetless string as +00:00 when casting to datetimeoffset.
186+
var value = ExecuteScalar("select cast('2026-05-04 13:45:30' as datetimeoffset(0))");
187+
var dto = IsInstanceOfType<DateTimeOffset>(value);
188+
AreEqual(new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.Zero), dto);
189+
AreEqual(TimeSpan.Zero, dto.Offset);
190+
}
191+
192+
[TestMethod]
193+
[DataRow("not a datetimeoffset")]
194+
[DataRow("2026-13-01 13:45:30 +00:00")]
195+
public void Cast_BadStringToDateTimeOffset_RaisesConversionFailed(string text)
196+
{
197+
var ex = Throws<DbException>(() => ExecuteScalar($"select cast('{text}' as datetimeoffset(7))"));
198+
AreEqual("Conversion failed when converting date and/or time from character string.", ex.Message);
199+
}
200+
201+
[TestMethod]
202+
[DataRow("cast(cast('2026-05-04 13:45:30 -07:00' as datetimeoffset(0)) as varchar(40))", "2026-05-04 13:45:30 -07:00")]
203+
[DataRow("cast(cast('2026-05-04 13:45:30.123 +03:00' as datetimeoffset(3)) as varchar(40))", "2026-05-04 13:45:30.123 +03:00")]
204+
public void Cast_DateTimeOffsetToString(string expression, string expected) =>
205+
AreEqual(expected, ExecuteScalar($"select {expression}"));
206+
207+
[TestMethod]
208+
public void Cast_DateTimeOffset_NarrowingPrecisionRoundsHalfUp()
209+
{
210+
var result = ExecuteScalar("select cast(cast('2026-05-04 13:45:30.5 -07:00' as datetimeoffset(7)) as datetimeoffset(0))");
211+
var dto = IsInstanceOfType<DateTimeOffset>(result);
212+
AreEqual(new DateTimeOffset(2026, 5, 4, 13, 45, 31, TimeSpan.FromHours(-7)), dto);
213+
}
214+
215+
[TestMethod]
216+
public void Cast_DateTime2ToDateTimeOffset_AssumesZeroOffset()
217+
{
218+
var result = ExecuteScalar("select cast(cast('2026-05-04 13:45:30' as datetime2(7)) as datetimeoffset(7))");
219+
var dto = IsInstanceOfType<DateTimeOffset>(result);
220+
AreEqual(new DateTimeOffset(2026, 5, 4, 13, 45, 30, TimeSpan.Zero), dto);
221+
}
222+
223+
[TestMethod]
224+
public void Cast_DateTimeOffsetToDateTime2_DropsOffset()
225+
{
226+
// SQL Server: casting datetimeoffset → datetime2 returns the LOCAL
227+
// (offset-bearing) wall-clock and discards the offset.
228+
var result = ExecuteScalar("select cast(cast('2026-05-04 13:45:30 -07:00' as datetimeoffset(7)) as datetime2(7))");
229+
AreEqual(new DateTime(2026, 5, 4, 13, 45, 30), IsInstanceOfType<DateTime>(result));
230+
}
231+
232+
[TestMethod]
233+
public void Cast_DateToDateTimeOffset_AppliesMidnightAndZeroOffset()
234+
{
235+
var result = ExecuteScalar("select cast(cast('2026-05-04' as date) as datetimeoffset(7))");
236+
AreEqual(new DateTimeOffset(2026, 5, 4, 0, 0, 0, TimeSpan.Zero), IsInstanceOfType<DateTimeOffset>(result));
237+
}
238+
239+
[TestMethod]
240+
public void Cast_DateTimeOffsetToDate_DropsTimeAndOffset()
241+
{
242+
var result = ExecuteScalar("select cast(cast('2026-05-04 23:45:30 -07:00' as datetimeoffset(7)) as date)");
243+
// Local date, not the UTC-shifted one.
244+
AreEqual(new DateTime(2026, 5, 4), IsInstanceOfType<DateTime>(result));
245+
}
246+
247+
[TestMethod]
248+
public void Cast_TimeToDateTimeOffset_FillsLegacyDateAndZeroOffset()
249+
{
250+
var result = ExecuteScalar("select cast(cast('13:45:30' as time(7)) as datetimeoffset(7))");
251+
AreEqual(new DateTimeOffset(1900, 1, 1, 13, 45, 30, TimeSpan.Zero), IsInstanceOfType<DateTimeOffset>(result));
252+
}
253+
254+
[TestMethod]
255+
public void Cast_DateTimeOffsetToTime_DropsDateAndOffset()
256+
{
257+
var result = ExecuteScalar("select cast(cast('2026-05-04 13:45:30 -07:00' as datetimeoffset(7)) as time(7))");
258+
AreEqual(new TimeSpan(13, 45, 30), IsInstanceOfType<TimeSpan>(result));
259+
}
260+
261+
[TestMethod]
262+
public void Cast_DateTimeOffsetNullPassesThrough() =>
263+
IsInstanceOfType<DBNull>(ExecuteScalar("select cast(cast(null as datetimeoffset(7)) as varchar(40))"));
170264
}

SqlServerSimulator.Tests/CreateTableTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,36 @@ public void CreateTableTime_PrecisionOutOfRange(int precision)
320320
var x = Assert.Throws<DbException>(() => command.ExecuteNonQuery());
321321
Assert.AreEqual($"Specified scale {precision} is invalid.", x.Message);
322322
}
323+
324+
[TestMethod]
325+
[DataRow("datetimeoffset")] // default precision = 7
326+
[DataRow("DATETIMEOFFSET")]
327+
[DataRow("datetimeoffset(0)")]
328+
[DataRow("datetimeoffset(3)")]
329+
[DataRow("datetimeoffset(7)")]
330+
public void CreateTableDateTimeOffset(string typeSpec)
331+
{
332+
var simulation = new Simulation();
333+
334+
using var connection = simulation.CreateDbConnection();
335+
using var command = connection.CreateCommand($"create table t ( v {typeSpec} )");
336+
337+
connection.Open();
338+
Assert.AreEqual(-1, command.ExecuteNonQuery());
339+
}
340+
341+
[TestMethod]
342+
[DataRow(8)]
343+
[DataRow(99)]
344+
public void CreateTableDateTimeOffset_PrecisionOutOfRange(int precision)
345+
{
346+
var simulation = new Simulation();
347+
348+
using var connection = simulation.CreateDbConnection();
349+
using var command = connection.CreateCommand($"create table t ( v datetimeoffset({precision}) )");
350+
351+
connection.Open();
352+
var x = Assert.Throws<DbException>(() => command.ExecuteNonQuery());
353+
Assert.AreEqual($"Specified scale {precision} is invalid.", x.Message);
354+
}
323355
}

0 commit comments

Comments
 (0)