< Summary

Information
Class: NGql.Core.Builders.ExpressionFieldExtractor
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Builders/ExpressionFieldExtractor.cs
Line coverage
100%
Covered lines: 138
Uncovered lines: 0
Coverable lines: 138
Total lines: 489
Line coverage: 100%
Branch coverage
100%
Covered branches: 110
Total branches: 110
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExtractFieldPaths(...)100%11100%
ExtractFieldPaths(...)100%11100%
ExtractFieldPaths(...)100%11100%
get_FieldPaths()100%11100%
.ctor()100%11100%
VisitMember(...)100%66100%
VisitNullCoalescingChain(...)100%66100%
VisitMethodCallsInChain(...)100%66100%
VisitMethodCall(...)100%22100%
VisitReceiver(...)100%44100%
VisitRemainingArguments(...)100%22100%
VisitArgument(...)100%66100%
VisitWithLambdaContext(...)100%11100%
IsLinqMethod(...)100%88100%
GetMethodCallBasePath(...)100%11100%
BuildPathFromExpression(...)100%44100%
BuildPathFromMember(...)100%44100%
TryStepUp(...)100%88100%
VisitLambda(...)100%66100%
VisitParameter(...)100%66100%
VisitBinary(...)100%11100%
VisitUnary(...)100%11100%
VisitNew(...)100%22100%
VisitMemberInit(...)100%44100%
VisitConditional(...)100%11100%
BuildMemberPath(...)100%1212100%
get_Next()100%11100%
get_Parameter()100%11100%
get_IsTerminal()100%11100%
get_Valid()100%11100%
Continue(...)100%11100%
TerminateAt(...)100%11100%
Invalid()100%11100%
StepUpChain(...)100%1414100%
MethodCallSourceExpression(...)100%44100%
SelectConditionalBranch(...)100%44100%
ShouldExcludeProperty(...)100%22100%

File(s)

