diff --git a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs index fbd6fe20..3cb8e7a5 100644 --- a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs +++ b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs @@ -385,6 +385,22 @@ protected override Expression VisitConstant(ConstantExpression c) return c; } + protected override Expression VisitNew(NewExpression n) + { + try + { + var lambda = Expression.Lambda(n); + var compiled = lambda.Compile(); + var value = compiled.DynamicInvoke(); + AppendByValueType(value, sb); + return n; + } + catch + { + throw new NotSupportedException($"The constructor for '{n.Type.Name}' is not supported"); + } + } + protected override Expression VisitMember(MemberExpression m) { // Handle string.Length property on entity properties @@ -578,6 +594,14 @@ private void AppendByValueType(object? value, StringBuilder sb) { sb.Append(FilterHelper.ToODataFilter(dateTimeOffset, odataVersion)); } + else if (value is TimeOnly timeOnly) + { + sb.Append(FilterHelper.ToODataFilter(timeOnly, odataVersion)); + } + else if (value is DateOnly dateOnly) + { + sb.Append(FilterHelper.ToODataFilter(dateOnly, odataVersion)); + } else { throw new NotSupportedException($"The constant for '{value}' is not supported"); @@ -599,18 +623,24 @@ private static string GetODataMemberName(MemberInfo member) { foreach (var accessName in memberAccessNames) { - var property = value.GetType().GetProperty(accessName); - if (property == null) + var type = value.GetType(); + var property = type.GetProperty(accessName); + if (property != null) { - throw new NotSupportedException($"Property '{accessName}' not found on type '{value.GetType().Name}'"); + var nextValue = property.GetValue(value); + if (nextValue == null) return null; + value = nextValue; } - - var nextValue = property.GetValue(value); - if (nextValue == null) + else { - return null; + var field = type.GetField(accessName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (field == null) + throw new NotSupportedException($"Property or field '{accessName}' not found on type '{type.Name}'"); + + var nextValue = field.GetValue(value); + if (nextValue == null) return null; + value = nextValue; } - value = nextValue; } return value; } diff --git a/src/Linq2OData.Core/Expressions/FilterHelper.cs b/src/Linq2OData.Core/Expressions/FilterHelper.cs index 2427605f..3368b886 100644 --- a/src/Linq2OData.Core/Expressions/FilterHelper.cs +++ b/src/Linq2OData.Core/Expressions/FilterHelper.cs @@ -60,6 +60,16 @@ public static string ToODataFilter(DateTimeOffset date, ODataVersion version) } } + public static string ToODataFilter(TimeOnly time, ODataVersion version) + { + return $"time'{time:HH:mm:ss}'"; + } + + public static string ToODataFilter(DateOnly date, ODataVersion version) + { + return $"date'{date:yyyy-MM-dd}'"; + } + } \ No newline at end of file diff --git a/test/Linq2OData.Tests/FilterExpressionTests.cs b/test/Linq2OData.Tests/FilterExpressionTests.cs index e3c25cca..95f7fad9 100644 --- a/test/Linq2OData.Tests/FilterExpressionTests.cs +++ b/test/Linq2OData.Tests/FilterExpressionTests.cs @@ -22,6 +22,10 @@ public class TestProduct : IODataEntitySet public DateTime? DiscontinuedDate { get; set; } public DateTimeOffset LastModified { get; set; } public DateTimeOffset? LastChecked { get; set; } + public TimeOnly OpenTime { get; set; } + public TimeOnly? CloseTime { get; set; } + public DateOnly OpenDate { get; set; } + public DateOnly? CloseDate { get; set; } public TestCategory? Category { get; set; } public string __Key => $"ID={ID}"; } @@ -256,6 +260,24 @@ public void ODataFilterVisitor_GreaterThanOrEqual_GeneratesCorrectFilter() Assert.Equal("(Stock ge 10)", result); } + [Fact] + public void ODataFilterVisitor_ConstantAsObject_GeneratesCorrectFilter() + { + + var product = new TestProduct { ID = 10, Name = "Test", Price = 9.99m, Stock = 100, IsAvailable = true, CreatedDate = DateTime.Now, LastModified = DateTimeOffset.Now, OpenTime = TimeOnly.FromTimeSpan(TimeSpan.FromHours(9)), OpenDate = DateOnly.FromDateTime(DateTime.Today) }; + + // Arrange + var visitor = new ODataFilterVisitor(); + Expression> expression = p => p.Stock >= product.ID; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(Stock ge 10)", result); + } + + #endregion #region Boolean Tests @@ -1419,4 +1441,205 @@ public void ODataFilterVisitor_EnumInComplexExpression_GeneratesCorrectFilter() } #endregion + + #region DateOnly Tests + + [Fact] + public void ODataFilterVisitor_DateOnlyConstant_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var openDate = new DateOnly(2024, 6, 15); + Expression> expression = p => p.OpenDate == openDate; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenDate eq date'2024-06-15')", result); + } + + [Fact] + public void ODataFilterVisitor_DateOnlyConstant_V2_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var openDate = new DateOnly(2024, 6, 15); + Expression> expression = p => p.OpenDate == openDate; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V2); + + // Assert + Assert.Equal("(OpenDate eq date'2024-06-15')", result); + } + + [Fact] + public void ODataFilterVisitor_DateOnlyGreaterThan_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var cutoff = new DateOnly(2024, 1, 1); + Expression> expression = p => p.OpenDate > cutoff; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenDate gt date'2024-01-01')", result); + } + + [Fact] + public void ODataFilterVisitor_DateOnlyLessThan_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var cutoff = new DateOnly(2024, 12, 31); + Expression> expression = p => p.OpenDate < cutoff; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenDate lt date'2024-12-31')", result); + } + + [Fact] + public void ODataFilterVisitor_NullableDateOnly_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var closeDate = new DateOnly(2024, 9, 30); + Expression> expression = p => p.CloseDate == closeDate; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(CloseDate eq date'2024-09-30')", result); + } + + [Fact] + public void ODataFilterVisitor_DateOnlyInlineConstant_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + Expression> expression = p => p.OpenDate >= new DateOnly(2024, 3, 1); + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenDate ge date'2024-03-01')", result); + } + + #endregion + + #region TimeOnly Tests + + [Fact] + public void ODataFilterVisitor_TimeOnlyConstant_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var openTime = new TimeOnly(9, 0, 0); + Expression> expression = p => p.OpenTime == openTime; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenTime eq time'09:00:00')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeOnlyConstant_V2_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var openTime = new TimeOnly(9, 0, 0); + Expression> expression = p => p.OpenTime == openTime; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V2); + + // Assert + Assert.Equal("(OpenTime eq time'09:00:00')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeOnlyGreaterThan_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var cutoff = new TimeOnly(17, 30, 0); + Expression> expression = p => p.OpenTime > cutoff; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenTime gt time'17:30:00')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeOnlyLessThan_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var cutoff = new TimeOnly(12, 0, 0); + Expression> expression = p => p.OpenTime < cutoff; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenTime lt time'12:00:00')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeOnlyWithSeconds_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var closeTime = new TimeOnly(23, 59, 59); + Expression> expression = p => p.OpenTime <= closeTime; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenTime le time'23:59:59')", result); + } + + [Fact] + public void ODataFilterVisitor_NullableTimeOnly_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var closeTime = new TimeOnly(18, 0, 0); + Expression> expression = p => p.CloseTime == closeTime; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(CloseTime eq time'18:00:00')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeOnlyInlineConstant_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + Expression> expression = p => p.OpenTime >= new TimeOnly(8, 30, 0); + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(OpenTime ge time'08:30:00')", result); + } + + #endregion }