From dcc81440aa70ea6b0ae8b39196785eb8b46d2b61 Mon Sep 17 00:00:00 2001 From: belov-a Date: Thu, 25 Dec 2025 23:46:59 +0700 Subject: [PATCH 1/4] Fix batch failed with relative url & Content-Transfer-Encoding have duplicate binary --- .../UnitTests/NestedExpandExpressionTests.cs | 2 +- .../UnitTests/ODataBatchBuilderTests.cs | 26 +++++++++++++++++++ .../UnitTests/ODataCrossJoinTests.cs | 3 ++- .../UnitTests/ODataDateTimeConverterTests.cs | 2 +- .../HttpMessageContent.cs | 4 +-- .../ODataClient.Batch.cs | 2 -- .../PanoramicData.OData.Client.csproj | 4 +++ 7 files changed, 36 insertions(+), 7 deletions(-) diff --git a/PanoramicData.OData.Client.Test/UnitTests/NestedExpandExpressionTests.cs b/PanoramicData.OData.Client.Test/UnitTests/NestedExpandExpressionTests.cs index 58b4707..1066e8f 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/NestedExpandExpressionTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/NestedExpandExpressionTests.cs @@ -50,7 +50,7 @@ public void Expand_WithSingleNestedProperty_ProducesNestedExpandSyntax() /// Tests that multiple independent expands still work correctly. /// [Fact] - public void Expand_WithMultipleIndependentProperties_ProducesCommaSepa­ratedExpands() + public void Expand_WithMultipleIndependentProperties_ProducesCommaSeparatedExpands() { // Arrange var builder = new ODataQueryBuilder("People", NullLogger.Instance); diff --git a/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs index 061750a..a4195d4 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs @@ -1,3 +1,5 @@ +using System.Net.Http.Headers; + namespace PanoramicData.OData.Client.Test.UnitTests; /// @@ -35,6 +37,30 @@ public void Dispose() _httpClient.Dispose(); GC.SuppressFinalize(this); } + + /// + /// + /// + [Fact] + public async Task WriteToStreamAsync_ShouldIncludeRequiredHeaders() + { + using var request = new HttpRequestMessage(HttpMethod.Post, "http://test.org/Customers"); + request.Content = new StringContent("{\"Name\":\"Test\"}"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using var content = new HttpMessageContent(request); + + // Act + var resultString = await content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Assert: Check the HttpContent headers specifically + content.Headers.TryGetValues("Content-Transfer-Encoding", out var values); + values.Should().Contain("binary"); + + // Assert: Check the serialized string content + resultString.Should().Contain("POST /Customers HTTP/1.1"); + resultString.Should().Contain("Content-Type: application/json"); + } #region Get Operation Tests diff --git a/PanoramicData.OData.Client.Test/UnitTests/ODataCrossJoinTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ODataCrossJoinTests.cs index 4036682..657e2b6 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ODataCrossJoinTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ODataCrossJoinTests.cs @@ -113,7 +113,7 @@ public void CrossJoin_MultipleEntitySets_ShouldIncludeAll() } [Fact] - public async Task CrossJoin_LessThanTwoEntitySets_ShouldThrow() + public Task CrossJoin_LessThanTwoEntitySets_ShouldThrow() { // Arrange var logger = NullLogger.Instance; @@ -121,6 +121,7 @@ public async Task CrossJoin_LessThanTwoEntitySets_ShouldThrow() // Act & Assert var act = () => new ODataCrossJoinBuilder(["Products"], logger); act.Should().ThrowExactly(); + return Task.CompletedTask; } [Fact] diff --git a/PanoramicData.OData.Client.Test/UnitTests/ODataDateTimeConverterTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ODataDateTimeConverterTests.cs index 9fb8f0c..6c74915 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ODataDateTimeConverterTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ODataDateTimeConverterTests.cs @@ -101,7 +101,7 @@ public void Read_InvalidDate_ReturnsDefault() public void Read_SimpleDateFormat_ParsesCorrectly() { // Arrange - var json = "\"2024-01-15\""; + var json = "\"2024-01-15Z\""; // Act var result = JsonSerializer.Deserialize(json, _options); diff --git a/PanoramicData.OData.Client/HttpMessageContent.cs b/PanoramicData.OData.Client/HttpMessageContent.cs index d2bdbdb..dbcff90 100644 --- a/PanoramicData.OData.Client/HttpMessageContent.cs +++ b/PanoramicData.OData.Client/HttpMessageContent.cs @@ -41,14 +41,14 @@ private async Task BuildContentAsync() var sb = new StringBuilder(); // Request line: METHOD path HTTP/1.1 - var requestUri = _request.RequestUri?.PathAndQuery ?? "/"; sb.Append(_request.Method.ToString()); sb.Append(' '); + var requestUri = (_request.RequestUri?.IsAbsoluteUri == true ? _request.RequestUri.PathAndQuery : _request.RequestUri?.ToString()) ?? "/"; sb.Append(requestUri); sb.AppendLine(" HTTP/1.1"); // Host header - if (_request.RequestUri?.Host is not null) + if (_request.RequestUri is { IsAbsoluteUri: true }) { sb.Append("Host: "); sb.AppendLine(_request.RequestUri.Host); diff --git a/PanoramicData.OData.Client/ODataClient.Batch.cs b/PanoramicData.OData.Client/ODataClient.Batch.cs index f518154..51fb105 100644 --- a/PanoramicData.OData.Client/ODataClient.Batch.cs +++ b/PanoramicData.OData.Client/ODataClient.Batch.cs @@ -109,8 +109,6 @@ private HttpMessageContent BuildOperationContent(ODataBatchOperation operation) } var content = new HttpMessageContent(innerRequest); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/http"); - content.Headers.TryAddWithoutValidation("Content-Transfer-Encoding", "binary"); return content; } diff --git a/PanoramicData.OData.Client/PanoramicData.OData.Client.csproj b/PanoramicData.OData.Client/PanoramicData.OData.Client.csproj index fc38907..d69aede 100644 --- a/PanoramicData.OData.Client/PanoramicData.OData.Client.csproj +++ b/PanoramicData.OData.Client/PanoramicData.OData.Client.csproj @@ -39,4 +39,8 @@ + + + + From 29039c6ab3d8bcd88ae7b5f443b9c523c99281c2 Mon Sep 17 00:00:00 2001 From: belov-a Date: Thu, 25 Dec 2025 23:51:05 +0700 Subject: [PATCH 2/4] Add xml docx --- .../UnitTests/ODataBatchBuilderTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs index a4195d4..79712fd 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ODataBatchBuilderTests.cs @@ -37,9 +37,9 @@ public void Dispose() _httpClient.Dispose(); GC.SuppressFinalize(this); } - + /// - /// + /// Verifies that the HTTP content written to the stream includes the required headers /// [Fact] public async Task WriteToStreamAsync_ShouldIncludeRequiredHeaders() @@ -47,12 +47,12 @@ public async Task WriteToStreamAsync_ShouldIncludeRequiredHeaders() using var request = new HttpRequestMessage(HttpMethod.Post, "http://test.org/Customers"); request.Content = new StringContent("{\"Name\":\"Test\"}"); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - + using var content = new HttpMessageContent(request); - + // Act var resultString = await content.ReadAsStringAsync(TestContext.Current.CancellationToken); - + // Assert: Check the HttpContent headers specifically content.Headers.TryGetValues("Content-Transfer-Encoding", out var values); values.Should().Contain("binary"); From 494ee80730ab6f5e1d21df5ff360a09874675810 Mon Sep 17 00:00:00 2001 From: belov-a Date: Fri, 20 Mar 2026 00:31:08 +0700 Subject: [PATCH 3/4] fix json body parse --- PanoramicData.OData.Client/ODataClient.Batch.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/PanoramicData.OData.Client/ODataClient.Batch.cs b/PanoramicData.OData.Client/ODataClient.Batch.cs index 51fb105..df6d75b 100644 --- a/PanoramicData.OData.Client/ODataClient.Batch.cs +++ b/PanoramicData.OData.Client/ODataClient.Batch.cs @@ -25,6 +25,8 @@ internal async Task ExecuteBatchAsync( ODataBatchBuilder batch, CancellationToken cancellationToken = default) { + if (batch.Items.Count == 0) return new ODataBatchResponse(); + var batchBoundary = $"batch_{Guid.NewGuid():N}"; LoggerMessages.ExecuteBatchAsync(_logger, batch.Items.Count, batchBoundary); @@ -238,18 +240,13 @@ private static string ExtractOperationId(string part, List private void ExtractResponseBody(string part, ODataBatchOperationResult result, List operations, int operationIndex) { - var bodyStart = part.IndexOf("\r\n\r\n", StringComparison.Ordinal); - if (bodyStart == -1) - { - bodyStart = part.IndexOf("\n\n", StringComparison.Ordinal); - } - + var bodyStart = part.IndexOf('{', StringComparison.Ordinal); if (bodyStart < 0) { return; } - result.ResponseBody = part[(bodyStart + 4)..].Trim(); + result.ResponseBody = part[bodyStart..].Trim(); TryDeserializeResult(result, operations, operationIndex); } From be100bb72ae53b0a489abb1616cdb578a71a4df9 Mon Sep 17 00:00:00 2001 From: belov-a Date: Fri, 20 Mar 2026 00:49:38 +0700 Subject: [PATCH 4/4] add evaluation of non parameterized expression & support odata date functions --- .../ODataQueryBuilder.ExpressionParsing.cs | 170 ++++++++++++++---- .../Visitors/ParameterReferenceVisitor.cs | 12 ++ 2 files changed, 145 insertions(+), 37 deletions(-) create mode 100644 PanoramicData.OData.Client/Visitors/ParameterReferenceVisitor.cs diff --git a/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs b/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs index be1ad93..a212a80 100644 --- a/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs +++ b/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs @@ -1,3 +1,4 @@ +using PanoramicData.OData.Client.Visitors; using System.Collections.Frozen; using System.Reflection; @@ -26,37 +27,42 @@ public partial class ODataQueryBuilder where T : class private static string ExpressionToODataFilter(Expression expression) => ExpressionToODataFilter(expression, parentOperator: null); - private static string ExpressionToODataFilter(Expression expression, ExpressionType? parentOperator) => expression switch - { - BinaryExpression binary => ParseBinaryExpression(binary, parentOperator), - MethodCallExpression methodCall => ParseMethodCallExpression(methodCall), - UnaryExpression unary when unary.NodeType == ExpressionType.Not => $"not ({ExpressionToODataFilter(unary.Operand, parentOperator)})", - UnaryExpression unary when unary.NodeType == ExpressionType.Convert => ExpressionToODataFilter(unary.Operand, parentOperator), - MemberExpression member when member.Type == typeof(bool) && !ShouldEvaluate(member) => GetMemberPath(member), - MemberExpression member when ShouldEvaluate(member) => FormatValue(EvaluateExpression(member)), - MemberExpression member => GetMemberPath(member), - ConstantExpression constant => FormatValue(constant.Value), - _ => throw new NotSupportedException($"Expression type {expression.NodeType} is not supported") - }; + private static string ExpressionToODataFilter(Expression expression, ExpressionType? parentOperator) => + expression switch + { + BinaryExpression binary => ParseBinaryExpression(binary, parentOperator), + + // If a call is fully constant (no lambda parameter inside), evaluate it for ANY return type. + MethodCallExpression methodCall when ShouldEvaluate(methodCall) => FormatValue( + EvaluateExpression(methodCall)), + MethodCallExpression methodCall => ParseMethodCallExpression(methodCall), + + UnaryExpression { NodeType: ExpressionType.Not } unary => + $"not ({ExpressionToODataFilter(unary.Operand, parentOperator)})", + UnaryExpression { NodeType: ExpressionType.Convert } unary => ExpressionToODataFilter( + unary.Operand, parentOperator), + + MemberExpression member when member.Type == typeof(bool) && !ShouldEvaluate(member) => + GetMemberPath(member), + MemberExpression member when ShouldEvaluate(member) => FormatValue(EvaluateExpression(member)), + MemberExpression member => GetMemberPath(member), + + ConstantExpression constant => FormatValue(constant.Value), + _ => throw new NotSupportedException($"Expression type {expression.NodeType} is not supported") + }; /// /// Determines if an expression should be evaluated (compiled and executed) /// rather than converted to an OData property path. /// - private static bool ShouldEvaluate(MemberExpression member) + private static bool ShouldEvaluate(Expression expression) { - Expression? current = member; - while (current is MemberExpression memberExpr) - { - current = memberExpr.Expression; - } + expression = StripConvert(expression) ?? expression; - if (current is null) - { - return true; - } + var visitor = new ParameterReferenceVisitor(); + visitor.Visit(expression); - return current is ConstantExpression; + return !visitor.FoundParameter; } /// @@ -234,10 +240,20 @@ private static string ParseStringMethodCall(MethodCallExpression methodCall, str private static string GetStringExpressionPath(Expression expression) => expression switch { + MemberExpression member when ShouldEvaluate(member) => FormatValue(EvaluateExpression(member)), MemberExpression member => GetMemberPath(member), + + // Same fix for string-path building: if a nested call is constant, evaluate it. + MethodCallExpression nestedMethodCall when ShouldEvaluate(nestedMethodCall) => FormatValue( + EvaluateExpression(nestedMethodCall)), MethodCallExpression nestedMethodCall => ParseMethodCallExpression(nestedMethodCall), - UnaryExpression unary when unary.Operand is MemberExpression unaryMember => GetMemberPath(unaryMember), - _ => throw new NotSupportedException($"Expression type {expression.GetType().Name} is not supported for string operations") + + UnaryExpression { Operand: MemberExpression unaryMember } when ShouldEvaluate(unaryMember) + => FormatValue(EvaluateExpression(unaryMember)), + UnaryExpression { Operand: MemberExpression unaryMember } => GetMemberPath(unaryMember), + + _ => throw new NotSupportedException( + $"Expression type {expression.GetType().Name} is not supported for string operations") }; private static string FormatInClause(string propertyPath, System.Collections.IEnumerable values) @@ -258,23 +274,27 @@ private static string FormatInClause(string propertyPath, System.Collections.IEn private static string GetMemberPath(MemberExpression member) { - // Use a stack to avoid List.Insert(0) which is O(n) - var pathStack = new Stack(); - Expression? current = member; + // If we're looking at a DateTime/DateTimeOffset component access (e.g. x.CreatedAt.Year), + // translate it to year(createdAt) / month(...) / etc. + var subject = StripConvert(member.Expression!); + if (subject is null) + { + return string.Empty; + } - while (current is MemberExpression memberExpr) + if (!TryGetDateComponentFunction(member, out var dateFunc) || !IsDateLike(subject.Type)) { - pathStack.Push(memberExpr.Member.Name); - current = memberExpr.Expression; + return BuildPlainPath(member); } - // For small paths (common case), avoid string.Join allocation - return pathStack.Count switch + // Allow x.CreatedAt.Value.Year (nullable hop) + if (subject is MemberExpression subjMember && IsNullableValueAccess(subjMember)) { - 0 => string.Empty, - 1 => pathStack.Pop(), - _ => string.Join("/", pathStack) - }; + subject = StripConvert(subjMember.Expression!); + } + + var innerPath = BuildPlainPath(subject); + return $"{dateFunc}({innerPath})"; } private static object? GetValue(Expression expression) => expression switch @@ -779,4 +799,80 @@ public string ToODataSyntax() return $"{Name}({string.Join(";", options)})"; } } + + private static bool IsNullableValueAccess(MemberExpression m) => + m.Member.Name == "Value" && Nullable.GetUnderlyingType(m.Expression?.Type ?? m.Type) is not null; + + private static bool IsDateLike(Type t) + { + t = Nullable.GetUnderlyingType(t) ?? t; + return t == typeof(DateTime) || t == typeof(DateTimeOffset); + } + + private static bool TryGetDateComponentFunction(MemberExpression memberExpression, out string functionName) + { + functionName = string.Empty; + if (memberExpression.Expression is null) + { + return false; + } + + var isDateLike = IsDateLike(memberExpression.Expression!.Type); + if (!isDateLike) + { + return false; + } + + var memberName = memberExpression.Member.Name; + functionName = memberName switch + { + nameof(DateTime.Year) => "year", + nameof(DateTime.Month) => "month", + nameof(DateTime.Day) => "day", + nameof(DateTime.Hour) => "hour", + nameof(DateTime.Minute) => "minute", + nameof(DateTime.Second) => "second", + _ => string.Empty + }; + return functionName.Length != 0; + } + + // Builds a plain OData property path like "A/B/C", skipping Nullable.Value hops. + private static string BuildPlainPath(Expression? expression) + { + if (expression is null) + { + return string.Empty; + } + + var pathStack = new Stack(); + var current = StripConvert(expression); + + while (current is MemberExpression memberExpr) + { + if (IsNullableValueAccess(memberExpr)) + { + current = StripConvert(memberExpr.Expression); + continue; + } + + pathStack.Push(memberExpr.Member.Name); + current = StripConvert(memberExpr.Expression); + } + + return pathStack.Count switch + { + 0 => string.Empty, + 1 => pathStack.Pop(), + _ => string.Join("/", pathStack) + }; + } + + private static Expression? StripConvert(Expression? e) + { + if (e is null) return null; + while (e is UnaryExpression { NodeType: ExpressionType.Convert } u) + e = u.Operand; + return e; + } } diff --git a/PanoramicData.OData.Client/Visitors/ParameterReferenceVisitor.cs b/PanoramicData.OData.Client/Visitors/ParameterReferenceVisitor.cs new file mode 100644 index 0000000..0117808 --- /dev/null +++ b/PanoramicData.OData.Client/Visitors/ParameterReferenceVisitor.cs @@ -0,0 +1,12 @@ +namespace PanoramicData.OData.Client.Visitors; + +internal sealed class ParameterReferenceVisitor : ExpressionVisitor +{ + public bool FoundParameter { get; private set; } + + protected override Expression VisitParameter(ParameterExpression node) + { + FoundParameter = true; + return base.VisitParameter(node); + } +} \ No newline at end of file