Skip to content

Commit 2a7dedf

Browse files
committed
Support the "datetime" type, minor reorganization.
1 parent 8857657 commit 2a7dedf

17 files changed

Lines changed: 1430 additions & 169 deletions

CLAUDE.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,15 @@ Prefer public-API tests when the behavior is reachable from SQL — they exercis
6767

6868
## Live limitations
6969

70-
Live state is best read from the code itself and recent commit history. Architectural-level gaps as of the page-storage milestone:
71-
- Transactions / locks / MVCC: not implemented. The page foundation is in place; the next phase hasn't started.
72-
- Row-overflow / LOB pages: not modeled. Rows whose encoded size exceeds SQL Server's 8060-byte in-row limit throw at insert time; `varchar(MAX)` is parsed but rejected with a clear "not yet supported" error rather than silently fall through.
73-
- Heap allocation: flat page list. No IAM/PFS allocation tracking.
74-
- Cross-string-type coercion (`varchar``nvarchar`) and other cross-category pairs (string ↔ integer in binary expressions): the binary-expression `Promote` path rejects them. String → integer-family `CAST` is implemented, with the SQL-Server-matched Msg 244 / 245 / 248 / 8115 error variants. Cross-family date/time pairs in `Promote` are wired (highest-precedence-with-max-precision wins; `time` paired with non-time raises Msg 402).
75-
- Numeric ↔ `datetime` coercion (`cast(0 as datetime)`, `dt + 1`): not modeled. SQL Server treats integers as days-since-1900-01-01; the simulator throws `NotSupportedException` for the pair.
76-
- `smalldatetime`: not yet modeled. The simulator's `GetByName` rejects the type name; this is the only remaining standard date/time type EF Core may emit.
77-
- `ORDER BY`: not modeled. The trailing clause is consumed but not honored, so rows come back in insertion order. EF Core queries that rely on ordering need an in-memory `.OrderBy(...)` after materialization.
78-
- Pattern matching (`LIKE`), `CONVERT`, fixed-length `char(N)` / `nchar(N)` / `binary(N)`, and identity columns: not modeled.
79-
- CAST to a smaller varchar/nvarchar than the value renders: SQL Server silently truncates (e.g. `cast(dt as varchar(10))` → first 10 chars). The simulator returns the full string today; truncation isn't enforced on the CAST path.
70+
Heavy-hitters someone might assume work but don't. Source and `git log` are the truth for what's done; this list is for what isn't.
71+
72+
- Transactions / locks / MVCC.
73+
- `ORDER BY` is parsed but not honored — rows come back in insertion order. EF Core queries that rely on ordering need an in-memory `.OrderBy(...)` after materialization.
74+
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
75+
- Decimal / numeric / float / real / money types. (Also blocks fractional-day `cast(0.5 as datetime)`.)
76+
- `uniqueidentifier`, fixed-length `char(N)` / `nchar(N)` / `binary(N)`, and identity columns.
77+
- Pattern matching (`LIKE`) and `CONVERT`.
78+
- Cross-category `Promote` for `varchar``nvarchar` and integer ↔ string. Only CAST works for those pairs.
79+
- EF Core compatibility: the SqlServer provider downcasts `DbParameter` to `SqlParameter` for some mappings — `DateTime → date`, `DateTime → smalldatetime`, `DateOnly`, `TimeOnly`, `TimeSpan` all break at SaveChanges. See `SimulatedDbParameter` for the matrix; a `SqlServerSimulator.EFCore` adapter package is planned to close the gap.
80+
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
81+
- Heap allocation tracking (IAM/PFS): the page list is flat.

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ internal class Person
3333
/// <c>datetimeoffset</c> column support through EF Core. CLR-side properties
3434
/// are <see cref="DateTime"/> and <see cref="DateTimeOffset"/> only;
3535
/// <see cref="DateOnly"/>, <see cref="TimeOnly"/>, <see cref="TimeSpan"/>,
36-
/// and <c>DateTime → date</c> remain unreachable through EF Core's SqlServer
37-
/// provider because those mappings downcast
38-
/// <see cref="System.Data.Common.DbParameter"/> to <c>SqlParameter</c>. See
39-
/// <see cref="SimulatedDbParameter"/> for the full compatibility matrix.
36+
/// <c>DateTime → date</c>, and <c>DateTime → smalldatetime</c> remain
37+
/// unreachable through EF Core's SqlServer provider because those mappings
38+
/// downcast <see cref="System.Data.Common.DbParameter"/> to <c>SqlParameter</c>.
39+
/// See <see cref="SimulatedDbParameter"/> for the full compatibility matrix.
4040
/// </summary>
4141
internal class Event
4242
{
@@ -64,6 +64,7 @@ internal class Event
6464

6565
[Column(TypeName = "datetime")]
6666
public DateTime? Ended { get; set; }
67+
6768
}
6869

