< Summary

Information
Class: NGql.Core.Caching.NavigationPropertyMetadata
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Caching/TypeCache.cs
Line coverage
100%
Covered lines: 16
Uncovered lines: 0
Coverable lines: 16
Total lines: 227
Line coverage: 100%
Branch coverage
100%
Covered branches: 8
Total branches: 8
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%11100%
get_Properties()100%11100%
Build(...)100%44100%
GetProperty(...)100%44100%

File(s)

/home/runner/work/NGql/NGql/src/Core/Caching/TypeCache.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics.CodeAnalysis;
 3using System.Reflection;
 4using System.Runtime.CompilerServices;
 5
 6namespace NGql.Core.Caching;
 7
 8/// <summary>
 9/// Simple cache for common GraphQL types with memory and CPU optimizations
 10/// </summary>
 11[SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions")]
 12internal static class TypeCache
 13{
 14    // Custom names live in two generations of at most MaxGenerationSize entries. A full current
 15    // generation becomes the previous one and the oldest is dropped; a hit in the previous
 16    // generation is promoted. Names still in use therefore survive rotation, misses never take
 17    // a global lock, and at most 2 * MaxGenerationSize names are retained.
 18    private const int MaxGenerationSize = 2048;
 19    private const int MaxCachedTypeLength = 256;
 20    private static Generation _current = new();
 21    private static Generation _previous = new();
 22
 23    // The first generation starts small: most applications never fill it. A rotated generation
 24    // is expected to fill, so it is sized up front instead of regrowing through every resize.
 25    private sealed class Generation(bool presized = false)
 26    {
 27        internal readonly ConcurrentDictionary<string, string> Names = presized
 28            ? new(Environment.ProcessorCount, MaxGenerationSize, StringComparer.Ordinal)
 29            : new(StringComparer.Ordinal);
 30        internal int Count;
 31    }
 32
 33    // Pre-intern the most common GraphQL types (ordered by frequency)
 34    private static readonly string[] CommonTypes =
 35    [
 36        Constants.DefaultFieldType,    // "String" - most common
 37        "Int", "Boolean", "ID",        // Other common scalars
 38        Constants.ObjectFieldType,     // "object" - for nested fields
 39        "String!", "Int!", "Boolean!", // Non-null variants
 40        "Float", "Float!",             // Less common but still frequent
 41        Constants.ArrayTypeMarker      // "[]" - array marker
 42    ];
 43
 44    // Pre-intern common nullable type patterns
 45    private static readonly string[] CommonNullableTypes =
 46    [
 47        "String?", "Int?", "Boolean?", "ID?", "Float?"
 48    ];
 49
 50    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 51    public static string GetInternedType(ReadOnlySpan<char> type)
 52    {
 53        // ULTRA FAST PATH: Empty type defaults to String
 54        if (type.IsEmpty)
 55        {
 56            return Constants.DefaultFieldType;
 57        }
 58
 59        // OPTIMIZED PATH: Single combined check for both common and nullable types
 60        // This eliminates the O(n+m) double-loop pattern and does a single pass
 61        var allTypes = CombinedCommonTypes.Value;
 62        foreach (var commonType in allTypes)
 63        {
 64            if (type.SequenceEqual(commonType.AsSpan()))
 65            {
 66                return commonType; // Already interned
 67            }
 68        }
 69
 70        if (type.Length > MaxCachedTypeLength) return type.ToString();
 71
 72        var current = Volatile.Read(ref _current);
 73        var previous = Volatile.Read(ref _previous);
 74#if NET9_0_OR_GREATER
 75        if (current.Names.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(type, out var cached))
 76        {
 77            return cached;
 78        }
 79
 80        if (previous.Names.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(type, out cached))
 81        {
 82            return Admit(current, cached);
 83        }
 84
 85        return Admit(current, type.ToString());
 86#else
 87        var typeString = type.ToString();
 88        if (current.Names.TryGetValue(typeString, out var cached)) return cached;
 89        return Admit(current, previous.Names.TryGetValue(typeString, out cached) ? cached : typeString);
 90#endif
 91    }
 92
 93    private static string Admit(Generation current, string type)
 94    {
 95        if (!current.Names.TryAdd(type, type)) return current.Names.GetValueOrDefault(type, type);
 96        if (Interlocked.Increment(ref current.Count) < MaxGenerationSize) return type;
 97
 98        // Exactly one thread wins the swap; a reader that still sees the old pair only misses.
 99        if (ReferenceEquals(Interlocked.CompareExchange(ref _current, new Generation(presized: true), current), current)
 100        {
 101            Volatile.Write(ref _previous, current);
 102        }
 103
 104        return type;
 105    }
 106
 107    // Lazy-initialized combined array to avoid allocation at static init time
 108    private static readonly Lazy<string[]> CombinedCommonTypes = new(() =>
 109    {
 110        var combined = new string[CommonTypes.Length + CommonNullableTypes.Length];
 111        CommonTypes.CopyTo(combined, 0);
 112        CommonNullableTypes.CopyTo(combined, CommonTypes.Length);
 113        return combined;
 114    });
 115
 116    /// <summary>
 117    /// Interns a type string for memory efficiency - alias for GetInternedType
 118    /// </summary>
 119    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 120    public static string InternType(ReadOnlySpan<char> type) => GetInternedType(type);
 121}
 122
 123/// <summary>
 124/// Caches reflection metadata for type introspection
 125/// </summary>
 126internal static class TypeMetadataCache
 127{
 128    /// <summary>
 129    /// Caches PropertyInfo pairs (Key, Value) for KeyValuePair&lt;,&gt; generic types.
 130    /// Caller must guarantee the cached type is a closed KeyValuePair&lt;TKey,TValue&gt; — those
 131    /// always expose Key and Value properties, so the cached pair is non-nullable.
 132    /// </summary>
 133    private static readonly ConditionalWeakTable<Type, KeyValueProperties> KvpPropertyCache = new();
 134
 135    internal static (PropertyInfo Key, PropertyInfo Value) GetKeyValueProperties(Type type)
 136    {
 137        var properties = KvpPropertyCache.GetValue(type, static t => new(t.GetProperty("Key")!, t.GetProperty("Value")!)
 138        return (properties.Key, properties.Value);
 139    }
 140
 141    private sealed record KeyValueProperties(PropertyInfo Key, PropertyInfo Value);
 142
 143    /// <summary>
 144    /// Caches PropertyInfo[] per object type for the default WriteObject reflection branch.
 145    /// </summary>
 146    private static readonly ConditionalWeakTable<Type, PropertyInfo[]> ObjectPropertyCache = new();
 147
 148    internal static PropertyInfo[] GetObjectProperties(Type type)
 149        => ObjectPropertyCache.GetValue(type, static t => t.GetProperties());
 150
 151    /// <summary>
 152    /// Caches the public-instance property metadata used by navigation-property expansion, so
 153    /// repeated <c>PreserveFromExpression&lt;T&gt;</c> calls don't re-walk the same type. The entry
 154    /// pairs the full <see cref="PropertyInfo"/> array (mirrors
 155    /// <c>GetProperties(Public | Instance)</c>) with a case-sensitive name lookup that reproduces
 156    /// <c>GetProperty(name, Public | Instance)</c> semantics, including the ambiguous-match throw
 157    /// when a name is shadowed by a <c>new</c> property.
 158    /// </summary>
 159    private static readonly ConditionalWeakTable<Type, NavigationPropertyMetadata> NavigationPropertyCache = new();
 160
 161    /// <summary>
 162    /// Returns the cached navigation-property metadata for <paramref name="type"/>, building it on
 163    /// first access via <c>GetProperties(Public | Instance)</c>.
 164    /// </summary>
 165    internal static NavigationPropertyMetadata GetNavigationProperties(Type type)
 166        => NavigationPropertyCache.GetValue(type, static t => NavigationPropertyMetadata.Build(t));
 167}
 168
 169/// <summary>
 170/// Immutable per-type snapshot of public-instance properties used by navigation-property expansion.
 171/// </summary>
 172internal sealed class NavigationPropertyMetadata
 173{
 174    private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance;
 175
 176    // Names shadowed by a `new` property map to null, matching Type.GetProperty's ambiguous throw.
 177    private readonly Dictionary<string, PropertyInfo?> _byName;
 178
 117179    private NavigationPropertyMetadata(PropertyInfo[] properties, Dictionary<string, PropertyInfo?> byName)
 180    {
 117181        Properties = properties;
 117182        _byName = byName;
 117183    }
 184
 185    /// <summary>All public instance properties, mirroring <c>GetProperties(Public | Instance)</c>.</summary>
 78186    public PropertyInfo[] Properties { get; }
 187
 188    internal static NavigationPropertyMetadata Build(Type type)
 189    {
 117190        var properties = type.GetProperties(PublicInstance);
 117191        var byName = new Dictionary<string, PropertyInfo?>(properties.Length, StringComparer.Ordinal);
 192
 193#pragma warning disable S3267
 1230194        foreach (var property in properties)
 195        {
 196            // A duplicate name means the property is shadowed (e.g. `new`); Type.GetProperty throws
 197            // AmbiguousMatchException for those, so mark the slot as ambiguous with a null sentinel.
 498198            if (!byName.TryAdd(property.Name, property))
 199            {
 3200                byName[property.Name] = null;
 201            }
 202        }
 203#pragma warning restore S3267
 204
 117205        return new NavigationPropertyMetadata(properties, byName);
 206    }
 207
 208    /// <summary>
 209    /// Case-sensitive lookup reproducing <c>GetProperty(name, Public | Instance)</c>: returns null
 210    /// when no property matches and throws <see cref="AmbiguousMatchException"/> when the name is
 211    /// shadowed.
 212    /// </summary>
 213    public PropertyInfo? GetProperty(string name)
 214    {
 447215        if (!_byName.TryGetValue(name, out var property))
 216        {
 132217            return null;
 218        }
 219
 315220        if (property == null)
 221        {
 9222            throw new AmbiguousMatchException();
 223        }
 224
 306225        return property;
 226    }
 227}