From be3f3e4d79b1821d01b6de84a651ab9a55d88876 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 20 Aug 2026 09:58:51 +0100 Subject: [PATCH] Re-enable DateOnly/TimeOnly support, fixing the defects that got it disabled DateOnly/TimeOnly support (#2051) was compiled out in 907a4d9 pending #2071/#2072. This re-enables it with the root causes fixed, and teaches the read paths that the date/time family has no IConvertible bridge - which box a date or time column yields is a provider/version decision (Npgsql 10: DateOnly/TimeOnly; SqlClient and Npgsql 9: DateTime/TimeSpan), so both shapes must convert: - GetFieldValue is no longer demanded of a column whose reported type needs a family conversion (a datetime column into a DateOnly member was the #2072 breakage; SqlDecimal-style entries, where GetFieldValue from a differently-reported column is the whole point, keep it); - the member, scalar and Parse paths all convert DateOnly/TimeOnly <-> DateTime/TimeSpan in both directions (IL for members, object-level elsewhere), fixing reads against Npgsql 10 date/time columns (#2226); - Query/ in scalar form no longer silently return default(T) (#2227): the typeMap entries make them simple types again; - the same-TypeCode direct-unbox shortcut is restricted to distinct codes: TypeCode.Object matching TypeCode.Object says nothing (TimeSpan into TimeOnly? threw), while object on either side (sql_variant columns, dynamic members) keeps the direct unbox; - the scalar conversion fallback keeps Convert.ChangeType's strict null contract (TestConversionExceptionMessages pins it). The parked DateTimeOnlyTests are re-enabled, with new tests for the #2072 shape (datetime column into DateOnly member), the #2227 scalar shape, and date-as-DateTime regression guards; PostgresqlTests gains the Npgsql-10 interchange matrix ([FactPostgresql], so it runs where the documented container is available). Fixes #2072, #2226, #2227, #1728; expected to also resolve #2071. --- Dapper/SqlMapper.cs | 99 ++++++++++++++++++- tests/Dapper.Tests/DateTimeOnlyTests.cs | 34 ++++++- .../Dapper.Tests/Providers/PostgresqlTests.cs | 26 +++++ 3 files changed, 153 insertions(+), 6 deletions(-) diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 2fa0e72b7..98560add3 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -202,7 +202,7 @@ public static implicit operator TypeMapEntry(DbType dbType) static SqlMapper() { typeMap = new Dictionary(41 -#if NET6_0_OR_GREATER && DATEONLY +#if NET6_0_OR_GREATER + 4 // {Date|Time}Only[?] #endif ) @@ -248,7 +248,7 @@ static SqlMapper() [typeof(SqlDecimal?)] = TypeMapEntry.DecimalFieldValue, [typeof(SqlMoney)] = TypeMapEntry.DecimalFieldValue, [typeof(SqlMoney?)] = TypeMapEntry.DecimalFieldValue, -#if NET6_0_OR_GREATER && DATEONLY +#if NET6_0_OR_GREATER [typeof(DateOnly)] = TypeMapEntry.DoNotSetFieldValue, [typeof(TimeOnly)] = TypeMapEntry.DoNotSetFieldValue, [typeof(DateOnly?)] = TypeMapEntry.DoNotSetFieldValue, @@ -1396,6 +1396,7 @@ private static T GetValue(DbDataReader reader, Type effectiveType, object? va try { var convertToType = Nullable.GetUnderlyingType(effectiveType) ?? effectiveType; + if (TryConvertDateTimeFamily(val, convertToType, out var converted)) return (T)converted; return (T)Convert.ChangeType(val, convertToType, CultureInfo.InvariantCulture)!; } catch (Exception ex) @@ -3169,7 +3170,9 @@ static Func ReadViaGetFieldValueFactory(Type type, int ind static readonly Hashtable s_ReadViaGetFieldValueCache = []; static Func UnderlyingReadViaGetFieldValueFactory(int index) - => reader => reader.IsDBNull(index) ? null! : reader.GetFieldValue(index)!; + => reader => reader.IsDBNull(index) ? null! + : IsDateTimeFamilyConversion(reader.GetFieldType(index), typeof(T)) ? (object)Parse(reader.GetValue(index))! + : reader.GetFieldValue(index)!; static bool UseGetFieldValue(Type type) => typeMap.TryGetValue(type, out var mapEntry) && (mapEntry.Flags & TypeMapEntryFlags.UseGetFieldValue) != 0; @@ -3196,9 +3199,48 @@ private static T Parse(object? value) { return (T)handler.Parse(type, value)!; } + if (TryConvertDateTimeFamily(value, type, out var converted)) return (T)converted; return (T)Convert.ChangeType(value, type, CultureInfo.InvariantCulture); } + // the date/time family has no IConvertible bridge; which box a "date" or "time" + // column yields is a provider/version decision (Npgsql 10: DateOnly/TimeOnly; + // SqlClient and Npgsql 9: DateTime/TimeSpan), so accept either shape + internal static bool IsDateTimeFamilyConversion(Type from, Type to) +#if NET6_0_OR_GREATER + => (from == typeof(DateTime) && (to == typeof(DateOnly) || to == typeof(TimeOnly))) + || (from == typeof(DateOnly) && to == typeof(DateTime)) + || (from == typeof(TimeSpan) && to == typeof(TimeOnly)) + || (from == typeof(TimeOnly) && to == typeof(TimeSpan)); +#else + => false; +#endif + + internal static bool TryConvertDateTimeFamily(object? value, Type to, [NotNullWhen(true)] out object? converted) + { +#if NET6_0_OR_GREATER + if (to == typeof(DateOnly)) + { + if (value is DateTime dateTime) { converted = DateOnly.FromDateTime(dateTime); return true; } + } + else if (to == typeof(TimeOnly)) + { + if (value is TimeSpan timeSpan) { converted = TimeOnly.FromTimeSpan(timeSpan); return true; } + if (value is DateTime dateTime) { converted = TimeOnly.FromDateTime(dateTime); return true; } + } + else if (to == typeof(DateTime)) + { + if (value is DateOnly dateOnly) { converted = dateOnly.ToDateTime(default); return true; } + } + else if (to == typeof(TimeSpan)) + { + if (value is TimeOnly timeOnly) { converted = timeOnly.ToTimeSpan(); return true; } + } +#endif + converted = null; + return false; + } + private static readonly MethodInfo enumParse = typeof(Enum).GetMethod(nameof(Enum.Parse), [typeof(Type), typeof(string), typeof(bool)])!, getItem = typeof(DbDataReader).GetProperties(BindingFlags.Instance | BindingFlags.Public) @@ -3728,7 +3770,7 @@ private static void LoadReaderValueViaGetFieldValue(ILGenerator il, int index, T private static void LoadReaderValueOrBranchToDBNullLabel(ILGenerator il, int index, ref LocalBuilder? stringEnumLocal, LocalBuilder? valueCopyLocal, Type colType, Type memberType, out Label isDbNullLabel, out bool popWhenNull) { isDbNullLabel = il.DefineLabel(); - if (UseGetFieldValue(memberType)) + if (UseGetFieldValue(memberType) && !IsDateTimeFamilyConversion(colType, Nullable.GetUnderlyingType(memberType) ?? memberType)) { LoadReaderValueViaGetFieldValue(il, index, memberType, valueCopyLocal, isDbNullLabel, out popWhenNull); return; @@ -3805,7 +3847,16 @@ private static void LoadReaderValueOrBranchToDBNullLabel(ILGenerator il, int ind { TypeCode dataTypeCode = Type.GetTypeCode(colType), unboxTypeCode = Type.GetTypeCode(unboxType); bool hasTypeHandler; - if ((hasTypeHandler = typeHandlers.ContainsKey(unboxType)) || colType == unboxType || dataTypeCode == unboxTypeCode || dataTypeCode == Type.GetTypeCode(nullUnderlyingType)) + // note the TypeCode tests are only meaningful for distinct codes: TypeCode.Object + // matching TypeCode.Object says nothing (TimeSpan vs TimeOnly?, say), and a direct + // unbox there throws; such pairs belong to the flexible-convert path below. A column + // reported as plain *object* (sql_variant etc) keeps the direct unbox: the runtime + // box is the only truth available there + if ((hasTypeHandler = typeHandlers.ContainsKey(unboxType)) || colType == unboxType + || colType == typeof(object) || unboxType == typeof(object) + || (dataTypeCode == unboxTypeCode && dataTypeCode != TypeCode.Object) + || (dataTypeCode == Type.GetTypeCode(nullUnderlyingType) && dataTypeCode != TypeCode.Object) + || colType == nullUnderlyingType) { if (hasTypeHandler) { @@ -3831,6 +3882,36 @@ private static void LoadReaderValueOrBranchToDBNullLabel(ILGenerator il, int ind } } +#if NET6_0_OR_GREATER + private static MethodInfo? GetDateTimeFamilyConversion(Type from, Type to) + { + string? name = null; + if (from == typeof(DateTime)) + { + if (to == typeof(DateOnly)) name = nameof(DateTimeToDateOnly); + else if (to == typeof(TimeOnly)) name = nameof(DateTimeToTimeOnly); + } + else if (from == typeof(DateOnly)) + { + if (to == typeof(DateTime)) name = nameof(DateOnlyToDateTime); + } + else if (from == typeof(TimeSpan)) + { + if (to == typeof(TimeOnly)) name = nameof(TimeSpanToTimeOnly); + } + else if (from == typeof(TimeOnly)) + { + if (to == typeof(TimeSpan)) name = nameof(TimeOnlyToTimeSpan); + } + return name is null ? null : typeof(SqlMapper).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic); + } + private static DateOnly DateTimeToDateOnly(DateTime value) => DateOnly.FromDateTime(value); + private static TimeOnly DateTimeToTimeOnly(DateTime value) => TimeOnly.FromDateTime(value); + private static DateTime DateOnlyToDateTime(DateOnly value) => value.ToDateTime(default); + private static TimeOnly TimeSpanToTimeOnly(TimeSpan value) => TimeOnly.FromTimeSpan(value); + private static TimeSpan TimeOnlyToTimeSpan(TimeOnly value) => value.ToTimeSpan(); +#endif + private static void FlexibleConvertBoxedFromHeadOfStack(ILGenerator il, Type from, Type to, Type? via) { MethodInfo? op; @@ -3844,6 +3925,14 @@ private static void FlexibleConvertBoxedFromHeadOfStack(ILGenerator il, Type fro il.Emit(OpCodes.Unbox_Any, from); // stack is now [target][target][data-typed-value] il.Emit(OpCodes.Call, op); // stack is now [target][target][typed-value] } +#if NET6_0_OR_GREATER + else if (GetDateTimeFamilyConversion(from, via ?? to) is { } conversion) + { + // no IConvertible bridge exists inside the date/time family (see Parse) + il.Emit(OpCodes.Unbox_Any, from); // stack is now [target][target][data-typed-value] + il.Emit(OpCodes.Call, conversion); // stack is now [target][target][typed-value] + } +#endif else { bool handled = false; diff --git a/tests/Dapper.Tests/DateTimeOnlyTests.cs b/tests/Dapper.Tests/DateTimeOnlyTests.cs index cabad699d..ead99355f 100644 --- a/tests/Dapper.Tests/DateTimeOnlyTests.cs +++ b/tests/Dapper.Tests/DateTimeOnlyTests.cs @@ -9,7 +9,7 @@ namespace Dapper.Tests; [Collection("DateTimeOnlyTests")] public sealed class SystemSqlClientDateTimeOnlyTests : DateTimeOnlyTests { } */ -#if MSSQLCLIENT && DATEONLY +#if MSSQLCLIENT [Collection("DateTimeOnlyTests")] public sealed class MicrosoftSqlClientDateTimeOnlyTests : DateTimeOnlyTests { } #endif @@ -81,5 +81,37 @@ public void UntypedInOut() Assert.Equal(date, DateOnly.FromDateTime((DateTime)row.Date)); Assert.Equal(time, TimeOnly.FromTimeSpan((TimeSpan)row.Time)); } + + [Fact] // #2072: a datetime column into a DateOnly member - the provider boxes DateTime, + // so this must convert rather than demand GetFieldValue of a datetime column + public void MembersFromDateTimeAndTimeSpanColumns() + { + var row = connection.QuerySingle( + "select 'x' as [Name], cast('2019-10-01' as datetime) as [Date], cast('03:03:03' as time) as [Time], cast('2019-10-02' as datetime) as [NDate], cast('04:04:04' as time) as [NTime]"); + Assert.Equal(new DateOnly(2019, 10, 1), row.Date); + Assert.Equal(new TimeOnly(3, 3, 3), row.Time); + Assert.Equal(new DateOnly(2019, 10, 2), row.NDate); + Assert.Equal(new TimeOnly(4, 4, 4), row.NTime); + } + + [Fact] // #2227: the scalar form must not silently yield default(T) + public void ScalarDateOnlyAndTimeOnly() + { + Assert.Equal(new DateOnly(2021, 1, 1), connection.QuerySingle("select cast('2021-01-01' as date)")); + Assert.Equal(new TimeOnly(3, 3, 3), connection.QuerySingle("select cast('03:03:03' as time)")); + Assert.Equal(new DateOnly(2021, 1, 1), connection.QuerySingle("select cast('2021-01-01' as date)")); + } + + [Fact] // the pre-DateOnly reading of a date column must keep working + public void DateColumnAsDateTime() + { + Assert.Equal(new DateTime(2021, 1, 1), connection.QuerySingle("select cast('2021-01-01' as date)")); + Assert.Equal(new DateTime(2021, 1, 1), connection.QuerySingle("select cast('2021-01-01' as date) as [When]").When); + } + + public class HazDateTime + { + public DateTime When { get; set; } + } } #endif diff --git a/tests/Dapper.Tests/Providers/PostgresqlTests.cs b/tests/Dapper.Tests/Providers/PostgresqlTests.cs index 261490a53..b9d60f389 100644 --- a/tests/Dapper.Tests/Providers/PostgresqlTests.cs +++ b/tests/Dapper.Tests/Providers/PostgresqlTests.cs @@ -44,6 +44,32 @@ private class Cat new Cat() { Breed = "Persian", Name="MAGNA"} }; +#if NET6_0_OR_GREATER + [FactPostgresql] // #2226: Npgsql 10 boxes DateOnly/TimeOnly for date/time columns; + // both spellings of a date read must work regardless of which box arrives + public void DateOnlyDateTimeInterchange() + { + using var conn = GetOpenNpgsqlConnection(); + Assert.Equal(new DateTime(2021, 1, 1), conn.QuerySingle("select '2021-01-01'::date")); + Assert.Equal(new DateTime(2021, 1, 1), conn.QuerySingle("select '2021-01-01'::date")); + Assert.Equal(new DateOnly(2021, 1, 1), conn.QuerySingle("select '2021-01-01'::date")); + Assert.Equal(new TimeOnly(3, 3, 3), conn.QuerySingle("select '03:03:03'::time")); + + var row = conn.QuerySingle( + "select '2021-01-01'::date as \"AsDateTime\", '2021-01-01'::date as \"AsDateOnly\", '2021-01-01'::date as \"AsNullableDateTime\""); + Assert.Equal(new DateTime(2021, 1, 1), row.AsDateTime); + Assert.Equal(new DateOnly(2021, 1, 1), row.AsDateOnly); + Assert.Equal(new DateTime(2021, 1, 1), row.AsNullableDateTime); + } + + private class DateReadings + { + public DateTime AsDateTime { get; set; } + public DateOnly AsDateOnly { get; set; } + public DateTime? AsNullableDateTime { get; set; } + } +#endif + [FactPostgresql] public void TestPostgresqlArrayParameters() {