6970
internal class TestDbContext(Simulation simulation) : DbContext

SqlServerSimulator.Tests/CastTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,4 +455,31 @@ public void Cast_InvalidScale_InLaterStatementOfBatch_FiresWhenReaderReachesIt()
455455
});
456456
AreEqual("Line 3: Specified scale 8 is invalid.", ex.Message);
457457
}
458+
459+
[TestMethod]
460+
[DataRow("date")]
461+
[DataRow("datetime2")]
462+
[DataRow("time")]
463+
[DataRow("datetimeoffset")]
464+
public void Cast_IntToNonLegacyDateType_RaisesMsg529(string targetType)
465+
{
466+
// Only legacy datetime/smalldatetime accept integer casts; the
467+
// non-legacy date types reject with Msg 529 ("Explicit conversion
468+
// ... is not allowed."). The implicit-promotion counterpart raises
469+
// Msg 206 — see TypePromotionTests.
470+
var ex = Throws<DbException>(() => ExecuteScalar($"select cast(0 as {targetType})"));
471+
AreEqual($"Explicit conversion from data type int to {targetType} is not allowed.", ex.Message);
472+
}
473+
474+
[TestMethod]
475+
[DataRow("date")]
476+
[DataRow("datetime2")]
477+
[DataRow("time")]
478+
[DataRow("datetimeoffset")]
479+
public void Cast_NonLegacyDateTypeToInt_RaisesMsg529(string sourceType)
480+
{
481+
var seed = sourceType == "time" ? "12:00:00" : "2024-01-15";
482+
var ex = Throws<DbException>(() => ExecuteScalar($"select cast(cast('{seed}' as {sourceType}) as int)"));
483+
AreEqual($"Explicit conversion from data type {sourceType} to int is not allowed.", ex.Message);
484+
}
458485
}

SqlServerSimulator.Tests/LegacyDateTimeTests.cs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,171 @@ public void Equality_DifferentTicksAreUnequal()
252252
AreEqual(1, ids[0]);
253253
}
254254

