< 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: 139
Uncovered lines: 0
Coverable lines: 139
Total lines: 486
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%88100%
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%88100%
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%1212100%
MethodCallSourceExpression(...)100%22100%
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)
 20124        => 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    {
 42960        var visitor = new FieldPathVisitor();
 42961        visitor.Visit(expression);
 42962        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    {
 151870        public HashSet<string> FieldPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
 42971        private readonly Stack<string> _lambdaContextPaths = new();
 72        private ParameterExpression? _rootParameter;
 42973        private readonly HashSet<Type> _rootParameterTypes = new();
 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)
 67582            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
 63389            var path = BuildMemberPath(node);
 90
 63391            if (!string.IsNullOrEmpty(path))
 92            {
 93                // If we're inside a lambda context (e.g., LINQ predicate), prepend the base path
 62794                if (_lambdaContextPaths.Count > 0)
 95                {
 6396                    var basePath = _lambdaContextPaths.Peek();
 6397                    path = $"{basePath}.{path}";
 98                }
 99
 627100                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
 633105            VisitMethodCallsInChain(node);
 106
 633107            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        {
 633136            Expression? current = node.Expression;
 137
 1410138            while (current != null)
 139            {
 1410140                if (current is MethodCallExpression methodCall)
 141                {
 142                    // Visit the method call to process its lambda arguments
 33143                    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. Methods with neither don't appear in
 147                    // valid lambdas.
 33148                    current = methodCall.Object ?? methodCall.Arguments[0];
 149                }
 1377150                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            }
 633160        }
 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        {
 153168            var isLinqMethod = IsLinqMethod(node);
 153169            var basePath = isLinqMethod ? GetMethodCallBasePath(node) : null;
 170
 153171            VisitReceiver(node);
 153172            VisitRemainingArguments(node, isLinqMethod, basePath);
 173
 153174            return node;
 175        }
 176
 177        private void VisitReceiver(MethodCallExpression node)
 178        {
 153179            if (node.Object is not null)
 180            {
 69181                Visit(node.Object);
 182            }
 84183            else if (node.Arguments.Count > 0)
 184            {
 84185                Visit(node.Arguments[0]);
 186            }
 84187        }
 188
 189        private void VisitRemainingArguments(MethodCallExpression node, bool isLinqMethod, string? basePath)
 190        {
 153191            var startIndex = node.Object is not null ? 0 : 1;
 540192            for (var i = startIndex; i < node.Arguments.Count; i++)
 193            {
 117194                VisitArgument(node.Arguments[i], isLinqMethod, basePath);
 195            }
 153196        }
 197
 198        private void VisitArgument(Expression arg, bool isLinqMethod, string? basePath)
 199        {
 117200            if (isLinqMethod && arg is LambdaExpression && !string.IsNullOrEmpty(basePath))
 201            {
 54202                VisitWithLambdaContext(arg, basePath);
 54203                return;
 204            }
 63205            Visit(arg);
 63206        }
 207
 208        private void VisitWithLambdaContext(Expression arg, string basePath)
 209        {
 54210            _lambdaContextPaths.Push(basePath);
 108211            try { Visit(arg); }
 108212            finally { _lambdaContextPaths.Pop(); }
 54213        }
 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        {
 153220            var declaringType = node.Method.DeclaringType;
 153221            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)
 84231            => 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>
 126237        private static string? BuildPathFromExpression(Expression? expr) => expr switch
 126238        {
 78239            MemberExpression memberExpr => BuildPathFromMember(memberExpr),
 39240            MethodCallExpression methodCallExpr => BuildPathFromExpression(MethodCallSourceExpression(methodCallExpr)),
 9241            _ => null,
 126242        };
 243
 244        private static string? BuildPathFromMember(MemberExpression start)
 245        {
 246            // Loop entry adds at least one part; parts.Count is never 0 on exit.
 78247            var parts = new List<string>();
 435248            for (MemberExpression? current = start; current is not null;)
 249            {
 282250                parts.Add(current.Member.Name);
 285251                if (!TryStepUp(current, parts, out current)) return null;
 252            }
 253
 75254            parts.Reverse();
 75255            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        {
 282262            switch (current.Expression)
 263            {
 264                case MemberExpression nextMember:
 204265                    next = nextMember;
 204266                    return true;
 267                case ParameterExpression:
 72268                    next = null;
 72269                    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:
 3276                    next = null;
 3277                    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)
 486287            if (_rootParameter == null && _lambdaContextPaths.Count == 0 && node.Parameters.Count > 0)
 288            {
 417289                _rootParameter = node.Parameters[0];
 290                // Track ALL parameter types for multi-parameter lambdas
 1746291                foreach (var param in node.Parameters)
 292                {
 456293                    _rootParameterTypes.Add(param.Type);
 294                }
 295            }
 296
 297            // Visit the body to extract paths from nested predicates
 298            // Example: items.First(p => p.sport == "F")
 486299            Visit(node.Body);
 486300            return node;
 301        }
 302
 303        /// <summary>
 304        /// Visits parameter expressions (e.g., direct parameter references like null checks).
 305        /// </summary>
 306        protected override Expression VisitParameter(ParameterExpression node)
 307        {
 308            // If this is the root parameter being used directly (not in a nested lambda context),
 309            // add it as a field path. This handles cases like: playerProfile => playerProfile == null
 310            // Lambdas authored in C# always carry a parameter name; the BCL's
 311            // Expression.Parameter overloads accept null but no public path produces such
 312            // parameters here, so we treat the name as non-null.
 45313            if (_rootParameter != null && node == _rootParameter && _lambdaContextPaths.Count == 0)
 314            {
 33315                FieldPaths.Add(node.Name!);
 316            }
 317
 45318            return node;
 319        }
 320
 321        /// <summary>
 322        /// Visits binary expressions (e.g., comparisons like greater than, equals, and logical operators).
 323        /// </summary>
 324        protected override Expression VisitBinary(BinaryExpression node)
 325        {
 326            // Visit both sides of the binary expression
 633327            Visit(node.Left);
 633328            Visit(node.Right);
 633329            return node;
 330        }
 331
 332        /// <summary>
 333        /// Visits unary expressions (e.g., !, conversions).
 334        /// </summary>
 335        protected override Expression VisitUnary(UnaryExpression node)
 336        {
 48337            Visit(node.Operand);
 48338            return node;
 339        }
 340
 341        /// <summary>
 342        /// Visits new expressions (e.g., anonymous type creation).
 343        /// </summary>
 344        protected override Expression VisitNew(NewExpression node)
 345        {
 346            // Visit all constructor arguments to extract paths
 347            // Handles: new { x.user.name, x.user.email }
 84348            foreach (var arg in node.Arguments)
 349            {
 24350                Visit(arg);
 351            }
 352
 18353            return node;
 354        }
 355
 356        /// <summary>
 357        /// Visits member initialization expressions (e.g., object initializers).
 358        /// </summary>
 359        protected override Expression VisitMemberInit(MemberInitExpression node)
 360        {
 3361            Visit(node.NewExpression);
 362
 12363            foreach (var binding in node.Bindings)
 364            {
 3365                if (binding is MemberAssignment assignment)
 366                {
 3367                    Visit(assignment.Expression);
 368                }
 369            }
 370
 3371            return node;
 372        }
 373
 374        /// <summary>
 375        /// Visits conditional expressions (e.g., ternary operator).
 376        /// </summary>
 377        protected override Expression VisitConditional(ConditionalExpression node)
 378        {
 18379            Visit(node.Test);
 18380            Visit(node.IfTrue);
 18381            Visit(node.IfFalse);
 18382            return node;
 383        }
 384
 385        /// <summary>
 386        /// Builds a dot-separated field path from a member expression chain.
 387        /// Handles LINQ method calls like First(), Where(), etc.
 388        /// </summary>
 389        /// <param name="node">The member expression to analyze</param>
 390        /// <returns>The dot-separated path (e.g., "user.profile.age") or null if not a valid path</returns>
 391        private string? BuildMemberPath(MemberExpression node)
 392        {
 393            // The first StepUpChain call always pushes node.Member.Name onto parts before
 394            // it can fail or terminate, so parts is non-empty by the time we exit the loop.
 633395            var parts = new Stack<string>();
 633396            Expression? currentExpr = node;
 633397            ParameterExpression? parameterExpr = null;
 398
 2067399            while (currentExpr != null)
 400            {
 2067401                var step = StepUpChain(currentExpr, parts);
 2067402                if (step.IsTerminal)
 403                {
 633404                    parameterExpr = step.Parameter;
 639405                    if (!step.Valid) return null;
 406                    break;
 407                }
 1434408                currentExpr = step.Next;
 409            }
 410
 411            // For multi-parameter lambdas (more than 1 root parameter type), include parameter name
 627412            if (_rootParameterTypes.Count > 1 && parameterExpr != null && !string.IsNullOrEmpty(parameterExpr.Name))
 413            {
 30414                parts.Push(parameterExpr.Name);
 415            }
 416
 627417            return string.Join(".", parts);
 418        }
 419
 420        /// <summary>
 421        /// One step of <see cref="BuildMemberPath"/>'s chain walk. Returns either the next
 422        /// expression to walk (<see cref="Next"/>) or a terminal status (<see cref="IsTerminal"/>)
 423        /// indicating whether the chain ended cleanly at a parameter (<see cref="Valid"/>) or hit
 424        /// an unsupported node shape.
 425        /// </summary>
 426        private readonly ref struct ChainStep
 427        {
 2868428            public Expression? Next { get; init; }
 1260429            public ParameterExpression? Parameter { get; init; }
 2700430            public bool IsTerminal { get; init; }
 1266431            public bool Valid { get; init; }
 432
 1434433            public static ChainStep Continue(Expression? next) => new() { Next = next };
 627434            public static ChainStep TerminateAt(ParameterExpression p) => new() { IsTerminal = true, Valid = true, Param
 6435            public static ChainStep Invalid() => new() { IsTerminal = true, Valid = false };
 436        }
 437
 438        private static ChainStep StepUpChain(Expression currentExpr, Stack<string> parts)
 439        {
 440            switch (currentExpr)
 441            {
 442                case MemberExpression memberExpr:
 1389443                    parts.Push(memberExpr.Member.Name);
 1389444                    return ChainStep.Continue(memberExpr.Expression);
 445
 446                case MethodCallExpression methodCall:
 33447                    return ChainStep.Continue(MethodCallSourceExpression(methodCall));
 448
 449                case BinaryExpression { NodeType: ExpressionType.Coalesce } binaryExpr:
 6450                    return ChainStep.Continue(binaryExpr.Left);
 451
 452                case ConditionalExpression conditionalExpr:
 6453                    return ChainStep.Continue(SelectConditionalBranch(conditionalExpr));
 454
 455                case ParameterExpression paramExpr:
 627456                    return ChainStep.TerminateAt(paramExpr);
 457
 458                default:
 6459                    return ChainStep.Invalid();
 460            }
 461        }
 462
 463        // Either methodCall.Object is non-null (instance method) or Arguments has at least one
 464        // entry (the source for an extension/static method); valid lambdas don't produce both
 465        // null Object and zero arguments.
 466        private static Expression? MethodCallSourceExpression(MethodCallExpression methodCall)
 75467            => methodCall.Object ?? methodCall.Arguments[0];
 468
 469        /// <summary>
 470        /// Picks the branch of a null-conditional-style ternary that carries member access.
 471        /// IfTrue takes precedence; the IfFalse branch wins only when IfTrue is the constant null.
 472        /// </summary>
 473        private static Expression SelectConditionalBranch(ConditionalExpression conditionalExpr)
 6474            => conditionalExpr.IfTrue is ConstantExpression { Value: null }
 6475                ? conditionalExpr.IfFalse
 6476                : conditionalExpr.IfTrue;
 477
 478        /// <summary>
 479        /// Determines if a member expression represents a property that should be excluded.
 480        /// Excludes only System.* types (like string.Length); IL-emitted properties from
 481        /// QueryBuilderTypeGenerator and user types are kept.
 482        /// </summary>
 483        private static bool ShouldExcludeProperty(MemberExpression memberExpr)
 675484            => memberExpr.Member.DeclaringType!.Namespace?.StartsWith("System") == true;
 485    }
 486}

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)