diff --git a/Insight.Database.Core/CodeGenerator/DbParameterGenerator.cs b/Insight.Database.Core/CodeGenerator/DbParameterGenerator.cs index e9740ef..67e2fe6 100644 --- a/Insight.Database.Core/CodeGenerator/DbParameterGenerator.cs +++ b/Insight.Database.Core/CodeGenerator/DbParameterGenerator.cs @@ -82,6 +82,12 @@ static class DbParameterGenerator { typeof(DateTime?), DbType.DateTime }, { typeof(DateTimeOffset?), DbType.DateTimeOffset }, { typeof(TimeSpan?), DbType.Time }, +#if NET6_0_OR_GREATER + { typeof(DateOnly), DbType.Date }, + { typeof(DateOnly?), DbType.Date }, + { typeof(TimeOnly), DbType.Time }, + { typeof(TimeOnly?), DbType.Time }, +#endif { TypeHelper.LinqBinaryType, DbType.Binary }, }; @@ -297,6 +303,27 @@ private static Action CreateClassInputParameterGenerator(IDb Label readyToSetLabel = il.DefineLabel(); ClassPropInfo.EmitGetValue(type, mapping.PathToMember, il, readyToSetLabel); +#if NET6_0_OR_GREATER + // Convert DateOnly to DateTime and TimeOnly to TimeSpan FIRST + // so that TimeOnly→TimeSpan can then be converted to DateTime if needed + var underlyingType = Nullable.GetUnderlyingType(memberType) ?? memberType; + if (underlyingType == typeof(DateOnly)) + { + // Call helper method to convert DateOnly to DateTime + il.Emit(OpCodes.Call, typeof(DbParameterGenerator).GetMethod("ConvertDateOnlyToDateTime", BindingFlags.NonPublic | BindingFlags.Static)); + } + else if (underlyingType == typeof(TimeOnly)) + { + // Call helper method to convert TimeOnly to TimeSpan + il.Emit(OpCodes.Call, typeof(DbParameterGenerator).GetMethod("ConvertTimeOnlyToTimeSpan", BindingFlags.NonPublic | BindingFlags.Static)); + } + else if (underlyingType == typeof(object)) + { + // For object type, check runtime type and convert if needed + il.Emit(OpCodes.Call, typeof(DbParameterGenerator).GetMethod("ConvertDateTimeOnlyTypes", BindingFlags.NonPublic | BindingFlags.Static)); + } +#endif + // special conversions for timespan to datetime if ((sqlType == DbType.Time && dbParameter.DbType != DbType.Time) || (dbParameter.DbType == DbType.DateTime || dbParameter.DbType == DbType.DateTime2 || dbParameter.DbType == DbType.DateTimeOffset)) @@ -468,6 +495,57 @@ private static void SetParameterStringValue(IDbDataParameter parameter, object v } } +#if NET6_0_OR_GREATER + /// + /// Convert DateOnly to DateTime. + /// + /// The DateOnly value. + /// The DateTime value. + private static object ConvertDateOnlyToDateTime(object value) + { + if (value == null || value is DBNull) + return DBNull.Value; + + var dateOnly = (DateOnly)value; + return dateOnly.ToDateTime(TimeOnly.MinValue); + } + + /// + /// Convert TimeOnly to TimeSpan. + /// + /// The TimeOnly value. + /// The TimeSpan value. + private static object ConvertTimeOnlyToTimeSpan(object value) + { + if (value == null || value is DBNull) + return DBNull.Value; + + var timeOnly = (TimeOnly)value; + return timeOnly.ToTimeSpan(); + } + + /// + /// Convert DateOnly/TimeOnly to DateTime/TimeSpan if needed (checks runtime type). + /// + /// The value to potentially convert. + /// The converted value or original value. + private static object ConvertDateTimeOnlyTypes(object value) + { + if (value == null || value is DBNull) + return value; + + var valueType = value.GetType(); + var underlyingType = Nullable.GetUnderlyingType(valueType) ?? valueType; + + if (underlyingType == typeof(DateOnly)) + return ConvertDateOnlyToDateTime(value); + else if (underlyingType == typeof(TimeOnly)) + return ConvertTimeOnlyToTimeSpan(value); + + return value; + } +#endif + /// /// Create a parameter generator for a dynamic object. /// @@ -510,6 +588,24 @@ private static Action CreateDynamicInputParameterGenerator(I } } + // Convert DateOnly/TimeOnly to DateTime/TimeSpan before setting parameter value +#if NET6_0_OR_GREATER + if (value != null && !(value is DBNull)) + { + var valueType = value.GetType(); + var underlyingType = Nullable.GetUnderlyingType(valueType) ?? valueType; + + if (underlyingType == typeof(DateOnly)) + { + value = ConvertDateOnlyToDateTime(value); + } + else if (underlyingType == typeof(TimeOnly)) + { + value = ConvertTimeOnlyToTimeSpan(value); + } + } +#endif + p.Value = value; // if it's a string, fill in the length diff --git a/Insight.Database.Core/CodeGenerator/DbReaderDeserializer.cs b/Insight.Database.Core/CodeGenerator/DbReaderDeserializer.cs index c28f2f4..283d8c9 100644 --- a/Insight.Database.Core/CodeGenerator/DbReaderDeserializer.cs +++ b/Insight.Database.Core/CodeGenerator/DbReaderDeserializer.cs @@ -74,6 +74,10 @@ static DbReaderDeserializer() _simpleDeserializers.TryAdd(typeof(DateTime), GetValueDeserializer()); _simpleDeserializers.TryAdd(typeof(DateTimeOffset), GetValueDeserializer()); _simpleDeserializers.TryAdd(typeof(TimeSpan), GetValueDeserializer(typeof(TimeSpan))); +#if NET6_0_OR_GREATER + _simpleDeserializers.TryAdd(typeof(DateOnly), GetValueDeserializer(typeof(DateOnly))); + _simpleDeserializers.TryAdd(typeof(TimeOnly), GetValueDeserializer(typeof(TimeOnly))); +#endif _simpleDeserializers.TryAdd(typeof(byte?), GetValueDeserializer()); _simpleDeserializers.TryAdd(typeof(short?), GetValueDeserializer()); @@ -85,6 +89,10 @@ static DbReaderDeserializer() _simpleDeserializers.TryAdd(typeof(DateTime?), GetValueDeserializer()); _simpleDeserializers.TryAdd(typeof(DateTimeOffset?), GetValueDeserializer()); _simpleDeserializers.TryAdd(typeof(TimeSpan?), GetValueDeserializer(typeof(TimeSpan?))); +#if NET6_0_OR_GREATER + _simpleDeserializers.TryAdd(typeof(DateOnly?), GetValueDeserializer(typeof(DateOnly?))); + _simpleDeserializers.TryAdd(typeof(TimeOnly?), GetValueDeserializer(typeof(TimeOnly?))); +#endif } #endregion @@ -251,6 +259,20 @@ private static Delegate CreateValueDeserializer(Type type) // convert whatever object it is to a boxed timespan il.Emit(OpCodes.Call, typeof(TypeConverterGenerator).GetMethod("SqlObjectToTimeSpan")); } +#if NET6_0_OR_GREATER + else if (type == typeof(DateOnly) || type == typeof(DateOnly?)) + { + // convert DateTime to DateOnly + il.Emit(OpCodes.Unbox_Any, typeof(DateTime)); + il.Emit(OpCodes.Call, typeof(DateOnly).GetMethod("FromDateTime", new[] { typeof(DateTime) })); + il.Emit(OpCodes.Box, typeof(DateOnly)); + } + else if (type == typeof(TimeOnly) || type == typeof(TimeOnly?)) + { + // convert SQL value (DateTime or TimeSpan) to TimeOnly + il.Emit(OpCodes.Call, typeof(TypeConverterGenerator).GetMethod("SqlObjectToTimeOnly")); + } +#endif // not null, so unbox it and return it if (underlyingType != null) diff --git a/Insight.Database.Core/CodeGenerator/TypeConverterGenerator.cs b/Insight.Database.Core/CodeGenerator/TypeConverterGenerator.cs index 525e984..f034b12 100644 --- a/Insight.Database.Core/CodeGenerator/TypeConverterGenerator.cs +++ b/Insight.Database.Core/CodeGenerator/TypeConverterGenerator.cs @@ -123,6 +123,34 @@ public static void EmitConvertValue(ILGenerator il, string memberName, Type sour // after: stack => [target][xDocument] } +#if NET6_0_OR_GREATER + else if (underlyingTargetType == typeof(DateOnly)) + { + // Convert DateTime to DateOnly + // before: stack => [target][object-value] + il.Emit(OpCodes.Unbox_Any, typeof(DateTime)); + il.Emit(OpCodes.Call, typeof(DateOnly).GetMethod("FromDateTime", new[] { typeof(DateTime) })); + + // if the target is nullable, then construct the nullable + if (Nullable.GetUnderlyingType(targetType) != null) + il.Emit(OpCodes.Newobj, targetType.GetConstructor(new[] { typeof(DateOnly) })); + + // after: stack => [target][DateOnly or DateOnly?] + } + else if (underlyingTargetType == typeof(TimeOnly)) + { + // Convert DateTime or TimeSpan to TimeOnly + // before: stack => [target][object-value] + il.Emit(OpCodes.Call, typeof(TypeConverterGenerator).GetMethod("SqlObjectToTimeOnly")); + il.Emit(OpCodes.Unbox_Any, typeof(TimeOnly)); + + // if the target is nullable, then construct the nullable + if (Nullable.GetUnderlyingType(targetType) != null) + il.Emit(OpCodes.Newobj, targetType.GetConstructor(new[] { typeof(TimeOnly) })); + + // after: stack => [target][TimeOnly or TimeOnly?] + } +#endif else if (serializer != null && serializer.CanDeserialize(sourceType, targetType)) { // we are getting a string from the database, but the target is not a string, and it's a reference type @@ -380,6 +408,33 @@ public static object SqlObjectToTimeSpan(object o) // We don't know how to convert it. Let .NET handle it. return o; } + +#if NET6_0_OR_GREATER + /// + /// Converts a SQL DateTime or TimeSpan to a .NET TimeOnly. + /// + /// The object to convert. + /// The corresponding .NET TimeOnly. + public static object SqlObjectToTimeOnly(object o) + { + if (o == null) + return null; + + // Convert DateTime to TimeSpan first, then to TimeOnly + if (o is DateTime) + { + var timeSpan = SqlDateTimeToTimeSpan((DateTime)o); + return TimeOnly.FromTimeSpan(timeSpan); + } + + // If it's already a TimeSpan, convert to TimeOnly + if (o is TimeSpan) + return TimeOnly.FromTimeSpan((TimeSpan)o); + + // Already TimeOnly or unknown type + return o; + } +#endif #endregion #region Code Generation Helpers diff --git a/Insight.Database.Core/CodeGenerator/TypeHelper.cs b/Insight.Database.Core/CodeGenerator/TypeHelper.cs index 85eb5ff..8ecc2cf 100644 --- a/Insight.Database.Core/CodeGenerator/TypeHelper.cs +++ b/Insight.Database.Core/CodeGenerator/TypeHelper.cs @@ -86,6 +86,10 @@ public static bool IsAtomicType(Type type) if (type == typeof(DateTimeOffset)) return true; if (type == typeof(Guid)) return true; if (type == typeof(TimeSpan)) return true; +#if NET6_0_OR_GREATER + if (type == typeof(DateOnly)) return true; + if (type == typeof(TimeOnly)) return true; +#endif // all of the primitive types, array, etc. are atomic return type.GetTypeInfo().IsPrimitive; diff --git a/Insight.Tests/TypeTests.cs b/Insight.Tests/TypeTests.cs index 14bc0f5..0f02acb 100644 --- a/Insight.Tests/TypeTests.cs +++ b/Insight.Tests/TypeTests.cs @@ -206,6 +206,10 @@ public void TestTypes() NullableData.Test(DateTimeOffset.Now, _connection, "datetimeoffset"); NullableData.Test(TimeSpan.Parse("00:00:00"), _connection, "time"); NullableData.Test(TimeSpan.Parse("00:00:00"), _connection, "datetime"); +#if NET6_0_OR_GREATER + NullableData.Test(DateOnly.FromDateTime(DateTime.Now), _connection, "date"); + NullableData.Test(TimeOnly.FromTimeSpan(TimeSpan.Parse("14:30:00")), _connection, "time"); +#endif // class types Data.Test("foo", _connection, "varchar(128)"); @@ -225,7 +229,55 @@ public void TestTypes() } class TestData { public int P; } -#endregion + +#if NET6_0_OR_GREATER + [Test] + public void TestDateOnlyType() + { + var _connection = Connection(); + var testDate = new DateOnly(2024, 3, 15); + + // Test basic round-trip + var result = _connection.QuerySql("SELECT @p", new { p = testDate }).First(); + ClassicAssert.AreEqual(testDate, result); + + // Test with SQL date conversion + var result2 = _connection.QuerySql("SELECT CONVERT(date, @p)", new { p = testDate }).First(); + ClassicAssert.AreEqual(testDate, result2); + + // Test nullable DateOnly + var nullableResult = _connection.QuerySql("SELECT @p", new { p = (DateOnly?)testDate }).First(); + ClassicAssert.AreEqual(testDate, nullableResult); + + // Test null DateOnly + var nullResult = _connection.QuerySql("SELECT @p", new { p = (DateOnly?)null }).First(); + ClassicAssert.IsNull(nullResult); + } + + [Test] + public void TestTimeOnlyType() + { + var _connection = Connection(); + var testTime = new TimeOnly(14, 30, 45); + + // Test basic round-trip + var result = _connection.QuerySql("SELECT @p", new { p = testTime }).First(); + ClassicAssert.AreEqual(testTime, result); + + // Test with SQL time conversion + var result2 = _connection.QuerySql("SELECT CONVERT(time, @p)", new { p = testTime }).First(); + ClassicAssert.AreEqual(testTime, result2); + + // Test nullable TimeOnly + var nullableResult = _connection.QuerySql("SELECT @p", new { p = (TimeOnly?)testTime }).First(); + ClassicAssert.AreEqual(testTime, nullableResult); + + // Test null TimeOnly + var nullResult = _connection.QuerySql("SELECT @p", new { p = (TimeOnly?)null }).First(); + ClassicAssert.IsNull(nullResult); + } +#endif + #endregion #region Single Column Tests [Test] @@ -1072,7 +1124,7 @@ public void DateTimeShouldSerializeProperlyToTextQueries() var p = new { @ConfirmTime = s }; var result = Connection().QuerySql(sql, p).FirstOrDefault(); - ClassicAssert.AreEqual(DateTime.Parse(s), result); + ClassicAssert.AreEqual(DateTime.Parse(s, System.Globalization.CultureInfo.InvariantCulture), result); } #endregion