diff --git a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs index 3cb8e7a..4ba1e31 100644 --- a/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs +++ b/src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs @@ -598,6 +598,10 @@ private void AppendByValueType(object? value, StringBuilder sb) { sb.Append(FilterHelper.ToODataFilter(timeOnly, odataVersion)); } + else if (value is TimeSpan timeSpan) + { + sb.Append(FilterHelper.ToODataFilter(timeSpan, odataVersion)); + } else if (value is DateOnly dateOnly) { sb.Append(FilterHelper.ToODataFilter(dateOnly, odataVersion)); diff --git a/src/Linq2OData.Core/Expressions/FilterHelper.cs b/src/Linq2OData.Core/Expressions/FilterHelper.cs index 0a55698..4a04898 100644 --- a/src/Linq2OData.Core/Expressions/FilterHelper.cs +++ b/src/Linq2OData.Core/Expressions/FilterHelper.cs @@ -1,4 +1,6 @@ -namespace Linq2OData.Core.Expressions; +using System.Xml; + +namespace Linq2OData.Core.Expressions; public static class FilterHelper { @@ -63,6 +65,23 @@ public static string ToODataFilter(DateOnly date, ODataVersion version) return $"date'{date:yyyy-MM-dd}'"; } + public static string ToODataFilter(TimeSpan? timeSpan, ODataVersion version) + { + if (!timeSpan.HasValue) return "null"; + return ToODataFilter(timeSpan.Value, version); + } + + public static string ToODataFilter(TimeSpan timeSpan, ODataVersion version) + { + var iso = XmlConvert.ToString(timeSpan); + if (version == ODataVersion.V4) + { + return $"duration'{iso}'"; + } + // OData v2/v3: Edm.Time literal + return $"time'{iso}'"; + } + } \ No newline at end of file diff --git a/test/Linq2OData.Tests/FilterExpressionTests.cs b/test/Linq2OData.Tests/FilterExpressionTests.cs index 1a89402..2285eea 100644 --- a/test/Linq2OData.Tests/FilterExpressionTests.cs +++ b/test/Linq2OData.Tests/FilterExpressionTests.cs @@ -26,6 +26,8 @@ public class TestProduct : IODataEntitySet public TimeOnly? CloseTime { get; set; } public DateOnly OpenDate { get; set; } public DateOnly? CloseDate { get; set; } + public TimeSpan Duration { get; set; } + public TimeSpan? OptionalDuration { get; set; } public TestCategory? Category { get; set; } public string _Key => $"ID={ID}"; } @@ -1642,4 +1644,97 @@ public void ODataFilterVisitor_TimeOnlyInlineConstant_V4_GeneratesCorrectFilter( } #endregion + + #region TimeSpan Tests + + [Fact] + public void ODataFilterVisitor_TimeSpanConstant_V2_GeneratesTimePrefix() + { + // Arrange - Edm.Time in OData v2 uses time'...' literal + var visitor = new ODataFilterVisitor(); + var duration = new TimeSpan(9, 18, 39); + Expression> expression = p => p.Duration == duration; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V2); + + // Assert + Assert.Equal("(Duration eq time'PT9H18M39S')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeSpanConstant_V4_GeneratesDurationPrefix() + { + // Arrange - Edm.Duration in OData v4 uses duration'...' literal + var visitor = new ODataFilterVisitor(); + var duration = new TimeSpan(9, 18, 39); + Expression> expression = p => p.Duration == duration; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(Duration eq duration'PT9H18M39S')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeSpanGreaterThan_V2_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var duration = new TimeSpan(1, 0, 0); + Expression> expression = p => p.Duration > duration; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V2); + + // Assert + Assert.Equal("(Duration gt time'PT1H')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeSpanLessThan_V4_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var duration = new TimeSpan(0, 30, 0); + Expression> expression = p => p.Duration < duration; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V4); + + // Assert + Assert.Equal("(Duration lt duration'PT30M')", result); + } + + [Fact] + public void ODataFilterVisitor_NullableTimeSpan_V2_GeneratesCorrectFilter() + { + // Arrange + var visitor = new ODataFilterVisitor(); + var duration = new TimeSpan(2, 15, 0); + Expression> expression = p => p.OptionalDuration == duration; + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V2); + + // Assert + Assert.Equal("(OptionalDuration eq time'PT2H15M')", result); + } + + [Fact] + public void ODataFilterVisitor_TimeSpanInlineConstant_V2_GeneratesCorrectFilter() + { + // Arrange - inline TimeSpan constant (the exact value from the problem report) + var visitor = new ODataFilterVisitor(); + Expression> expression = p => p.Duration == new TimeSpan(9, 18, 39); + + // Act + var result = visitor.ToFilter(expression, ODataVersion.V2); + + // Assert + Assert.Equal("(Duration eq time'PT9H18M39S')", result); + } + + #endregion } diff --git a/test/Linq2OData.Tests/ODataTimeSpanConverterTests.cs b/test/Linq2OData.Tests/ODataTimeSpanConverterTests.cs index 9039a0e..f948c66 100644 --- a/test/Linq2OData.Tests/ODataTimeSpanConverterTests.cs +++ b/test/Linq2OData.Tests/ODataTimeSpanConverterTests.cs @@ -11,6 +11,7 @@ public class ODataTimeSpanConverterTests [InlineData("PT2H", 2, 0, 0)] // ISO 8601 Duration: 2 hours [InlineData("PT15M", 0, 15, 0)] // ISO 8601 Duration: 15 minutes [InlineData("PT1H30M45S", 1, 30, 45)] // ISO 8601 Duration: 1 hour 30 minutes 45 seconds + [InlineData("PT09H18M39S", 9, 18, 39)] // OData v2 Edm.Time format from server (with leading zero in hours) [InlineData("P1DT2H", 26, 0, 0)] // ISO 8601 Duration: 1 day 2 hours = 26 hours [InlineData("1:30:00", 1, 30, 0)] // Standard TimeSpan format [InlineData("02:00:00", 2, 0, 0)] // Standard TimeSpan format @@ -149,5 +150,47 @@ public void NullableWrite_Null_ShouldWriteNull() // Assert Assert.Equal("null", json); } + + [Fact] + public void ODataV2_EdmTime_DeserializesFromODataResponse() + { + // Arrange - OData v2 wraps entity responses in {"d":{...}} + // Edm.Time arrives as ISO 8601 duration string, e.g. "PT09H18M39S" + var rawResponse = """{"d":{"results":[{"Duration":"PT09H18M39S"}]}}"""; + var odataClient = new Core.ODataClient(new System.Net.Http.HttpClient(), Core.ODataVersion.V2); + + // Act + var result = odataClient.ProcessQueryResponse>(rawResponse); + + // Assert + Assert.NotNull(result.Data); + Assert.Single(result.Data); + Assert.Equal(new TimeSpan(9, 18, 39), result.Data[0].Duration); + } + + [Fact] + public void ODataV2_EdmTime_NullableProperty_DeserializesFromODataResponse() + { + // Arrange - nullable Edm.Time property in OData v2 response + var rawResponse = """{"d":{"results":[{"Duration":"PT09H18M39S","OptionalDuration":null}]}}"""; + var odataClient = new Core.ODataClient(new System.Net.Http.HttpClient(), Core.ODataVersion.V2); + + // Act + var result = odataClient.ProcessQueryResponse>(rawResponse); + + // Assert + Assert.NotNull(result.Data); + Assert.Single(result.Data); + Assert.Equal(new TimeSpan(9, 18, 39), result.Data[0].Duration); + Assert.Null(result.Data[0].OptionalDuration); + } + + [Core.ODataEntitySet("TestEntities")] + private class TestEdmTimeEntity : Core.IODataEntitySet + { + public TimeSpan Duration { get; set; } + public TimeSpan? OptionalDuration { get; set; } + public string _Key => string.Empty; + } } }