Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public void Expand_WithSingleNestedProperty_ProducesNestedExpandSyntax()
/// Tests that multiple independent expands still work correctly.
/// </summary>
[Fact]
public void Expand_WithMultipleIndependentProperties_ProducesCommaSepa­ratedExpands()
public void Expand_WithMultipleIndependentProperties_ProducesCommaSeparatedExpands()
{
// Arrange
var builder = new ODataQueryBuilder<Person>("People", NullLogger.Instance);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Net.Http.Headers;

namespace PanoramicData.OData.Client.Test.UnitTests;

/// <summary>
Expand Down Expand Up @@ -36,6 +38,30 @@ public void Dispose()
GC.SuppressFinalize(this);
}

/// <summary>
/// Verifies that the HTTP content written to the stream includes the required headers
/// </summary>
[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

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,15 @@ public void CrossJoin_MultipleEntitySets_ShouldIncludeAll()
}

[Fact]
public async Task CrossJoin_LessThanTwoEntitySets_ShouldThrow()
public Task CrossJoin_LessThanTwoEntitySets_ShouldThrow()
{
// Arrange
var logger = NullLogger.Instance;

// Act & Assert
var act = () => new ODataCrossJoinBuilder(["Products"], logger);
act.Should().ThrowExactly<ArgumentException>();
return Task.CompletedTask;
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime>(json, _options);
Expand Down
4 changes: 2 additions & 2 deletions PanoramicData.OData.Client/HttpMessageContent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ private async Task<byte[]> 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);
Expand Down
13 changes: 4 additions & 9 deletions PanoramicData.OData.Client/ODataClient.Batch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
ODataBatchBuilder batch,
CancellationToken cancellationToken = default)
{
if (batch.Items.Count == 0) return new ODataBatchResponse();

Check notice on line 28 in PanoramicData.OData.Client/ODataClient.Batch.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.OData.Client/ODataClient.Batch.cs#L28

Add curly braces around the nested statement(s) in this 'if' block.

var batchBoundary = $"batch_{Guid.NewGuid():N}";

LoggerMessages.ExecuteBatchAsync(_logger, batch.Items.Count, batchBoundary);
Expand Down Expand Up @@ -109,8 +111,6 @@
}

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;
}
Expand Down Expand Up @@ -240,18 +240,13 @@

private void ExtractResponseBody(string part, ODataBatchOperationResult result, List<ODataBatchOperation> 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);
}

Expand Down
170 changes: 133 additions & 37 deletions PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using PanoramicData.OData.Client.Visitors;

Check warning on line 1 in PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs#L1

File PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs has 775 non-comment lines of code
using System.Collections.Frozen;
using System.Reflection;

Expand Down Expand Up @@ -26,37 +27,42 @@
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")
};

/// <summary>
/// Determines if an expression should be evaluated (compiled and executed)
/// rather than converted to an OData property path.
/// </summary>
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;
}

/// <summary>
Expand Down Expand Up @@ -234,10 +240,20 @@

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)
Expand All @@ -258,23 +274,27 @@

private static string GetMemberPath(MemberExpression member)
{
// Use a stack to avoid List.Insert(0) which is O(n)
var pathStack = new Stack<string>();
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
Expand Down Expand Up @@ -779,4 +799,80 @@
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<T>.Value hops.
private static string BuildPlainPath(Expression? expression)
{
if (expression is null)
{
return string.Empty;
}

var pathStack = new Stack<string>();
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;

Check notice on line 873 in PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs#L873

Add curly braces around the nested statement(s) in this 'if' block.
while (e is UnaryExpression { NodeType: ExpressionType.Convert } u)

Check notice on line 874 in PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs#L874

Add curly braces around the nested statement(s) in this 'while' block.
e = u.Operand;
return e;
}
}
4 changes: 4 additions & 0 deletions PanoramicData.OData.Client/PanoramicData.OData.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="PanoramicData.OData.Client.Test" />
</ItemGroup>

</Project>
12 changes: 12 additions & 0 deletions PanoramicData.OData.Client/Visitors/ParameterReferenceVisitor.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}