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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [10.0.88] - 2026-07-08

### Fixed
- Fix `.OrderBy(p => p.Nav.Prop)` and `.NavigateTo(p => p.Nav.Prop)` resolving only the leaf property name (e.g. `$orderby=FirstName`) instead of the full navigation path (`$orderby=BestFriend/FirstName`) for nested (dotted) property selectors

## [10.0.87] - 2026-06-12

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,19 +242,18 @@ public void NavigateTo_NonGenericExpr_ProducesCorrectPath()
}

/// <summary>
/// Tests that non-generic NavigateTo(expr) with a nested (dotted) member path truncates
/// to the leaf segment. Characterization test: NavigateTo shares GetMemberName with
/// OrderBy, which reads only the selector body's immediate Member.Name (no chain walk).
/// Pins this bug as a safety net before the MemberPathResolver consolidation.
/// Tests that non-generic NavigateTo(expr) with a nested (dotted) member path resolves the
/// full navigation path. NavigateTo shares GetMemberName with OrderBy; both now walk the
/// full chain instead of returning just the leaf segment.
/// </summary>
[Fact]
public void NavigateTo_NonGenericExprNested_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
public void NavigateTo_NonGenericExprNested_ResolvesFullPath()
{
// Act
var url = _client.For<Person>("People").Key("russellwhyte").NavigateTo(x => x.BestFriend!.Friends).BuildUrl();

// Assert - "BestFriend/" is silently dropped, leaving just the leaf segment
url.Should().Be("People('russellwhyte')/Friends");
// Assert
url.Should().Be("People('russellwhyte')/BestFriend/Friends");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,21 +226,31 @@ public void OrderBy_WithExpressionDescending_GeneratesCorrectUrl()
}

/// <summary>
/// Tests orderby with a nested (dotted) member path.
/// Characterization test: GetMemberName reads only the selector body's immediate
/// Member.Name (no chain walk), so a nested selector silently truncates to the leaf
/// segment, dropping the navigation property. Pins this bug as a safety net before
/// the MemberPathResolver consolidation.
/// Tests orderby with a nested (dotted) member path resolves the full navigation path.
/// OData v4's $orderby grammar supports "/"-paths through navigation properties, so this
/// is spec-compliant (unlike the still-deferred $select-family nested-path fixes).
/// </summary>
[Fact]
public void OrderBy_WithNestedExpression_TruncatesToLeafSegment_CharacterizationOfExistingBehavior()
public void OrderBy_WithNestedExpression_ResolvesFullPath()
{
var url = new ODataQueryBuilder<Person>("People", NullLogger.Instance)
.OrderBy(p => p.BestFriend!.FirstName)
.BuildUrl();

url.Should().Contain("$orderby=FirstName");
url.Should().NotContain("BestFriend");
url.Should().Contain("$orderby=BestFriend/FirstName");
}

/// <summary>
/// Tests orderby with a 3-level nested member path resolves the full navigation path.
/// </summary>
[Fact]
public void OrderBy_WithThreeLevelNestedExpression_ResolvesFullPath()
{
var url = new ODataQueryBuilder<Person>("People", NullLogger.Instance)
.OrderBy(p => p.BestFriend!.BestFriend!.FirstName)
.BuildUrl();

url.Should().Contain("$orderby=BestFriend/BestFriend/FirstName");
}

/// <summary>
Expand Down
27 changes: 27 additions & 0 deletions PanoramicData.OData.Client/MemberPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ internal static bool TryGetLeafMemberNameLoose(Expression expression, out string
return false;
}

/// <summary>
/// Resolves the full, slash-separated OData path of a selector body, using the same loose
/// Unary-gated unwrap as <see cref="TryGetLeafMemberNameLoose"/>. Unlike that method, this
/// walks the full chain via <see cref="GetFlatPath"/> instead of returning just the leaf
/// segment (e.g. <c>p.BestFriend.FirstName</c> resolves to <c>"BestFriend/FirstName"</c>).
/// Kept deliberately distinct from <see cref="TryGetLeafMemberNameLoose"/> for the same
/// reason that method is kept distinct from <see cref="GetLeafMemberNameOrEmpty"/> - the
/// gates coincide today but must not be silently unified.
/// </summary>
internal static bool TryGetPathLoose(Expression expression, out string path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ LOW RISK

Suggestion: This method duplicates the expression unwrapping logic (handling MemberExpression and UnaryExpression) found in TryGetLeafMemberNameLoose. Consider consolidating this logic into a shared helper method to improve maintainability.

Try running the following prompt in your IDE agent:

Refactor MemberPathResolver.cs to introduce a private static method TryUnwrapMemberLoose(Expression expression, out MemberExpression member) that encapsulates the logic for extracting a MemberExpression from both direct member access and unary-wrapped expressions. Update TryGetLeafMemberNameLoose and TryGetPathLoose to use this new helper.

{
if (expression is MemberExpression member)
{
path = GetFlatPath(member);
return true;
}

if (expression is UnaryExpression unary && unary.Operand is MemberExpression unaryMember)
{
path = GetFlatPath(unaryMember);
return true;
}

path = string.Empty;
return false;
}

/// <summary>
/// Determines if a property is a navigation property (vs a scalar property).
/// Navigation properties are entity references or collections of entities.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ private static string GetMemberPathFromExpression(Expression expression)
}

private static string GetMemberName(Expression<Func<T, object?>> selector) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ LOW RISK

Suggestion: This method now resolves full navigation paths rather than just the leaf member name. Rename it to GetMemberPath and update the internal name variable to path to better reflect the behavior and maintain consistency with naming conventions in the codebase.

Try running the following prompt in your IDE agent:

Rename the private static method GetMemberName to GetMemberPath in PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs, update the internal variable name to path, and update all internal calls to it.

MemberPathResolver.TryGetLeafMemberNameLoose(selector.Body, out var name)
MemberPathResolver.TryGetPathLoose(selector.Body, out var name)
? name
: throw new ArgumentException("Invalid selector expression");

Expand Down