diff --git a/PanoramicData.OData.Client.Test/UnitTests/ExpandWithSelectTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ExpandWithSelectTests.cs index 51c7ea1..99b0e11 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ExpandWithSelectTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ExpandWithSelectTests.cs @@ -126,6 +126,49 @@ public void ExpandWithSelect_CombinedWithOtherOptions_GeneratesCorrectUrl() url.Should().Contain("$top=10"); } + /// + /// Tests ExpandWithSelect with a nested (dotted) property in the select selector. + /// Characterization test: GetSelectFieldNames/GetDirectMemberName read only the + /// immediate Member.Name (no chain walk), so a nested selector silently truncates + /// to the leaf segment, dropping the intermediate navigation property. Pins this + /// bug as a safety net before the MemberPathResolver consolidation. + /// + [Fact] + public void ExpandWithSelect_NestedPropertyInSelector_TruncatesToLeafSegment_CharacterizationOfExistingBehavior() + { + // Arrange & Act + var url = _client.For("ReportBatchJobs") + .ExpandWithSelect( + rbj => rbj.ReportSchedule, + rs => rs.Owner!.Name); + + var builtUrl = url.BuildUrl(); + + // Assert - "Owner/" is silently dropped, leaving just the leaf segment + builtUrl.Should().Contain("$expand=ReportSchedule($select=Name)"); + } + + /// + /// Tests ExpandWithSelect with a NewExpression selector mixing a direct property and a + /// nested (dotted) property. Characterization test: same truncation as above, but via + /// the NewExpression/GetDirectMemberName branch - note this silently produces the same + /// output as selecting ReportSchedule's own Id/Name directly. + /// + [Fact] + public void ExpandWithSelect_NewExpressionWithNestedProperty_TruncatesToLeafSegment_CharacterizationOfExistingBehavior() + { + // Arrange & Act + var url = _client.For("ReportBatchJobs") + .ExpandWithSelect( + rbj => rbj.ReportSchedule, + rs => new { rs.Id, rs.Owner!.Name }); + + var builtUrl = url.BuildUrl(); + + // Assert - "Owner/" is silently dropped from the second field + builtUrl.Should().Contain("$expand=ReportSchedule($select=Id,Name)"); + } + #endregion #region Expand with NestedExpandBuilder Tests @@ -148,6 +191,29 @@ public void Expand_WithNestedSelect_GeneratesCorrectSyntax() url.Should().Contain("$expand=ReportSchedule($select=Id,Name)"); } + /// + /// Tests Expand with a nested Select using a nested (dotted) property. + /// Characterization test: NestedExpandBuilder.Select's private GetDirectMemberName + /// reads only the immediate Member.Name (no chain walk), so a nested selector + /// silently truncates to the leaf segment, dropping the intermediate navigation + /// property. Pins this bug as a safety net before the MemberPathResolver + /// consolidation. + /// + [Fact] + public void Expand_NestedSelectWithNestedProperty_TruncatesToLeafSegment_CharacterizationOfExistingBehavior() + { + // Arrange & Act + var query = _client.For("ReportBatchJobs") + .Expand( + rbj => rbj.ReportSchedule, + nested => nested.Select(rs => rs.Owner!.Name)); + + var url = query.BuildUrl(); + + // Assert - "Owner/" is silently dropped, leaving just the leaf segment + url.Should().Contain("$expand=ReportSchedule($select=Name)"); + } + /// /// Tests Expand with both nested select and nested expand. /// diff --git a/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs b/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs index 89c5b20..031ba36 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/ODataClientQueryTests.cs @@ -241,6 +241,22 @@ public void NavigateTo_NonGenericExpr_ProducesCorrectPath() url.Should().Be("People('russellwhyte')/Friends"); } + /// + /// 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. + /// + [Fact] + public void NavigateTo_NonGenericExprNested_TruncatesToLeafSegment_CharacterizationOfExistingBehavior() + { + // Act + var url = _client.For("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"); + } + /// /// Tests that As<T>() after non-generic NavigateTo preserves the navigation path. /// diff --git a/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderAnyAllTests.cs b/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderAnyAllTests.cs index ca719dc..ba74864 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderAnyAllTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderAnyAllTests.cs @@ -72,6 +72,26 @@ public void Filter_WithAnyOnNavigationProperty_GeneratesCorrectUrl() url.Should().Contain("Friends%2Fany%28f%3A%20f%2FFirstName%20eq%20%27John%27%29"); } + /// + /// Tests that Any() with a nested (two-hop) member path inside the predicate generates + /// correct URL. Characterization test: GetLambdaMemberPath currently walks the full + /// chain correctly here, but this exact scenario was previously untested. Pins it as a + /// safety net before the MemberPathResolver consolidation (which changes the internal + /// List.Insert(0,...) walk to a Stack-based one with byte-identical output). + /// + [Fact] + public void Filter_WithAnyNestedMemberPath_GeneratesCorrectUrl() + { + // Arrange & Act + var url = _queryBuilder + .Filter(p => p.Friends!.Any(f => f.BestFriend!.FirstName == "John")) + .BuildUrl(); + + // Assert + // Friends/any(f: f/BestFriend/FirstName eq 'John') + url.Should().Contain("Friends%2Fany%28f%3A%20f%2FBestFriend%2FFirstName%20eq%20%27John%27%29"); + } + /// /// Tests that Any() with greater than comparison generates correct URL. /// @@ -255,6 +275,30 @@ public void Filter_AnyWithCapturedVariable_GeneratesCorrectUrl() url.Should().Contain("f%2FAge%20ge%2021"); } + /// + /// Tests that a two-hop closure member access (a captured anonymous object's property, + /// rather than a captured local directly) works in an Any lambda. Characterization test: + /// GetLambdaMemberPath walks the chain until it hits the ConstantExpression root, then + /// discards the partially-built path and evaluates the original member expression + /// directly - this exercises that discard-and-evaluate branch with more than one hop + /// before the root, which the existing single-hop closure tests don't reach. Pins this + /// as a safety net before the MemberPathResolver consolidation. + /// + [Fact] + public void Filter_WithAnyNestedClosureMemberAccess_GeneratesCorrectUrl() + { + // Arrange + var ageFilter = new { MinAge = 21 }; + + // Act + var url = _queryBuilder + .Filter(p => p.Friends!.Any(f => f.Age >= ageFilter.MinAge)) + .BuildUrl(); + + // Assert + url.Should().Contain("f%2FAge%20ge%2021"); + } + /// /// Tests that captured string variables work in Any lambda. /// diff --git a/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderFilterTests.cs b/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderFilterTests.cs index b27244b..91ef308 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderFilterTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderFilterTests.cs @@ -380,4 +380,25 @@ public void Filter_WithDateTimeUtcNow_EvaluatesCurrentTime() } #endregion + + #region Nested Member Paths + + /// + /// Tests filter with a nested (dotted) member path resolves the full path. + /// Characterization test: pins the currently-correct full-chain walk in GetMemberPath + /// as a safety net before the MemberPathResolver consolidation. + /// + [Fact] + public void Filter_WithNestedMemberPath_GeneratesCorrectUrl() + { + // Arrange & Act + var url = new ODataQueryBuilder("People", NullLogger.Instance) + .Filter(p => p.BestFriend!.FirstName == "John") + .BuildUrl(); + + // Assert - BestFriend/FirstName eq 'John' + url.Should().Contain("BestFriend%2FFirstName%20eq%20%27John%27"); + } + + #endregion } diff --git a/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderQueryOptionsTests.cs b/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderQueryOptionsTests.cs index 040bd25..231fa0a 100644 --- a/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderQueryOptionsTests.cs +++ b/PanoramicData.OData.Client.Test/UnitTests/QueryBuilderQueryOptionsTests.cs @@ -50,6 +50,24 @@ public void Select_MultipleCalls_CombinesFields() url.Should().Contain("$select=ID,Name,Price"); } + /// + /// Tests select with a nested (dotted) member path. + /// Characterization test: GetMemberNames currently takes only the FIRST path segment + /// (Split('/')[0]) of the full path GetMemberPath resolves, so a nested selector + /// silently collapses to just the navigation property name. Pins this quirk as a + /// safety net before the MemberPathResolver consolidation. + /// + [Fact] + public void Select_WithNestedExpression_UsesOnlyFirstSegment_CharacterizationOfExistingBehavior() + { + var url = new ODataQueryBuilder("People", NullLogger.Instance) + .Select(p => p.BestFriend!.FirstName) + .BuildUrl(); + + url.Should().Contain("$select=BestFriend"); + url.Should().NotContain("FirstName"); + } + #endregion #region $expand @@ -207,6 +225,24 @@ public void OrderBy_WithExpressionDescending_GeneratesCorrectUrl() url.Should().Contain("$orderby=Price desc"); } + /// + /// 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. + /// + [Fact] + public void OrderBy_WithNestedExpression_TruncatesToLeafSegment_CharacterizationOfExistingBehavior() + { + var url = new ODataQueryBuilder("People", NullLogger.Instance) + .OrderBy(p => p.BestFriend!.FirstName) + .BuildUrl(); + + url.Should().Contain("$orderby=FirstName"); + url.Should().NotContain("BestFriend"); + } + /// /// Tests multiple orderby clauses. /// diff --git a/PanoramicData.OData.Client/MemberPathResolver.cs b/PanoramicData.OData.Client/MemberPathResolver.cs new file mode 100644 index 0000000..4ca7f0e --- /dev/null +++ b/PanoramicData.OData.Client/MemberPathResolver.cs @@ -0,0 +1,185 @@ +using System.Reflection; + +namespace PanoramicData.OData.Client; + +/// +/// Shared logic for resolving a LINQ chain into OData path segments. +/// Consolidates what were previously several independent, hand-rolled implementations across +/// and . +/// +internal static class MemberPathResolver +{ + /// + /// Walks a member access chain (e.g. p.BestFriend.FirstName) from leaf to root, + /// returning each segment's name and , plus the terminal + /// (non-) root expression - typically the lambda parameter, + /// a closure , or . + /// + internal static (Stack<(string Name, MemberInfo Member)> Segments, Expression? Root) WalkChain(MemberExpression member) + { + var segments = new Stack<(string Name, MemberInfo Member)>(); + Expression? current = member; + + while (current is MemberExpression memberExpr) + { + segments.Push((memberExpr.Member.Name, memberExpr.Member)); + current = memberExpr.Expression; + } + + return (segments, current); + } + + /// + /// Resolves a member access chain to a flat, slash-separated OData path (e.g. BestFriend/FirstName). + /// + internal static string GetFlatPath(MemberExpression member) + { + var (segments, _) = WalkChain(member); + + // For small paths (common case), avoid string.Join allocation + return segments.Count switch + { + 0 => string.Empty, + 1 => segments.Pop().Name, + _ => string.Join("/", segments.Select(s => s.Name)) + }; + } + + /// + /// Resolves a member access chain to a root-to-leaf list of , + /// each flagged as navigation (entity reference/collection) or scalar. Returns + /// only when 's chain is empty, which + /// cannot occur given a non-null input - preserved to + /// mirror the original implementation's guard exactly. + /// + internal static List? GetExpandSegments(MemberExpression member) + { + var (chain, _) = WalkChain(member); + + if (chain.Count == 0) + { + return null; + } + + var segments = new List(chain.Count); + + foreach (var (name, memberInfo) in chain) + { + segments.Add(memberInfo is PropertyInfo propInfo + ? new ExpandSegment(propInfo.Name, IsNavigationProperty(propInfo)) + : new ExpandSegment(name, false)); + } + + return segments; + } + + /// + /// Resolves the leaf (immediate) member name of a single-hop selector body (e.g. p.Orders), + /// unwrapping a single (such as the null-forgiving operator) + /// if present. Does NOT walk nested chains - returns just the last segment's name for a + /// multi-hop selector (e.g. p.Nav.Prop resolves to "Prop"). Returns + /// if isn't (optionally Convert-wrapped) + /// a . + /// + internal static string GetLeafMemberNameOrEmpty(Expression expression) + { + if (expression is UnaryExpression unary && unary.NodeType == ExpressionType.Convert) + { + expression = unary.Operand; + } + + return expression is MemberExpression member ? member.Member.Name : string.Empty; + } + + /// + /// Resolves the leaf (immediate) member name of a single-hop selector body, using a looser + /// unwrap than : matches ANY + /// wrapping a , not just , and + /// does not itself unwrap before the primary check (only the fallback branch inspects a + /// Unary's ). Kept deliberately distinct from + /// - the two gates coincide for every expression shape + /// the C# compiler currently emits here, but must not be silently unified. Does NOT walk nested + /// chains - returns just the last segment's name for a multi-hop selector. + /// + internal static bool TryGetLeafMemberNameLoose(Expression expression, out string name) + { + if (expression is MemberExpression member) + { + name = member.Member.Name; + return true; + } + + if (expression is UnaryExpression unary && unary.Operand is MemberExpression unaryMember) + { + name = unaryMember.Member.Name; + return true; + } + + name = string.Empty; + return false; + } + + /// + /// Determines if a property is a navigation property (vs a scalar property). + /// Navigation properties are entity references or collections of entities. + /// Scalar properties are primitives, strings, dates, guids, etc. + /// + internal static bool IsNavigationProperty(PropertyInfo property) + { + var propertyType = property.PropertyType; + + // Check for nullable types - get underlying type + var underlyingType = Nullable.GetUnderlyingType(propertyType); + if (underlyingType is not null) + { + propertyType = underlyingType; + } + + // Primitives are scalar + if (propertyType.IsPrimitive) + { + return false; + } + + // Common scalar types + if (propertyType == typeof(string) || + propertyType == typeof(DateTime) || + propertyType == typeof(DateTimeOffset) || + propertyType == typeof(DateOnly) || + propertyType == typeof(TimeOnly) || + propertyType == typeof(TimeSpan) || + propertyType == typeof(Guid) || + propertyType == typeof(decimal)) + { + return false; + } + + // Enums are scalar + if (propertyType.IsEnum) + { + return false; + } + + // byte[] is scalar (used for binary data) + if (propertyType == typeof(byte[])) + { + return false; + } + + // Collections of entities are navigation properties (but not string which is IEnumerable) + if (typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) && + propertyType != typeof(string)) + { + return true; + } + + // Reference types that are classes are typically navigation properties + // (entity references like Tenant, Role, etc.) + return propertyType.IsClass; + } +} + +/// +/// Represents a segment in an expand path with property type information. +/// +internal sealed record ExpandSegment(string Name, bool IsNavigation); diff --git a/PanoramicData.OData.Client/NestedExpandBuilder.cs b/PanoramicData.OData.Client/NestedExpandBuilder.cs index 74ad930..d0546b9 100644 --- a/PanoramicData.OData.Client/NestedExpandBuilder.cs +++ b/PanoramicData.OData.Client/NestedExpandBuilder.cs @@ -29,7 +29,7 @@ public NestedExpandBuilder Select(Expression> selector) { foreach (var arg in newExpr.Arguments) { - var memberName = GetDirectMemberName(arg); + var memberName = MemberPathResolver.GetLeafMemberNameOrEmpty(arg); if (!string.IsNullOrEmpty(memberName) && !_selectFields.Contains(memberName)) { _selectFields.Add(memberName); @@ -62,16 +62,11 @@ public NestedExpandBuilder Select(string fields) /// public NestedExpandBuilder Expand(Expression> selector) { - var body = selector.Body; - - if (body is UnaryExpression unary && unary.NodeType == ExpressionType.Convert) - { - body = unary.Operand; - } + var memberName = MemberPathResolver.GetLeafMemberNameOrEmpty(selector.Body); - if (body is MemberExpression member) + if (!string.IsNullOrEmpty(memberName)) { - _expandFields.Add(member.Member.Name); + _expandFields.Add(memberName); } return this; @@ -174,19 +169,4 @@ internal string Build() return string.Join(";", options); } - - private static string GetDirectMemberName(Expression expression) - { - if (expression is UnaryExpression unary && unary.NodeType == ExpressionType.Convert) - { - expression = unary.Operand; - } - - if (expression is MemberExpression member) - { - return member.Member.Name; - } - - return string.Empty; - } } diff --git a/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs b/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs index 017878a..573c6cb 100644 --- a/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs +++ b/PanoramicData.OData.Client/ODataQueryBuilder.ExpressionParsing.cs @@ -278,26 +278,7 @@ private static string FormatInClause(string propertyPath, System.Collections.IEn return $"{propertyPath} in ({string.Join(",", formattedValues)})"; } - 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; - - while (current is MemberExpression memberExpr) - { - pathStack.Push(memberExpr.Member.Name); - current = memberExpr.Expression; - } - - // For small paths (common case), avoid string.Join allocation - return pathStack.Count switch - { - 0 => string.Empty, - 1 => pathStack.Pop(), - _ => string.Join("/", pathStack) - }; - } + private static string GetMemberPath(MemberExpression member) => MemberPathResolver.GetFlatPath(member); private static object? GetValue(Expression expression) => expression switch { @@ -527,20 +508,10 @@ private static string GetMemberPathFromExpression(Expression expression) return string.Empty; } - private static string GetMemberName(Expression> selector) - { - if (selector.Body is MemberExpression member) - { - return member.Member.Name; - } - - if (selector.Body is UnaryExpression unary && unary.Operand is MemberExpression unaryMember) - { - return unaryMember.Member.Name; - } - - throw new ArgumentException("Invalid selector expression"); - } + private static string GetMemberName(Expression> selector) => + MemberPathResolver.TryGetLeafMemberNameLoose(selector.Body, out var name) + ? name + : throw new ArgumentException("Invalid selector expression"); /// /// Gets full member paths from an expand expression. @@ -598,88 +569,9 @@ private static List GetExpandMemberPathsWithInfo(Expression(); - Expression? current = member; - - while (current is MemberExpression memberExpr) - { - if (memberExpr.Member is PropertyInfo propInfo) - { - segments.Insert(0, new ExpandSegment(propInfo.Name, IsNavigationProperty(propInfo))); - } - else - { - segments.Insert(0, new ExpandSegment(memberExpr.Member.Name, false)); - } - - current = memberExpr.Expression; - } - - if (segments.Count == 0) - { - return null; - } - - return new ExpandPathInfo(segments); - } - - /// - /// Determines if a property is a navigation property (vs a scalar property). - /// Navigation properties are entity references or collections of entities. - /// Scalar properties are primitives, strings, dates, guids, etc. - /// - private static bool IsNavigationProperty(PropertyInfo property) - { - var propertyType = property.PropertyType; - - // Check for nullable types - get underlying type - var underlyingType = Nullable.GetUnderlyingType(propertyType); - if (underlyingType is not null) - { - propertyType = underlyingType; - } - - // Primitives are scalar - if (propertyType.IsPrimitive) - { - return false; - } - - // Common scalar types - if (propertyType == typeof(string) || - propertyType == typeof(DateTime) || - propertyType == typeof(DateTimeOffset) || - propertyType == typeof(DateOnly) || - propertyType == typeof(TimeOnly) || - propertyType == typeof(TimeSpan) || - propertyType == typeof(Guid) || - propertyType == typeof(decimal)) - { - return false; - } - - // Enums are scalar - if (propertyType.IsEnum) - { - return false; - } + var segments = MemberPathResolver.GetExpandSegments(member); - // byte[] is scalar (used for binary data) - if (propertyType == typeof(byte[])) - { - return false; - } - - // Collections of entities are navigation properties (but not string which is IEnumerable) - if (typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) && - propertyType != typeof(string)) - { - return true; - } - - // Reference types that are classes are typically navigation properties - // (entity references like Tenant, Role, etc.) - return propertyType.IsClass; + return segments is null ? null : new ExpandPathInfo(segments); } /// @@ -769,11 +661,6 @@ private static void AddPathToTreeWithInfo(Dictionary nodes, AddPathToTreeWithInfo(node.Children, segments, index + 1); } - /// - /// Represents a segment in an expand path with property type information. - /// - private sealed record ExpandSegment(string Name, bool IsNavigation); - /// /// Represents an expand path with its segments and property type information. /// diff --git a/PanoramicData.OData.Client/ODataQueryBuilder.LambdaParsing.cs b/PanoramicData.OData.Client/ODataQueryBuilder.LambdaParsing.cs index adceeb1..f55d7c2 100644 --- a/PanoramicData.OData.Client/ODataQueryBuilder.LambdaParsing.cs +++ b/PanoramicData.OData.Client/ODataQueryBuilder.LambdaParsing.cs @@ -196,24 +196,19 @@ private static string ParseLambdaStringMethod(MethodCallExpression methodCall, P private static string GetLambdaMemberPath(MemberExpression member, ParameterExpression lambdaParam, string odataParamName) { - var path = new List(); - Expression? current = member; + var (segments, root) = MemberPathResolver.WalkChain(member); + var names = segments.Select(s => s.Name); - while (current is MemberExpression memberExpr) + if (root == lambdaParam) { - path.Insert(0, memberExpr.Member.Name); - current = memberExpr.Expression; + return string.Join("/", new[] { odataParamName }.Concat(names)); } - if (current == lambdaParam) - { - path.Insert(0, odataParamName); - } - else if (current is ConstantExpression) + if (root is ConstantExpression) { return FormatValue(EvaluateExpression(member)); } - return string.Join("/", path); + return string.Join("/", names); } } diff --git a/PanoramicData.OData.Client/ODataQueryBuilder.cs b/PanoramicData.OData.Client/ODataQueryBuilder.cs index fe9208a..15fe684 100644 --- a/PanoramicData.OData.Client/ODataQueryBuilder.cs +++ b/PanoramicData.OData.Client/ODataQueryBuilder.cs @@ -463,41 +463,11 @@ public ODataQueryBuilder Expand( return this; } - private static string GetNavigationPropertyName(Expression> selector) - { - var body = selector.Body; + private static string GetNavigationPropertyName(Expression> selector) => + MemberPathResolver.GetLeafMemberNameOrEmpty(selector.Body); - // Handle null-forgiving operator (!.) - if (body is UnaryExpression unary && unary.NodeType == ExpressionType.Convert) - { - body = unary.Operand; - } - - if (body is MemberExpression member) - { - return member.Member.Name; - } - - return string.Empty; - } - - private static string GetCollectionNavigationPropertyName(Expression?>> selector) - { - var body = selector.Body; - - // Handle null-forgiving operator (!.) - if (body is UnaryExpression unary && unary.NodeType == ExpressionType.Convert) - { - body = unary.Operand; - } - - if (body is MemberExpression member) - { - return member.Member.Name; - } - - return string.Empty; - } + private static string GetCollectionNavigationPropertyName(Expression?>> selector) => + MemberPathResolver.GetLeafMemberNameOrEmpty(selector.Body); private static List GetSelectFieldNames(Expression> selector) { @@ -515,7 +485,7 @@ private static List GetSelectFieldNames(Expression GetSelectFieldNames(Expression /// Adds an order by clause. ///