From 3a49368009dd56bac1431e2ec28c37cc65c1fa21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Wed, 25 Mar 2026 13:48:11 +0100 Subject: [PATCH 1/2] Add OData support for DateOnly and TimeOnly types Extend filter generation to handle DateOnly and TimeOnly properties in LINQ expressions. Implement formatting helpers and update the expression visitor to support inline and constant usage. Add comprehensive tests for equality, comparison, and nullable scenarios for both OData V2 and V4. --- .../Expressions/FilterExpressionVisitor.cs | 24 ++ .../Expressions/FilterHelper.cs | 10 + .../Linq2OData.Tests/FilterExpressionTests.cs | 205 ++++++++++++++++++ 3 files changed, 239 insertions(+) diff --git a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs index fbd6fe20..7c3c32ba 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"); 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..9223566f 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}"; } @@ -1419,4 +1423,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 } From d462fe88ebf73f51d221cc74d7993aadeb22bfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Wed, 25 Mar 2026 14:34:29 +0100 Subject: [PATCH 2/2] Support field access in AccessMultipleMembers; add test Enhanced AccessMultipleMembers to handle both properties and fields when traversing member names, improving flexibility and robustness. Added a unit test to verify correct OData filter generation when using constant object properties in expressions. --- .../Expressions/FilterExpressionVisitor.cs | 22 ++++++++++++------- .../Linq2OData.Tests/FilterExpressionTests.cs | 18 +++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs index 7c3c32ba..3cb8e7a5 100644 --- a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs +++ b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs @@ -623,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/test/Linq2OData.Tests/FilterExpressionTests.cs b/test/Linq2OData.Tests/FilterExpressionTests.cs index 9223566f..95f7fad9 100644 --- a/test/Linq2OData.Tests/FilterExpressionTests.cs +++ b/test/Linq2OData.Tests/FilterExpressionTests.cs @@ -260,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