/home/runner/work/NGql/NGql/src/Core/Builders/ExpressionFieldExtractor.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2
 3namespace NGql.Core.Builders;
 4
 5/// <summary>
 6/// Extracts field paths from C# expression trees.
 7/// Supports both compile-time lambda expressions and runtime-parsed expressions.
 8/// </summary>
 9public static class ExpressionFieldExtractor
 10{
 11    /// <summary>
 12    /// Extracts field paths from a typed predicate expression.
 13    /// </summary>
 14    /// <typeparam name="T">The type being queried</typeparam>
 15    /// <param name="predicate">The predicate expression (e.g., x => x.user.profile.age > 10)</param>
 16    /// <returns>Set of field paths referenced in the expression</returns>
 17    /// <example>
 18    /// <code>
 19    /// var paths = ExpressionFieldExtractor.ExtractFieldPaths&lt;MyModel&gt;(x => x.user.profile.age > 10);
 20    /// // Returns: ["user.profile.age"]
 21    /// </code>
 22    /// </example>
 23    public static HashSet<string> ExtractFieldPaths<T>(Expression<Func<T, bool>> predicate)
 20424        => ExtractFieldPaths((Expression)predicate);
 25
 26    /// <summary>
 27    /// Extracts field paths from a typed selector expression.
 28    /// Useful for selecting specific fields without a predicate.
 29    /// </summary>
 30    /// <typeparam name="T">The type being queried</typeparam>
 31    /// <typeparam name="TResult">The result type (can be object for anonymous types)</typeparam>
 32    /// <param name="selector">The selector expression (e.g., x => x.user.profile)</param>
 33    /// <returns>Set of field paths referenced in the expression</returns>
 34    /// <example>
 35    /// <code>
 36    /// var paths = ExpressionFieldExtractor.ExtractFieldPaths&lt;MyModel, object&gt;(x => new { x.user.name, x.user.ema
 37    /// // Returns: ["user.name", "user.email"]
 38    /// </code>
 39    /// </example>
 40    public static HashSet<string> ExtractFieldPaths<T, TResult>(Expression<Func<T, TResult>> selector)
 4241        => ExtractFieldPaths((Expression)selector);
 42
 43    /// <summary>
 44    /// Extracts field paths from any expression.
 45    /// Works with runtime-parsed expressions (e.g., from DynamicExpresso).
 46    /// </summary>
 47    /// <param name="expression">The expression to analyze</param>
 48    /// <returns>Set of field paths referenced in the expression</returns>
 49    /// <example>
 50    /// <code>
 51    /// // With DynamicExpresso (in tests or application code):
 52    /// var interpreter = new Interpreter();
 53    /// var expr = interpreter.Parse("user.profile.email != null");
 54    /// var paths = ExpressionFieldExtractor.ExtractFieldPaths(expr);
 55    /// // Returns: ["user.profile.email"]
 56    /// </code>
 57    /// </example>
 58    public static HashSet<string> ExtractFieldPaths(Expression expression)
 59    {
 46860        var visitor = new FieldPathVisitor();
 46861        visitor.Visit(expression);
 46862        return visitor.FieldPaths;
 63    }
 64
 65    /// <summary>
 66    /// Internal visitor that walks the expression tree and collects field paths.
 67    /// </summary>
 68    private sealed class FieldPathVisitor : ExpressionVisitor
 69    {
 165670        public HashSet<string> FieldPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
 46871        private readonly Stack<string> _lambdaContextPaths = new();
 72        private ParameterExpression? _rootParameter;
 73        private int _rootParameterCount;
 74
 75        /// <summary>
 76        /// Visits member access expressions (e.g., user.profile.age).
 77        /// Only collects complete leaf paths to avoid collecting intermediate segments.
 78        /// </summary>
 79        protected override Expression VisitMember(MemberExpression node)
 80        {
 81            // Skip properties that should be excluded (like string.Length)
 73882            if (ShouldExcludeProperty(node))
 83            {
 84                // Still visit the expression to extract paths from null coalescing chains
 4285                VisitNullCoalescingChain(node.Expression);
 4286                return node;
 87            }
 88
 69689            var path = BuildMemberPath(node);
 90
 69691            if (!string.IsNullOrEmpty(path))
 92            {
 93                // If we're inside a lambda context (e.g., LINQ predicate), prepend the base path
 68194                if (_lambdaContextPaths.Count > 0)
 95                {
 6696                    var basePath = _lambdaContextPaths.Peek();
 6697                    path = $"{basePath}.{path}";
 98                }
 99
 681100                FieldPaths.Add(path);
 101            }
 102
 103            // Visit any method calls in the chain (e.g., First(), Where())
 104            // This ensures lambda contexts are established properly
 696105            VisitMethodCallsInChain(node);
 106
 696107            return node;
 108        }
 109
 110        /// <summary>
 111        /// Visits null coalescing chains to extract all field paths.
 112        /// This handles cases like (x.user.profile.name ?? x.user.email ?? "default").Length
 113        /// where we want to extract both user.profile.name and user.email.
 114        /// </summary>
 115        private void VisitNullCoalescingChain(Expression? expression)
 116        {
 42117            if (expression is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Coalesce)
 118            {
 119                // Visit both sides of the null coalescing operator
 9120                Visit(binaryExpr.Left);
 9121                Visit(binaryExpr.Right);
 122            }
 33123            else if (expression != null)
 124            {
 125                // Visit the expression normally
 33126                Visit(expression);
 127            }
 33128        }
 129
 130        /// <summary>
 131        /// Visits any method call expressions in a member expression chain.
 132        /// This is necessary to establish lambda contexts for LINQ methods.
 133        /// </summary>
 134        private void VisitMethodCallsInChain(MemberExpression node)
 135        {
 696136            Expression? current = node.Expression;
 137
 1476138            while (current != null)
 139            {
 1473140                if (current is MethodCallExpression methodCall)
 141                {
 142                    // Visit the method call to process its lambda arguments
 36143                    Visit(methodCall);
 144                    // Continue from the collection the method was called on. For instance
 145                    // methods Object is non-null; for static/extension methods (LINQ) the
 146                    // first argument is the source. A parameterless static-like call has
 147                    // neither, so there is nothing further to walk.
 36148                    current = MethodCallSourceExpression(methodCall);
 149                }
 1437150                else if (current is MemberExpression memberExpr)
 151                {
 744152                    current = memberExpr.Expression;
 153                }
 154                else
 155                {
 156                    // Reached parameter or other expression type
 157                    break;
 158                }
 159            }
 696160        }
 161
 162        /// <summary>
 163        /// Visits method call expressions (e.g., LINQ methods like First, Where, Any).
 164        /// Tracks the base path for LINQ methods to properly resolve lambda parameter references.
 165        /// </summary>
 166        protected override Expression VisitMethodCall(MethodCallExpression node)
 167        {
 165168            var isLinqMethod = IsLinqMethod(node);
 165169            var basePath = isLinqMethod ? GetMethodCallBasePath(node) : null;
 170
 165171            VisitReceiver(node);
 165172            VisitRemainingArguments(node, isLinqMethod, basePath);
 173
 165174            return node;
 175        }
 176
 177        private void VisitReceiver(MethodCallExpression node)
 178        {
 165179            if (node.Object is not null)
 180            {
 69181                Visit(node.Object);
 182            }
 96183            else if (node.Arguments.Count > 0)
 184            {
 93185                Visit(node.Arguments[0]);
 186            }
 96187        }
 188
 189        private void VisitRemainingArguments(MethodCallExpression node, bool isLinqMethod, string? basePath)
 190        {
 165191            var startIndex = node.Object is not null ? 0 : 1;
 582192            for (var i = startIndex; i < node.Arguments.Count; i++)
 193            {
 126194                VisitArgument(node.Arguments[i], isLinqMethod, basePath);
 195            }
 165196        }
 197
 198        private void VisitArgument(Expression arg, bool isLinqMethod, string? basePath)
 199        {
 126200            if (isLinqMethod && arg is LambdaExpression && !string.IsNullOrEmpty(basePath))
 201            {
 57202                VisitWithLambdaContext(arg, basePath);
 57203                return;
 204            }
 69205            Visit(arg);
 69206        }
 207
 208        private void VisitWithLambdaContext(Expression arg, string basePath)
 209        {
 57210            _lambdaContextPaths.Push(basePath);
 114211            try { Visit(arg); }
 114212            finally { _lambdaContextPaths.Pop(); }
 57213        }
 214
 215        /// <summary>
 216        /// Determines if a method is a LINQ extension method from System.Linq.
 217        /// </summary>
 218        private static bool IsLinqMethod(MethodCallExpression node)
 219        {
 165220            var declaringType = node.Method.DeclaringType;
 165221            return declaringType is { Namespace: "System.Linq", Name: "Enumerable" or "Queryable" };
 222        }
 223
 224        /// <summary>
 225        /// Gets the base path from a LINQ method call (the collection being operated on).
 226        /// LINQ on Enumerable/Queryable is always an extension method whose first argument is
 227        /// the source collection — instance LINQ methods do not exist in the BCL, so
 228        /// node.Arguments is guaranteed non-empty here.
 229        /// </summary>
 230        private static string? GetMethodCallBasePath(MethodCallExpression node)
 93231            => BuildPathFromExpression(node.Arguments[0]);
 232
 233        /// <summary>
 234        /// Builds a path from any expression (member or method chain).
 235        /// Lightweight version for determining base paths without marking as visited.
 236        /// </summary>
 135237        private static string? BuildPathFromExpression(Expression? expr) => expr switch
 135238        {
 87239            MemberExpression memberExpr => BuildPathFromMember(memberExpr),
 39240            MethodCallExpression methodCallExpr => BuildPathFromExpression(MethodCallSourceExpression(methodCallExpr)),
 9241            _ => null,
 135242        };
 243
 244        private static string? BuildPathFromMember(MemberExpression start)
 245        {
 246            // Loop entry adds at least one part; parts.Count is never 0 on exit.
 87247            var parts = new List<string>();
 456248            for (MemberExpression? current = start; current is not null;)
 249            {
 291250                parts.Add(current.Member.Name);
 300251                if (!TryStepUp(current, parts, out current)) return null;
 252            }
 253
 78254            parts.Reverse();
 78255            return string.Join(".", parts);
 256        }
 257
 258        // Returns true if the chain continues (next set, possibly null when terminating at a parameter
 259        // or a successfully-resolved method call); returns false to signal an invalid chain shape.
 260        private static bool TryStepUp(MemberExpression current, List<string> parts, out MemberExpression? next)
 261        {
 291262            switch (current.Expression)
 263            {
 264                case MemberExpression nextMember:
 204265                    next = nextMember;
 204266                    return true;
 267                case ParameterExpression:
 75268                    next = null;
 75269                    return true;
 270                case MethodCallExpression methodCall:
 3271                    var basePath = BuildPathFromExpression(MethodCallSourceExpression(methodCall));
 6272                    if (basePath is not null) parts.Add(basePath);
 3273                    next = null;
 3274                    return true;
 275                default:
 9276                    next = null;
 9277                    return false;
 278            }
 279        }
 280
 281        /// <summary>
 282        /// Visits lambda expressions (e.g., predicates in First, Where).
 283        /// </summary>
 284        protected override Expression VisitLambda<T>(Expression<T> node)
 285        {
 286            // Track the root parameter for the outermost lambda (not nested LINQ lambdas)
 534287            if (_rootParameter == null && _lambdaContextPaths.Count == 0 && node.Parameters.Count > 0)
 288            {
 456289                _rootParameter = node.Parameters[0];
 290                // Track the outermost lambda's parameter COUNT (not distinct types): two
 291                // parameters of the same type must still be disambiguated by name, otherwise
 292                // their fields leak across sibling nodes during downstream preservation.
 456293                _rootParameterCount = node.Parameters.Count;
 294            }
 295
 296            // Visit the body to extract paths from nested predicates
 297            // Example: items.First(p => p.sport == "F")
 534298            Visit(node.Body);
 534299            return node;
 300        }
 301
 302        /// <summary>
 303        /// Visits parameter expressions (e.g., direct parameter references like null checks).
 304        /// </summary>
 305        protected override Expression VisitParameter(ParameterExpression node)
 306        {
 307            // If this is the root parameter being used directly (not in a nested lambda context),
 308            // add it as a field path. This handles cases like: playerProfile => playerProfile == null
 309            // Lambdas authored in C# always carry a parameter name; the BCL's
 310            // Expression.Parameter overloads accept null but no public path produces such
 311            // parameters here, so we treat the name as non-null.
 51312            if (_rootParameter != null && node == _rootParameter && _lambdaContextPaths.Count == 0)
 313            {
 39314                FieldPaths.Add(node.Name!);
 315            }
 316
 51317            return node;
 318        }
 319
 320        /// <summary>
 321        /// Visits binary expressions (e.g., comparisons like greater than, equals, and logical operators).
 322        /// </summary>
 323        protected override Expression VisitBinary(BinaryExpression node)
 324        {
 325            // Visit both sides of the binary expression
 714326            Visit(node.Left);
 714327            Visit(node.Right);
 714328            return node;
 329        }
 330
 331        /// <summary>
 332        /// Visits unary expressions (e.g., !, conversions).
 333        /// </summary>
 334        protected override Expression VisitUnary(UnaryExpression node)
 335        {
 48336            Visit(node.Operand);
 48337            return node;
 338        }
 339
 340        /// <summary>
 341        /// Visits new expressions (e.g., anonymous type creation).
 342        /// </summary>
 343        protected override Expression VisitNew(NewExpression node)
 344        {
 345            // Visit all constructor arguments to extract paths
 346            // Handles: new { x.user.name, x.user.email }
 84347            foreach (var arg in node.Arguments)
 348            {
 24349                Visit(arg);
 350            }
 351
 18352            return node;
 353        }
 354
 355        /// <summary>
 356        /// Visits member initialization expressions (e.g., object initializers).
 357        /// </summary>
 358        protected override Expression VisitMemberInit(MemberInitExpression node)
 359        {
 3360            Visit(node.NewExpression);
 361
 12362            foreach (var binding in node.Bindings)
 363            {
 3364                if (binding is MemberAssignment assignment)
 365                {
 3366                    Visit(assignment.Expression);
 367                }
 368            }
 369
 3370            return node;
 371        }
 372
 373        /// <summary>
 374        /// Visits conditional expressions (e.g., ternary operator).
 375        /// </summary>
 376        protected override Expression VisitConditional(ConditionalExpression node)
 377        {
 18378            Visit(node.Test);
 18379            Visit(node.IfTrue);
 18380            Visit(node.IfFalse);
 18381            return node;
 382        }
 383
 384        /// <summary>
 385        /// Builds a dot-separated field path from a member expression chain.
 386        /// Handles LINQ method calls like First(), Where(), etc.
 387        /// </summary>
 388        /// <param name="node">The member expression to analyze</param>
 389        /// <returns>The dot-separated path (e.g., "user.profile.age") or null if not a valid path</returns>
 390        private string? BuildMemberPath(MemberExpression node)
 391        {
 392            // The first StepUpChain call always pushes node.Member.Name onto parts before
 393            // it can fail or terminate, so parts is non-empty by the time we exit the loop.
 696394            var parts = new Stack<string>();
 696395            Expression? currentExpr = node;
 696396            ParameterExpression? parameterExpr = null;
 397
 2193398            while (currentExpr != null)
 399            {
 2193400                var step = StepUpChain(currentExpr, parts);
 2193401                if (step.IsTerminal)
 402                {
 696403                    parameterExpr = step.Parameter;
 711404                    if (!step.Valid) return null;
 405                    break;
 406                }
 1497407                currentExpr = step.Next;
 408            }
 409
 410            // For multi-parameter lambdas (more than 1 root parameter), include parameter name
 681411            if (_rootParameterCount > 1 && parameterExpr != null && !string.IsNullOrEmpty(parameterExpr.Name))
 412            {
 78413                parts.Push(parameterExpr.Name);
 414            }
 415
 681416            return string.Join(".", parts);
 417        }
 418
 419        /// <summary>
 420        /// One step of <see cref="BuildMemberPath"/>'s chain walk. Returns either the next
 421        /// expression to walk (<see cref="Next"/>) or a terminal status (<see cref="IsTerminal"/>)
 422        /// indicating whether the chain ended cleanly at a parameter (<see cref="Valid"/>) or hit
 423        /// an unsupported node shape.
 424        /// </summary>
 425        private readonly ref struct ChainStep
 426        {
 2994427            public Expression? Next { get; init; }
 1377428            public ParameterExpression? Parameter { get; init; }
 2889429            public bool IsTerminal { get; init; }
 1392430            public bool Valid { get; init; }
 431
 1497432            public static ChainStep Continue(Expression? next) => new() { Next = next };
 681433            public static ChainStep TerminateAt(ParameterExpression p) => new() { IsTerminal = true, Valid = true, Param
 15434            public static ChainStep Invalid() => new() { IsTerminal = true, Valid = false };
 435        }
 436
 437        private static ChainStep StepUpChain(Expression currentExpr, Stack<string> parts)
 438        {
 439            switch (currentExpr)
 440            {
 441                case MemberExpression memberExpr:
 1452442                    parts.Push(memberExpr.Member.Name);
 1452443                    return ChainStep.Continue(memberExpr.Expression);
 444
 445                case MethodCallExpression methodCall:
 36446                    var source = MethodCallSourceExpression(methodCall);
 447                    // A parameterless static-like call (null Object, zero arguments) has no
 448                    // source to walk toward the lambda parameter, so the chain cannot resolve
 449                    // to a valid field path.
 36450                    return source is null ? ChainStep.Invalid() : ChainStep.Continue(source);
 451
 452                case BinaryExpression { NodeType: ExpressionType.Coalesce } binaryExpr:
 6453                    return ChainStep.Continue(binaryExpr.Left);
 454
 455                case ConditionalExpression conditionalExpr:
 6456                    return ChainStep.Continue(SelectConditionalBranch(conditionalExpr));
 457
 458                case ParameterExpression paramExpr:
 681459                    return ChainStep.TerminateAt(paramExpr);
 460
 461                default:
 12462                    return ChainStep.Invalid();
 463            }
 464        }
 465
 466        // The source a method call is chained onto: Object for an instance method, or the first
 467        // argument for an extension/static method. A parameterless static-like call has neither,
 468        // so this returns null and the caller treats the sub-expression as contributing no path.
 469        private static Expression? MethodCallSourceExpression(MethodCallExpression methodCall)
 114470            => methodCall.Object ?? (methodCall.Arguments.Count > 0 ? methodCall.Arguments[0] : null);
 471
 472        /// <summary>
 473        /// Picks the branch of a null-conditional-style ternary that carries member access.
 474        /// IfTrue takes precedence; the IfFalse branch wins only when IfTrue is the constant null.
 475        /// </summary>
 476        private static Expression SelectConditionalBranch(ConditionalExpression conditionalExpr)
 6477            => conditionalExpr.IfTrue is ConstantExpression { Value: null }
 6478                ? conditionalExpr.IfFalse
 6479                : conditionalExpr.IfTrue;
 480
 481        /// <summary>
 482        /// Determines if a member expression represents a property that should be excluded.
 483        /// Excludes only System.* types (like string.Length); IL-emitted properties from
 484        /// QueryBuilderTypeGenerator and user types are kept.
 485        /// </summary>
 486        private static bool ShouldExcludeProperty(MemberExpression memberExpr)
 738487            => memberExpr.Member.DeclaringType!.Namespace?.StartsWith("System") == true;
 488    }
 489}

Methods/Properties

ExtractFieldPaths(System.Linq.Expressions.Expression`1<System.Func`2<T,System.Boolean>>)
ExtractFieldPaths(System.Linq.Expressions.Expression`1<System.Func`2<T,TResult>>)
ExtractFieldPaths(System.Linq.Expressions.Expression)
get_FieldPaths()
.ctor()
VisitMember(System.Linq.Expressions.MemberExpression)
VisitNullCoalescingChain(System.Linq.Expressions.Expression)
VisitMethodCallsInChain(System.Linq.Expressions.MemberExpression)
VisitMethodCall(System.Linq.Expressions.MethodCallExpression)
VisitReceiver(System.Linq.Expressions.MethodCallExpression)
VisitRemainingArguments(System.Linq.Expressions.MethodCallExpression,System.Boolean,System.String)
VisitArgument(System.Linq.Expressions.Expression,System.Boolean,System.String)
VisitWithLambdaContext(System.Linq.Expressions.Expression,System.String)
IsLinqMethod(System.Linq.Expressions.MethodCallExpression)
GetMethodCallBasePath(System.Linq.Expressions.MethodCallExpression)
BuildPathFromExpression(System.Linq.Expressions.Expression)
BuildPathFromMember(System.Linq.Expressions.MemberExpression)
TryStepUp(System.Linq.Expressions.MemberExpression,System.Collections.Generic.List`1<System.String>,System.Linq.Expressions.MemberExpression&)
VisitLambda(System.Linq.Expressions.Expression`1<T>)
VisitParameter(System.Linq.Expressions.ParameterExpression)
VisitBinary(System.Linq.Expressions.BinaryExpression)
VisitUnary(System.Linq.Expressions.UnaryExpression)
VisitNew(System.Linq.Expressions.NewExpression)
VisitMemberInit(System.Linq.Expressions.MemberInitExpression)
VisitConditional(System.Linq.Expressions.ConditionalExpression)
BuildMemberPath(System.Linq.Expressions.MemberExpression)
get_Next()
get_Parameter()
get_IsTerminal()
get_Valid()
Continue(System.Linq.Expressions.Expression)
TerminateAt(System.Linq.Expressions.ParameterExpression)
Invalid()
StepUpChain(System.Linq.Expressions.Expression,System.Collections.Generic.Stack`1<System.String>)
MethodCallSourceExpression(System.Linq.Expressions.MethodCallExpression)
SelectConditionalBranch(System.Linq.Expressions.ConditionalExpression)
ShouldExcludeProperty(System.Linq.Expressions.MemberExpression)