< Summary

Information
Class: NGql.Core.Extensions.Helpers
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Extensions/Helpers.cs
Line coverage
100%
Covered lines: 259
Uncovered lines: 0
Coverable lines: 259
Total lines: 762
Line coverage: 100%
Branch coverage
100%
Covered branches: 326
Total branches: 326
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExtractVariablesFromValue(...)100%11100%
ExtractVariablesFromValueCore(...)100%1010100%
ExtractVariablesFromDictionary(...)100%88100%
ExtractVariablesFromList(...)100%88100%
ShouldExtractFromObjectProperties(...)100%1010100%
ExtractVariablesFromObjectProperties(...)100%88100%
MergeNullableDictionaries(...)100%1010100%
MergeMetadata(...)100%1010100%
ConvertToNullable(...)100%22100%
MergeNullableMetadata(...)100%1414100%
MergedNullableMetadataValue(...)100%66100%
MergeMetadataDictionaries(...)100%44100%
MergedMetadataValue(...)100%66100%
MergeNullableMetadataDictionaries(...)100%44100%
SortArgumentValue(...)100%44100%
SortNonPrimitive(...)100%1010100%
SortDictionary(...)100%22100%
SortArrayItems(...)100%22100%
IsDecomposable(...)100%88100%
DecomposeToDictionary(...)100%22100%
SortListItems(...)100%44100%
AreArgumentsEqual(...)100%44100%
CompareArgumentShapes(...)100%1010100%
ArgumentEntriesMatch(...)100%66100%
CombineHash(...)100%11100%
ComputeArgumentFingerprint(...)100%66100%
ComputeValueFingerprint(...)100%4040100%
AreValuesEqual(...)100%1212100%
AreReferenceTypedValuesEqual(...)100%88100%
AreDictionariesEqual(...)100%88100%
AreListsEqual(...)100%66100%
AreObjectsStructurallyEqual(...)100%44100%
ParseFieldTypeFromPath(...)100%44100%
LooksLikeTypeAnnotation(...)100%1010100%
CreateFieldDefinition(...)100%22100%
CreateFieldDefinition(...)100%22100%
CreateFieldDefinitionCore(...)100%88100%
FindExistingField(...)100%88100%
FindExistingField(...)100%22100%
FindByOrdinalNameAndAlias(...)100%1212100%
ValidateFieldName(...)100%1010100%
ValidateIdentifier(...)100%88100%
IsValidGraphQlNameStart(...)100%88100%
IsValidGraphQlNameChar(...)100%1212100%
WriteCollection(...)100%44100%

File(s)

/home/runner/work/NGql/NGql/src/Core/Extensions/Helpers.cs

