< Summary

Information
Class: NGql.Core.Features.PreserveExtensions
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Features/PreserveExtensions.cs
Line coverage
100%
Covered lines: 125
Uncovered lines: 0
Coverable lines: 125
Total lines: 337
Line coverage: 100%
Branch coverage
100%
Covered branches: 126
Total branches: 126
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/NGql/NGql/src/Core/Features/PreserveExtensions.cs

#LineLine coverage
 1using System.Runtime.CompilerServices;
 2using NGql.Core.Abstractions;
 3using NGql.Core.Builders;
 4using NGql.Core.Extensions;
 5
 6namespace NGql.Core.Features;
 7
 8/// <summary>
 9/// Extension methods for preserving specific QueryBuilder fields based on specified paths.
 10/// </summary>
 11internal static class PreserveExtensions
 12{
 13    /// <summary>
 14    /// Preserves only the fields that match or are descendants of the specified paths.
 15    /// </summary>
 16    /// <param name="query">The query builder to preserve fields from.</param>
 17    /// <param name="fieldPaths">The field paths to preserve.</param>
 18    /// <returns>A new QueryBuilder instance with only the preserved fields.</returns>
 19    public static QueryBuilder Preserve(this QueryBuilder query, params string[]? fieldPaths)
 20    {
 101721        if (fieldPaths == null || fieldPaths.Length == 0)
 622            return query;
 23
 101124        var newQuery = QueryBuilder.CreateDefaultBuilder(query.Definition.Name);
 25
 26        // Copy merging strategy and metadata
 101127        newQuery.Definition.MergingStrategy = query.Definition.MergingStrategy;
 101128        if (query.Definition._metadata != null)
 29        {
 6030            foreach (var metadata in query.Definition.Metadata)
 31            {
 1832                newQuery.Definition.MetadataInternal[metadata.Key] = metadata.Value;
 33            }
 34        }
 35
 889836        foreach (var targetPath in fieldPaths)
 37        {
 343838            ExtractMatchingFields(query.Definition.FieldsInternal, newQuery.Definition.FieldsInternal, targetPath.AsSpan
 39        }
 40
 41        // Carry over the named-fragment definitions the preserved tree still references —
 42        // spreads without their definitions render as broken GraphQL the server rejects.
 101143        CopyReferencedNamedFragments(query.Definition, newQuery.Definition);
 44
 45        // Extract variables from all preserved fields
 101146        ExtractVariablesFromFields(newQuery.Definition.FieldsInternal, newQuery.Definition.Variables);
 47
 101148        return newQuery;
 49    }
 50
 51    /// <summary>
 52    /// Copies into <paramref name="targetDefinition"/> every named-fragment definition that the
 53    /// preserved field tree references via spreads, following spreads transitively through the
 54    /// copied fragments themselves. Unreferenced definitions are skipped — GraphQL rejects
 55    /// operations with unused fragments. Undeclared spread names are left alone (NGql is
 56    /// schemaless; they render verbatim and the server reports them).
 57    /// </summary>
 58    private static void CopyReferencedNamedFragments(QueryDefinition sourceDefinition, QueryDefinition targetDefinition)
 59    {
 101160        var declared = sourceDefinition._namedFragments;
 201061        if (declared is not { Count: > 0 } || targetDefinition._fields is not { Count: > 0 }) return;
 62
 1263        var seen = new HashSet<string>(StringComparer.Ordinal);
 1264        var pending = new Stack<string>();
 4865        foreach (var field in targetDefinition._fields.Values)
 66        {
 1267            CollectSpreadNames(field, seen, pending);
 68        }
 69
 2770        while (pending.Count > 0)
 71        {
 1572            var name = pending.Pop();
 1573            if (!declared.TryGetValue(name, out var fragment)) continue;
 74
 1575            var clone = fragment.DeepClone();
 1576            targetDefinition._namedFragments ??= new Dictionary<string, NamedFragmentDefinition>(StringComparer.Ordinal)
 1577            targetDefinition._namedFragments[name] = clone;
 78
 1579            if (clone._fields is { Count: > 0 })
 80            {
 1581                ExtractVariablesFromFields(clone._fields, targetDefinition.Variables);
 82            }
 1583            CollectFragmentBodySpreadNames(clone._fields, clone._fragments, clone._spreadFragments, seen, pending);
 84        }
 1285    }
 86
 87    private static void CollectSpreadNames(FieldDefinition field, HashSet<string> seen, Stack<string> pending)
 3088        => CollectFragmentBodySpreadNames(field._children, field._fragments, field._spreadFragments, seen, pending);
 89
 90    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 91        "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method",
 92        Justification = "HashSet.Add is the filter AND the side effect — a Where(seen.Add) would hide the mutation; plai
 93    private static void CollectFragmentBodySpreadNames(
 94        FieldChildren? fields,
 95        Dictionary<string, InlineFragmentDefinition>? fragments,
 96        List<string>? spreads,
 97        HashSet<string> seen,
 98        Stack<string> pending)
 99    {
 48100        if (spreads is { Count: > 0 })
 101        {
 60102            foreach (var name in spreads)
 103            {
 30104                if (seen.Add(name)) pending.Push(name);
 105            }
 106        }
 107
 48108        if (fragments is { Count: > 0 })
 109        {
 12110            foreach (var fragment in fragments.Values)
 111            {
 3112                CollectFragmentBodySpreadNames(fragment._fields, fragment._fragments, fragment._spreadFragments, seen, p
 113            }
 114        }
 115
 48116        if (fields is { Count: > 0 })
 117        {
 72118            foreach (var child in fields.AsSpan())
 119            {
 18120                CollectSpreadNames(child, seen, pending);
 121            }
 122        }
 48123    }
 124
 125    private static void ExtractMatchingFields(Dictionary<string, FieldDefinition> sourceFields,
 126        Dictionary<string, FieldDefinition> targetFields, ReadOnlySpan<char> path)
 127    {
 3438128        var isLeafPath = SplitFirstSegment(path, out var currentSegment, out var remainingPath);
 129
 3438130        var match = FindFieldByNameOrAlias(sourceFields, currentSegment);
 3462131        if (!match.HasValue) return;
 132
 3414133        var (fieldKey, fieldDef) = match.Value;
 134
 3414135        if (isLeafPath)
 136        {
 137            // Deep-clone the preserved subtree: storing the source reference would alias the
 138            // two builders, letting later mutations of either leak into the other (the
 139            // role-based filtering scenario hands the preserved query out while the source
 140            // keeps evolving).
 492141            targetFields[fieldKey] = fieldDef.DeepClone();
 492142            return;
 143        }
 144
 2928145        if (fieldDef._children is null) return;
 146
 2916147        var existing = GetOrAddTargetChild(targetFields, fieldKey, fieldDef);
 2916148        ExtractMatchingFields(fieldDef._children, existing._children!, remainingPath);
 2916149    }
 150
 151    private static void ExtractMatchingFields(FieldChildren sourceFields, FieldChildren targetFields, ReadOnlySpan<char>
 152    {
 4932153        var isLeafPath = SplitFirstSegment(path, out var currentSegment, out var remainingPath);
 154
 4941155        if (!TryFindByNameOrAlias(sourceFields, currentSegment, out var fieldKey, out var fieldDef)) return;
 156
 4923157        if (isLeafPath)
 158        {
 159            // Same isolation rule as the Dictionary variant above: the preserved tree must
 160            // never share FieldDefinition instances with the source.
 2904161            targetFields.Set(fieldKey, fieldDef.DeepClone());
 2904162            return;
 163        }
 164
 2022165        if (fieldDef._children is null) return;
 166
 2016167        var targetChild = GetOrAddTargetChild(targetFields, fieldKey, fieldDef);
 2016168        ExtractMatchingFields(fieldDef._children, targetChild._children!, remainingPath);
 2016169    }
 170
 171    private static bool SplitFirstSegment(ReadOnlySpan<char> path, out ReadOnlySpan<char> current, out ReadOnlySpan<char
 172    {
 8370173        var dot = path.IndexOf('.');
 8370174        if (dot < 0)
 175        {
 3411176            current = path;
 3411177            remaining = ReadOnlySpan<char>.Empty;
 3411178            return true;
 179        }
 4959180        current = path[..dot];
 4959181        remaining = path[(dot + 1)..];
 4959182        return false;
 183    }
 184
 185    private static bool TryFindByNameOrAlias(FieldChildren children, ReadOnlySpan<char> nameOrAlias, out string key, out
 186    {
 17919187        foreach (var f in children.AsSpan())
 188        {
 6489189            if (f.Name.AsSpan().Equals(nameOrAlias, StringComparison.OrdinalIgnoreCase)
 6489190                || (f.Alias is { Length: > 0 } alias && alias.AsSpan().Equals(nameOrAlias, StringComparison.OrdinalIgnor
 191            {
 4923192                key = f.Name;
 4923193                field = f;
 4923194                return true;
 195            }
 196        }
 9197        key = string.Empty;
 9198        field = null!;
 9199        return false;
 200    }
 201
 202    private static FieldDefinition GetOrAddTargetChild(Dictionary<string, FieldDefinition> target, string fieldKey, Fiel
 203    {
 2916204        if (!target.TryGetValue(fieldKey, out var existing))
 205        {
 1500206            existing = CloneFieldWithoutChildren(source);
 1500207            target[fieldKey] = existing;
 208        }
 2916209        existing._children ??= new FieldChildren();
 2916210        return existing;
 211    }
 212
 213    private static FieldDefinition GetOrAddTargetChild(FieldChildren targetFields, string fieldKey, FieldDefinition sour
 214    {
 2016215        if (!targetFields.TryGetValue(fieldKey, out var existing) || existing is null)
 216        {
 1152217            existing = CloneFieldWithoutChildren(source);
 1152218            targetFields.Set(fieldKey, existing);
 219        }
 2016220        existing._children ??= new FieldChildren();
 2016221        return existing;
 222    }
 223
 224    /// <summary>
 225    /// Creates a shallow clone of a field without its children, preserving arguments, metadata,
 226    /// and directives.
 227    /// </summary>
 228    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 229    private static FieldDefinition CloneFieldWithoutChildren(FieldDefinition source)
 230    {
 231        // FieldDefinition._type is always set by every constructor; the null-coalesce was
 232        // a defensive guard for the rare `with { Type = null }` path and never fires here.
 233        // Arguments are copied, not referenced — intermediate path nodes must be isolated
 234        // from the source the same way leaf subtrees are (Where on a preserved intermediate
 235        // would otherwise mutate the source's argument dictionary).
 2652236        var cloned = new FieldDefinition(
 2652237            source.Name,
 2652238            source._type!,
 2652239            source.Alias,
 2652240            source._arguments is null ? null : new SortedDictionary<string, object?>(source._arguments, StringComparer.O
 2652241        {
 2652242            Path = source.Path
 2652243        };
 244
 2652245        cloned.IsNeverMerge = source.IsNeverMerge;
 246
 2652247        if (source._metadata != null)
 248        {
 18249            foreach (var meta in source._metadata)
 250            {
 6251                cloned.Metadata[meta.Key] = meta.Value;
 252            }
 253        }
 254
 255        // An intermediate node on a preserved path (e.g. "user" when preserving "user.id") can
 256        // itself carry a conditional include/skip directive that gates the whole subtree beneath
 257        // it — dropping it here would silently make the preserved projection unconditional where
 258        // the source was conditional. FieldDirective is an immutable record, so sharing the list
 259        // entries is safe; only the LIST needs to be a fresh instance so mutating the clone's
 260        // directives can never reach back into the source.
 2652261        cloned._directives = source._directives is { Count: > 0 }
 2652262            ? new List<FieldDirective>(source._directives)
 2652263            : null;
 264
 2652265        return cloned;
 266    }
 267
 268    // All call sites pre-check the fields collection for null (NavigatePath, PreserveAtPath,
 269    // QueryDefinitionExtensions.NavigatePath); the parameter could safely be tightened but
 270    // is kept nullable to preserve the existing internal API signature.
 271    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 272    internal static KeyValuePair<string, FieldDefinition>? FindFieldByNameOrAlias(IReadOnlyDictionary<string, FieldDefin
 273    {
 15897274        foreach (var kvp in fields!)
 275        {
 5694276            if (MatchesKeyNameOrAlias(kvp, nameOrAlias))
 277            {
 4311278                return kvp;
 279            }
 280        }
 281
 99282        return null;
 4311283    }
 284
 285    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 286    private static bool MatchesKeyNameOrAlias(KeyValuePair<string, FieldDefinition> kvp, ReadOnlySpan<char> nameOrAlias)
 5694287        => nameOrAlias.Equals(kvp.Key.AsSpan(), StringComparison.OrdinalIgnoreCase)
 5694288        || nameOrAlias.Equals(kvp.Value.Name.AsSpan(), StringComparison.OrdinalIgnoreCase)
 5694289        || (!string.IsNullOrEmpty(kvp.Value.Alias) && nameOrAlias.Equals(kvp.Value.Alias.AsSpan(), StringComparison.Ordi
 290
 291    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 292    private static void ExtractVariablesFromFields(FieldChildren children, SortedSet<Variable> variables)
 293    {
 14100294        foreach (var field in children.AsSpan())
 295        {
 4227296            ExtractVariablesFromField(field, variables);
 297        }
 2823298    }
 299
 300    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 301    private static void ExtractVariablesFromFields(Dictionary<string, FieldDefinition> fields, SortedSet<Variable> varia
 302    {
 5274303        foreach (var field in fields.Values)
 304        {
 1626305            ExtractVariablesFromField(field, variables);
 306        }
 1011307    }
 308
 309    // A preserved field's conditional-include directive — or any custom directive carrying a
 310    // Variable argument — must keep its variable declared in the projected query's signature,
 311    // exactly like a field argument does. Without this, a preserved field with an include/skip
 312    // condition would render referencing a variable never declared, which a GraphQL server rejects.
 313    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 314        "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method",
 315        Justification = "Plain foreach avoids a Where enumerator allocation on the preservation hot path.")]
 316    private static void ExtractVariablesFromField(FieldDefinition field, SortedSet<Variable> variables)
 317    {
 5853318        if (field._arguments?.Count > 0)
 319        {
 15320            Helpers.ExtractVariablesFromValue(field._arguments, variables);
 321        }
 5853322        if (field._directives is { Count: > 0 } directives)
 323        {
 24324            foreach (var directive in directives)
 325            {
 6326                if (directive.Arguments is { Count: > 0 })
 327                {
 6328                    Helpers.ExtractVariablesFromValue(directive.Arguments, variables);
 329                }
 330            }
 331        }
 5853332        if (field._children is { Count: > 0 })
 333        {
 2808334            ExtractVariablesFromFields(field._children, variables);
 335        }
 5853336    }
 337}

Methods/Properties

Preserve(NGql.Core.Builders.QueryBuilder,System.String[])
CopyReferencedNamedFragments(NGql.Core.Abstractions.QueryDefinition,NGql.Core.Abstractions.QueryDefinition)
CollectSpreadNames(NGql.Core.Abstractions.FieldDefinition,System.Collections.Generic.HashSet`1<System.String>,System.Collections.Generic.Stack`1<System.String>)
CollectFragmentBodySpreadNames(NGql.Core.Abstractions.FieldChildren,System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.InlineFragmentDefinition>,System.Collections.Generic.List`1<System.String>,System.Collections.Generic.HashSet`1<System.String>,System.Collections.Generic.Stack`1<System.String>)
ExtractMatchingFields(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.ReadOnlySpan`1<System.Char>)
ExtractMatchingFields(NGql.Core.Abstractions.FieldChildren,NGql.Core.Abstractions.FieldChildren,System.ReadOnlySpan`1<System.Char>)
SplitFirstSegment(System.ReadOnlySpan`1<System.Char>,System.ReadOnlySpan`1<System.Char>&,System.ReadOnlySpan`1<System.Char>&)
TryFindByNameOrAlias(NGql.Core.Abstractions.FieldChildren,System.ReadOnlySpan`1<System.Char>,System.String&,NGql.Core.Abstractions.FieldDefinition&)
GetOrAddTargetChild(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.String,NGql.Core.Abstractions.FieldDefinition)
GetOrAddTargetChild(NGql.Core.Abstractions.FieldChildren,System.String,NGql.Core.Abstractions.FieldDefinition)
CloneFieldWithoutChildren(NGql.Core.Abstractions.FieldDefinition)
FindFieldByNameOrAlias(System.Collections.Generic.IReadOnlyDictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.ReadOnlySpan`1<System.Char>)
MatchesKeyNameOrAlias(System.Collections.Generic.KeyValuePair`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.ReadOnlySpan`1<System.Char>)
ExtractVariablesFromFields(NGql.Core.Abstractions.FieldChildren,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>)
ExtractVariablesFromFields(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>)
ExtractVariablesFromField(NGql.Core.Abstractions.FieldDefinition,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>)