| | | 1 | | using System.Runtime.CompilerServices; |
| | | 2 | | using System.Text; |
| | | 3 | | using NGql.Core.Abstractions; |
| | | 4 | | using NGql.Core.Features; |
| | | 5 | | using NGql.Core.Pooling; |
| | | 6 | | |
| | | 7 | | namespace NGql.Core.Extensions; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Extension methods for QueryDefinition and field navigation/lookup utilities. |
| | | 11 | | /// </summary> |
| | | 12 | | internal static class QueryDefinitionExtensions |
| | | 13 | | { |
| | | 14 | | /// <summary> |
| | | 15 | | /// Navigates through a dot-separated path using name or alias matching. |
| | | 16 | | /// Returns the final field and optionally builds the resolved path. |
| | | 17 | | /// Optimized with ReadOnlySpan to avoid allocations. |
| | | 18 | | /// </summary> |
| | | 19 | | /// <param name="fields">Starting field collection</param> |
| | | 20 | | /// <param name="path">Dot-separated path (can use ReadOnlySpan for zero-alloc)</param> |
| | | 21 | | /// <param name="resolvedPath">Optional: outputs the actual path with field keys (not aliases)</param> |
| | | 22 | | /// <param name="prependPath">Optional: path to prepend to resolvedPath</param> |
| | | 23 | | /// <returns>The final field definition, or null if not found</returns> |
| | | 24 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 25 | | internal static FieldDefinition? NavigatePath( |
| | | 26 | | IReadOnlyDictionary<string, FieldDefinition>? fields, |
| | | 27 | | ReadOnlySpan<char> path, |
| | | 28 | | out string? resolvedPath, |
| | | 29 | | string? prependPath = null) |
| | | 30 | | { |
| | 399 | 31 | | resolvedPath = null; |
| | 414 | 32 | | if (fields is null || path.Length == 0) return null; |
| | | 33 | | |
| | | 34 | | // Build the resolved path into a pooled builder while walking — replaces the prior |
| | | 35 | | // List<string> + string.Join + prepend-concat trio with a single final ToString. |
| | 384 | 36 | | using var pooled = LockFreeStringBuilderPool.Get(); |
| | 384 | 37 | | var pathBuilder = pooled.StringBuilder; |
| | 549 | 38 | | if (prependPath is not null) pathBuilder.Append(prependPath).Append('.'); |
| | | 39 | | |
| | 384 | 40 | | var currentField = WalkPath(fields, path, pathBuilder); |
| | 435 | 41 | | if (currentField is null) return null; |
| | | 42 | | |
| | 333 | 43 | | resolvedPath = pathBuilder.ToString(); |
| | 333 | 44 | | return currentField; |
| | 384 | 45 | | } |
| | | 46 | | |
| | | 47 | | private static FieldDefinition? WalkPath(IReadOnlyDictionary<string, FieldDefinition> fields, ReadOnlySpan<char> pat |
| | | 48 | | { |
| | 384 | 49 | | var currentFields = fields; |
| | 384 | 50 | | FieldDefinition? currentField = null; |
| | 384 | 51 | | var firstSegment = true; |
| | | 52 | | |
| | 828 | 53 | | while (path.Length > 0) |
| | | 54 | | { |
| | 828 | 55 | | var dotIndex = path.IndexOf('.'); |
| | 828 | 56 | | var segment = dotIndex >= 0 ? path[..dotIndex] : path; |
| | | 57 | | |
| | 828 | 58 | | var match = PreserveExtensions.FindFieldByNameOrAlias(currentFields, segment); |
| | 876 | 59 | | if (!match.HasValue) return null; |
| | | 60 | | |
| | 780 | 61 | | currentField = match.Value.Value; |
| | 1206 | 62 | | if (!firstSegment) resolvedPath.Append('.'); |
| | 780 | 63 | | firstSegment = false; |
| | 780 | 64 | | resolvedPath.Append(match.Value.Key); |
| | | 65 | | |
| | 780 | 66 | | if (dotIndex < 0) break; |
| | 450 | 67 | | if (currentField._children is null) return null; |
| | | 68 | | |
| | 444 | 69 | | currentFields = currentField.Fields; |
| | 444 | 70 | | path = path[(dotIndex + 1)..]; |
| | | 71 | | } |
| | | 72 | | |
| | 333 | 73 | | return currentField; |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | /// <summary> |
| | | 77 | | /// Recursively searches for a field by name through all descendant nodes. |
| | | 78 | | /// Returns all matching paths. |
| | | 79 | | /// </summary> |
| | | 80 | | internal static List<string> FindFieldRecursively( |
| | | 81 | | IReadOnlyDictionary<string, FieldDefinition> fields, |
| | | 82 | | string fieldName, |
| | | 83 | | string basePath) |
| | | 84 | | { |
| | 27 | 85 | | var results = new List<string>(); |
| | 27 | 86 | | FindFieldRecursivelyCore(fields, fieldName, basePath, results); |
| | 27 | 87 | | return results; |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | private static void FindFieldRecursivelyCore( |
| | | 91 | | IReadOnlyDictionary<string, FieldDefinition> fields, |
| | | 92 | | string fieldName, |
| | | 93 | | string basePath, |
| | | 94 | | List<string> results) |
| | | 95 | | { |
| | 282 | 96 | | foreach (var (key, fieldDef) in fields) |
| | | 97 | | { |
| | 96 | 98 | | var currentPath = string.IsNullOrEmpty(basePath) ? key : $"{basePath}.{key}"; |
| | | 99 | | |
| | 96 | 100 | | if (NameOrAliasMatches(fieldDef, fieldName)) |
| | | 101 | | { |
| | 21 | 102 | | results.Add(currentPath); |
| | | 103 | | } |
| | | 104 | | |
| | 96 | 105 | | if (fieldDef.HasFields) |
| | | 106 | | { |
| | 18 | 107 | | FindFieldRecursivelyCore(fieldDef._children!, fieldName, currentPath, results); |
| | | 108 | | } |
| | | 109 | | } |
| | 45 | 110 | | } |
| | | 111 | | |
| | | 112 | | // A null or empty Alias needs no separate guard: string.Equals never matches the caller's |
| | | 113 | | // `name`, which is always a non-empty LINQ member name (see ExpressionPreservationProcessor). |
| | | 114 | | private static bool NameOrAliasMatches(FieldDefinition field, string name) |
| | 96 | 115 | | => string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase) |
| | 96 | 116 | | || string.Equals(field.Alias, name, StringComparison.OrdinalIgnoreCase); |
| | | 117 | | } |