< 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: 426
Uncovered lines: 0
Coverable lines: 426
Total lines: 971
Line coverage: 100%
Branch coverage
100%
Covered branches: 238
Total branches: 238
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%
.cctor()100%1414100%
GetFromPool()100%22100%
ReturnToPool(...)100%1010100%
RetainLargestWithinBudget(...)100%1010100%
GetPadding(...)100%22100%
Build(...)100%11100%
Build(...)100%11100%
BuildInto(...)100%11100%
BuildInto(...)100%11100%
BuildInto(...)100%11100%
BuildInto(...)100%11100%
BuildInto(...)100%11100%
BuildInto(...)100%11100%
WriteBuilderTo(...)100%22100%
WriteBuilderToUtf8(...)100%66100%
EncodeChunk(...)100%44100%
BuildBlockCore(...)100%22100%
BuildDefinitionCore(...)100%1818100%
BuildBlock(...)100%66100%
BuildFieldDefinitions(...)100%22100%
BuildFieldDefinitions(...)100%88100%
RenderSortedFields(...)100%11100%
RenderFields(...)100%2424100%
BuildSelectionSetBody(...)100%1212100%
BuildInlineFragments(...)100%88100%
BuildSpreadFragments(...)100%22100%
BuildNamedFragmentDefinitions(...)100%66100%
BuildFieldDirectives(...)100%1010100%
BuildFieldArguments(...)100%44100%
WriteObject(...)100%1212100%
WriteTypedDictionary(...)100%44100%
ExtractKeyValuePairProperties(...)100%44100%
WriteObjectReflection(...)100%44100%
AddFields(...)100%1414100%
AddArguments(...)100%4646100%
get_Key()100%11100%
AppendArgument(...)100%22100%

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
 144016    private QueryTextBuilder() => _stringBuilder = new StringBuilder();
 17
 18    private const int IndentSize = 4;
 19    private const int MaxBuilderCapacity = 256 * 1024; // UTF-16 characters, not bytes
 20
 21    // Pre-allocated padding strings for common indentation levels to avoid repeated allocations
 922    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.
 26    //
 27    // Array.Sort with a comparer is an unstable introsort, so the primary key (effective name,
 28    // case-insensitive) alone leaves fields that share an effective key — e.g. a plain `a` and a
 29    // `b` aliased to `a` — in an order chosen arbitrarily by introsort internals, which can differ
 30    // across runtimes and .NET versions and breaks the deterministic render guarantee. The Name and
 31    // Alias tiebreakers (ordinal) turn the comparison into a total order derived purely from each
 32    // field's own data, so equal effective keys resolve to one well-defined order with no extra
 33    // allocation and no insertion-index threading. Primary key semantics are unchanged.
 934    private static readonly Comparison<FieldDefinition> FieldSortComparer =
 935        static (a, b) =>
 936        {
 7317530437            var effectiveNameComparison =
 7317530438                StringComparer.OrdinalIgnoreCase.Compare(a.Alias ?? a.Name, b.Alias ?? b.Name);
 14595462439            if (effectiveNameComparison != 0) return effectiveNameComparison;
 940
 39598441            var nameComparison = string.CompareOrdinal(a.Name, b.Name);
 39703142            if (nameComparison != 0) return nameComparison;
 943
 39493744            return string.CompareOrdinal(a.Alias, b.Alias);
 945        };
 46
 47    // SHARED thread-local builder pool used by both QueryBlock and QueryDefinition
 48    // This consolidates the pooling strategy and prevents duplicate ThreadLocal instances
 949    private static readonly ThreadLocal<Stack<QueryTextBuilder>> SharedBuilderStack =
 63650        new(() => new Stack<QueryTextBuilder>());
 51
 52    private const int MaxPooledBuilders = 4;
 53
 54    static QueryTextBuilder()
 55    {
 37856        for (int i = 0; i < PaddingCache.Length; i++)
 57        {
 18058            PaddingCache[i] = new string(' ', i * IndentSize);
 59        }
 960    }
 61
 62    /// <summary>
 63    /// Gets a builder instance from the thread-local pool or creates a new one.
 64    /// Caller must return the builder via <see cref="ReturnToPool(QueryTextBuilder)"/>.
 65    /// </summary>
 66    internal static QueryTextBuilder GetFromPool()
 67    {
 2870468        var stack = SharedBuilderStack.Value!;
 2870469        return stack.Count > 0 ? stack.Pop() : new QueryTextBuilder();
 70    }
 71
 72    /// <summary>
 73    /// Returns a builder to the thread-local pool for reuse.
 74    /// Clears the builder and checks capacity to prevent unbounded memory growth.
 75    /// </summary>
 76    internal static void ReturnToPool(QueryTextBuilder builder)
 77    {
 78        // Reject before Clear: clearing a multi-chunk builder can allocate a large
 79        // contiguous buffer that would immediately be discarded.
 2868680        if (builder._stringBuilder.Capacity > MaxBuilderCapacity)
 81        {
 2182            return;
 83        }
 84
 2866585        var stack = SharedBuilderStack.Value!;
 2866586        if (stack.Count < MaxPooledBuilders)
 87        {
 2866588            if (stack.Count > 0)
 89            {
 33090                var retainedCapacity = builder._stringBuilder.Capacity;
 327091                foreach (var pooled in stack) retainedCapacity += pooled._stringBuilder.Capacity;
 33092                if (retainedCapacity > MaxBuilderCapacity)
 93                {
 5194                    RetainLargestWithinBudget(stack, builder);
 5195                    return;
 96                }
 97            }
 2861498            builder._stringBuilder.Clear();
 2861499            stack.Push(builder);
 100        }
 101        // If pool is full, let GC handle it
 28614102    }
 103
 104    // Over budget: keep the largest builders that fit, because those are the costly ones to regrow.
 105    // Rejecting the incoming builder unconditionally let a small pooled builder block a large one
 106    // on every nested render. The largest ends on top, where the outermost render rents it.
 107    private static void RetainLargestWithinBudget(Stack<QueryTextBuilder> stack, QueryTextBuilder incoming)
 108    {
 109        // Nested renders of large queries land here on every return, so the scratch array is
 110        // reused per thread and cleared afterwards to avoid rooting rejected builders.
 51111        var candidates = _retentionScratch ??= new QueryTextBuilder[MaxPooledBuilders];
 51112        var count = stack.Count + 1;
 51113        stack.CopyTo(candidates, 0);
 51114        candidates[count - 1] = incoming;
 51115        candidates.AsSpan(0, count).Sort(LargestCapacityFirst);
 116
 51117        stack.Clear();
 51118        var retainedCapacity = 0;
 51119        var kept = 0;
 306120        for (var i = 0; i < count; i++)
 121        {
 102122            var capacity = candidates[i]._stringBuilder.Capacity;
 102123            if (retainedCapacity + capacity > MaxBuilderCapacity) continue;
 51124            retainedCapacity += capacity;
 51125            candidates[kept++] = candidates[i];
 126        }
 127
 204128        for (var i = kept - 1; i >= 0; i--)
 129        {
 54130            if (ReferenceEquals(candidates[i], incoming)) incoming._stringBuilder.Clear();
 51131            stack.Push(candidates[i]);
 132        }
 133
 51134        Array.Clear(candidates, 0, count);
 51135    }
 136
 137    [ThreadStatic]
 138    private static QueryTextBuilder[]? _retentionScratch;
 139
 9140    private static readonly Comparison<QueryTextBuilder> LargestCapacityFirst =
 60141        static (left, right) => right._stringBuilder.Capacity.CompareTo(left._stringBuilder.Capacity);
 142
 143    /// <summary>
 144    /// Gets padding string for the specified indent level, using cache for common levels.
 145    /// </summary>
 146    /// <param name="indent">Indentation level</param>
 147    /// <returns>Padding string</returns>
 148    private static string GetPadding(int indent)
 149    {
 297426150        var paddingLevel = indent / IndentSize;
 297426151        return paddingLevel < PaddingCache.Length
 297426152            ? PaddingCache[paddingLevel]
 297426153            : new string(' ', indent);
 154    }
 155
 156    public string Build(QueryBlock queryBlock, int indent = 0, string? prefix = null)
 157    {
 1212158        BuildBlockCore(queryBlock, indent, prefix);
 1212159        return _stringBuilder.ToString();
 160    }
 161
 162    public string Build(QueryDefinition queryDefinition)
 163    {
 24789164        BuildDefinitionCore(queryDefinition);
 24765165        return _stringBuilder.ToString();
 166    }
 167
 168    /// <summary>
 169    /// Renders <paramref name="queryDefinition"/> into the pooled builder and appends the result
 170    /// to the caller-supplied <paramref name="target"/> chunk-by-chunk, with no intermediate
 171    /// string allocation. Shares the exact render machinery used by <see cref="Build(QueryDefinition)"/>.
 172    /// </summary>
 173    internal void BuildInto(QueryDefinition queryDefinition, StringBuilder target)
 174    {
 48175        BuildDefinitionCore(queryDefinition);
 48176        target.Append(_stringBuilder);
 48177    }
 178
 179    /// <summary>
 180    /// Renders <paramref name="queryDefinition"/> into the pooled builder and writes the result
 181    /// to the caller-supplied <paramref name="writer"/> chunk-by-chunk, with no intermediate
 182    /// string allocation. Shares the exact render machinery used by <see cref="Build(QueryDefinition)"/>.
 183    /// </summary>
 184    internal void BuildInto(QueryDefinition queryDefinition, TextWriter writer)
 185    {
 1221186        BuildDefinitionCore(queryDefinition);
 1221187        WriteBuilderTo(writer);
 1221188    }
 189
 190    /// <summary>
 191    /// Renders <paramref name="queryBlock"/> into the pooled builder and appends the result to the
 192    /// caller-supplied <paramref name="target"/> chunk-by-chunk, with no intermediate string
 193    /// allocation. Shares the exact render machinery used by <see cref="Build(QueryBlock, int, string?)"/>.
 194    /// </summary>
 195    internal void BuildInto(QueryBlock queryBlock, StringBuilder target, string? prefix = null)
 196    {
 6197        BuildBlockCore(queryBlock, 0, prefix);
 6198        target.Append(_stringBuilder);
 6199    }
 200
 201    /// <summary>
 202    /// Renders <paramref name="queryBlock"/> into the pooled builder and writes the result to the
 203    /// caller-supplied <paramref name="writer"/> chunk-by-chunk, with no intermediate string
 204    /// allocation. Shares the exact render machinery used by <see cref="Build(QueryBlock, int, string?)"/>.
 205    /// </summary>
 206    internal void BuildInto(QueryBlock queryBlock, TextWriter writer, string? prefix = null)
 207    {
 1206208        BuildBlockCore(queryBlock, 0, prefix);
 1206209        WriteBuilderTo(writer);
 1206210    }
 211
 212    /// <summary>
 213    /// Renders <paramref name="queryDefinition"/> into the pooled builder and transcodes the result
 214    /// to UTF-8 directly into the caller-supplied <paramref name="bufferWriter"/>, with no
 215    /// intermediate <see cref="string"/> or <c>byte[]</c> allocation. Shares the exact render
 216    /// machinery used by <see cref="Build(QueryDefinition)"/>.
 217    /// </summary>
 218    internal void BuildInto(QueryDefinition queryDefinition, IBufferWriter<byte> bufferWriter)
 219    {
 90220        BuildDefinitionCore(queryDefinition);
 90221        WriteBuilderToUtf8(bufferWriter);
 90222    }
 223
 224    /// <summary>
 225    /// Renders <paramref name="queryBlock"/> into the pooled builder and transcodes the result to
 226    /// UTF-8 directly into the caller-supplied <paramref name="bufferWriter"/>, with no intermediate
 227    /// <see cref="string"/> or <c>byte[]</c> allocation. Shares the exact render machinery used by
 228    /// <see cref="Build(QueryBlock, int, string?)"/>.
 229    /// </summary>
 230    internal void BuildInto(QueryBlock queryBlock, IBufferWriter<byte> bufferWriter, string? prefix = null)
 231    {
 12232        BuildBlockCore(queryBlock, 0, prefix);
 12233        WriteBuilderToUtf8(bufferWriter);
 12234    }
 235
 236    // Streams the pooled builder's backing chunks straight to the writer — TextWriter.Write(span)
 237    // (net8+) copies each chunk with no intermediate .ToString() materialization.
 238    private void WriteBuilderTo(TextWriter writer)
 239    {
 9716240        foreach (var chunk in _stringBuilder.GetChunks())
 241        {
 2431242            writer.Write(chunk.Span);
 243        }
 2427244    }
 245
 246    // Transcodes the pooled builder's backing chunks to UTF-8 straight into the buffer writer with
 247    // no intermediate string or byte[]. A single Encoder carries encoder state across chunks: a
 248    // surrogate PAIR (astral codepoint / emoji) can be split across two GetChunks() boundaries, and
 249    // a per-chunk stateless Encoding.GetBytes would mis-encode each half as U+FFFD. Convert with the
 250    // stateful encoder — flushing only on the final chunk — reassembles the pair correctly.
 251    private void WriteBuilderToUtf8(IBufferWriter<byte> bufferWriter)
 252    {
 253        // A StringBuilder always exposes a first chunk, even when it is empty.
 102254        var chunks = _stringBuilder.GetChunks();
 102255        chunks.MoveNext();
 256
 102257        var first = chunks.Current;
 102258        if (!chunks.MoveNext())
 259        {
 260            // A writer that hands back less than the requested size (bounded or slab-capped
 261            // buffers) cannot take the chunk in one call; the encoder loop below fills it in parts.
 93262            var destination = bufferWriter.GetSpan(Encoding.UTF8.GetMaxByteCount(first.Length));
 93263            if (Encoding.UTF8.TryGetBytes(first.Span, destination, out var written))
 264            {
 84265                bufferWriter.Advance(written);
 84266                return;
 267            }
 268
 9269            EncodeChunk(Encoding.UTF8.GetEncoder(), first.Span, true, bufferWriter);
 9270            return;
 271        }
 272
 9273        var encoder = Encoding.UTF8.GetEncoder();
 274
 275        // GetChunks exposes no count, so track the last chunk by comparing against the total length
 276        // consumed. Flush must happen exactly once, on the final Convert call, so any trailing
 277        // high-surrogate left dangling by malformed input is emitted rather than silently swallowed.
 9278        var remaining = _stringBuilder.Length - first.Length;
 9279        EncodeChunk(encoder, first.Span, false, bufferWriter);
 280        do
 281        {
 594282            var chars = chunks.Current.Span;
 594283            remaining -= chars.Length;
 594284            var isLast = remaining == 0;
 594285            EncodeChunk(encoder, chars, isLast, bufferWriter);
 594286        } while (chunks.MoveNext());
 9287    }
 288
 289    private static void EncodeChunk(Encoder encoder, ReadOnlySpan<char> chars, bool flush, IBufferWriter<byte> bufferWri
 290    {
 291        while (true)
 292        {
 293            // Size the destination for the whole remaining slice; GetMaxByteCount(0) is still a
 294            // positive minimum so an empty final flush chunk gets a valid span.
 21948295            var span = bufferWriter.GetSpan(Encoding.UTF8.GetMaxByteCount(chars.Length));
 21948296            encoder.Convert(chars, span, flush, out var charsUsed, out var bytesUsed, out var completed);
 21948297            bufferWriter.Advance(bytesUsed);
 21948298            chars = chars[charsUsed..];
 299
 300            // completed=false means the destination span filled before the source drained — loop to
 301            // rent more. Otherwise the chunk is done (a pending surrogate is retained in encoder
 302            // state for the next chunk when flush=false).
 21948303            if (completed && chars.IsEmpty)
 304            {
 305                break;
 306            }
 307        }
 612308    }
 309
 310    private void BuildBlockCore(QueryBlock queryBlock, int indent, string? prefix)
 311    {
 312        // Clear only at the top-level call; recursive sub-query calls (indent > 0) accumulate.
 4872313        if (indent == 0) _stringBuilder.Clear();
 314
 2436315        BuildBlock(queryBlock, indent, prefix);
 2436316    }
 317
 318    private void BuildDefinitionCore(QueryDefinition queryDefinition)
 319    {
 26148320        _stringBuilder.Clear();
 26148321        _stringBuilder.Append(queryDefinition.OperationType switch
 26148322        {
 24323            OperationType.Subscription => "subscription ",
 60324            OperationType.Mutation => "mutation ",
 26064325            _ => "query ",
 26148326        });
 327
 26148328        if (!string.IsNullOrEmpty(queryDefinition.Name))
 329        {
 26148330            _stringBuilder.Append(queryDefinition.Name);
 331        }
 332
 26148333        if (queryDefinition._variables?.Count > 0)
 334        {
 195335            _stringBuilder.Append('(');
 195336            bool first = true;
 930337            foreach (var variable in queryDefinition._variables)
 338            {
 270339                if (!first)
 340                {
 75341                    _stringBuilder.Append(", ");
 342                }
 343
 270344                first = false;
 270345                variable.Print(_stringBuilder, variable.Name, true);
 346            }
 347
 195348            _stringBuilder.Append(')');
 349        }
 350
 26148351        _stringBuilder.AppendLine("{");
 352
 26148353        BuildFieldDefinitions(queryDefinition.FieldsInternal, IndentSize);
 354
 26124355        _stringBuilder.Append("}");
 356
 26124357        if (queryDefinition._namedFragments is { Count: > 0 })
 358        {
 123359            BuildNamedFragmentDefinitions(queryDefinition._namedFragments);
 360        }
 26124361    }
 362
 363    /// <summary>
 364    /// Appends one query block (and, via <see cref="AddFields"/>, its sub-blocks) to the shared
 365    /// builder without materializing a string. Only the top-level <see cref="Build(QueryBlock, int, string?)"/>
 366    /// call pays for ToString.
 367    /// </summary>
 368    private void BuildBlock(QueryBlock queryBlock, int indent, string? prefix = null)
 369    {
 3051370        var pad = GetPadding(indent);
 3051371        var prevPad = pad;
 372
 3051373        if (!string.IsNullOrWhiteSpace(queryBlock.Alias) && indent != 0)
 374        {
 108375            _stringBuilder.Append(pad);
 108376            _stringBuilder.Append(queryBlock.Alias);
 108377            _stringBuilder.Append(':');
 108378            pad = "";
 379        }
 380
 3051381        _stringBuilder.Append(pad);
 3051382        if (!string.IsNullOrWhiteSpace(prefix))
 383        {
 1503384            _stringBuilder.Append(prefix).Append(' ');
 385        }
 386
 3051387        _stringBuilder.Append(queryBlock.Name);
 388
 3051389        AddArguments(queryBlock, indent == 0);
 3051390        indent += IndentSize;
 391
 3051392        AddFields(queryBlock, prevPad, indent);
 3051393    }
 394
 395    private void BuildFieldDefinitions(FieldChildren children, int indent)
 396    {
 397        // Take ONE snapshot of the concurrent collection. FieldChildren.Count and AsSpan() each do
 398        // an independent volatile read; reading them separately can tear if a concurrent Append lands
 399        // between the two, yielding a span longer than the rented count and silently dropping the tail
 400        // field(s). Rent, copy, render, clear and return all key off this single snapshot's length.
 265038401        var span = children.AsSpan();
 265038402        var count = span.Length;
 265038403        if (count <= 1)
 404        {
 242365405            RenderFields(span, indent);
 242365406            return;
 407        }
 22673408        var arr = ArrayPool<FieldDefinition>.Shared.Rent(count);
 409        try
 410        {
 22673411            span.CopyTo(arr);
 22673412            RenderSortedFields(arr, count, indent);
 22673413        }
 414        finally
 415        {
 22673416            Array.Clear(arr, 0, count);
 22673417            ArrayPool<FieldDefinition>.Shared.Return(arr, clearArray: false);
 22673418        }
 22673419    }
 420
 421    private void BuildFieldDefinitions(Dictionary<string, FieldDefinition> fields, int indent)
 422    {
 26148423        var count = fields.Count;
 26181424        if (count == 0) return;
 26115425        if (count == 1)
 426        {
 122766427            foreach (var field in fields.Values) RenderFields(new ReadOnlySpan<FieldDefinition>(in field), indent);
 24534428            return;
 429        }
 430
 431        // Dictionary<TKey,TValue> is insertion-ordered, not alphabetical. Copy values to a
 432        // pooled buffer so RenderSortedFields can sort once and render with a stable order.
 1557433        var arr = ArrayPool<FieldDefinition>.Shared.Rent(count);
 434        try
 435        {
 1557436            int i = 0;
 376263437            foreach (var f in fields.Values) arr[i++] = f;
 1557438            RenderSortedFields(arr, count, indent);
 1557439        }
 440        finally
 441        {
 1557442            Array.Clear(arr, 0, count);
 1557443            ArrayPool<FieldDefinition>.Shared.Return(arr, clearArray: false);
 1557444        }
 1557445    }
 446
 447    /// <summary>
 448    /// Sorts <paramref name="arr"/> in place via <see cref="FieldSortComparer"/> and writes the
 449    /// rendered fields into <see cref="_stringBuilder"/>. Shared rendering core for both backing
 450    /// collection types (Dictionary at the root, FieldChildren for nested levels).
 451    /// </summary>
 452    private void RenderSortedFields(FieldDefinition[] arr, int count, int indent)
 453    {
 24230454        arr.AsSpan(0, count).Sort(FieldSortComparer);
 24230455        RenderFields(arr.AsSpan(0, count), indent);
 24230456    }
 457
 458    private void RenderFields(ReadOnlySpan<FieldDefinition> fields, int indent)
 459    {
 291153460        var padding = GetPadding(indent);
 461
 14168438462        foreach (var field in fields)
 463        {
 6793078464            _stringBuilder.Append(padding);
 465
 6793078466            if (field.Alias != null)
 467            {
 2193468                _stringBuilder.Append(field.Alias);
 2193469                _stringBuilder.Append(':');
 470            }
 471
 6793078472            _stringBuilder.Append(field.Name);
 473
 6793078474            if (field._arguments is { Count: > 0 })
 475            {
 8058476                BuildFieldArguments(field._arguments);
 477            }
 478
 479            // Directives render after the name+arguments and before the selection set (or the
 480            // bare-leaf newline), space-separated, in insertion order — order is user-visible so
 481            // it is never sorted.
 6793054482            if (field._directives is { Count: > 0 })
 483            {
 159484                BuildFieldDirectives(field._directives);
 485            }
 486
 487            // A field gets a `{ … }` block when it has child fields, inline fragments, or
 488            // named-fragment spreads. Plain leaf fields (none of the above) render as a bare
 489            // name + newline.
 6793054490            var hasSelections = field._children is { Count: > 0 }
 6793054491                || field._fragments is { Count: > 0 }
 6793054492                || field._spreadFragments is { Count: > 0 };
 493
 6793054494            if (hasSelections)
 495            {
 264933496                _stringBuilder.AppendLine("{");
 264933497                BuildSelectionSetBody(field._children, field._fragments, field._spreadFragments, indent + IndentSize);
 264933498                _stringBuilder.Append(padding);
 264933499                _stringBuilder.AppendLine("}");
 500            }
 501            else
 502            {
 6528121503                _stringBuilder.AppendLine();
 504            }
 505        }
 291129506    }
 507
 508    /// <summary>
 509    /// Renders one selection-set body — plain fields, then inline fragments, then named-fragment
 510    /// spreads. Single shared implementation for the three nesting contexts (regular fields,
 511    /// inline fragments, named-fragment definitions) so the member order and Count guards live
 512    /// in exactly one place.
 513    /// </summary>
 514    private void BuildSelectionSetBody(
 515        FieldChildren? fields,
 516        Dictionary<string, InlineFragmentDefinition>? fragments,
 517        List<string>? spreadFragments,
 518        int indent)
 519    {
 265227520        if (fields is { Count: > 0 })
 521        {
 265038522            BuildFieldDefinitions(fields, indent);
 523        }
 265227524        if (fragments is { Count: > 0 })
 525        {
 126526            BuildInlineFragments(fragments, indent);
 527        }
 265227528        if (spreadFragments is { Count: > 0 })
 529        {
 141530            BuildSpreadFragments(spreadFragments, indent);
 531        }
 265227532    }
 533
 534    /// <summary>
 535    /// Renders the inline fragments attached to a field. Each fragment is written as
 536    /// <c>... on TypeName { … }</c> with its own selection set rendered recursively.
 537    /// Fragments are sorted alphabetically by type name (case-sensitive — GraphQL type names
 538    /// are case-sensitive) for deterministic output.
 539    /// </summary>
 540    private void BuildInlineFragments(Dictionary<string, InlineFragmentDefinition> fragments, int indent)
 541    {
 542        // Snapshot keys AND values into paired pooled buffers and sort them together — keeps
 543        // the output stable regardless of insertion order without re-looking each fragment up
 544        // by key after the sort.
 126545        var count = fragments.Count;
 126546        var typeNames = ArrayPool<string>.Shared.Rent(count);
 126547        var definitions = ArrayPool<InlineFragmentDefinition>.Shared.Rent(count);
 548        try
 549        {
 126550            int i = 0;
 546551            foreach (var (key, value) in fragments)
 552            {
 147553                typeNames[i] = key;
 147554                definitions[i] = value;
 147555                i++;
 556            }
 126557            Array.Sort(typeNames, definitions, 0, count, StringComparer.Ordinal);
 558
 126559            var padding = GetPadding(indent);
 546560            for (int j = 0; j < count; j++)
 561            {
 147562                var fragment = definitions[j];
 147563                _stringBuilder.Append(padding);
 147564                _stringBuilder.Append("... on ");
 147565                _stringBuilder.Append(fragment.TypeName);
 566
 567                // Directives render after the type condition and before the selection set, exactly
 568                // like on a plain field: `... on Admin @include(if:$a){ … }`.
 147569                if (fragment._directives is { Count: > 0 })
 570                {
 24571                    BuildFieldDirectives(fragment._directives);
 572                }
 573
 147574                _stringBuilder.AppendLine("{");
 575
 147576                BuildSelectionSetBody(fragment._fields, fragment._fragments, fragment._spreadFragments, indent + IndentS
 577
 147578                _stringBuilder.Append(padding);
 147579                _stringBuilder.AppendLine("}");
 580            }
 126581        }
 582        finally
 583        {
 126584            Array.Clear(typeNames, 0, count);
 126585            Array.Clear(definitions, 0, count);
 126586            ArrayPool<string>.Shared.Return(typeNames, clearArray: false);
 126587            ArrayPool<InlineFragmentDefinition>.Shared.Return(definitions, clearArray: false);
 126588        }
 126589    }
 590
 591    /// <summary>
 592    /// Renders <c>...Name</c> spreads of named fragments inside a selection set. Order is
 593    /// preserved as recorded by <c>FieldBuilder.SpreadFragment</c> calls — we don't sort
 594    /// because spread order can become user-visible once directives (which differ per spread
 595    /// site) ship in a future release.
 596    /// </summary>
 597    private void BuildSpreadFragments(List<string> spreadFragments, int indent)
 598    {
 141599        var padding = GetPadding(indent);
 582600        for (int i = 0; i < spreadFragments.Count; i++)
 601        {
 150602            _stringBuilder.Append(padding);
 150603            _stringBuilder.Append("...");
 150604            _stringBuilder.AppendLine(spreadFragments[i]);
 605        }
 141606    }
 607
 608    /// <summary>
 609    /// Renders the operation's named-fragment definitions as <c>fragment Name on Type{ … }</c>
 610    /// blocks after the operation's closing brace. Fragments are sorted alphabetically by name
 611    /// (case-sensitive) for deterministic output, consistent with how inline fragments and
 612    /// fields are sorted elsewhere.
 613    /// </summary>
 614    private void BuildNamedFragmentDefinitions(Dictionary<string, NamedFragmentDefinition> fragments)
 615    {
 123616        var count = fragments.Count;
 123617        var names = ArrayPool<string>.Shared.Rent(count);
 123618        var definitions = ArrayPool<NamedFragmentDefinition>.Shared.Rent(count);
 619        try
 620        {
 123621            int i = 0;
 540622            foreach (var (key, value) in fragments)
 623            {
 147624                names[i] = key;
 147625                definitions[i] = value;
 147626                i++;
 627            }
 123628            Array.Sort(names, definitions, 0, count, StringComparer.Ordinal);
 629
 540630            for (int j = 0; j < count; j++)
 631            {
 147632                var fragment = definitions[j];
 633                // First fragment: operation ended with `}` (no trailing newline) — emit a
 634                // newline to break onto its own line. Subsequent fragments: previous fragment
 635                // already ended with `}\n`, so go straight to `fragment` with no extra blank line.
 147636                if (j == 0)
 637                {
 123638                    _stringBuilder.AppendLine();
 639                }
 147640                _stringBuilder.Append("fragment ");
 147641                _stringBuilder.Append(fragment.Name);
 147642                _stringBuilder.Append(" on ");
 147643                _stringBuilder.Append(fragment.OnType);
 147644                _stringBuilder.AppendLine("{");
 645
 147646                BuildSelectionSetBody(fragment._fields, fragment._fragments, fragment._spreadFragments, IndentSize);
 647
 147648                _stringBuilder.AppendLine("}");
 649            }
 123650        }
 651        finally
 652        {
 123653            Array.Clear(names, 0, count);
 123654            Array.Clear(definitions, 0, count);
 123655            ArrayPool<string>.Shared.Return(names, clearArray: false);
 123656            ArrayPool<NamedFragmentDefinition>.Shared.Return(definitions, clearArray: false);
 123657        }
 123658    }
 659
 660    /// <summary>
 661    /// Renders a field's directives in insertion order: <c> @Name</c>, followed by
 662    /// <c>(argName:value, …)</c> when the directive has arguments. Argument values reuse the exact
 663    /// formatter used for field arguments, so <c>if:$x</c> and complex values render identically.
 664    /// The directive LIST order is never sorted — it is user-visible, exactly like fragment-spread
 665    /// order. Individual directive ARGUMENTS, however, are stored in a case-insensitive sorted
 666    /// dictionary (normalized at <see cref="FieldDirective"/> construction, like field arguments),
 667    /// so their key order here is deterministic rather than caller-insertion order.
 668    /// </summary>
 669    private void BuildFieldDirectives(List<FieldDirective> directives)
 670    {
 750671        for (int i = 0; i < directives.Count; i++)
 672        {
 192673            var directive = directives[i];
 192674            _stringBuilder.Append(" @");
 192675            _stringBuilder.Append(directive.Name);
 676
 192677            var arguments = directive.Arguments;
 192678            if (arguments is { Count: > 0 })
 679            {
 180680                _stringBuilder.Append('(');
 180681                bool first = true;
 726682                foreach (var (key, value) in arguments)
 683                {
 183684                    if (!first)
 685                    {
 3686                        _stringBuilder.Append(", ");
 687                    }
 688
 183689                    first = false;
 183690                    _stringBuilder.Append(key);
 183691                    _stringBuilder.Append(':');
 183692                    WriteObject(_stringBuilder, value);
 693                }
 694
 180695                _stringBuilder.Append(')');
 696            }
 697        }
 183698    }
 699
 700    private void BuildFieldArguments(IReadOnlyDictionary<string, object?> arguments)
 701    {
 8058702        _stringBuilder.Append('(');
 703
 8058704        bool first = true;
 46188705        foreach (var (key, value) in arguments)
 706        {
 15048707            if (!first)
 708            {
 6990709                _stringBuilder.Append(", ");
 710            }
 711
 15048712            first = false;
 713
 15048714            _stringBuilder.Append(key);
 15048715            _stringBuilder.Append(':');
 15048716            WriteObject(_stringBuilder, value);
 717        }
 718
 8034719        _stringBuilder.Append(')');
 8034720    }
 721
 722    internal static void WriteObject(StringBuilder builder, object? value)
 723    {
 25607724        if (value is null)
 725        {
 42726            builder.Append("null");
 42727            return;
 728        }
 729
 25565730        if (ValueFormatter.TryAppendPrimitive(value, builder))
 24416731            return;
 732
 1125733        var valueType = value.GetType();
 1149734        if (ExtractKeyValuePairProperties(builder, value, valueType)) return;
 735
 736        switch (value)
 737        {
 738            case IList listValue:
 739                {
 129740                    Helpers.WriteCollection('[', ']', listValue, builder, WriteObject);
 129741                    break;
 742                }
 743
 744            // Arguments are normalized to SortedDictionary<string, object?> (see
 745            // Helpers.SortArgumentValue), so the common case implements the generic interface.
 746            // Enumerating it through the generic enumerator writes each key directly and recurses
 747            // on the value with no boxed KeyValuePair and no reflected PropertyInfo.GetValue —
 748            // exactly the same {k:v, …} output the non-generic IDictionary path below would emit.
 749            case IDictionary<string, object?> typedDict:
 750                {
 921751                    WriteTypedDictionary(builder, typedDict);
 921752                    break;
 753                }
 754
 755            case IDictionary dictValue:
 756                {
 6757                    Helpers.WriteCollection('{', '}', dictValue, builder, WriteObject);
 6758                    break;
 759                }
 760
 761            default:
 762                {
 45763                    WriteObjectReflection(builder, value, valueType);
 764                    break;
 765                }
 766        }
 45767    }
 768
 769    private static void WriteTypedDictionary(StringBuilder builder, IDictionary<string, object?> dictionary)
 770    {
 921771        builder.Append('{');
 921772        bool first = true;
 4764773        foreach (var kvp in dictionary)
 774        {
 2007775            if (!first) builder.Append(", ");
 1461776            first = false;
 1461777            builder.Append(kvp.Key);
 1461778            builder.Append(':');
 1461779            WriteObject(builder, kvp.Value);
 780        }
 921781        builder.Append('}');
 921782    }
 783
 784    private static bool ExtractKeyValuePairProperties(StringBuilder builder, object value, Type valueType)
 785    {
 1125786        if (!valueType.IsGenericType || valueType.GetGenericTypeDefinition() != typeof(KeyValuePair<,>))
 787        {
 1101788            return false;
 789        }
 790
 791        // KeyValuePair<,> is a sealed BCL struct that always exposes Key and Value properties —
 792        // GetProperty cannot return null here, so cache the pair without nullable wrapping.
 24793        var (keyProp, valueProp) = TypeMetadataCache.GetKeyValueProperties(valueType);
 794
 24795        builder.Append(keyProp.GetValue(value));
 24796        builder.Append(':');
 24797        WriteObject(builder, valueProp.GetValue(value));
 24798        return true;
 799    }
 800
 801    private static void WriteObjectReflection(StringBuilder builder, object value, Type valueType)
 802    {
 45803        var props = TypeMetadataCache.GetObjectProperties(valueType);
 45804        builder.Append('{');
 45805        bool first = true;
 238806        foreach (var prop in props)
 807        {
 109808            if (!first) builder.Append(", ");
 74809            first = false;
 74810            builder.Append(prop.Name);
 74811            builder.Append(':');
 74812            WriteObject(builder, prop.GetValue(value));
 813        }
 45814        builder.Append('}');
 45815    }
 816
 817    // Stable sort key for QueryBlock field lists: effective name first, original index as the
 818    // tiebreaker so equal names keep insertion order (matching LINQ OrderBy's stability).
 9819    private static readonly Comparison<(string Key, int Index, object Item)> BlockFieldComparer =
 9820        static (a, b) =>
 9821        {
 276822            var nameComparison = StringComparer.OrdinalIgnoreCase.Compare(a.Key, b.Key);
 276823            return nameComparison != 0 ? nameComparison : a.Index.CompareTo(b.Index);
 9824        };
 825
 826    private void AddFields(QueryBlock queryBlock, string prevPad, int indent = 0)
 827    {
 3051828        if (queryBlock.IsEmpty)
 829        {
 96830            return;
 831        }
 832
 2955833        _stringBuilder.AppendLine("{");
 2955834        var padding = GetPadding(indent);
 835
 836        // Sort fields by their effective name into a pooled buffer. QueryBlock.HandleAddField
 837        // rejects everything except string and QueryBlock at insert time, so a single ternary
 838        // covers every reachable case without a switch default.
 2955839        var fields = queryBlock.FieldsList;
 2955840        var count = fields.Count;
 2955841        var entries = ArrayPool<(string Key, int Index, object Item)>.Shared.Rent(count);
 842        try
 843        {
 10302844            for (int i = 0; i < count; i++)
 845            {
 2196846                var field = fields[i];
 2196847                var key = field is QueryBlock block ? (block.Alias ?? block.Name) : (string)field;
 2196848                entries[i] = (key, i, field);
 849            }
 850
 2955851            entries.AsSpan(0, count).Sort(BlockFieldComparer);
 852
 10302853            for (int i = 0; i < count; i++)
 854            {
 2196855                switch (entries[i].Item)
 856                {
 857                    case string strValue:
 1581858                        _stringBuilder.Append(padding);
 1581859                        _stringBuilder.AppendLine(strValue);
 1581860                        break;
 861                    case QueryBlock subQuery:
 615862                        BuildBlock(subQuery, indent);
 615863                        _stringBuilder.AppendLine();
 864                        break;
 865                }
 866            }
 2955867        }
 868        finally
 869        {
 2955870            Array.Clear(entries, 0, count);
 2955871            ArrayPool<(string Key, int Index, object Item)>.Shared.Return(entries, clearArray: false);
 2955872        }
 873
 2955874        _stringBuilder.Append(prevPad);
 2955875        _stringBuilder.Append('}');
 2955876    }
 877
 878    private void AddArguments(QueryBlock queryBlock, bool isRootElement)
 879    {
 3051880        if (queryBlock.Arguments.Count == 0 && (!isRootElement || queryBlock.Variables.Count == 0))
 881        {
 381882            return;
 883        }
 884
 2670885        _stringBuilder.Append('(');
 2670886        if ((!isRootElement || queryBlock.Variables.Count == 0) && queryBlock.TryGetSingleArgument(out var singleKey, ou
 887        {
 708888            AppendArgument(singleKey, singleValue, isRootElement);
 708889            _stringBuilder.Append(')');
 708890            return;
 891        }
 892
 1962893        if (isRootElement && queryBlock.Arguments.Count == 0 && queryBlock.Variables.Count == 1)
 894        {
 657895            var variable = queryBlock.VariablesInternal.Min!;
 657896            variable.Print(_stringBuilder, variable.Name, true);
 657897            _stringBuilder.Append(')');
 657898            return;
 899        }
 900
 1305901        var explicitCount = queryBlock.Arguments.Count;
 1305902        var count = explicitCount + (isRootElement ? queryBlock.Variables.Count : 0);
 1305903        var entries = ArrayPool<ArgumentEntry>.Shared.Rent(count);
 904        try
 905        {
 1305906            var index = 0;
 907            // An empty SortedDictionary still allocates its traversal stack when enumerated.
 1305908            if (explicitCount > 0)
 909            {
 38388910                foreach (var (key, value) in queryBlock.ArgumentsInternal)
 911                {
 18201912                    var renderedKey = isRootElement && value is Variable variable ? variable.Name : key;
 18201913                    entries[index] = new ArgumentEntry(renderedKey, value, index);
 18201914                    index++;
 915                }
 916            }
 917
 1305918            if (isRootElement && count > explicitCount)
 919            {
 13776920                foreach (var variable in queryBlock.VariablesInternal)
 921                {
 5928922                    entries[index] = new ArgumentEntry(variable.Name, variable, index);
 5928923                    index++;
 924                }
 925            }
 926
 1305927            entries.AsSpan(0, count).Sort(ArgumentEntryComparer);
 1305928            var i = 0;
 17193929            while (i < count)
 930            {
 30471931                if (i != 0) _stringBuilder.Append(", ");
 15888932                var entry = entries[i++];
 24129933                while (i < count && string.Equals(entry.Key, entries[i].Key, StringComparison.Ordinal))
 934                {
 8241935                    var next = entries[i++];
 13005936                    if (next.Order < explicitCount || entry.Value is not Variable) entry = next;
 937                }
 15888938                AppendArgument(entry.Key, entry.Value, isRootElement);
 939            }
 1305940        }
 941        finally
 942        {
 1305943            Array.Clear(entries, 0, count);
 1305944            ArrayPool<ArgumentEntry>.Shared.Return(entries, clearArray: false);
 1305945        }
 946
 1305947        _stringBuilder.Append(')');
 1305948    }
 949
 293151950    private readonly record struct ArgumentEntry(string Key, object Value, int Order);
 951
 9952    private static readonly Comparison<ArgumentEntry> ArgumentEntryComparer =
 9953        static (left, right) =>
 9954        {
 87960955            var keyComparison = string.CompareOrdinal(left.Key, right.Key);
 87960956            return keyComparison != 0 ? keyComparison : left.Order.CompareTo(right.Order);
 9957        };
 958
 959    private void AppendArgument(string key, object value, bool isRootElement)
 960    {
 16596961        if (value is Variable variable)
 962        {
 8196963            variable.Print(_stringBuilder, key, isRootElement);
 8196964            return;
 965        }
 966
 8400967        _stringBuilder.Append(key);
 8400968        _stringBuilder.Append(':');
 8400969        WriteObject(_stringBuilder, value);
 8400970    }
 971}

Methods/Properties

.ctor()
.cctor()
GetFromPool()
ReturnToPool(NGql.Core.Builders.QueryTextBuilder)
RetainLargestWithinBudget(System.Collections.Generic.Stack`1<NGql.Core.Builders.QueryTextBuilder>,NGql.Core.Builders.QueryTextBuilder)
GetPadding(System.Int32)
Build(NGql.Core.Abstractions.QueryBlock,System.Int32,System.String)
Build(NGql.Core.Abstractions.QueryDefinition)
BuildInto(NGql.Core.Abstractions.QueryDefinition,System.Text.StringBuilder)
BuildInto(NGql.Core.Abstractions.QueryDefinition,System.IO.TextWriter)
BuildInto(NGql.Core.Abstractions.QueryBlock,System.Text.StringBuilder,System.String)
BuildInto(NGql.Core.Abstractions.QueryBlock,System.IO.TextWriter,System.String)
BuildInto(NGql.Core.Abstractions.QueryDefinition,System.Buffers.IBufferWriter`1<System.Byte>)
BuildInto(NGql.Core.Abstractions.QueryBlock,System.Buffers.IBufferWriter`1<System.Byte>,System.String)
WriteBuilderTo(System.IO.TextWriter)
WriteBuilderToUtf8(System.Buffers.IBufferWriter`1<System.Byte>)
EncodeChunk(System.Text.Encoder,System.ReadOnlySpan`1<System.Char>,System.Boolean,System.Buffers.IBufferWriter`1<System.Byte>)
BuildBlockCore(NGql.Core.Abstractions.QueryBlock,System.Int32,System.String)
BuildDefinitionCore(NGql.Core.Abstractions.QueryDefinition)
BuildBlock(NGql.Core.Abstractions.QueryBlock,System.Int32,System.String)
BuildFieldDefinitions(NGql.Core.Abstractions.FieldChildren,System.Int32)
BuildFieldDefinitions(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.FieldDefinition>,System.Int32)
RenderSortedFields(NGql.Core.Abstractions.FieldDefinition[],System.Int32,System.Int32)
RenderFields(System.ReadOnlySpan`1<NGql.Core.Abstractions.FieldDefinition>,System.Int32)
BuildSelectionSetBody(NGql.Core.Abstractions.FieldChildren,System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.InlineFragmentDefinition>,System.Collections.Generic.List`1<System.String>,System.Int32)
BuildInlineFragments(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.InlineFragmentDefinition>,System.Int32)
BuildSpreadFragments(System.Collections.Generic.List`1<System.String>,System.Int32)
BuildNamedFragmentDefinitions(System.Collections.Generic.Dictionary`2<System.String,NGql.Core.Abstractions.NamedFragmentDefinition>)
BuildFieldDirectives(System.Collections.Generic.List`1<NGql.Core.Abstractions.FieldDirective>)
BuildFieldArguments(System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Object>)
WriteObject(System.Text.StringBuilder,System.Object)
WriteTypedDictionary(System.Text.StringBuilder,System.Collections.Generic.IDictionary`2<System.String,System.Object>)
ExtractKeyValuePairProperties(System.Text.StringBuilder,System.Object,System.Type)
WriteObjectReflection(System.Text.StringBuilder,System.Object,System.Type)
AddFields(NGql.Core.Abstractions.QueryBlock,System.String,System.Int32)
AddArguments(NGql.Core.Abstractions.QueryBlock,System.Boolean)
get_Key()
AppendArgument(System.String,System.Object,System.Boolean)