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
4 changes: 4 additions & 0 deletions src/Linq2OData.Core/Expressions/FilterExpressionVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
21 changes: 20 additions & 1 deletion src/Linq2OData.Core/Expressions/FilterHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace Linq2OData.Core.Expressions;
using System.Xml;

namespace Linq2OData.Core.Expressions;

public static class FilterHelper
{
Expand Down Expand Up @@ -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}'";
}



}
95 changes: 95 additions & 0 deletions test/Linq2OData.Tests/FilterExpressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
Expand Down Expand Up @@ -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<Func<TestProduct, bool>> 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<Func<TestProduct, bool>> 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<Func<TestProduct, bool>> 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<Func<TestProduct, bool>> 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<Func<TestProduct, bool>> 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<Func<TestProduct, bool>> 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
}
43 changes: 43 additions & 0 deletions test/Linq2OData.Tests/ODataTimeSpanConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<TestEdmTimeEntity>>(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<List<TestEdmTimeEntity>>(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;
}
}
}
Loading