Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 94 additions & 5 deletions Dapper/SqlMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ public static implicit operator TypeMapEntry(DbType dbType)
static SqlMapper()
{
typeMap = new Dictionary<Type, TypeMapEntry>(41
#if NET6_0_OR_GREATER && DATEONLY
#if NET6_0_OR_GREATER
+ 4 // {Date|Time}Only[?]
#endif
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1396,6 +1396,7 @@ private static T GetValue<T>(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)
Expand Down Expand Up @@ -3169,7 +3170,9 @@ static Func<DbDataReader, object> ReadViaGetFieldValueFactory(Type type, int ind
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;
Expand All @@ -3196,9 +3199,48 @@ private static T Parse<T>(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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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;
Expand All @@ -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<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;
Expand Down
34 changes: 33 additions & 1 deletion tests/Dapper.Tests/DateTimeOnlyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<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
26 changes: 26 additions & 0 deletions tests/Dapper.Tests/Providers/PostgresqlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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()
{
Expand Down
Loading