#LineLine coverage
 1using System.Collections;
 2using System.Diagnostics.CodeAnalysis;
 3using System.Runtime.CompilerServices;
 4using System.Text;
 5using NGql.Core.Abstractions;
 6using NGql.Core.Caching;
 7
 8namespace NGql.Core.Extensions;
 9
 10[SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions")]
 11internal static class Helpers
 12{
 13    internal static void ExtractVariablesFromValue(object? value, SortedSet<Variable> variables)
 14    {
 4834515        ExtractVariablesFromValueCore(value, variables, null);
 4834516    }
 17
 18    private static void ExtractVariablesFromValueCore(object? value, SortedSet<Variable> variables, HashSet<object>? vis
 19    {
 6583520        if (value == null)
 21        {
 3022            return;
 23        }
 24
 6580525        if (value is Variable variable)
 26        {
 654627            variables.Add(variable);
 654628            return;
 29        }
 30
 5925931        if (value is IDictionary dict)
 32        {
 966933            ExtractVariablesFromDictionary(dict, variables, visited);
 966934            return;
 35        }
 36
 4959037        if (value is IList list)
 38        {
 13839            ExtractVariablesFromList(list, variables, visited);
 13840            return;
 41        }
 42
 4945243        if (ShouldExtractFromObjectProperties(value))
 44        {
 11745            ExtractVariablesFromObjectProperties(value, variables, visited);
 46        }
 4945247    }
 48
 49    private static void ExtractVariablesFromDictionary(IDictionary dict, SortedSet<Variable> variables, HashSet<object>?
 50    {
 968451        if (dict.Count == 0) return;
 965452        visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
 966653        if (!visited.Add(dict)) return; // cycle detected
 54
 5338255        foreach (var val in dict.Values)
 56        {
 1704957            ExtractVariablesFromValueCore(val, variables, visited);
 58        }
 964259    }
 60
 61    private static void ExtractVariablesFromList(IList list, SortedSet<Variable> variables, HashSet<object>? visited)
 62    {
 15363        if (list.Count == 0) return;
 12364        visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
 13265        if (!visited.Add(list)) return; // cycle detected
 66
 70867        foreach (var item in list)
 68        {
 24069            ExtractVariablesFromValueCore(item, variables, visited);
 70        }
 11471    }
 72
 73    private static bool ShouldExtractFromObjectProperties(object obj)
 74    {
 4945275        return obj is not string &&
 4945276               obj is not Variable &&
 4945277               obj is not QueryBlock &&
 4945278               obj is not IDictionary &&
 4945279               obj is not IList &&
 4945280               !ValueFormatter.IsPrimitiveType(obj);
 81    }
 82
 83    private static void ExtractVariablesFromObjectProperties(object obj, SortedSet<Variable> variables, HashSet<object>?
 84    {
 11785        visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
 12086        if (!visited.Add(obj)) return; // cycle detected
 11487        var properties = TypeMetadataCache.GetObjectProperties(obj.GetType());
 63688        foreach (var property in properties)
 89        {
 20490            var propertyValue = property.GetValue(obj);
 20491            if (propertyValue != null)
 92            {
 20193                ExtractVariablesFromValueCore(propertyValue, variables, visited);
 94            }
 95        }
 11496    }
 97
 98    /// <summary>
 99    /// Generic dictionary merging with recursive support for nested dictionaries.
 100    /// </summary>
 101    /// <summary>
 102    /// Merges two dictionaries with support for nullable values and recursive merging of nested dictionaries.
 103    /// This is the primary efficient implementation that avoids extra allocations.
 104    /// </summary>
 105    /// <param name="existing">The existing dictionary</param>
 106    /// <param name="update">The dictionary to merge in</param>
 107    /// <returns>A merged SortedDictionary with nullable values</returns>
 108    internal static SortedDictionary<string, object?> MergeNullableDictionaries(
 109        IDictionary<string, object?> existing,
 110        IDictionary<string, object?> update)
 111    {
 57112        var result = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 113
 114        // Copy existing entries
 258115        foreach (var kvp in existing)
 116        {
 72117            result[kvp.Key] = kvp.Value;
 118        }
 119
 120        // Merge update entries with recursive handling for nested dictionaries
 252121        foreach (var (key, newValue) in update)
 122        {
 69123            if (result.TryGetValue(key, out var existingValue) &&
 69124                existingValue is IDictionary<string, object?> existingDict &&
 69125                newValue is IDictionary<string, object?> newDict)
 126            {
 127                // Recursively merge nested dictionaries
 12128                result[key] = MergeNullableDictionaries(existingDict, newDict);
 129            }
 130            else
 131            {
 132                // Override with new value for non-dictionary values or new keys
 57133                result[key] = newValue;
 134            }
 135        }
 136
 57137        return result;
 138    }
 139
 140    /// <summary>
 141    /// Merges metadata dictionaries, handling nullable values appropriately with deep/recursive merging for nested dict
 142    /// </summary>
 143    /// <param name="existing">The existing metadata dictionary</param>
 144    /// <param name="update">The metadata dictionary to merge in</param>
 145    /// <returns>A merged Dictionary with nullable values suitable for metadata</returns>
 146    internal static Dictionary<string, object?> MergeMetadata(
 147        Dictionary<string, object?>? existing,
 148        Dictionary<string, object> update)
 149    {
 249150        if (existing is null || existing.Count == 0) return ConvertToNullable(update);
 63151        if (update.Count == 0) return existing;
 152
 51153        var result = new Dictionary<string, object?>(existing.Count + update.Count, StringComparer.OrdinalIgnoreCase);
 372154        foreach (var (key, value) in existing) result[key] = value;
 264155        foreach (var (key, newValue) in update)
 156        {
 81157            result[key] = MergedMetadataValue(result, key, newValue);
 158        }
 159
 51160        return result;
 161    }
 162
 163    private static Dictionary<string, object?> ConvertToNullable(Dictionary<string, object> source)
 164    {
 96165        var converted = new Dictionary<string, object?>(source.Count, StringComparer.OrdinalIgnoreCase);
 723166        foreach (var (key, value) in source) converted[key] = value;
 96167        return converted;
 168    }
 169
 170    /// <summary>
 171    /// Merges metadata dictionaries where both existing and update can have nullable values.
 172    /// </summary>
 173    /// <param name="existing">The existing metadata dictionary</param>
 174    /// <param name="update">The metadata dictionary to merge in</param>
 175    /// <returns>A merged Dictionary with nullable values suitable for metadata</returns>
 176    internal static Dictionary<string, object?> MergeNullableMetadata(
 177        Dictionary<string, object?>? existing,
 178        Dictionary<string, object?>? update)
 179    {
 39180        if (update is null || update.Count == 0)
 6181            return existing ?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 182
 33183        if (existing is null || existing.Count == 0)
 6184            return new Dictionary<string, object?>(update, StringComparer.OrdinalIgnoreCase);
 185
 27186        var result = new Dictionary<string, object?>(existing.Count + update.Count, StringComparer.OrdinalIgnoreCase);
 153187        foreach (var (key, value) in existing) result[key] = value;
 114188        foreach (var (key, newValue) in update)
 189        {
 30190            result[key] = MergedNullableMetadataValue(result, key, newValue);
 191        }
 27192        return result;
 193    }
 194
 195    private static object? MergedNullableMetadataValue(Dictionary<string, object?> target, string key, object? newValue)
 196    {
 45197        if (target.TryGetValue(key, out var existingValue)
 45198            && existingValue is Dictionary<string, object?> existingDict
 45199            && newValue is Dictionary<string, object?> newDict)
 200        {
 15201            return MergeNullableMetadataDictionaries(existingDict, newDict);
 202        }
 30203        return newValue;
 204    }
 205
 206    /// <summary>
 207    /// Deep merges two metadata dictionaries recursively.
 208    /// </summary>
 209    /// <param name="existing">The existing dictionary</param>
 210    /// <param name="update">The dictionary to merge in</param>
 211    /// <returns>A merged dictionary with deep merging of nested dictionaries</returns>
 212    private static Dictionary<string, object?> MergeMetadataDictionaries(
 213        Dictionary<string, object?> existing,
 214        Dictionary<string, object> update)
 215    {
 18216        var result = new Dictionary<string, object?>(existing.Count + update.Count, StringComparer.OrdinalIgnoreCase);
 126217        foreach (var (key, value) in existing) result[key] = value;
 90218        foreach (var (key, newValue) in update)
 219        {
 27220            result[key] = MergedMetadataValue(result, key, newValue);
 221        }
 18222        return result;
 223    }
 224
 225    private static object? MergedMetadataValue(Dictionary<string, object?> target, string key, object newValue)
 226    {
 108227        if (target.TryGetValue(key, out var existingValue)
 108228            && existingValue is Dictionary<string, object?> existingNested
 108229            && newValue is Dictionary<string, object> newNested)
 230        {
 18231            return MergeMetadataDictionaries(existingNested, newNested);
 232        }
 90233        return newValue;
 234    }
 235
 236    private static Dictionary<string, object?> MergeNullableMetadataDictionaries(
 237        Dictionary<string, object?> existing,
 238        Dictionary<string, object?> update)
 239    {
 15240        var result = new Dictionary<string, object?>(existing.Count + update.Count, StringComparer.OrdinalIgnoreCase);
 75241        foreach (var (key, value) in existing) result[key] = value;
 60242        foreach (var (key, newValue) in update)
 243        {
 15244            result[key] = MergedNullableMetadataValue(result, key, newValue);
 245        }
 15246        return result;
 247    }
 248
 249    internal static object? SortArgumentValue(object? value)
 250    {
 57687251        if (value is null) return null;
 113826252        if (ValueFormatter.IsPrimitiveType(value)) return value;
 1404253        return SortNonPrimitive(value);
 254    }
 255
 1404256    private static object? SortNonPrimitive(object value) => value switch
 1404257    {
 1158258        IDictionary<string, object?> dict => SortDictionary(dict),
 96259        Array arr => SortArrayItems(arr),
 66260        IEnumerable<object> list when !value.GetType().IsArray => SortListItems(list),
 117261        _ => IsDecomposable(value) ? DecomposeToDictionary(value) : value,
 1404262    };
 263
 264    private static SortedDictionary<string, object?> SortDictionary(IDictionary<string, object?> dict)
 265    {
 1158266        var sorted = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 5613267        foreach (var kvp in dict)
 268        {
 269            // Add (not the indexer): keys colliding under OrdinalIgnoreCase must throw, not
 270            // silently last-win — otherwise the rendered query carries different data than
 271            // the caller supplied.
 1656272            sorted.Add(kvp.Key, SortArgumentValue(kvp.Value));
 273        }
 1143274        return sorted;
 275    }
 276
 277    private static object?[] SortArrayItems(Array arr)
 278    {
 96279        var result = new object?[arr.Length];
 96280        var i = 0;
 570281        foreach (var item in arr)
 282        {
 189283            result[i++] = SortArgumentValue(item);
 284        }
 96285        return result;
 286    }
 287
 288    /// <summary>True for arbitrary CLR objects whose properties should be reflected into a
 289    /// sorted dictionary. Excludes types we already format/serialize specially.</summary>
 290    private static bool IsDecomposable(object obj)
 117291        => obj is not string and not Variable and not QueryBlock and not IDictionary and not IList;
 292
 293    private static SortedDictionary<string, object?> DecomposeToDictionary(object obj)
 294    {
 295        // SortedDictionary orders by its comparer on insert — pre-sorting the properties or
 296        // staging them in an intermediate Dictionary is wasted work.
 111297        var sorted = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 615298        foreach (var property in TypeMetadataCache.GetObjectProperties(obj.GetType()))
 299        {
 300            // Add (not the indexer): property names colliding under OrdinalIgnoreCase must
 301            // throw — reflection order is unspecified, so last-wins would be nondeterministic.
 198302            sorted.Add(property.Name, SortArgumentValue(property.GetValue(obj)));
 303        }
 108304        return sorted;
 305    }
 306
 307    /// <summary>
 308    /// Efficiently sorts list items in a single pass without double allocations.
 309    /// Avoids: .Select().ToList() which creates intermediate IEnumerable + List allocations.
 310    /// </summary>
 311    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 312    private static List<object?> SortListItems(IEnumerable<object> list)
 313    {
 33314        var result = new List<object?>(list.TryGetNonEnumeratedCount(out var count) ? count : 0);
 186315        foreach (var item in list)
 316        {
 60317            result.Add(SortArgumentValue(item));
 318        }
 33319        return result;
 320    }
 321
 322    /// <summary>
 323    /// Compares two argument dictionaries for equality.
 324    /// </summary>
 325    /// <param name="args1">First argument dictionary</param>
 326    /// <param name="args2">Second argument dictionary</param>
 327    /// <returns>True if arguments are equal, false otherwise</returns>
 328    internal static bool AreArgumentsEqual(SortedDictionary<string, object?>? args1, SortedDictionary<string, object?>? 
 329    {
 3873330        var status = CompareArgumentShapes(args1, args2);
 3873331        return status switch
 3873332        {
 3018333            ArgumentShape.Equal => true,
 12334            ArgumentShape.Mismatch => false,
 843335            _ => ArgumentEntriesMatch(args1!, args2!),
 3873336        };
 337    }
 338
 339    private enum ArgumentShape { Equal, Mismatch, NeedsEntryComparison }
 340
 341    private static ArgumentShape CompareArgumentShapes(SortedDictionary<string, object?>? a, SortedDictionary<string, ob
 342    {
 6888343        if (ReferenceEquals(a, b)) return ArgumentShape.Equal;
 864344        if (a is null || b is null) return ArgumentShape.Mismatch;
 858345        if (a.Count != b.Count) return ArgumentShape.Mismatch;
 849346        if (a.Count == 0) return ArgumentShape.Equal;
 843347        return ArgumentShape.NeedsEntryComparison;
 348    }
 349
 350    private static bool ArgumentEntriesMatch(SortedDictionary<string, object?> a, SortedDictionary<string, object?> b)
 351    {
 3357352        foreach (var (key, value1) in a)
 353        {
 873354            if (!b.TryGetValue(key, out var value2)) return false;
 933355            if (!AreValuesEqual(value1, value2)) return false;
 356        }
 774357        return true;
 69358    }
 359
 360    // Fixed sentinel hash contributed for any argument value whose type is not on the
 361    // verified-safe allow list below. Every such value collides into the same bucket, so
 362    // FindMergeTarget falls back to running the full CanMergeFields check for all of them —
 363    // correctness over speed. This is what keeps the fingerprint conservative: a hash MUST
 364    // NEVER cause AreArgumentsEqual-equal values to land in different buckets, and folding
 365    // an unrecognized shape into one shared bucket guarantees that.
 366    private const ulong ConservativeValueSentinel = 0x9E3779B97F4A7C15UL;
 367
 368    // Distinct sentinel for null so "null" never accidentally collides with the fallback
 369    // bucket used for unrecognized non-null shapes (harmless either way, but keeps hash
 370    // distribution meaningful when arguments frequently omit optional keys).
 371    private const ulong NullValueSentinel = 0xD1B54A32D192ED03UL;
 372
 373    private const ulong FnvOffsetBasis = 14695981039346656037UL;
 374    private const ulong FnvPrime = 1099511628211UL;
 375
 376    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 377    private static ulong CombineHash(ulong hash, ulong value)
 378    {
 10131379        hash = (hash ^ value) * FnvPrime;
 10131380        return hash;
 381    }
 382
 383    /// <summary>
 384    /// Computes a conservative fingerprint over a field's own arguments, for use as a cheap
 385    /// pre-filter bucket key in <c>QueryMerger.FindMergeTarget</c> — NEVER as a merge decision
 386    /// by itself. The contract this fingerprint must uphold: any two argument dictionaries that
 387    /// <see cref="AreArgumentsEqual"/> considers equal MUST produce the same fingerprint. A hash
 388    /// collision between unequal arguments is harmless (costs one extra, already-unmodified
 389    /// <c>CanMergeFields</c> call); a fingerprint mismatch between equal arguments would be a
 390    /// silent correctness bug (a genuine merge candidate skipped), so this method is designed to
 391    /// only ever be "too coarse", never "too fine".
 392    ///
 393    /// <see cref="SortedDictionary{TKey,TValue}"/> already enumerates keys in a fixed, deterministic
 394    /// order (its own comparer), so entries are hashed in that existing order — no extra sort.
 395    /// </summary>
 396    internal static ulong ComputeArgumentFingerprint(SortedDictionary<string, object?>? arguments)
 397    {
 22962398        if (arguments is null || arguments.Count == 0) return FnvOffsetBasis;
 399
 3444400        var hash = FnvOffsetBasis;
 13812401        foreach (var (key, value) in arguments)
 402        {
 403            // Keys compare OrdinalIgnoreCase inside the argument dictionary itself (its own
 404            // comparer) — mirror that here via the case-insensitive hash overload so two keys
 405            // differing only by case still contribute the same hash contribution.
 3462406            hash = CombineHash(hash, (ulong)key.GetHashCode(StringComparison.OrdinalIgnoreCase));
 3462407            hash = CombineHash(hash, ComputeValueFingerprint(value));
 408        }
 3444409        return hash;
 410    }
 411
 412    /// <summary>
 413    /// Conservative per-value hash contribution. Only contributes a "real" (equality-consistent)
 414    /// hash for shapes verified against <see cref="AreValuesEqual"/>'s own comparison rule:
 415    /// strings (ordinal, matching <c>string.Equals</c>) and CLR value types whose documented
 416    /// <c>GetHashCode</c>/<c>Equals</c> contract is known to agree (numeric primitives, bool,
 417    /// char, Guid, DateTime/DateTimeOffset, Enum-derived types, and NGql's own <see cref="EnumValue"/>,
 418    /// whose <c>Equals</c>/<c>GetHashCode</c> pair is explicitly case-insensitive-consistent).
 419    /// Everything else — nested <c>IDictionary&lt;string,object?&gt;</c>, <c>IList</c>/arrays,
 420    /// reflected objects compared via <c>AreObjectsStructurallyEqual</c>, and any other type not
 421    /// on this allow list — returns the shared <see cref="ConservativeValueSentinel"/> so it is
 422    /// never split from a value it might structurally equal.
 423    /// </summary>
 424    private static ulong ComputeValueFingerprint(object? value)
 425    {
 3465426        if (value is null) return NullValueSentinel;
 427
 428        switch (value)
 429        {
 430            case string s:
 431                // AreValuesEqual uses value1.Equals(value2) for strings, i.e. ordinal equality —
 432                // hash ordinally to match, not case-insensitively.
 2640433                return CombineHash((ulong)typeof(string).GetHashCode(), (ulong)s.GetHashCode(StringComparison.Ordinal));
 434            case bool or byte or sbyte or short or ushort or int or uint or long or ulong
 435                or float or double or decimal or char or Guid or DateTime or DateTimeOffset or Enum or NGql.Core.EnumVal
 436                // These types' Equals/GetHashCode pairs are the standard, documented .NET (or
 437                // NGql, for EnumValue) contract: GetHashCode is consistent with Equals. Folding
 438                // the runtime type into the hash mirrors AreValuesEqual's own
 439                // `type1 != value2.GetType()` early rejection.
 567440                return CombineHash((ulong)value.GetType().GetHashCode(), (ulong)value.GetHashCode());
 441            default:
 252442                return ConservativeValueSentinel;
 443        }
 444    }
 445
 446    /// <summary>
 447    /// Compares two values for equality, using optimized comparison strategies.
 448    /// </summary>
 449    /// <param name="value1">First value</param>
 450    /// <param name="value2">Second value</param>
 451    /// <returns>True if values are equal, false otherwise</returns>
 452    private static bool AreValuesEqual(object? value1, object? value2)
 453    {
 1557454        if (ReferenceEquals(value1, value2)) return true;
 645455        if (value1 is null || value2 is null) return false;
 456
 633457        var type1 = value1.GetType();
 645458        if (type1 != value2.GetType()) return false;
 459
 1095460        if (type1.IsValueType || value1 is string) return value1.Equals(value2);
 461
 147462        return AreReferenceTypedValuesEqual(value1, value2);
 463    }
 464
 465    private static bool AreReferenceTypedValuesEqual(object value1, object value2)
 466    {
 147467        if (value1 is IDictionary<string, object?> dict1 && value2 is IDictionary<string, object?> dict2)
 87468            return AreDictionariesEqual(dict1, dict2);
 469
 60470        if (value1 is IList list1 && value2 is IList list2)
 36471            return AreListsEqual(list1, list2);
 472
 24473        return AreObjectsStructurallyEqual(value1, value2);
 474    }
 475
 476    /// <summary>
 477    /// Optimized dictionary equality comparison
 478    /// </summary>
 479    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 480    private static bool AreDictionariesEqual(IDictionary<string, object?> dict1, IDictionary<string, object?> dict2)
 481    {
 90482        if (dict1.Count != dict2.Count) return false;
 342483        foreach (var kvp in dict1)
 484        {
 108485            if (!dict2.TryGetValue(kvp.Key, out var value2)) return false;
 120486            if (!AreValuesEqual(kvp.Value, value2)) return false;
 487        }
 54488        return true;
 30489    }
 490
 491    /// <summary>
 492    /// Optimized list equality comparison
 493    /// </summary>
 494    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 495    private static bool AreListsEqual(IList list1, IList list2)
 496    {
 36497        var count = list1.Count;
 39498        if (count != list2.Count) return false;
 198499        for (int i = 0; i < count; i++)
 500        {
 84501            if (!AreValuesEqual(list1[i], list2[i])) return false;
 502        }
 24503        return true;
 504    }
 505
 506    /// <summary>
 507    /// Structural equality comparison for complex objects using reflection
 508    /// </summary>
 509    private static bool AreObjectsStructurallyEqual(object obj1, object obj2)
 510    {
 24511        var type = obj1.GetType();
 24512        var properties = TypeMetadataCache.GetObjectProperties(type);
 513
 165514        foreach (var property in properties)
 515        {
 60516            var value1 = property.GetValue(obj1);
 60517            var value2 = property.GetValue(obj2);
 518
 60519            if (!AreValuesEqual(value1, value2))
 520            {
 3521                return false;
 522            }
 523        }
 524
 21525        return true;
 526    }
 527
 528    /// <summary>
 529    /// Parses field type from path if provided in format "Type fieldPath".
 530    /// </summary>
 531    /// <param name="fieldPath">Field path to parse</param>
 532    /// <param name="defaultType">Default type to use if none specified</param>
 533    /// <param name="type">Parsed type output</param>
 534    /// <returns>Field path with type removed and trimmed</returns>
 535    internal static ReadOnlySpan<char> ParseFieldTypeFromPath(ReadOnlySpan<char> fieldPath, ReadOnlySpan<char> defaultTy
 536    {
 8055537        var spaceIndex = fieldPath.IndexOf(' ');
 8055538        if (spaceIndex <= 0)
 539        {
 7542540            type = defaultType;
 7542541            return fieldPath.TrimEndDotsAndSpaces();
 542        }
 543
 513544        var potentialType = fieldPath[..spaceIndex];
 513545        if (LooksLikeTypeAnnotation(potentialType))
 546        {
 474547            type = potentialType;
 474548            fieldPath = fieldPath[(spaceIndex + 1)..];
 549        }
 550        else
 551        {
 39552            type = defaultType;
 553        }
 513554        return fieldPath.TrimEndDotsAndSpaces();
 555    }
 556
 557    // Type annotation must start with a letter or '[', contain no dots, and either include a
 558    // letter/digit OR be the bare "[]" array marker.
 559    private static bool LooksLikeTypeAnnotation(ReadOnlySpan<char> candidate)
 513560        => candidate.Length > 0
 513561        && (char.IsLetter(candidate[0]) || candidate[0] == '[')
 513562        && candidate.IndexOf('.') < 0
 513563        && (candidate.HasLetterOrDigit() || candidate.SequenceEqual("[]".AsSpan()));
 564
 565    /// <summary>
 566    /// Creates a new FieldDefinition with sorted arguments for consistent behavior.
 567    /// Normalizes argument values into an independent sorted dictionary.
 568    /// <param name="name">Field name</param>
 569    /// <param name="type">Field type</param>
 570    /// <param name="alias">Optional field alias</param>
 571    /// <param name="arguments">Field arguments to normalize</param>
 572    /// <param name="path">Field path for caching</param>
 573    /// <param name="metadata">Optional field metadata</param>
 574    /// <returns>New FieldDefinition instance</returns>
 575    /// </summary>
 576    internal static FieldDefinition CreateFieldDefinition(ReadOnlySpan<char> name, ReadOnlySpan<char> type, ReadOnlySpan
 221244577        => CreateFieldDefinitionCore(name.ToString(), TypeCache.GetInternedType(type),
 221244578            alias.IsEmpty ? null : alias.ToString(), arguments, path.ToString(), metadata);
 579
 580    // Merge inputs already own immutable name/path/alias strings. Reuse those strings while
 581    // retaining exactly the same type and recursive argument normalization as parsed fields.
 582    internal static FieldDefinition CreateFieldDefinition(string name, ReadOnlySpan<char> type, string? alias, IDictiona
 453583        => CreateFieldDefinitionCore(name, TypeCache.GetInternedType(type),
 453584            string.IsNullOrEmpty(alias) ? null : alias, arguments, path, metadata);
 585
 586    private static FieldDefinition CreateFieldDefinitionCore(string name, string type, string? alias, IDictionary<string
 587    {
 588        // FAST PATH: Skip dictionary operations when arguments are empty or null
 221697589        if (arguments?.Count == 0 || arguments == null)
 590        {
 213003591            return new FieldDefinition(name, type, alias, null)
 213003592            {
 213003593                Path = path,
 213003594                _metadata = metadata
 213003595            };
 596        }
 597
 598        // Create a new sorted dictionary to ensure consistent argument ordering
 8694599        var sortedArguments = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
 49074600        foreach (var kvp in arguments)
 601        {
 15843602            sortedArguments[kvp.Key] = SortArgumentValue(kvp.Value);
 603        }
 604
 8694605        return new FieldDefinition(name, type, alias, sortedArguments)
 8694606        {
 8694607            Path = path,
 8694608            _metadata = metadata
 8694609        };
 610    }
 611
 612    /// <summary>
 613    /// Finds an existing field in the collection by name, alias, or path.
 614    /// </summary>
 615    /// <param name="fields">Field collection to search</param>
 616    /// <param name="fieldDefinition">Field definition to find</param>
 617    /// <returns>Existing field if found, null otherwise</returns>
 618    internal static FieldDefinition? FindExistingField(Dictionary<string, FieldDefinition> fields, FieldDefinition field
 619    {
 303620        FieldDefinition? existingField = null;
 900621        foreach (var f in fields.Values)
 622        {
 243623            if (f.Name == fieldDefinition.Name && f._alias == fieldDefinition._alias)
 624            {
 192625                existingField = f;
 192626                break;
 627            }
 628        }
 303629        return existingField ?? fields.GetValueOrDefault(fieldDefinition.Path);
 630    }
 631
 632    internal static FieldDefinition? FindExistingField(FieldChildren children, FieldDefinition fieldDefinition)
 633    {
 634        // Shared ordinal (Name, alias) probe — see FindByOrdinalNameAndAlias's own remarks for the
 635        // fast-path/slow-path rationale. A miss here does NOT prove absence for this caller: unlike
 636        // FieldDefinitionExtensions.FindChildByNameAndAlias (which returns null and lets its callers
 637        // treat that as "append a new sibling"), CreateOrMergeField additionally tolerates a child
 638        // that shares this field's Path under a DIFFERENT (Name, alias) — the Path fallback below is
 639        // load-bearing for that caller and must not be dropped.
 426640        var byIdentity = FindByOrdinalNameAndAlias(children, fieldDefinition.Name, fieldDefinition._alias);
 426641        return byIdentity ?? children.Find(fieldDefinition.Path.AsSpan());
 642    }
 643
 644    /// <summary>
 645    /// Finds a child matching an exact GraphQL identity — (Name, alias), not Name alone. Two
 646    /// children may legitimately share a Name while differing by alias (distinct response keys); a
 647    /// Name-only lookup would wrongly treat one as a match for the other. Shared kernel for
 648    /// <see cref="FindExistingField(FieldChildren, FieldDefinition)"/> and
 649    /// <see cref="Extensions.FieldDefinitionExtensions.CanMergeFields"/>'s merge-compatibility
 650    /// lookups — both need exactly this ordinal probe; what differs between them is what each does
 651    /// on an outright miss (a Path fallback here, a plain null there), which stays with the caller.
 652    /// </summary>
 653    internal static FieldDefinition? FindByOrdinalNameAndAlias(FieldChildren children, string name, string? alias)
 654    {
 655        // FAST PATH #1: a NAME-based (case-insensitive) miss via FieldChildren's own Find is
 656        // strictly broader than this method's ordinal (Name, alias) match — if no child shares the
 657        // Name even case-insensitively, none can share it ordinally either, so absence here proves
 658        // absence in the exhaustive scan below too. This is the dominant case for a growing set of
 659        // never-before-seen sibling names (e.g. Including N distinct fields under one shared
 660        // parent) and is what turns the old unconditional per-Include linear scan into an O(1)/
 661        // O(log) index lookup once FieldChildren's index is built.
 8715662        var candidate = children.Find(name);
 8715663        if (candidate is null)
 664        {
 819665            return null;
 666        }
 667
 668        // FAST PATH #2: a case-insensitive HIT that also matches ordinally (Name) and by alias is
 669        // exactly the entry a subsequent Include() of the "same" field will hit, since FieldChildren's
 670        // index always maps a name to its LAST-appended occurrence (see FieldChildren.Append and
 671        // BuildIndexLocked) — precisely the slot a genuine repeated-merge candidate lives in.
 7896672        if (candidate.Name == name && candidate._alias == alias)
 673        {
 7764674            return candidate;
 675        }
 676
 677        // SLOW PATH: the index found *a* same-name (case-insensitively) child, but it doesn't match
 678        // ordinally or by alias — this does NOT prove absence, because two children CAN legitimately
 679        // share a Name with different aliases (FieldFactory.CreateOrMergeField itself appends such a
 680        // sibling when the caller's own fallback finds no match) and FieldChildren's index only ever
 681        // remembers ONE slot per name. Fall back to the exhaustive scan, which remains authoritative
 682        // and preserves first-match-in-append-order semantics.
 1002683        foreach (var f in children.AsSpan())
 684        {
 393685            if (f.Name == name && f._alias == alias)
 48686                return f;
 687        }
 84688        return null;
 689    }
 690
 691    /// <summary>
 692    /// Validates that a field name conforms to GraphQL identifier rules: [_A-Za-z][_0-9A-Za-z]*
 693    /// Handles type-annotation prefixes (e.g. "[User!]! user" → validates "user") and
 694    /// alias prefixes (e.g. "alias:name" → validates both "alias" and "name").
 695    /// For dotted paths, validate each segment individually before calling this method.
 696    /// </summary>
 697    internal static void ValidateFieldName(ReadOnlySpan<char> name)
 698    {
 5238699        if (name.IsEmpty)
 3700            throw new ArgumentException("Field name cannot be empty.");
 701
 702        // Strip type annotation prefix: take identifier part after the last space
 5235703        var spaceIndex = name.LastIndexOf(' ');
 5235704        var identifier = spaceIndex >= 0 ? name[(spaceIndex + 1)..].Trim() : name.Trim();
 705
 706        // Handle alias:name syntax
 5235707        var colonIndex = identifier.IndexOf(':');
 5235708        if (colonIndex >= 0)
 709        {
 177710            var alias = identifier[..colonIndex].Trim();
 177711            var fieldName = identifier[(colonIndex + 1)..].Trim();
 351712            if (!alias.IsEmpty) ValidateIdentifier(alias);
 342713            if (!fieldName.IsEmpty) ValidateIdentifier(fieldName);
 162714            return;
 715        }
 716
 5058717        ValidateIdentifier(identifier);
 5046718    }
 719
 720    private static void ValidateIdentifier(ReadOnlySpan<char> name)
 721    {
 5403722        if (name.IsEmpty)
 3723            throw new ArgumentException("Field name cannot be empty.");
 724
 5400725        if (!IsValidGraphQlNameStart(name[0]))
 9726            throw new ArgumentException($"Invalid GraphQL field name '{name.ToString()}': must start with a letter or un
 727
 64464728        for (int i = 1; i < name.Length; i++)
 729        {
 26856730            if (!IsValidGraphQlNameChar(name[i]))
 15731                throw new ArgumentException($"Invalid GraphQL field name '{name.ToString()}': contains invalid character
 732        }
 5376733    }
 734
 735    private static bool IsValidGraphQlNameStart(char c)
 5400736        => c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
 737
 738    private static bool IsValidGraphQlNameChar(char c)
 26856739        => c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
 740
 741    /// <summary>
 742    /// Writes a collection with specified prefix/suffix characters and custom item writer
 743    /// </summary>
 744    internal static void WriteCollection(char prefix, char suffix, IEnumerable list, StringBuilder builder, Action<Strin
 745    {
 183746        builder.Append(prefix);
 747
 183748        bool first = true;
 1164749        foreach (var obj in list)
 750        {
 399751            if (!first)
 752            {
 231753                builder.Append(", ");
 754            }
 755
 399756            first = false;
 399757            itemWriter(builder, obj);
 758        }
 759
 183760        builder.Append(suffix);
 183761    }
 762}

Methods/Properties

ExtractVariablesFromValue(System.Object,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>)
ExtractVariablesFromValueCore(System.Object,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>,System.Collections.Generic.HashSet`1<System.Object>)
ExtractVariablesFromDictionary(System.Collections.IDictionary,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>,System.Collections.Generic.HashSet`1<System.Object>)
ExtractVariablesFromList(System.Collections.IList,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>,System.Collections.Generic.HashSet`1<System.Object>)
ShouldExtractFromObjectProperties(System.Object)
ExtractVariablesFromObjectProperties(System.Object,System.Collections.Generic.SortedSet`1<NGql.Core.Variable>,System.Collections.Generic.HashSet`1<System.Object>)
MergeNullableDictionaries(System.Collections.Generic.IDictionary`2<System.String,System.Object>,System.Collections.Generic.IDictionary`2<System.String,System.Object>)
MergeMetadata(System.Collections.Generic.Dictionary`2<System.String,System.Object>,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
ConvertToNullable(System.Collections.Generic.Dictionary`2<System.String,System.Object>)
MergeNullableMetadata(System.Collections.Generic.Dictionary`2<System.String,System.Object>,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
MergedNullableMetadataValue(System.Collections.Generic.Dictionary`2<System.String,System.Object>,System.String,System.Object)
MergeMetadataDictionaries(System.Collections.Generic.Dictionary`2<System.String,System.Object>,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
MergedMetadataValue(System.Collections.Generic.Dictionary`2<System.String,System.Object>,System.String,System.Object)
MergeNullableMetadataDictionaries(System.Collections.Generic.Dictionary`2<System.String,System.Object>,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
SortArgumentValue(System.Object)
SortNonPrimitive(System.Object)
SortDictionary(System.Collections.Generic.IDictionary`2<System.String,System.Object>)
SortArrayItems(System.Array)
IsDecomposable(System.Object)
DecomposeToDictionary(System.Object)
SortListItems(System.Collections.Generic.IEnumerable`1<System.Object>)
AreArgumentsEqual(System.Collections.Generic.SortedDictionary`2<System.String,System.Object>,System.Collections.Generic.SortedDictionary`2<System.String,System.Object>)
CompareArgumentShapes(System.Collections.Generic.SortedDictionary`2<System.String,System.Object>,System.Collections.Generic.SortedDictionary`2<System.String,System.Object>)
ArgumentEntriesMatch(System.Collections.Generic.SortedDictionary`2<System.String,System.Object>,System.Collections.Generic.SortedDictionary`2<System.String,System.Object>)
CombineHash(System.UInt64,System.UInt64)
ComputeArgumentFingerprint(System.Collections.Generic.SortedDictionary`2<System.String,System.Object>)
ComputeValueFingerprint(System.Object)
AreValuesEqual(System.Object,System.Object)
AreReferenceTypedValuesEqual(System.Object,System.Object)
AreDictionariesEqual(System.Collections.Generic.IDictionary`2<System.String,System.Object>,System.Collections.Generic.IDictionary`2<System.String,System.Object>)
AreListsEqual(System.Collections.IList,System.Collections.IList)
AreObjectsStructurallyEqual(System.Object,System.Object)
ParseFieldTypeFromPath(System.ReadOnlySpan`1<System.Char>,System.ReadOnlySpan`1<System.Char>,System.ReadOnlySpan`1<System.Char>&)
LooksLikeTypeAnnotation(System.ReadOnlySpan`1<System.Char>)
CreateFieldDefinition(System.ReadOnlySpan`1<System.Char>,System.ReadOnlySpan`1<System.Char>,System.ReadOnlySpan`1<System.Char>,System.Collections.Generic.IDictionary`2<System.String,System.Object>,System.ReadOnlySpan`1<System.Char>,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
CreateFieldDefinition(System.String,System.ReadOnlySpan`1<System.Char>,System.String,System.Collections.Generic.IDictionary`2<System.String,System.Object>,System.String,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
CreateFieldDefinitionCore(System.String,System.String,System.String,System.Collections.Generic.IDictionary`2<System.String,System.Object>,System.String,System.Collections.Generic.Dictionary`2<System.String,System.Object>)
FindExistingField(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,NGql.Core.Abstractions.FieldDefinition)
FindExistingField(NGql.Core.Abstractions.FieldChildren,NGql.Core.Abstractions.FieldDefinition)
FindByOrdinalNameAndAlias(NGql.Core.Abstractions.FieldChildren,System.String,System.String)
ValidateFieldName(System.ReadOnlySpan`1<System.Char>)
ValidateIdentifier(System.ReadOnlySpan`1<System.Char>)
IsValidGraphQlNameStart(System.Char)
IsValidGraphQlNameChar(System.Char)
WriteCollection(System.Char,System.Char,System.Collections.IEnumerable,System.Text.StringBuilder,System.Action`2<System.Text.StringBuilder,System.Object>)