Unify member-path resolution behind a shared MemberPathResolver#36
Conversation
Pins current behavior (including four confirmed truncation bugs) across the 8 call sites that resolve a LINQ MemberExpression chain into an OData path segment, as a regression safety net ahead of consolidating them into a single shared resolver. No production code changes.
First step of unifying the seven independent member-path-resolution implementations. GetMemberPath (used by $filter, Contains/in clauses, string methods, and top-level Select) now delegates to the new shared resolver with byte-identical output. No behavior change - full suite green (786/786), including the new characterization tests.
…ndSegments Relocates ExpandSegment and IsNavigationProperty (both T-independent) to MemberPathResolver alongside the new GetExpandSegments, built on WalkChain. Best-covered path in the codebase (NestedExpandExpressionTests, 3-level nesting) - strong evidence the shared primitive is correct before riskier migrations rely on it. No behavior change - full suite green (786/786).
… to MemberPathResolver.GetLeafMemberNameOrEmpty Simplest, bug-free, single-hop-by-design consumer - first migration onto the new leaf-read primitive. No behavior change - full suite green (786/786).
…Empty ExpandWithSelect's nested $select field resolution now uses the shared leaf-read primitive; the local GetDirectMemberName is removed as redundant. First call site with a confirmed untested truncation bug (nested property in the select selector), gated on its new characterization test. No behavior change - full suite green (786/786).
…eafMemberNameOrEmpty First cross-class consumer of MemberPathResolver (NestedExpandBuilder<T> is a separate generic class from ODataQueryBuilder<T>), proving the non-generic static design works across both. Removes the now-unused local GetDirectMemberName. No behavior change - full suite green (786/786), including the new nested-Select truncation characterization test.
Two call sites (OrderBy, NavigateTo) re-verified against the shared primitive. This is where the Convert-vs-any-Unary discrepancy from the other leaf-read call sites lives, so it's kept as its own distinct method rather than folded into GetLeafMemberNameOrEmpty. No behavior change - full suite green (786/786), including the OrderBy/NavigateTo truncation characterization tests.
Final migration - consumes WalkChain directly rather than a flat-path helper, since Any/All predicates need the raw root expression to decide between lambda-parameter prefixing and closure-constant evaluation; that dual-root branching stays local to LambdaParsing.cs. As a side effect this replaces the old List.Insert(0,...) O(n^2) walk with the Stack-based O(n) one, with byte-identical output. Highest-risk step (genuinely different logic, weakest prior coverage) - saved for last, gated on the new two-hop-predicate and two-level-closure characterization tests. No behavior change - full suite green (786/786).
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Complexity | 1 medium |
🟢 Metrics 23 complexity · -4 duplication
Metric Results Complexity 23 Duplication -4
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Pull Request Overview
This PR consolidates LINQ member-expression resolution into a shared MemberPathResolver, which is a positive architectural step. However, the current implementation is not up to standards due to high cyclomatic complexity and inconsistent application across query builders.
The MemberPathResolver.cs file introduces a significant complexity spike (delta of 30), primarily driven by the IsNavigationProperty method, which exceeds project thresholds and presents a maintenance risk. Furthermore, while the intent was to unify path resolution, the implementation of top-level $select parsing in ODataQueryBuilder.cs remains inconsistent, using the new resolver for anonymous types (NewExpression) but reverting to legacy direct member access for single properties. This inconsistency could lead to unpredictable OData URL generation and should be addressed before merging to ensure the characterization of legacy quirks is stable.
About this PR
- Consolidation of path resolution is incomplete.
ODataQueryBuilder.ExpressionParsing.csstill contains significant parsing logic (e.g.,GetSelectFieldNames) that operates independently of theMemberPathResolver. This bypasses the goal of a single source of truth for member-path translation. - There is systemic inconsistency in how the new resolver is utilized. Several files still access
member.Member.Namedirectly instead of using the resolver's leaf-name methods, which undermines the goal of simplifying future support for attributes like[JsonPropertyName].
1 comment outside of the diff
PanoramicData.OData.Client/NestedExpandBuilder.cs
line 41⚪ LOW RISK
Suggestion: ```suggestion
_selectFields.Add(MemberPathResolver.GetLeafMemberNameOrEmpty(member));
Test suggestions
- Verify $filter correctly resolves full nested paths (p.A.B -> A/B)
- Verify top-level $select preserves legacy first-segment truncation quirk (p.A.B -> A)
- Verify $orderby preserves legacy leaf-segment truncation quirk (p.A.B -> B)
- Verify NavigateTo(p.A.B) preserves legacy leaf-segment truncation (A/B -> B)
- Verify ExpandWithSelect and NestedExpandBuilder.Select preserve leaf-segment truncation
- Verify Any/All predicates correctly resolve full nested paths inside the lambda (f => f.A.B -> A/B)
- Verify nested closure member access evaluation (captured.Nested.Prop -> Value) in Any/All predicates
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| /// Navigation properties are entity references or collections of entities. | ||
| /// Scalar properties are primitives, strings, dates, guids, etc. | ||
| /// </summary> | ||
| internal static bool IsNavigationProperty(PropertyInfo property) |
There was a problem hiding this comment.
🔴 HIGH RISK
The IsNavigationProperty method exceeds complexity limits (15 vs limit of 8). This is largely due to the serial type checks for scalar types. You can simplify this and reduce the branching factor by using a lookup set for known scalar types and grouping the boolean checks.
Try running the following prompt in your IDE agent:
Refactor the
IsNavigationPropertymethod inMemberPathResolver.csto reduce its cyclomatic complexity below 8. Consolidate the individual type checks (string, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, Guid, decimal, byte[]) into a staticHashSet<Type>and simplify the conditional logic for primitives, enums, and collections.
| foreach (var arg in newExpr.Arguments) | ||
| { | ||
| var memberName = GetDirectMemberName(arg); | ||
| var memberName = MemberPathResolver.GetLeafMemberNameOrEmpty(arg); |
There was a problem hiding this comment.
🟡 MEDIUM RISK
The property resolution logic is inconsistent between 'NewExpression' and 'MemberExpression' branches. While the 'NewExpression' branch resolves to the leaf name ('FirstName'), the single property branch in 'ExpressionParsing.cs' resolves to the first path segment ('BestFriend'). These should be unified behind a consistent strategy in 'MemberPathResolver' to ensure the generated OData $select is predictable.
There was a problem hiding this comment.
Pull Request Overview
This PR successfully consolidates seven disparate LINQ MemberExpression resolution implementations into a shared internal MemberPathResolver. It achieves the goal of eliminating duplication and improves the performance of Any/All predicates from O(n²) to O(n) using a stack-based approach. All acceptance criteria, including the preservation of legacy path-truncation quirks, have been verified via characterization tests.
However, the PR is currently not up to standards according to the quality analysis. The main concern is the significant increase in cyclomatic complexity within MemberPathResolver.cs (+30). Specifically, the IsNavigationProperty method exceeds complexity thresholds and requires refactoring to maintainable patterns like switch expressions or static HashSets before this PR should be merged.
Test suggestions
- Filter resolves full nested member paths (e.g., p.A.B -> A/B)
- OrderBy/NavigateTo truncates nested paths to the leaf segment (e.g., p.A.B -> B)
- Top-level Select truncates nested paths to the root segment (e.g., p.A.B -> A)
- ExpandWithSelect and NestedExpandBuilder.Select truncate nested paths to the leaf segment
- Any/All predicates resolve full nested member paths correctly in the lambda body
- Nested closure member access (captured objects) in Any predicates evaluates correctly
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| /// Navigation properties are entity references or collections of entities. | ||
| /// Scalar properties are primitives, strings, dates, guids, etc. | ||
| /// </summary> | ||
| internal static bool IsNavigationProperty(PropertyInfo property) |
There was a problem hiding this comment.
🟡 MEDIUM RISK
The IsNavigationProperty method has high cyclomatic complexity (15) due to the sequential if-statements used for type classification. This should be refactored into a more concise switch expression or a static HashSet for scalar types to improve maintainability and satisfy quality standards.
| /// (non-<see cref="MemberExpression"/>) root expression - typically the lambda parameter, | ||
| /// a closure <see cref="ConstantExpression"/>, or <see langword="null"/>. | ||
| /// </summary> | ||
| internal static (Stack<(string Name, MemberInfo Member)> Segments, Expression? Root) WalkChain(MemberExpression member) |
There was a problem hiding this comment.
⚪ LOW RISK
Nitpick: The Name element in the Segments stack is redundant because it is already available via the MemberInfo.Name property. Consider changing the return type to Stack<MemberInfo> to simplify the walk and its consumers.
Summary
Consolidates seven independent, hand-rolled implementations that resolve a LINQ
MemberExpressionchain into an OData path segment - used by$filter, top-level$select,$orderby/NavigateTo, top-level$expand,ExpandWithSelect, theNestedExpandBuildernested-configure callback, andAny/Allpredicates - into one shared internalMemberPathResolver.Zero intended behavior change. ~11 characterization tests were added first, in their own commit, before any production code changed, pinning today's exact output for nested/dotted property paths across all seven call sites - including four pre-existing truncation bugs/quirks that are deliberately preserved, not fixed, in this PR:
.OrderBy(p => p.Nav.Prop)/.NavigateTo(p => p.Nav.Prop)silently truncate toProp, droppingNav/..Select(p => p.Nav.Prop)silently truncates toNav, dropping/Prop(opposite direction)..ExpandWithSelect(nav, x => x.Deeper.Prop)andNestedExpandBuilder's nested.Select(x => x.Deeper.Prop)truncate the same way asOrderBy.As a side effect, this also replaces the
Any/Alllambda parser'sList.Insert(0,...)(O(n²)) walk with the same Stack-based (O(n)) approach already used elsewhere, with byte-identical output.Why: while reviewing an external PR that patches
[JsonPropertyName]-awareness into exactly one of these seven call sites (creating an inconsistency -.Filter()would honor the attribute,.OrderBy()wouldn't), we found the underlying duplication was the real problem. This PR does the prerequisite consolidation only. Adopting[JsonPropertyName]support, or fixing the four truncation bugs above, is intentionally out of scope here - each becomes a single-place change toMemberPathResolveronce this lands.Verified
TreatWarningsAsErrors).git diffon those files.MemberPathResolverand the relocatedExpandSegmentrecord remaininternal- no public API surface added.Test plan
dotnet build -c Release- 0 warnings, 0 errorsdotnet test- 786/786 passed🤖 Generated with Claude Code