Re-enable DateOnly/TimeOnly support, fixing the defects that got it disabled (#2228)

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<T> 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<T> 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<DateOnly>/<TimeOnly> 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.
This commit is contained in:
Marc Gravell
2026-08-20 15:20:51 +01:00
committed by GitHub
parent 41d76c783c
commit 6d48ef664a
3 changed files with 153 additions and 6 deletions
+94 -5
View File
@@ -202,7 +202,7 @@ namespace Dapper
static SqlMapper()
{
typeMap = new Dictionary<Type, TypeMapEntry>(41
#if NET6_0_OR_GREATER && DATEONLY
#if NET6_0_OR_GREATER
+ 4 // {Date|Time}Only[?]
#endif
)
@@ -248,7 +248,7 @@ namespace Dapper
[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 @@ namespace Dapper
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 @@ namespace Dapper
static readonly Hashtable s_ReadViaGetFieldValueCache = [];
static Func<DbDataReader, object> UnderlyingReadViaGetFieldValueFactory<T>(int index)
=> reader => reader.IsDBNull(index) ? null! : reader.GetFieldValue<T>(index)!;
=> reader => reader.IsDBNull(index) ? null!
: IsDateTimeFamilyConversion(reader.GetFieldType(index), typeof(T)) ? (object)Parse<T>(reader.GetValue(index))!
: reader.GetFieldValue<T>(index)!;
static bool UseGetFieldValue(Type type) => typeMap.TryGetValue(type, out var mapEntry)
&& (mapEntry.Flags & TypeMapEntryFlags.UseGetFieldValue) != 0;
@@ -3196,9 +3199,48 @@ namespace Dapper
{
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 @@ namespace Dapper
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 @@ namespace Dapper
{
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 @@ namespace Dapper
}
}
#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 @@ namespace Dapper
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<T>)
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;
+33 -1
View File
@@ -9,7 +9,7 @@ namespace Dapper.Tests;
[Collection("DateTimeOnlyTests")]
public sealed class SystemSqlClientDateTimeOnlyTests : DateTimeOnlyTests<SystemSqlClientProvider> { }
*/
#if MSSQLCLIENT && DATEONLY
#if MSSQLCLIENT
[Collection("DateTimeOnlyTests")]
public sealed class MicrosoftSqlClientDateTimeOnlyTests : DateTimeOnlyTests<MicrosoftSqlClientProvider> { }
#endif
@@ -81,5 +81,37 @@ public abstract class DateTimeOnlyTests<TProvider> : TestBase<TProvider> where T
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<DateOnly> of a datetime column
public void MembersFromDateTimeAndTimeSpanColumns()
{
var row = connection.QuerySingle<HazDateTimeOnly>(
"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<DateOnly>("select cast('2021-01-01' as date)"));
Assert.Equal(new TimeOnly(3, 3, 3), connection.QuerySingle<TimeOnly>("select cast('03:03:03' as time)"));
Assert.Equal(new DateOnly(2021, 1, 1), connection.QuerySingle<DateOnly?>("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<DateTime>("select cast('2021-01-01' as date)"));
Assert.Equal(new DateTime(2021, 1, 1), connection.QuerySingle<HazDateTime>("select cast('2021-01-01' as date) as [When]").When);
}
public class HazDateTime
{
public DateTime When { get; set; }
}
}
#endif
@@ -44,6 +44,32 @@ namespace Dapper.Tests
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<DateTime>("select '2021-01-01'::date"));
Assert.Equal(new DateTime(2021, 1, 1), conn.QuerySingle<DateTime?>("select '2021-01-01'::date"));
Assert.Equal(new DateOnly(2021, 1, 1), conn.QuerySingle<DateOnly>("select '2021-01-01'::date"));
Assert.Equal(new TimeOnly(3, 3, 3), conn.QuerySingle<TimeOnly>("select '03:03:03'::time"));
var row = conn.QuerySingle<DateReadings>(
"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()
{