255+
[TestMethod]
256+
[DataRow("0", "1900-01-01T00:00:00")]
257+
[DataRow("1", "1900-01-02T00:00:00")]
258+
[DataRow("-1", "1899-12-31T00:00:00")]
259+
[DataRow("-53690", "1753-01-01T00:00:00")]
260+
[DataRow("2958463", "9999-12-31T00:00:00")]
261+
[DataRow("45000", "2023-03-17T00:00:00")]
262+
public void Cast_IntToDateTime_TreatsAsDaysSince1900(string input, string expectedIso)
263+
{
264+
// Legacy datetime accepts integer casts as days-since-1900-01-01.
265+
var value = (DateTime)ExecuteScalar($"select cast({input} as datetime)")!;
266+
AreEqual(DateTime.Parse(expectedIso, System.Globalization.CultureInfo.InvariantCulture), value);
267+
}
268+
269+
[TestMethod]
270+
public void Cast_IntToDateTime_BelowMin_RaisesMsg8115()
271+
{
272+
// -53691 is one day before 1753-01-01 — legacy datetime's minimum
273+
// — so SQL Server raises arithmetic-overflow rather than the
274+
// varchar-source Msg 242.
275+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(-53691 as datetime)"));
276+
AreEqual("Arithmetic overflow error converting expression to data type datetime.", ex.Message);
277+
}
278+
279+
[TestMethod]
280+
public void Cast_IntToDateTime_AboveMax_RaisesMsg8115()
281+
{
282+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(2958464 as datetime)"));
283+
AreEqual("Arithmetic overflow error converting expression to data type datetime.", ex.Message);
284+
}
285+
286+
[TestMethod]
287+
public void Cast_BigintToDateTime_FarOutOfRange_RaisesMsg8115()
288+
{
289+
// Pick a value comfortably past datetime's MaxDayCount (2,958,463)
290+
// that still fits in int — keeps the literal-parse path simple.
291+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(cast(100000000 as bigint) as datetime)"));
292+
AreEqual("Arithmetic overflow error converting expression to data type datetime.", ex.Message);
293+
}
294+
295+
[TestMethod]
296+
[DataRow("'1900-01-01'", 0)]
297+
[DataRow("'1900-01-02'", 1)]
298+
[DataRow("'1899-12-31'", -1)]
299+
[DataRow("'2024-01-15'", 45304)]
300+
public void Cast_DateTimeToInt_ReturnsDaysSince1900(string seed, int expectedDays) =>
301+
AreEqual(expectedDays, ExecuteScalar($"select cast(cast({seed} as datetime) as int)"));
302+
303+
[TestMethod]
304+
[DataRow("06:00:00", 0)]
305+
// 11:59:59.998 quantizes to 11:59:59.997 (under half-day) → rounds down.
306+
[DataRow("11:59:59.998", 0)]
307+
// 11:59:59.999 quantizes to next-second tick → exactly half-day → up.
308+
[DataRow("11:59:59.999", 1)]
309+
[DataRow("12:00:00", 1)]
310+
[DataRow("18:00:00", 1)]
311+
public void Cast_DateTimeToInt_RoundsHalfAwayFromZero(string time, int expectedDays) =>
312+
AreEqual(expectedDays, ExecuteScalar($"select cast(cast('1900-01-01 {time}' as datetime) as int)"));
313+
314+
[TestMethod]
315+
public void Cast_NegativeDateTimeToInt_RoundsTowardMoreNegative()
316+
{
317+
// 1899-12-31 12:00:00 = -0.5 days → rounds to -1 (away from zero).
318+
AreEqual(-1, ExecuteScalar("select cast(cast('1899-12-31 12:00:00' as datetime) as int)"));
319+
// 1899-12-30 18:00:00 = -1.25 days → -0.25 fractional → -1 (toward zero).
320+
AreEqual(-1, ExecuteScalar("select cast(cast('1899-12-30 18:00:00' as datetime) as int)"));
321+
}
322+
323+
[TestMethod]
324+
public void Cast_DateTimeToTinyint_OverflowRaisesMsg8115()
325+
{
326+
// Day 256 = 1900-09-14, which doesn't fit in tinyint.
327+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(cast('1900-09-14' as datetime) as tinyint)"));
328+
AreEqual("Arithmetic overflow error converting expression to data type tinyint.", ex.Message);
329+
}
330+
331+
[TestMethod]
332+
public void Cast_DateTimeToBit_NonZeroIsTrue()
333+
{
334+
AreEqual(false, ExecuteScalar("select cast(cast('1900-01-01' as datetime) as bit)"));
335+
AreEqual(true, ExecuteScalar("select cast(cast('1900-01-02' as datetime) as bit)"));
336+
}
337+
338+
[TestMethod]
339+
public void Cast_BitToDateTime_ZeroAndOneAreFirstTwoDays()
340+
{
341+
AreEqual(new DateTime(1900, 1, 1), ExecuteScalar("select cast(cast(0 as bit) as datetime)"));
342+
AreEqual(new DateTime(1900, 1, 2), ExecuteScalar("select cast(cast(1 as bit) as datetime)"));
343+
}
344+
345+
[TestMethod]
346+
public void Arithmetic_DateTimePlusInt_AddsDays()
347+
{
348+
var value = (DateTime)ExecuteScalar("select cast('2024-01-15' as datetime) + 1")!;
349+
AreEqual(new DateTime(2024, 1, 16), value);
350+
}
351+
352+
[TestMethod]
353+
public void Arithmetic_IntPlusDateTime_AddsDays()
354+
{
355+
var value = (DateTime)ExecuteScalar("select 1 + cast('2024-01-15' as datetime)")!;
356+
AreEqual(new DateTime(2024, 1, 16), value);
357+
}
358+
359+
[TestMethod]
360+
public void Arithmetic_DateTimeMinusInt_SubtractsDays()
361+
{
362+
var value = (DateTime)ExecuteScalar("select cast('2024-01-15' as datetime) - 1")!;
363+
AreEqual(new DateTime(2024, 1, 14), value);
364+
}
365+
366+
[TestMethod]
367+
public void Arithmetic_DateTimePlusInt_PreservesTimeOfDay()
368+
{
369+
// Adding a whole-day integer doesn't disturb the time portion.
370+
var value = (DateTime)ExecuteScalar("select cast('2024-01-15 13:30:00' as datetime) + 1")!;
371+
AreEqual(new DateTime(2024, 1, 16, 13, 30, 0), value);
372+
}
373+
374+
[TestMethod]
375+
public void Arithmetic_DateTimePlusBigInt_StaysDateTime()
376+
{
377+
var value = (DateTime)ExecuteScalar("select cast('2024-01-15' as datetime) + cast(1 as bigint)")!;
378+
AreEqual(new DateTime(2024, 1, 16), value);
379+
}
380+
381+
[TestMethod]
382+
public void Arithmetic_DateTimePlusDateTime_SumDaysFromBase()
383+
{
384+
// SQL Server's legacy `dt + dt` quirk: re-interprets the sum of
385+
// the two day-counts as days-since-1900-01-01. `'2024-01-15' +
386+
// '2024-01-10'` lands at 90,605 days from 1900 = 2148-01-24.
387+
var value = (DateTime)ExecuteScalar("select cast('2024-01-15' as datetime) + cast('2024-01-10' as datetime)")!;
388+
AreEqual(new DateTime(2148, 1, 24), value);
389+
}
390+
391+
[TestMethod]
392+
public void Arithmetic_DateTimeMinusDateTime_DiffDaysFromBase()
393+
{
394+
// `'2024-01-15' - '2024-01-10'` = 5 days → re-interpret as
395+
// 5 days from 1900-01-01 = 1900-01-06.
396+
var value = (DateTime)ExecuteScalar("select cast('2024-01-15' as datetime) - cast('2024-01-10' as datetime)")!;
397+
AreEqual(new DateTime(1900, 1, 6), value);
398+
}
399+
400+
[TestMethod]
401+
public void Arithmetic_DateTimePlus_OverflowRaisesMsg8115()
402+
{
403+
var ex = Throws<DbException>(() => ExecuteScalar("select cast('9999-12-30' as datetime) + 100"));
404+
AreEqual("Arithmetic overflow error converting expression to data type datetime.", ex.Message);
405+
}
406+
407+
[TestMethod]
408+
public void Arithmetic_DateTimePlus_NullIntReturnsNull()
409+
{
410+
// DbDataReader surfaces SQL NULL as DBNull at the public boundary.
411+
AreEqual(DBNull.Value, ExecuteScalar("select cast('2024-01-15' as datetime) + cast(null as int)"));
412+
}
413+
414+
[TestMethod]
415+
public void Arithmetic_NullDateTimePlusInt_ReturnsNull()
416+
{
417+
AreEqual(DBNull.Value, ExecuteScalar("select cast(null as datetime) + 1"));
418+
}
419+
255420
[TestMethod]
256421
public void Ordering_DateTimeValues_CompareByInstant()
257422
{

0 commit comments

Comments
 (0)