< Summary

Information
Class: NGql.Core.Features.FieldSignatureGenerator
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Features/FieldSignatureGenerator.cs
Line coverage
100%
Covered lines: 85
Uncovered lines: 0
Coverable lines: 85
Total lines: 237
Line coverage: 100%
Branch coverage
100%
Covered branches: 40
Total branches: 40
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
GenerateSignature(...)100%1010100%
AppendFieldSignature(...)100%66100%
AppendFieldSignatureRemainder(...)100%1212100%
AppendCaseInsensitive(...)100%22100%
AppendArgumentValue(...)100%44100%
AppendComplexValue(...)100%66100%
AppendDictionary(...)100%11100%
AppendEnumerable(...)100%11100%

File(s)

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

#LineLine coverage
 1using System.Buffers;
 2using System.Collections;
 3using System.Text;
 4using NGql.Core.Abstractions;
 5using NGql.Core.Extensions;
 6
 7namespace NGql.Core.Features;
 8
 9/// <summary>
 10/// Generates unique signatures for field definitions based on their arguments/filters.
 11/// Optimized for performance using Span operations and reduced string allocations.
 12/// </summary>
 13public static class FieldSignatureGenerator
 14{
 15    // Pre-allocated StringBuilder for signature generation to avoid repeated allocations
 1116    private static readonly ThreadLocal<StringBuilder> SignatureBuilder = new(() => new StringBuilder(InitialBuilderCapa
 17
 18    private const int InitialBuilderCapacity = 256;
 19    // One oversized signature must not permanently bloat the thread-local builder — drop and
 20    // replace it past this threshold (same discipline as QueryTextBuilder's pool).
 21    private const int MaxBuilderCapacity = 64 * 1024;
 22
 23    // Singleton comparer for sorting by Name for deterministic signature generation.
 324    private static readonly IComparer<FieldDefinition> FieldNameComparer =
 7825        Comparer<FieldDefinition>.Create(static (a, b) => StringComparer.OrdinalIgnoreCase.Compare(a.Name, b.Name));
 26
 27    /// <summary>
 28    /// Generates a unique hash signature for a collection of field definitions.
 29    /// </summary>
 30    /// <param name="fields">The field definitions to generate signature for.</param>
 31    /// <returns>A unique hash representing the filter signature of the fields.</returns>
 32    public static int GenerateSignature(Dictionary<string, FieldDefinition> fields)
 33    {
 29134        if (fields.Count == 0)
 35        {
 636            return 0;
 37        }
 38
 28539        var builder = SignatureBuilder.Value!;
 28540        builder.Clear();
 41
 724242        foreach (var field in fields.Values)
 43        {
 333644            AppendFieldSignature(builder, field, ReadOnlySpan<char>.Empty);
 45        }
 46
 47        // builder.Length is always > 0 here: the empty-fields case was handled above and
 48        // every field appends at least its name to the signature.
 49        // Hash directly over StringBuilder chunks — avoids allocating a temporary string.
 50        int hash;
 51        unchecked
 52        {
 28553            hash = 5381;
 139254            foreach (var chunk in builder.GetChunks())
 55            {
 134497256                foreach (var c in chunk.Span)
 67207557                    hash = (hash << 5) + hash + c; // djb2: hash*33 + c
 58            }
 59        }
 60
 28561        if (builder.Capacity > MaxBuilderCapacity)
 62        {
 663            SignatureBuilder.Value = new StringBuilder(InitialBuilderCapacity);
 64        }
 65
 28566        return hash;
 67    }
 68
 69    /// <summary>
 70    /// Appends field signature to the builder using Span operations for efficient path construction.
 71    /// </summary>
 72    /// <param name="builder">StringBuilder to append to</param>
 73    /// <param name="field">Field definition to process</param>
 74    /// <param name="parentPath">Parent path as ReadOnlySpan to avoid string allocations</param>
 75    private static void AppendFieldSignature(StringBuilder builder, FieldDefinition field, ReadOnlySpan<char> parentPath
 76    {
 77        // Build the current path efficiently using Span operations
 357978        Span<char> currentPathBuffer = stackalloc char[256]; // Stack allocation for path building
 79
 357980        var fieldNameSpan = field.Name.AsSpan();
 81        // Separator ('.') is only added when a parent path is present. Compute the exact length
 82        // the combined path needs and bail to the heap fallback BEFORE any CopyTo when it won't
 83        // fit — a parent path already produced on the heap can itself be >= the buffer size, so
 84        // guarding the field-name copy alone is not enough to keep the parent copy in bounds.
 357985        int separatorLength = parentPath.IsEmpty ? 0 : 1;
 357986        int requiredLength = parentPath.Length + separatorLength + fieldNameSpan.Length;
 87
 357988        if (requiredLength >= currentPathBuffer.Length)
 89        {
 90            // Fallback to string concatenation for very long paths. The path is still appended
 91            // through AppendCaseInsensitive so this heap path yields the same case-folded
 92            // signature as the stackalloc path above — the two must never disagree on case.
 93            // Interpolating parentPath directly (rather than parentPath.ToString()) lets the
 94            // interpolated-string handler format the span in place, avoiding materializing an
 95            // intermediate string just to concatenate it again.
 4896            var currentPath = parentPath.IsEmpty ? field.Name : $"{parentPath}.{field.Name}";
 4897            AppendCaseInsensitive(builder, currentPath.AsSpan());
 4898            AppendFieldSignatureRemainder(builder, field, currentPath.AsSpan());
 4899            return;
 100        }
 101
 3531102        int pathLength = 0;
 103
 3531104        if (!parentPath.IsEmpty)
 105        {
 222106            parentPath.CopyTo(currentPathBuffer);
 222107            pathLength = parentPath.Length;
 222108            currentPathBuffer[pathLength++] = '.';
 109        }
 110
 3531111        fieldNameSpan.CopyTo(currentPathBuffer[pathLength..]);
 3531112        pathLength += fieldNameSpan.Length;
 113
 3531114        var currentPathSpan = currentPathBuffer[..pathLength];
 115
 116        // Append the path to the signature, case-folded so {User} and {user} hash equal —
 117        // matching the OrdinalIgnoreCase merge/sort semantics used elsewhere.
 3531118        AppendCaseInsensitive(builder, currentPathSpan);
 119
 3531120        AppendFieldSignatureRemainder(builder, field, currentPathSpan);
 3531121    }
 122
 123    /// <summary>
 124    /// Appends the remainder of the field signature (arguments and child fields).
 125    /// </summary>
 126    /// <param name="builder">StringBuilder to append to</param>
 127    /// <param name="field">Field definition to process</param>
 128    /// <param name="currentPath">Current path (either as span or string)</param>
 129    private static void AppendFieldSignatureRemainder(StringBuilder builder, FieldDefinition field, ReadOnlySpan<char> c
 130    {
 131        // Add arguments if present
 3579132        if (field._arguments is { Count: > 0 })
 133        {
 147134            builder.Append('[');
 135            // _arguments is SortedDictionary — already ordered by key, no OrderBy needed.
 1374136            foreach (var arg in field._arguments)
 137            {
 138                // Argument KEYS are case-insensitive (the arg dictionaries use
 139                // StringComparer.OrdinalIgnoreCase); VALUES stay case-sensitive.
 540140                AppendCaseInsensitive(builder, arg.Key.AsSpan());
 540141                builder.Append(':');
 540142                AppendArgumentValue(builder, arg.Value);
 540143                builder.Append(';');
 144            }
 147145            builder.Append(']');
 146        }
 147
 3579148        builder.Append('|');
 149
 150        // Recursively process child fields sorted by Name for a deterministic hash.
 151        // Dictionary<TKey,TValue> is unordered; explicit sort ensures signature stability.
 3579152        if (field._children is { Count: > 0 })
 153        {
 177154            var fieldCount = field._children.Count;
 177155            var rented = ArrayPool<FieldDefinition>.Shared.Rent(fieldCount);
 156            try
 157            {
 177158                field._children.AsSpan().CopyTo(rented);
 177159                Array.Sort(rented, 0, fieldCount, FieldNameComparer);
 840160                for (var i = 0; i < fieldCount; i++)
 243161                    AppendFieldSignature(builder, rented[i], currentPath);
 177162            }
 163            finally
 164            {
 177165                ArrayPool<FieldDefinition>.Shared.Return(rented, clearArray: false);
 177166            }
 167        }
 3579168    }
 169
 170
 171    /// <summary>
 172    /// Appends a field name / path / argument key to the signature, folding each char with
 173    /// <see cref="char.ToUpperInvariant(char)"/> so casing does not affect the hash — the rest
 174    /// of the library merges and sorts these under OrdinalIgnoreCase. Folds char-by-char to stay
 175    /// allocation-free; no upper-cased copy of the string is materialised. Value text is NOT
 176    /// routed here — GraphQL argument values are case-sensitive.
 177    /// </summary>
 178    private static void AppendCaseInsensitive(StringBuilder builder, ReadOnlySpan<char> text)
 179    {
 1336962180        foreach (var c in text)
 664332181            builder.Append(char.ToUpperInvariant(c));
 4149182    }
 183
 184    /// <summary>
 185    /// Appends argument value to the signature with optimized handling for common types.
 186    /// </summary>
 187    /// <param name="builder">StringBuilder to append to</param>
 188    /// <param name="value">Argument value to append</param>
 189    private static void AppendArgumentValue(StringBuilder builder, object? value)
 190    {
 606191        if (value is null)
 192        {
 3193            builder.Append("null");
 3194            return;
 195        }
 196
 603197        if (ValueFormatter.TryAppendPrimitive(value, builder))
 567198            return;
 199
 36200        AppendComplexValue(builder, value);
 36201    }
 202
 203    private static void AppendComplexValue(StringBuilder builder, object value)
 204    {
 36205        switch (value)
 206        {
 207            case IDictionary<string, object?> dict:
 21208                AppendDictionary(builder, dict);
 21209                break;
 12210            case IEnumerable enumerable when value is not string:
 12211                AppendEnumerable(builder, enumerable);
 12212                break;
 213            default:
 3214                builder.Append(value);
 215                break;
 216        }
 3217    }
 218
 219    private static void AppendDictionary(StringBuilder builder, IDictionary<string, object?> dict)
 220    {
 221        // Argument dictionaries reach here through Helpers.SortArgumentValue, which converts
 222        // every nested dict to SortedDictionary; the IOrderedEnumerable fallback for plain
 223        // Dictionary instances was a defensive guard that the public API never triggers.
 21224        Helpers.WriteCollection('{', '}', dict, builder, (sb, item) =>
 21225        {
 30226            var kvp = (KeyValuePair<string, object?>)item!;
 21227            // Nested-dictionary KEYS are case-insensitive (SortArgumentValue rebuilds every
 21228            // nested dict with StringComparer.OrdinalIgnoreCase); VALUES stay case-sensitive.
 30229            AppendCaseInsensitive(sb, kvp.Key.AsSpan());
 30230            sb.Append(':');
 30231            AppendArgumentValue(sb, kvp.Value);
 51232        });
 21233    }
 234
 235    private static void AppendEnumerable(StringBuilder builder, IEnumerable enumerable)
 48236        => Helpers.WriteCollection('[', ']', enumerable, builder, (sb, item) => AppendArgumentValue(sb, item));
 237}