mirror of
https://github.com/DapperLib/Dapper.git
synced 2026-10-16 21:03:18 +08:00
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:
+94
-5
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user