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..79712fd 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; /// @@ -36,6 +38,30 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Verifies that the HTTP content written to the stream includes the required headers + /// + [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 defa00e..bc630e4 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..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); @@ -109,8 +111,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; } @@ -240,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); } 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/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 @@ + + + + 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