< Summary

Information
Class: NGql.Core.Builders.QueryTextBuilder
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Builders/QueryTextBuilder.cs
Line coverage
100%
Covered lines: 199
Uncovered lines: 0
Coverable lines: 199
Total lines: 459
Line coverage: 100%
Branch coverage
99%
Covered branches: 111
Total branches: 112
Branch coverage: 99.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
.cctor()100%66100%
GetFromPool()100%22100%
ReturnToPool(...)100%44100%
GetPadding(...)100%22100%
Build(...)100%88100%
Build(...)100%1212100%
BuildFieldDefinitions(...)100%11100%
BuildFieldDefinitions(...)100%44100%
RenderSortedFields(...)100%1818100%
BuildInlineFragments(...)92.85%1414100%
BuildFieldArguments(...)100%44100%
WriteObject(...)100%1010100%
ExtractKeyValuePairProperties(...)100%44100%
WriteObjectReflection(...)100%44100%
AddFields(...)100%1212100%
AddArguments(...)100%88100%

File(s)

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

#LineLine coverage
 1using System.Buffers;
 2using System.Collections;
 3using System.Text;
 4using NGql.Core;
 5using NGql.Core.Abstractions;
 6using NGql.Core.Caching;
 7using NGql.Core.Extensions;
 8
 9namespace NGql.Core.Builders;
 10
 11internal sealed class QueryTextBuilder
 12{
 13    private readonly StringBuilder _stringBuilder;
 14
 15    // Constructor for creating new instances
 70816    private QueryTextBuilder() => _stringBuilder = new StringBuilder();
 17
 18    private const int IndentSize = 4;
 19    private const int MaxBuilderCapacity = 256 * 1024;  // 256KB threshold before reset
 20
 21    // Pre-allocated padding strings for common indentation levels to avoid repeated allocations
 622    private static readonly string[] PaddingCache = new string[20];
 23
 24    // Singleton comparer — Dictionary<string,FieldDefinition> is unordered, so we sort at render time.
 25    // Static readonly avoids any per-call allocation; the static lambda is stored as a cached delegate.
 626    private static readonly IComparer<FieldDefinition> FieldSortComparer =
 627        Comparer<FieldDefinition>.Create(static (a, b) =>
 3765328            StringComparer.OrdinalIgnoreCase.Compare(a.Alias ?? a.Name, b.Alias ?? b.Name));
 29
 30    // SHARED thread-local builder pool used by both QueryBlock and QueryDefinition
 31    // This consolidates the pooling strategy and prevents duplicate ThreadLocal instances
 632    private static readonly ThreadLocal<Stack<QueryTextBuilder>> SharedBuilderStack =
 35733        new(() => new Stack<QueryTextBuilder>());
 34
 35    private const int MaxPooledBuilders = 4;
 36
 37    static QueryTextBuilder()
 38    {
 25239        for (int i = 0; i < PaddingCache.Length; i++)
 40        {
 12041            PaddingCache[i] = new string(' ', i * IndentSize);
 42        }
 643    }
 44
 45    /// <summary>
 46    /// Gets a builder instance from the thread-local pool or creates a new one.
 47    /// Caller must return the builder via <see cref="ReturnToPool(QueryTextBuilder)"/>.
 48    /// </summary>
 49    internal static QueryTextBuilder GetFromPool()
 50    {
 831951        var stack = SharedBuilderStack.Value!;
 831952        return stack.Count > 0 ? stack.Pop() : new QueryTextBuilder();
 53    }
 54
 55    /// <summary>
 56    /// Returns a builder to the thread-local pool for reuse.
 57    /// Clears the builder and checks capacity to prevent unbounded memory growth.
 58    /// </summary>
 59    internal static void ReturnToPool(QueryTextBuilder builder)
 60    {
 831961        builder._stringBuilder.Clear();
 62
 63        // Don't repool builders that have grown too large (prevents memory leak)
 831964        if (builder._stringBuilder.Capacity > MaxBuilderCapacity)
 65        {
 366            return;
 67        }
 68
 831669        var stack = SharedBuilderStack.Value!;
 831670        if (stack.Count < MaxPooledBuilders)
 71        {
 831672            stack.Push(builder);
 73        }
 74        // If pool is full, let GC handle it
 831675    }
 76
 77    /// <summary>
 78    /// Gets padding string for the specified indent level, using cache for common levels.
 79    /// </summary>
 80    /// <param name="indent">Indentation level</param>
 81    /// <returns>Padding string</returns>
 82    private static string GetPadding(int indent)
 83    {
 2980584        var paddingLevel = indent / IndentSize;
 2980585        return paddingLevel < PaddingCache.Length
 2980586            ? PaddingCache[paddingLevel]
 2980587            : new string(' ', indent);
 88    }
 89
 90    public string Build(QueryBlock queryBlock, int indent = 0, string? prefix = null)
 91    {
 92        // Clear only at the top-level call; recursive sub-query calls (indent > 0) accumulate.
 63993        if (indent == 0) _stringBuilder.Clear();
 94
 43295        var pad = GetPadding(indent);
 43296        var prevPad = pad;
 97
 43298        if (!string.IsNullOrWhiteSpace(queryBlock.Alias) && indent != 0)
 99        {
 108100            _stringBuilder.Append(pad);
 108101            _stringBuilder.Append(queryBlock.Alias);
 108102            _stringBuilder.Append(':');
 108103            pad = "";
 104        }
 105
 432106        _stringBuilder.Append(pad);
 432107        if (!string.IsNullOrWhiteSpace(prefix))
 108        {
 198109            _stringBuilder.Append(prefix).Append(' ');
 110        }
 111
 432112        _stringBuilder.Append(queryBlock.Name);
 113
 432114        AddArguments(queryBlock, indent == 0);
 432115        indent += IndentSize;
 116
 432117        AddFields(queryBlock, prevPad, indent);
 432118        return _stringBuilder.ToString();
 119    }
 120
 121    public string Build(QueryDefinition queryDefinition)
 122    {
 8106123        _stringBuilder.Clear();
 8106124        _stringBuilder.Append(queryDefinition.OperationType == OperationType.Mutation ? "mutation " : "query ");
 125
 8106126        if (!string.IsNullOrEmpty(queryDefinition.Name))
 127        {
 8106128            _stringBuilder.Append(queryDefinition.Name);
 129        }
 130
 8106131        if (queryDefinition._variables?.Count > 0)
 132        {
 33133            _stringBuilder.Append('(');
 33134            bool first = true;
 180135            foreach (var variable in queryDefinition._variables)
 136            {
 57137                if (!first)
 138                {
 24139                    _stringBuilder.Append(", ");
 140                }
 141
 57142                first = false;
 57143                variable.Print(_stringBuilder, variable.Name, true);
 144            }
 145
 33146            _stringBuilder.Append(')');
 147        }
 148
 8106149        _stringBuilder.AppendLine("{");
 150
 8106151        BuildFieldDefinitions(queryDefinition.Fields, IndentSize);
 152
 8106153        _stringBuilder.Append("}");
 8106154        return _stringBuilder.ToString();
 155    }
 156
 157    private void BuildFieldDefinitions(FieldChildren children, int indent)
 158    {
 20901159        var count = children.Count;
 20901160        var arr = ArrayPool<FieldDefinition>.Shared.Rent(count);
 161        try
 162        {
 20901163            children.AsSpan().CopyTo(arr);
 20901164            RenderSortedFields(arr, count, indent);
 20901165        }
 166        finally
 167        {
 20901168            Array.Clear(arr, 0, count);
 20901169            ArrayPool<FieldDefinition>.Shared.Return(arr, clearArray: false);
 20901170        }
 20901171    }
 172
 173    private void BuildFieldDefinitions(Dictionary<string, FieldDefinition> fields, int indent)
 174    {
 8106175        var count = fields.Count;
 8133176        if (count == 0) return;
 177
 178        // Dictionary<TKey,TValue> is insertion-ordered, not alphabetical. Copy values to a
 179        // pooled buffer so RenderSortedFields can sort once and render with a stable order.
 8079180        var arr = ArrayPool<FieldDefinition>.Shared.Rent(count);
 181        try
 182        {
 8079183            int i = 0;
 50538184            foreach (var f in fields.Values) arr[i++] = f;
 8079185            RenderSortedFields(arr, count, indent);
 8079186        }
 187        finally
 188        {
 8079189            Array.Clear(arr, 0, count);
 8079190            ArrayPool<FieldDefinition>.Shared.Return(arr, clearArray: false);
 8079191        }
 8079192    }
 193
 194    /// <summary>
 195    /// Sorts <paramref name="arr"/> in place via <see cref="FieldSortComparer"/> and writes the
 196    /// rendered fields into <see cref="_stringBuilder"/>. Shared rendering core for both backing
 197    /// collection types (Dictionary at the root, FieldChildren for nested levels).
 198    /// </summary>
 199    private void RenderSortedFields(FieldDefinition[] arr, int count, int indent)
 200    {
 28980201        Array.Sort(arr, 0, count, FieldSortComparer);
 28980202        var padding = GetPadding(indent);
 203
 136128204        for (int j = 0; j < count; j++)
 205        {
 39084206            var field = arr[j];
 39084207            _stringBuilder.Append(padding);
 208
 39084209            if (field.Alias != null)
 210            {
 837211                _stringBuilder.Append(field.Alias);
 837212                _stringBuilder.Append(':');
 213            }
 214
 39084215            _stringBuilder.Append(field.Name);
 216
 39084217            if (field._arguments is { Count: > 0 })
 218            {
 7023219                BuildFieldArguments(field._arguments);
 220            }
 221
 222            // A field gets a `{ … }` block when it has either child fields OR inline fragments.
 223            // Plain leaf fields (no children, no fragments) render as a bare name + newline.
 39084224            var hasChildren = field._children is { Count: > 0 };
 39084225            var hasFragments = field._fragments is { Count: > 0 };
 226
 39084227            if (hasChildren || hasFragments)
 228            {
 20889229                _stringBuilder.AppendLine("{");
 20889230                if (hasChildren)
 231                {
 20865232                    BuildFieldDefinitions(field._children!, indent + IndentSize);
 233                }
 20889234                if (hasFragments)
 235                {
 27236                    BuildInlineFragments(field._fragments!, indent + IndentSize);
 237                }
 20889238                _stringBuilder.Append(padding);
 20889239                _stringBuilder.AppendLine("}");
 240            }
 241            else
 242            {
 18195243                _stringBuilder.AppendLine();
 244            }
 245        }
 28980246    }
 247
 248    /// <summary>
 249    /// Renders the inline fragments attached to a field. Each fragment is written as
 250    /// <c>... on TypeName { … }</c> with its own selection set rendered recursively.
 251    /// Fragments are sorted alphabetically by type name (case-sensitive — GraphQL type names
 252    /// are case-sensitive) for deterministic output.
 253    /// </summary>
 254    private void BuildInlineFragments(Dictionary<string, InlineFragmentDefinition> fragments, int indent)
 255    {
 30256        if (fragments.Count == 0) return;
 257
 258        // Snapshot type names into a pooled buffer and sort, mirroring how fields are sorted
 259        // before rendering — keeps the output stable regardless of insertion order.
 30260        var typeNames = ArrayPool<string>.Shared.Rent(fragments.Count);
 261        try
 262        {
 30263            int i = 0;
 168264            foreach (var key in fragments.Keys) typeNames[i++] = key;
 30265            Array.Sort(typeNames, 0, fragments.Count, StringComparer.Ordinal);
 266
 30267            var padding = GetPadding(indent);
 132268            for (int j = 0; j < fragments.Count; j++)
 269            {
 36270                var fragment = fragments[typeNames[j]];
 36271                _stringBuilder.Append(padding);
 36272                _stringBuilder.Append("... on ");
 36273                _stringBuilder.Append(fragment.TypeName);
 36274                _stringBuilder.AppendLine("{");
 275
 36276                if (fragment._fields is { Count: > 0 })
 277                {
 36278                    BuildFieldDefinitions(fragment._fields, indent + IndentSize);
 279                }
 36280                if (fragment._fragments is { Count: > 0 })
 281                {
 3282                    BuildInlineFragments(fragment._fragments, indent + IndentSize);
 283                }
 284
 36285                _stringBuilder.Append(padding);
 36286                _stringBuilder.AppendLine("}");
 287            }
 30288        }
 289        finally
 290        {
 30291            Array.Clear(typeNames, 0, fragments.Count);
 30292            ArrayPool<string>.Shared.Return(typeNames, clearArray: false);
 30293        }
 30294    }
 295
 296    private void BuildFieldArguments(IReadOnlyDictionary<string, object?> arguments)
 297    {
 7023298        _stringBuilder.Append('(');
 299
 7023300        bool first = true;
 41784301        foreach (var (key, value) in arguments)
 302        {
 13869303            if (!first)
 304            {
 6846305                _stringBuilder.Append(", ");
 306            }
 307
 13869308            first = false;
 309
 13869310            _stringBuilder.Append(key);
 13869311            _stringBuilder.Append(':');
 13869312            WriteObject(_stringBuilder, value);
 313        }
 314
 7023315        _stringBuilder.Append(')');
 7023316    }
 317
 318    internal static void WriteObject(StringBuilder builder, object? value)
 319    {
 16920320        if (value is null)
 321        {
 27322            builder.Append("null");
 27323            return;
 324        }
 325
 16893326        if (ValueFormatter.TryAppendPrimitive(value, builder))
 14655327            return;
 328
 2238329        var valueType = value.GetType();
 3519330        if (ExtractKeyValuePairProperties(builder, value, valueType)) return;
 331
 332        switch (value)
 333        {
 334            case IList listValue:
 335                {
 84336                    Helpers.WriteCollection('[', ']', listValue, builder, WriteObject);
 84337                    break;
 338                }
 339
 340            case IDictionary dictValue:
 341                {
 828342                    Helpers.WriteCollection('{', '}', dictValue, builder, WriteObject);
 828343                    break;
 344                }
 345
 346            default:
 347                {
 45348                    WriteObjectReflection(builder, value, valueType);
 349                    break;
 350                }
 351        }
 45352    }
 353
 354    private static bool ExtractKeyValuePairProperties(StringBuilder builder, object value, Type valueType)
 355    {
 2238356        if (!valueType.IsGenericType || valueType.GetGenericTypeDefinition() != typeof(KeyValuePair<,>))
 357        {
 957358            return false;
 359        }
 360
 361        // KeyValuePair<,> is a sealed BCL struct that always exposes Key and Value properties —
 362        // GetProperty cannot return null here, so cache the pair without nullable wrapping.
 1281363        var (keyProp, valueProp) = TypeMetadataCache.KvpPropertyCache.GetOrAdd(
 1281364            valueType,
 1296365            static t => (t.GetProperty("Key")!, t.GetProperty("Value")!));
 366
 1281367        builder.Append(keyProp.GetValue(value));
 1281368        builder.Append(':');
 1281369        WriteObject(builder, valueProp.GetValue(value));
 1281370        return true;
 371    }
 372
 373    private static void WriteObjectReflection(StringBuilder builder, object value, Type valueType)
 374    {
 84375        var props = TypeMetadataCache.ObjectPropertyCache.GetOrAdd(valueType, static t => t.GetProperties());
 45376        builder.Append('{');
 45377        bool first = true;
 240378        foreach (var prop in props)
 379        {
 111380            if (!first) builder.Append(", ");
 75381            first = false;
 75382            builder.Append(prop.Name);
 75383            builder.Append(':');
 75384            WriteObject(builder, prop.GetValue(value));
 385        }
 45386        builder.Append('}');
 45387    }
 388
 389    private void AddFields(QueryBlock queryBlock, string prevPad, int indent = 0)
 390    {
 432391        if (queryBlock.IsEmpty)
 392        {
 69393            return;
 394        }
 395
 363396        _stringBuilder.AppendLine("{");
 363397        var padding = GetPadding(indent);
 398
 399        // Sort fields by their effective name. QueryBlock.HandleAddField rejects everything
 400        // except string and QueryBlock at insert time, so a single ternary covers every
 401        // reachable case without a switch default.
 363402        var orderedFields = queryBlock.FieldsList
 462403            .OrderBy(field => field is QueryBlock block ? (block.Alias ?? block.Name) : (string)field,
 363404                StringComparer.OrdinalIgnoreCase);
 405
 1650406        foreach (var field in orderedFields)
 407        {
 408            switch (field)
 409            {
 410                case string strValue:
 237411                    _stringBuilder.Append(padding);
 237412                    _stringBuilder.AppendLine(strValue);
 237413                    break;
 414                case QueryBlock subQuery:
 225415                    this.Build(subQuery, indent);
 225416                    _stringBuilder.AppendLine();
 417                    break;
 418            }
 419        }
 420
 363421        _stringBuilder.Append(prevPad);
 363422        _stringBuilder.Append('}');
 363423    }
 424
 425    private void AddArguments(QueryBlock queryBlock, bool isRootElement)
 426    {
 432427        var arguments = queryBlock.GetArguments(isRootElement);
 428
 432429        if (arguments.Count == 0)
 430        {
 273431            return;
 432        }
 433
 159434        _stringBuilder.Append('(');
 435
 159436        bool first = true;
 846437        foreach (var (key, value) in arguments)
 438        {
 264439            if (!first)
 440            {
 105441                _stringBuilder.Append(", ");
 442            }
 443
 264444            first = false;
 264445            if (value is Variable variable)
 446            {
 141447                variable.Print(_stringBuilder, key, isRootElement);
 141448                continue;
 449            }
 450
 123451            _stringBuilder.Append(key);
 123452            _stringBuilder.Append(':');
 453
 123454            WriteObject(_stringBuilder, value);
 455        }
 456
 159457        _stringBuilder.Append(')');
 159458    }
 459}