< Summary

Information
Class: NGql.Core.Builders.PreservationBuilder
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Builders/PreservationBuilder.cs
Line coverage
100%
Covered lines: 86
Uncovered lines: 0
Coverable lines: 86
Total lines: 318
Line coverage: 100%
Branch coverage
100%
Covered branches: 58
Total branches: 58
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
Create(...)100%11100%
Preserve(...)100%66100%
PreserveOne(...)100%66100%
PreserveAtPath(...)100%66100%
PreserveAtPathInRoot(...)100%1414100%
PreserveAtPathForRoot(...)100%66100%
PreserveResolvedNestedPath(...)100%44100%
PreserveDirectMatch(...)100%22100%
JoinPath(...)100%44100%
PreserveFromExpression(...)100%11100%
PreserveFromExpression(...)100%11100%
PreserveFromExpression(...)100%11100%
PreserveFromExpression(...)100%11100%
PreserveFromExpression(...)100%11100%
Build()100%22100%
InferTypeFromLambda(...)100%44100%
PreserveFromExpressionCore(...)100%11100%

File(s)

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

#LineLine coverage
 1using System.Linq.Expressions;
 2using NGql.Core.Extensions;
 3using NGql.Core.Features;
 4
 5namespace NGql.Core.Builders;
 6
 7/// <summary>
 8/// Builds a derived <see cref="QueryBuilder"/> that contains only a chosen subset of an
 9/// existing query's fields.
 10/// </summary>
 11/// <remarks>
 12/// Use <see cref="Create(QueryBuilder)"/> to start, accumulate paths via <see cref="Preserve(string[])"/>
 13/// or <see cref="PreserveFromExpression{T}(Expression{Func{T, bool}}, string?)"/>, then call
 14/// <see cref="Build"/> to materialize the trimmed query. The source builder is never mutated.
 15/// <para>
 16/// Typical use: role-based field filtering — start from a "full" query and emit a smaller
 17/// projection per caller without re-building from scratch.
 18/// </para>
 19/// </remarks>
 20public sealed class PreservationBuilder
 21{
 22    private readonly QueryBuilder _sourceQuery;
 23    private readonly HashSet<string> _pathsToPreserve;
 76824    private int _minimumAddedPathLength = int.MaxValue;
 25#if !NET9_0_OR_GREATER
 26    // .NET 8 has no span lookup on HashSet<string>. Probing these hashes first means a prefix
 27    // string is only allocated when a stored path can actually match it.
 25628    private readonly HashSet<int> _pathHashes = new();
 29#endif
 30
 76831    private PreservationBuilder(QueryBuilder sourceQuery)
 32    {
 76833        _sourceQuery = sourceQuery ?? throw new ArgumentNullException(nameof(sourceQuery));
 76234        _pathsToPreserve = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 76235    }
 36
 37    /// <summary>Creates a new <see cref="PreservationBuilder"/> over <paramref name="query"/>.</summary>
 38    /// <param name="query">The source query to project from. Not mutated.</param>
 39    /// <exception cref="ArgumentNullException"><paramref name="query"/> is null.</exception>
 76840    public static PreservationBuilder Create(QueryBuilder query) => new(query);
 41
 42    /// <summary>
 43    /// Adds dotted field paths to the preservation set. When a more-specific child path is
 44    /// added, any previously-added parent paths are dropped automatically (you don't end up
 45    /// preserving both <c>user</c> and <c>user.name</c> — only the more specific wins).
 46    /// </summary>
 47    /// <param name="fieldPaths">Dot-separated field paths to preserve. Null/whitespace entries are skipped.</param>
 48    /// <returns>This builder, for chaining.</returns>
 49    public PreservationBuilder Preserve(params string[]? fieldPaths)
 50    {
 631551        if (fieldPaths == null || fieldPaths.Length == 0) return this;
 52
 3760853        foreach (var path in fieldPaths)
 54        {
 1250755            PreserveOne(path);
 56        }
 57
 629758        return this;
 59    }
 60
 61    /// <summary>
 62    /// Adds a single dotted field path to the preservation set, pruning any previously-added
 63    /// parent paths that <paramref name="path"/> is a more specific descendant of. This is the
 64    /// per-path body shared by <see cref="Preserve(string[])"/> and the internal single-path
 65    /// call sites in <see cref="PreserveAtPathForRoot"/>, so those sites don't need to allocate
 66    /// a single-element <c>params string[]</c> just to add one path.
 67    /// </summary>
 68    /// <param name="path">Dot-separated field path to preserve. Null/whitespace is a no-op.</param>
 69    private void PreserveOne(string path)
 70    {
 1415471        if (string.IsNullOrWhiteSpace(path)) return;
 72
 1145473        var remaining = path.AsSpan();
 2724374        while (_pathsToPreserve.Count > 0)
 75        {
 2646976            var dot = remaining.LastIndexOf('.');
 2646977            if (dot < _minimumAddedPathLength) break;
 78
 1578979            remaining = remaining[..dot];
 80#if NET9_0_OR_GREATER
 1052681            _pathsToPreserve.GetAlternateLookup<ReadOnlySpan<char>>().Remove(remaining);
 82#else
 526383            if (_pathHashes.Contains(string.GetHashCode(remaining, StringComparison.OrdinalIgnoreCase)))
 84            {
 99385                _pathsToPreserve.Remove(remaining.ToString());
 86            }
 87#endif
 88        }
 89
 1145490        _pathsToPreserve.Add(path);
 91#if !NET9_0_OR_GREATER
 381892        _pathHashes.Add(string.GetHashCode(path.AsSpan(), StringComparison.OrdinalIgnoreCase));
 93#endif
 1145494        _minimumAddedPathLength = Math.Min(_minimumAddedPathLength, path.Length);
 1145495    }
 96
 97    /// <summary>
 98    /// Preserves <paramref name="fieldPath"/> within the subtree at <paramref name="nodePath"/>.
 99    /// Useful when the same field name appears multiple times in the tree and you want to
 100    /// scope the preservation to one specific parent.
 101    /// </summary>
 102    /// <remarks>
 103    /// <paramref name="nodePath"/> is resolved relative to every root in the source query — it
 104    /// is attempted under each root in turn, and the preservation applies wherever it resolves.
 105    /// A dotted <paramref name="nodePath"/> is not interpreted as "first segment = root name";
 106    /// it is simply a relative path searched under each root independently. Callers who need to
 107    /// scope preservation to exactly one named root — and get a clean no-op instead of touching
 108    /// any other root when it doesn't resolve there — should use
 109    /// <see cref="PreserveAtPathInRoot(string, string, string)"/> instead.
 110    /// </remarks>
 111    /// <param name="fieldPath">Field name (or dotted relative path) inside the node.</param>
 112    /// <param name="nodePath">Dotted path identifying which parent node to scope to, resolved under every root.</param>
 113    /// <returns>This builder, for chaining.</returns>
 114    public PreservationBuilder PreserveAtPath(string fieldPath, string nodePath)
 115    {
 33116        var lastIndex = nodePath.LastIndexOf('.');
 33117        var lastSegment = lastIndex == -1 ? nodePath : nodePath.Substring(lastIndex + 1);
 33118        var fieldPathHasDot = fieldPath.Contains('.');
 119
 144120        foreach (var rootField in _sourceQuery.Definition.FieldsInternal.Values)
 121        {
 39122            PreserveAtPathForRoot(rootField.Alias ?? rootField.Name, fieldPath, nodePath, lastSegment, fieldPathHasDot);
 123        }
 33124        return this;
 125    }
 126
 127    /// <summary>
 128    /// Preserves <paramref name="fieldPath"/> within the subtree at <paramref name="nodePath"/>,
 129    /// scoped exclusively to the query root named <paramref name="root"/>.
 130    /// </summary>
 131    /// <remarks>
 132    /// Unlike <see cref="PreserveAtPath(string, string)"/>, this never falls back to searching
 133    /// other roots. <paramref name="root"/> is matched against each root's alias-or-name using
 134    /// ordinal case-insensitive comparison. If <paramref name="root"/> does not name an existing
 135    /// root, or <paramref name="nodePath"/> does not resolve under it, the call is a no-op —
 136    /// no other root is touched and nothing is retargeted.
 137    /// </remarks>
 138    /// <param name="fieldPath">Field name (or dotted relative path) inside the node.</param>
 139    /// <param name="nodePath">Dotted path identifying which parent node to scope to, resolved under <paramref name="roo
 140    /// <param name="root">Alias-or-name of the single query root to scope preservation to.</param>
 141    /// <returns>This builder, for chaining.</returns>
 142    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 143        "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method",
 144        Justification = "Plain foreach over Dictionary.Values avoids the Where enumerator allocation on this lookup.")]
 145    public PreservationBuilder PreserveAtPathInRoot(string fieldPath, string nodePath, string root)
 146    {
 39147        if (string.IsNullOrWhiteSpace(fieldPath) || string.IsNullOrWhiteSpace(nodePath) || string.IsNullOrWhiteSpace(roo
 148        {
 18149            return this;
 150        }
 151
 21152        var lastIndex = nodePath.LastIndexOf('.');
 21153        var lastSegment = lastIndex == -1 ? nodePath : nodePath.Substring(lastIndex + 1);
 21154        var fieldPathHasDot = fieldPath.Contains('.');
 155
 75156        foreach (var rootField in _sourceQuery.Definition.FieldsInternal.Values)
 157        {
 24158            var rootName = rootField.Alias ?? rootField.Name;
 24159            if (!string.Equals(rootName, root, StringComparison.OrdinalIgnoreCase))
 160            {
 161                continue;
 162            }
 163
 15164            PreserveAtPathForRoot(rootName, fieldPath, nodePath, lastSegment, fieldPathHasDot);
 15165            break;
 166        }
 167
 21168        return this;
 169    }
 170
 171    private void PreserveAtPathForRoot(string rootName, string fieldPath, string nodePath, string lastSegment, bool fiel
 172    {
 173        // rootName always names one of the query's own roots, so GetPathTo resolves it and
 174        // returns at least the root segment — no empty-result guard is reachable here.
 54175        var pathToNode = _sourceQuery.GetPathTo(rootName, nodePath);
 176
 54177        var fullNodePath = JoinPath(pathToNode, lastSegment);
 54178        var nodeField = QueryDefinitionExtensions.NavigatePath(_sourceQuery.Definition.Fields, fullNodePath.AsSpan(), ou
 66179        if (nodeField is null) return;
 48180        if (!nodeField.HasFields) return;
 181
 36182        if (fieldPathHasDot)
 183        {
 6184            PreserveResolvedNestedPath(nodeField.Fields, fieldPath, fullNodePath);
 185        }
 186        else
 187        {
 30188            PreserveDirectMatch(nodeField.Fields, fieldPath, fullNodePath);
 189        }
 30190    }
 191
 192    private void PreserveResolvedNestedPath(IReadOnlyDictionary<string, Abstractions.FieldDefinition> nodeFields, string
 193    {
 6194        if (QueryDefinitionExtensions.NavigatePath(nodeFields, fieldPath.AsSpan(), out var resolved, fullNodePath) != nu
 6195            && resolved is not null)
 196        {
 3197            PreserveOne(resolved);
 198        }
 6199    }
 200
 201    private void PreserveDirectMatch(IReadOnlyDictionary<string, Abstractions.FieldDefinition> nodeFields, string fieldP
 202    {
 30203        var match = PreserveExtensions.FindFieldByNameOrAlias(nodeFields, fieldPath.AsSpan());
 30204        if (match.HasValue)
 205        {
 24206            PreserveOne(string.Concat(fullNodePath, ".", match.Value.Key));
 207        }
 30208    }
 209
 210    /// <summary>
 211    /// Builds <c>{segments[0]}.{segments[1]}...{segments[N-1]}.{tail}</c> in a single allocation —
 212    /// replaces the prior <c>string.Join + "." + tail</c> two-step.
 213    /// </summary>
 214    private static string JoinPath(string[] segments, string tail)
 215    {
 54216        var totalLength = tail.Length + segments.Length; // dots between segments + before tail
 342217        for (int i = 0; i < segments.Length; i++) totalLength += segments[i].Length;
 218
 54219        return string.Create(totalLength, (segments, tail), static (span, st) =>
 54220        {
 54221            var pos = 0;
 264222            for (int i = 0; i < st.segments.Length; i++)
 54223            {
 78224                var seg = st.segments[i].AsSpan();
 78225                seg.CopyTo(span[pos..]);
 78226                pos += seg.Length;
 78227                span[pos++] = '.';
 54228            }
 54229            st.tail.AsSpan().CopyTo(span[pos..]);
 108230        });
 231    }
 232
 233    /// <summary>
 234    /// Preserves every field touched by the predicate <paramref name="expression"/>. The
 235    /// expression is walked (not executed) to collect member access chains; comparisons,
 236    /// logical operators, ternaries, null-coalescing, and LINQ method calls (<c>Any</c>,
 237    /// <c>Where</c>, <c>First</c>) are all supported.
 238    /// </summary>
 239    /// <typeparam name="T">CLR type whose property hierarchy mirrors the query tree.</typeparam>
 240    /// <param name="expression">Predicate expression whose member access chains identify fields to preserve.</param>
 241    /// <param name="nodePath">Optional dotted path to scope preservation to a specific subtree.</param>
 242    /// <returns>This builder, for chaining.</returns>
 243    public PreservationBuilder PreserveFromExpression<T>(Expression<Func<T, bool>> expression, string? nodePath = null)
 90244        => PreserveFromExpressionCore(expression, nodePath, null, typeof(T), null);
 245
 246    /// <summary>
 247    /// Same as <see cref="PreserveFromExpression{T}(Expression{Func{T, bool}}, string?)"/>
 248    /// but adds a <paramref name="localMap"/> that translates expression parameter names
 249    /// to relative dotted paths inside <paramref name="nodePath"/>'s subtree. Useful when
 250    /// the predicate references multiple sibling fields.
 251    /// </summary>
 252    /// <param name="expression">Predicate expression.</param>
 253    /// <param name="nodePath">Dotted path to scope preservation to.</param>
 254    /// <param name="localMap">Map of expression-parameter name to relative path segments.</param>
 255    /// <returns>This builder, for chaining.</returns>
 256    public PreservationBuilder PreserveFromExpression<T>(Expression<Func<T, bool>> expression, string nodePath, Dictiona
 6257        => PreserveFromExpressionCore(expression, nodePath, localMap, typeof(T), null);
 258
 259    /// <summary>
 260    /// Untyped overload of <see cref="PreserveFromExpression{T}(Expression{Func{T, bool}}, string?)"/>.
 261    /// Useful when the predicate is constructed dynamically (e.g. via DynamicExpresso) and
 262    /// you don't have a compile-time <c>T</c>.
 263    /// </summary>
 264    /// <param name="expression">Lambda expression whose body is walked for member-access chains.</param>
 265    /// <param name="nodePath">Optional dotted path to scope preservation to.</param>
 266    /// <returns>This builder, for chaining.</returns>
 267    public PreservationBuilder PreserveFromExpression(Expression expression, string? nodePath = null)
 9268        => PreserveFromExpressionCore(expression, nodePath, null, InferTypeFromLambda(expression), null);
 269
 270    /// <summary>Untyped overload accepting a parameter-name-to-path <paramref name="localMap"/>.</summary>
 271    /// <param name="expression">Lambda expression.</param>
 272    /// <param name="nodePath">Dotted path to scope preservation to.</param>
 273    /// <param name="localMap">Map of expression-parameter name to relative path segments.</param>
 274    /// <returns>This builder, for chaining.</returns>
 275    public PreservationBuilder PreserveFromExpression(Expression expression, string nodePath, Dictionary<string, string[
 30276        => PreserveFromExpressionCore(expression, nodePath, localMap, InferTypeFromLambda(expression), null);
 277
 278    /// <summary>
 279    /// Untyped overload that, in addition to expression-derived paths, unconditionally
 280    /// preserves <paramref name="alwaysPreserveFields"/> at <paramref name="nodePath"/> —
 281    /// useful for IDs and tracking columns that callers always want regardless of predicate.
 282    /// </summary>
 283    /// <param name="expression">Lambda expression.</param>
 284    /// <param name="nodePath">Dotted path to scope preservation to.</param>
 285    /// <param name="localMap">Map of expression-parameter name to relative path segments.</param>
 286    /// <param name="alwaysPreserveFields">Field names always added under <paramref name="nodePath"/>.</param>
 287    /// <returns>This builder, for chaining.</returns>
 288    public PreservationBuilder PreserveFromExpression(Expression expression, string nodePath, Dictionary<string, string[
 30289        => PreserveFromExpressionCore(expression, nodePath, localMap, InferTypeFromLambda(expression), alwaysPreserveFie
 290
 291    /// <summary>
 292    /// Materializes a new <see cref="QueryBuilder"/> containing only the accumulated
 293    /// preserved paths. Returns the source builder unchanged when no paths were added.
 294    /// The source builder is never mutated.
 295    /// </summary>
 296    /// <returns>A new builder containing the preserved subset, or the source if no paths were added.</returns>
 297    public QueryBuilder Build()
 735298        => _pathsToPreserve.Count == 0
 735299            ? _sourceQuery
 735300            : _sourceQuery.Preserve(_pathsToPreserve.ToArray());
 301
 302    private static Type? InferTypeFromLambda(Expression expression)
 69303        => expression is LambdaExpression lambda && lambda.Parameters.Count > 0
 69304            ? lambda.Parameters[0].Type
 69305            : null;
 306
 307    private PreservationBuilder PreserveFromExpressionCore(
 308        Expression expression,
 309        string? nodePath,
 310        Dictionary<string, string[]>? localMap,
 311        Type? parameterType,
 312        string[]? alwaysPreserveFields)
 313    {
 165314        var processor = new ExpressionPreservationProcessor(_sourceQuery, PreserveOne);
 165315        processor.ProcessExpression(expression, nodePath, localMap, parameterType, alwaysPreserveFields);
 165316        return this;
 317    }
 318}

Methods/Properties

.ctor(NGql.Core.Builders.QueryBuilder)
Create(NGql.Core.Builders.QueryBuilder)
Preserve(System.String[])
PreserveOne(System.String)
PreserveAtPath(System.String,System.String)
PreserveAtPathInRoot(System.String,System.String,System.String)
PreserveAtPathForRoot(System.String,System.String,System.String,System.String,System.Boolean)
PreserveResolvedNestedPath(System.Collections.Generic.IReadOnlyDictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.String,System.String)
PreserveDirectMatch(System.Collections.Generic.IReadOnlyDictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.String,System.String)
JoinPath(System.String[],System.String)
PreserveFromExpression(System.Linq.Expressions.Expression`1<System.Func`2<T,System.Boolean>>,System.String)
PreserveFromExpression(System.Linq.Expressions.Expression`1<System.Func`2<T,System.Boolean>>,System.String,System.Collections.Generic.Dictionary`2<System.String,System.String[]>)
PreserveFromExpression(System.Linq.Expressions.Expression,System.String)
PreserveFromExpression(System.Linq.Expressions.Expression,System.String,System.Collections.Generic.Dictionary`2<System.String,System.String[]>)
PreserveFromExpression(System.Linq.Expressions.Expression,System.String,System.Collections.Generic.Dictionary`2<System.String,System.String[]>,System.String[])
Build()
InferTypeFromLambda(System.Linq.Expressions.Expression)
PreserveFromExpressionCore(System.Linq.Expressions.Expression,System.String,System.Collections.Generic.Dictionary`2<System.String,System.String[]>,System.Type,System.String[])