| | | 1 | | using System.Buffers; |
| | | 2 | | using System.Collections; |
| | | 3 | | using System.Text; |
| | | 4 | | using NGql.Core.Abstractions; |
| | | 5 | | using NGql.Core.Extensions; |
| | | 6 | | |
| | | 7 | | namespace 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> |
| | | 13 | | public static class FieldSignatureGenerator |
| | | 14 | | { |
| | | 15 | | // Pre-allocated StringBuilder for signature generation to avoid repeated allocations |
| | 11 | 16 | | 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. |
| | 3 | 24 | | private static readonly IComparer<FieldDefinition> FieldNameComparer = |
| | 78 | 25 | | 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 | | { |
| | 291 | 34 | | if (fields.Count == 0) |
| | | 35 | | { |
| | 6 | 36 | | return 0; |
| | | 37 | | } |
| | | 38 | | |
| | 285 | 39 | | var builder = SignatureBuilder.Value!; |
| | 285 | 40 | | builder.Clear(); |
| | | 41 | | |
| | 7242 | 42 | | foreach (var field in fields.Values) |
| | | 43 | | { |
| | 3336 | 44 | | 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 | | { |
| | 285 | 53 | | hash = 5381; |
| | 1392 | 54 | | foreach (var chunk in builder.GetChunks()) |
| | | 55 | | { |
| | 1344972 | 56 | | foreach (var c in chunk.Span) |
| | 672075 | 57 | | hash = (hash << 5) + hash + c; // djb2: hash*33 + c |
| | | 58 | | } |
| | | 59 | | } |
| | | 60 | | |
| | 285 | 61 | | if (builder.Capacity > MaxBuilderCapacity) |
| | | 62 | | { |
| | 6 | 63 | | SignatureBuilder.Value = new StringBuilder(InitialBuilderCapacity); |
| | | 64 | | } |
| | | 65 | | |
| | 285 | 66 | | 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 |
| | 3579 | 78 | | Span<char> currentPathBuffer = stackalloc char[256]; // Stack allocation for path building |
| | | 79 | | |
| | 3579 | 80 | | 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. |
| | 3579 | 85 | | int separatorLength = parentPath.IsEmpty ? 0 : 1; |
| | 3579 | 86 | | int requiredLength = parentPath.Length + separatorLength + fieldNameSpan.Length; |
| | | 87 | | |
| | 3579 | 88 | | 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. |
| | 48 | 96 | | var currentPath = parentPath.IsEmpty ? field.Name : $"{parentPath}.{field.Name}"; |
| | 48 | 97 | | AppendCaseInsensitive(builder, currentPath.AsSpan()); |
| | 48 | 98 | | AppendFieldSignatureRemainder(builder, field, currentPath.AsSpan()); |
| | 48 | 99 | | return; |
| | | 100 | | } |
| | | 101 | | |
| | 3531 | 102 | | int pathLength = 0; |
| | | 103 | | |
| | 3531 | 104 | | if (!parentPath.IsEmpty) |
| | | 105 | | { |
| | 222 | 106 | | parentPath.CopyTo(currentPathBuffer); |
| | 222 | 107 | | pathLength = parentPath.Length; |
| | 222 | 108 | | currentPathBuffer[pathLength++] = '.'; |
| | | 109 | | } |
| | | 110 | | |
| | 3531 | 111 | | fieldNameSpan.CopyTo(currentPathBuffer[pathLength..]); |
| | 3531 | 112 | | pathLength += fieldNameSpan.Length; |
| | | 113 | | |
| | 3531 | 114 | | 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. |
| | 3531 | 118 | | AppendCaseInsensitive(builder, currentPathSpan); |
| | | 119 | | |
| | 3531 | 120 | | AppendFieldSignatureRemainder(builder, field, currentPathSpan); |
| | 3531 | 121 | | } |
| | | 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 |
| | 3579 | 132 | | if (field._arguments is { Count: > 0 }) |
| | | 133 | | { |
| | 147 | 134 | | builder.Append('['); |
| | | 135 | | // _arguments is SortedDictionary — already ordered by key, no OrderBy needed. |
| | 1374 | 136 | | foreach (var arg in field._arguments) |
| | | 137 | | { |
| | | 138 | | // Argument KEYS are case-insensitive (the arg dictionaries use |
| | | 139 | | // StringComparer.OrdinalIgnoreCase); VALUES stay case-sensitive. |
| | 540 | 140 | | AppendCaseInsensitive(builder, arg.Key.AsSpan()); |
| | 540 | 141 | | builder.Append(':'); |
| | 540 | 142 | | AppendArgumentValue(builder, arg.Value); |
| | 540 | 143 | | builder.Append(';'); |
| | | 144 | | } |
| | 147 | 145 | | builder.Append(']'); |
| | | 146 | | } |
| | | 147 | | |
| | 3579 | 148 | | 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. |
| | 3579 | 152 | | if (field._children is { Count: > 0 }) |
| | | 153 | | { |
| | 177 | 154 | | var fieldCount = field._children.Count; |
| | 177 | 155 | | var rented = ArrayPool<FieldDefinition>.Shared.Rent(fieldCount); |
| | | 156 | | try |
| | | 157 | | { |
| | 177 | 158 | | field._children.AsSpan().CopyTo(rented); |
| | 177 | 159 | | Array.Sort(rented, 0, fieldCount, FieldNameComparer); |
| | 840 | 160 | | for (var i = 0; i < fieldCount; i++) |
| | 243 | 161 | | AppendFieldSignature(builder, rented[i], currentPath); |
| | 177 | 162 | | } |
| | | 163 | | finally |
| | | 164 | | { |
| | 177 | 165 | | ArrayPool<FieldDefinition>.Shared.Return(rented, clearArray: false); |
| | 177 | 166 | | } |
| | | 167 | | } |
| | 3579 | 168 | | } |
| | | 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 | | { |
| | 1336962 | 180 | | foreach (var c in text) |
| | 664332 | 181 | | builder.Append(char.ToUpperInvariant(c)); |
| | 4149 | 182 | | } |
| | | 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 | | { |
| | 606 | 191 | | if (value is null) |
| | | 192 | | { |
| | 3 | 193 | | builder.Append("null"); |
| | 3 | 194 | | return; |
| | | 195 | | } |
| | | 196 | | |
| | 603 | 197 | | if (ValueFormatter.TryAppendPrimitive(value, builder)) |
| | 567 | 198 | | return; |
| | | 199 | | |
| | 36 | 200 | | AppendComplexValue(builder, value); |
| | 36 | 201 | | } |
| | | 202 | | |
| | | 203 | | private static void AppendComplexValue(StringBuilder builder, object value) |
| | | 204 | | { |
| | 36 | 205 | | switch (value) |
| | | 206 | | { |
| | | 207 | | case IDictionary<string, object?> dict: |
| | 21 | 208 | | AppendDictionary(builder, dict); |
| | 21 | 209 | | break; |
| | 12 | 210 | | case IEnumerable enumerable when value is not string: |
| | 12 | 211 | | AppendEnumerable(builder, enumerable); |
| | 12 | 212 | | break; |
| | | 213 | | default: |
| | 3 | 214 | | builder.Append(value); |
| | | 215 | | break; |
| | | 216 | | } |
| | 3 | 217 | | } |
| | | 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. |
| | 21 | 224 | | Helpers.WriteCollection('{', '}', dict, builder, (sb, item) => |
| | 21 | 225 | | { |
| | 30 | 226 | | var kvp = (KeyValuePair<string, object?>)item!; |
| | 21 | 227 | | // Nested-dictionary KEYS are case-insensitive (SortArgumentValue rebuilds every |
| | 21 | 228 | | // nested dict with StringComparer.OrdinalIgnoreCase); VALUES stay case-sensitive. |
| | 30 | 229 | | AppendCaseInsensitive(sb, kvp.Key.AsSpan()); |
| | 30 | 230 | | sb.Append(':'); |
| | 30 | 231 | | AppendArgumentValue(sb, kvp.Value); |
| | 51 | 232 | | }); |
| | 21 | 233 | | } |
| | | 234 | | |
| | | 235 | | private static void AppendEnumerable(StringBuilder builder, IEnumerable enumerable) |
| | 48 | 236 | | => Helpers.WriteCollection('[', ']', enumerable, builder, (sb, item) => AppendArgumentValue(sb, item)); |
| | | 237 | | } |