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() static SqlMapper()
{ {
typeMap = new Dictionary<Type, TypeMapEntry>(41 typeMap = new Dictionary<Type, TypeMapEntry>(41
#if NET6_0_OR_GREATER && DATEONLY #if NET6_0_OR_GREATER
+ 4 // {Date|Time}Only[?] + 4 // {Date|Time}Only[?]
#endif #endif
) )
@@ -248,7 +248,7 @@ namespace Dapper
[typeof(SqlDecimal?)] = TypeMapEntry.DecimalFieldValue, [typeof(SqlDecimal?)] = TypeMapEntry.DecimalFieldValue,
[typeof(SqlMoney)] = TypeMapEntry.DecimalFieldValue, [typeof(SqlMoney)] = 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(DateOnly)] = TypeMapEntry.DoNotSetFieldValue,
[typeof(TimeOnly)] = TypeMapEntry.DoNotSetFieldValue, [typeof(TimeOnly)] = TypeMapEntry.DoNotSetFieldValue,
[typeof(DateOnly?)] = TypeMapEntry.DoNotSetFieldValue, [typeof(DateOnly?)] = TypeMapEntry.DoNotSetFieldValue,
@@ -1396,6 +1396,7 @@ namespace Dapper
try try
{ {
var convertToType = Nullable.GetUnderlyingType(effectiveType) ?? effectiveType; var convertToType = Nullable.GetUnderlyingType(effectiveType) ?? effectiveType;
if (TryConvertDateTimeFamily(val, convertToType, out var converted)) return (T)converted;
return (T)Convert.ChangeType(val, convertToType, CultureInfo.InvariantCulture)!; return (T)Convert.ChangeType(val, convertToType, CultureInfo.InvariantCulture)!;
} }
catch (Exception ex) catch (Exception ex)
@@ -3169,7 +3170,9 @@ namespace Dapper
static readonly Hashtable s_ReadViaGetFieldValueCache = []; static readonly Hashtable s_ReadViaGetFieldValueCache = [];
static Func<DbDataReader, object> UnderlyingReadViaGetFieldValueFactory<T>(int index) 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) static bool UseGetFieldValue(Type type) => typeMap.TryGetValue(type, out var mapEntry)
&& (mapEntry.Flags & TypeMapEntryFlags.UseGetFieldValue) != 0; && (mapEntry.Flags & TypeMapEntryFlags.UseGetFieldValue) != 0;
@@ -3196,9 +3199,48 @@ namespace Dapper
{ {
return (T)handler.Parse(type, 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); 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 private static readonly MethodInfo
enumParse = typeof(Enum).GetMethod(nameof(Enum.Parse), [typeof(Type), typeof(string), typeof(bool)])!, enumParse = typeof(Enum).GetMethod(nameof(Enum.Parse), [typeof(Type), typeof(string), typeof(bool)])!,
getItem = typeof(DbDataReader).GetProperties(BindingFlags.Instance | BindingFlags.Public) 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) 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(); isDbNullLabel = il.DefineLabel();
if (UseGetFieldValue(memberType)) if (UseGetFieldValue(memberType) && !IsDateTimeFamilyConversion(colType, Nullable.GetUnderlyingType(memberType) ?? memberType))
{ {
LoadReaderValueViaGetFieldValue(il, index, memberType, valueCopyLocal, isDbNullLabel, out popWhenNull); LoadReaderValueViaGetFieldValue(il, index, memberType, valueCopyLocal, isDbNullLabel, out popWhenNull);
return; return;
@@ -3805,7 +3847,16 @@ namespace Dapper
{ {
TypeCode dataTypeCode = Type.GetTypeCode(colType), unboxTypeCode = Type.GetTypeCode(unboxType); TypeCode dataTypeCode = Type.GetTypeCode(colType), unboxTypeCode = Type.GetTypeCode(unboxType);
bool hasTypeHandler; 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) 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) private static void FlexibleConvertBoxedFromHeadOfStack(ILGenerator il, Type from, Type to, Type? via)
{ {
MethodInfo? op; 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.Unbox_Any, from); // stack is now [target][target][data-typed-value]
il.Emit(OpCodes.Call, op); // stack is now [target][target][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 else
{ {
bool handled = false; bool handled = false;
+33 -1
View File
@@ -9,7 +9,7 @@ namespace Dapper.Tests;
[Collection("DateTimeOnlyTests")] [Collection("DateTimeOnlyTests")]
public sealed class SystemSqlClientDateTimeOnlyTests : DateTimeOnlyTests<SystemSqlClientProvider> { } public sealed class SystemSqlClientDateTimeOnlyTests : DateTimeOnlyTests<SystemSqlClientProvider> { }
*/ */
#if MSSQLCLIENT && DATEONLY #if MSSQLCLIENT
[Collection("DateTimeOnlyTests")] [Collection("DateTimeOnlyTests")]
public sealed class MicrosoftSqlClientDateTimeOnlyTests : DateTimeOnlyTests<MicrosoftSqlClientProvider> { } public sealed class MicrosoftSqlClientDateTimeOnlyTests : DateTimeOnlyTests<MicrosoftSqlClientProvider> { }
#endif #endif
@@ -81,5 +81,37 @@ public abstract class DateTimeOnlyTests<TProvider> : TestBase<TProvider> where T
Assert.Equal(date, DateOnly.FromDateTime((DateTime)row.Date)); Assert.Equal(date, DateOnly.FromDateTime((DateTime)row.Date));
Assert.Equal(time, TimeOnly.FromTimeSpan((TimeSpan)row.Time)); 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 #endif
@@ -44,6 +44,32 @@ namespace Dapper.Tests
new Cat() { Breed = "Persian", Name="MAGNA"} 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] [FactPostgresql]
public void TestPostgresqlArrayParameters() public void TestPostgresqlArrayParameters()
{ {