< Summary

Information
Class: NGql.Core.Abstractions.QueryBlock
Assembly: NGql.Core
File(s): /home/runner/work/NGql/NGql/src/Core/Abstractions/QueryBlock.cs
Line coverage
100%
Covered lines: 121
Uncovered lines: 0
Coverable lines: 121
Total lines: 405
Line coverage: 100%
Branch coverage
100%
Covered branches: 56
Total branches: 56
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_FieldsList()100%11100%
get_Arguments()100%11100%
get_ArgumentsInternal()100%11100%
get_VariablesInternal()100%11100%
TryGetSingleArgument(...)100%44100%
get_Variables()100%11100%
get_Name()100%11100%
get_Alias()100%11100%
AddVariable(...)100%11100%
AddVariable(...)100%11100%
AddField(...)100%11100%
AddField(...)100%11100%
AddField(...)100%11100%
AddArgument(...)100%11100%
AddArgument(...)100%1010100%
ValidateNoCaseCollisions(...)100%66100%
.ctor(...)100%44100%
ToString()100%11100%
AppendTo(...)100%11100%
WriteTo(...)100%11100%
WriteUtf8(...)100%11100%
HandleAddField(...)100%66100%
AddSubQuery(...)100%22100%
AddStringField(...)100%88100%
AddListItems(...)100%66100%
HandleAddVariable(...)100%11100%
HandleAddArgument(...)100%44100%
CollidesByCase(...)100%66100%

File(s)

/home/runner/work/NGql/NGql/src/Core/Abstractions/QueryBlock.cs

#LineLine coverage
 1using System.Buffers;
 2using System.Collections;
 3using System.Text;
 4using NGql.Core.Builders;
 5using NGql.Core.Extensions;
 6
 7namespace NGql.Core.Abstractions;
 8
 9/// <summary>
 10/// Represents a GraphQL Query Block.
 11/// </summary>
 12public sealed class QueryBlock
 13{
 14    private readonly string _prefix;
 15    private readonly SortedDictionary<string, object> _arguments;
 16    private readonly SortedSet<Variable> _variables;
 17    private readonly List<object> _fieldsList;
 18
 19    /// <summary>
 20    /// The list of fields to retrieve from GraphQL.
 21    /// </summary>
 305422    public IReadOnlyList<object> FieldsList => _fieldsList;
 23
 24    /// <summary>
 25    /// The collection of arguments related to <see cref="FieldsList"/>.
 26    /// </summary>
 666927    public IReadOnlyDictionary<string, object> Arguments => _arguments;
 28
 29    // Arguments are never removed, so while exactly one exists it is the first key written, in
 30    // its original casing. Rendering reads it directly: enumerating a SortedDictionary allocates
 31    // a traversal stack, and doing so through the interface also boxes the enumerator.
 32    private string? _firstArgumentKey;
 33
 99334    internal SortedDictionary<string, object> ArgumentsInternal => _arguments;
 35
 161736    internal SortedSet<Variable> VariablesInternal => _variables;
 37
 38    internal bool TryGetSingleArgument([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? key, [System.Diag
 39    {
 105340        key = _firstArgumentKey;
 105341        value = null;
 105342        return _arguments.Count == 1 && key is not null && _arguments.TryGetValue(key, out value);
 43    }
 44
 45    /// <summary>
 46    /// The collection of variables related to <see cref="FieldsList"/> or <see cref="Arguments"/>.
 47    /// </summary>
 638748    public IReadOnlyCollection<Variable> Variables => _variables;
 49
 50    /// <summary>
 51    /// The Query name.
 52    /// </summary>
 359753    public string Name { get; }
 54
 55    /// <summary>
 56    /// The Query alias.
 57    /// </summary>
 380158    public string? Alias { get; }
 59
 60    /// <summary>
 61    /// Indicates if the query is empty.
 62    /// </summary>
 63    internal bool IsEmpty = false;
 64
 65    /// <summary>
 66    /// Adds the variable with give name into <see cref="Variables"/> part of the query.
 67    /// </summary>
 68    /// <param name="variable">The variable</param>
 69    public void AddVariable(Variable variable)
 3070        => HandleAddVariable(variable);
 71
 72    /// <summary>
 73    /// Adds the variable with give name into <see cref="Variables"/> part of the query.
 74    /// </summary>
 75    /// <param name="name">The variable name</param>
 76    /// <param name="type">The value of the variable</param>
 77    public void AddVariable(string name, string type)
 125178        => HandleAddVariable(new Variable(name, type));
 79
 80    /// <summary>
 81    /// Adds the given generic list to the <see cref="FieldsList"/> part of the query.
 82    /// </summary>
 83    /// <remarks>
 84    /// Accepts any type of list, but must contain one of supported types of data.
 85    /// </remarks>
 86    /// <param name="selectList">Generic list of select fields.</param>
 87    public void AddField(IEnumerable<object> selectList)
 2788        => HandleAddField(selectList);
 89
 90    /// <summary>
 91    /// Adds the given list of strings to the <see cref="FieldsList"/> part of the query.
 92    /// </summary>
 93    /// <param name="selects">List of strings.</param>
 94    /// <returns>Query</returns>
 95    public void AddField(params string[] selects)
 32196        => HandleAddField(selects);
 97
 98    /// <summary>
 99    /// Adds the given sub query to the <see cref="FieldsList"/> part of the query.
 100    /// </summary>
 101    /// <param name="subQuery">A sub-query.</param>
 102    /// <returns>Query</returns>
 103    public void AddField(QueryBlock subQuery)
 606104        => HandleAddField(subQuery);
 105
 106    /// <summary>
 107    /// Adds the given key into <see cref="Arguments"/> part of the query.
 108    /// </summary>
 109    /// <param name="key">The Parameter Name</param>
 110    /// <param name="where">The value of the parameter, primitive or object</param>
 111    /// <returns></returns>
 112    public void AddArgument(string key, object where)
 39261113        => HandleAddArgument(key, where);
 114
 115    /// <summary>
 116    /// Add a dict of key value pairs &lt;string, object&gt; into <see cref="Arguments"/> part of the query.
 117    /// </summary>
 118    /// <param name="dict">An existing Dictionary that takes &lt;string, object&gt;</param>
 119    /// <returns>Query</returns>
 120    /// <exception cref="ArgumentException">
 121    /// Thrown when two keys in <paramref name="dict"/> collide under case-insensitive comparison
 122    /// (either against each other or against a key already present in <see cref="Arguments"/>), or
 123    /// when sorting a nested dictionary/decomposed-object value inside <paramref name="dict"/>
 124    /// uncovers the same kind of collision.
 125    /// <para>
 126    /// For <paramref name="dict"/> with more than one entry, every value is validated and sorted
 127    /// into a staging buffer before anything is committed to this block, so a throw — top-level or
 128    /// nested — leaves <see cref="Arguments"/> and <see cref="Variables"/> exactly as they were
 129    /// beforehand.
 130    /// </para>
 131    /// <para>
 132    /// For a single-entry <paramref name="dict"/>, this guarantee does NOT extend to
 133    /// <see cref="Variables"/>: that path routes through <see cref="HandleAddArgument"/>, which
 134    /// extracts variables from the value before sorting it, so a throw caused by a nested
 135    /// collision inside that single value can leave an extracted variable in <see cref="Variables"/>
 136    /// even though <see cref="Arguments"/> itself was never written. This is a pre-existing
 137    /// characteristic of the single-entry fast path, not new behavior.
 138    /// </para>
 139    /// </exception>
 140    public void AddArgument(IReadOnlyDictionary<string, object> dict)
 141    {
 60142        if (dict.Count == 1)
 143        {
 144            // Single entry: no cross-entry collision is possible within dict itself, and the
 145            // nested-collision staging below would just allocate a one-element buffer for
 146            // nothing. HandleAddArgument validates against stored keys before touching this
 147            // block, but — unlike the staged multi-entry path below — it extracts variables from
 148            // the value before sorting it, so a throw from a nested collision inside the value can
 149            // still leave a variable behind (see the AddArgument XML doc's single-entry caveat).
 12150            foreach (var (key, value) in dict)
 3151                HandleAddArgument(key, value);
 3152            return;
 153        }
 154
 57155        ValidateNoCaseCollisions(dict);
 156
 157        // Stage every value's sorted form before committing anything: SortArgumentValue can
 158        // itself throw on an OrdinalIgnoreCase collision inside a nested dictionary or a
 159        // decomposed object's properties, and that must not leave _arguments or _variables
 160        // partially written (see AddArgument XML doc).
 39161        var staged = new (string Key, object? Value)[dict.Count];
 39162        var i = 0;
 282163        foreach (var (key, value) in dict)
 164        {
 105165            staged[i++] = (key, Helpers.SortArgumentValue(value));
 166        }
 167
 252168        foreach (var (key, sortedValue) in staged)
 169        {
 93170            Helpers.ExtractVariablesFromValue(sortedValue, _variables);
 120171            if (_arguments.Count == 0) _firstArgumentKey = key;
 93172            _arguments[key] = sortedValue!; // SortArgumentValue preserves non-null input
 173        }
 33174    }
 175
 176    /// <summary>
 177    /// Validates that no key in <paramref name="dict"/> collides, under OrdinalIgnoreCase, with
 178    /// another key in <paramref name="dict"/> or with a key already stored in <see cref="Arguments"/>.
 179    /// Run before any entry is applied so a rejected call leaves the block untouched.
 180    /// </summary>
 181    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 182        "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method",
 183        Justification = "Plain foreach avoids allocating an enumerator/closure per incoming dictionary; mirrors Collides
 184    private void ValidateNoCaseCollisions(IReadOnlyDictionary<string, object> dict)
 185    {
 57186        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 366187        foreach (var key in dict.Keys)
 188        {
 135189            if (!seen.Add(key))
 190            {
 9191                throw new ArgumentException(
 9192                    $"An item with the same key has already been added. Colliding key: '{key}'.",
 9193                    nameof(dict));
 194            }
 195
 126196            if (CollidesByCase(key))
 197            {
 9198                throw new ArgumentException(
 9199                    $"An item with the same key has already been added. Colliding key: '{key}'.",
 9200                    nameof(dict));
 201            }
 202        }
 39203    }
 204
 205    /// <summary>
 206    /// Initializes a new instance of the <see cref="QueryBlock"/> class.
 207    /// </summary>
 1677208    public QueryBlock(string name, string prefix = "", string? alias = null, params Variable[]? variables)
 209    {
 1677210        _prefix = prefix;
 1677211        Name = name;
 1677212        Alias = alias;
 213
 1677214        _fieldsList = new List<object>();
 1818215        _variables = variables is null ? [] : [.. variables.DistinctBy(x => x.Name)];
 1677216        _arguments = new SortedDictionary<string, object>(StringComparer.OrdinalIgnoreCase);
 1677217    }
 218
 219    public override string ToString()
 220    {
 1212221        var builder = QueryTextBuilder.GetFromPool();
 222        try
 223        {
 1212224            return builder.Build(this, prefix: _prefix);
 225        }
 226        finally
 227        {
 1212228            QueryTextBuilder.ReturnToPool(builder);
 1212229        }
 1212230    }
 231
 232    /// <summary>
 233    /// Renders this block's GraphQL and appends it to <paramref name="builder"/> without
 234    /// materializing the intermediate string that <see cref="ToString()"/> allocates. The
 235    /// appended text is byte-for-byte identical to <see cref="ToString()"/>.
 236    /// </summary>
 237    /// <param name="builder">The target <see cref="StringBuilder"/> to append to.</param>
 238    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
 239    public void AppendTo(StringBuilder builder)
 240    {
 12241        ArgumentNullException.ThrowIfNull(builder);
 6242        var textBuilder = QueryTextBuilder.GetFromPool();
 243        try
 244        {
 6245            textBuilder.BuildInto(this, builder, _prefix);
 6246        }
 247        finally
 248        {
 6249            QueryTextBuilder.ReturnToPool(textBuilder);
 6250        }
 6251    }
 252
 253    /// <summary>
 254    /// Renders this block's GraphQL and writes it to <paramref name="writer"/> without
 255    /// materializing the intermediate string that <see cref="ToString()"/> allocates. The
 256    /// written text is byte-for-byte identical to <see cref="ToString()"/>.
 257    /// </summary>
 258    /// <param name="writer">The target <see cref="TextWriter"/> to write to.</param>
 259    /// <exception cref="ArgumentNullException">Thrown when <paramref name="writer"/> is null.</exception>
 260    public void WriteTo(TextWriter writer)
 261    {
 1212262        ArgumentNullException.ThrowIfNull(writer);
 1206263        var textBuilder = QueryTextBuilder.GetFromPool();
 264        try
 265        {
 1206266            textBuilder.BuildInto(this, writer, _prefix);
 1206267        }
 268        finally
 269        {
 1206270            QueryTextBuilder.ReturnToPool(textBuilder);
 1206271        }
 1206272    }
 273
 274    /// <summary>
 275    /// Renders this block's GraphQL and transcodes it as UTF-8 directly into
 276    /// <paramref name="bufferWriter"/>, with no intermediate <see cref="string"/> or <c>byte[]</c>
 277    /// allocation. The written bytes are identical to
 278    /// <c>System.Text.Encoding.UTF8.GetBytes(</c><see cref="ToString()"/><c>)</c>.
 279    /// </summary>
 280    /// <param name="bufferWriter">The target <see cref="IBufferWriter{Byte}"/> to write UTF-8 bytes to.</param>
 281    /// <exception cref="ArgumentNullException">Thrown when <paramref name="bufferWriter"/> is null.</exception>
 282    public void WriteUtf8(IBufferWriter<byte> bufferWriter)
 283    {
 18284        ArgumentNullException.ThrowIfNull(bufferWriter);
 12285        var textBuilder = QueryTextBuilder.GetFromPool();
 286        try
 287        {
 12288            textBuilder.BuildInto(this, bufferWriter, _prefix);
 12289        }
 290        finally
 291        {
 12292            QueryTextBuilder.ReturnToPool(textBuilder);
 12293        }
 12294    }
 295
 296    private void HandleAddField(object value)
 297    {
 298        switch (value)
 299        {
 300            case QueryBlock subQuery:
 612301                AddSubQuery(subQuery);
 612302                return;
 303            case string field:
 465304                AddStringField(field);
 465305                return;
 306            case IList list:
 348307                AddListItems(list);
 345308                return;
 309            default:
 3310                throw new InvalidOperationException("Unsupported Field type found, must be a `string` or `QueryBlock`");
 311        }
 312    }
 313
 314    private void AddSubQuery(QueryBlock subQuery)
 315    {
 5220316        foreach (var variable in subQuery.Variables)
 317        {
 1998318            _variables.Add(variable);
 319        }
 612320        _fieldsList.Add(subQuery);
 612321    }
 322
 323    private void AddStringField(string field)
 324    {
 483325        if (string.IsNullOrWhiteSpace(field)) return;
 326
 327        // Insert before the first string field that does not sort below `field`; non-string
 328        // entries are skipped without advancing the index (same semantics as the previous
 329        // OfType<string>().TakeWhile().Count() chain, without the LINQ allocations).
 447330        var insertIndex = 0;
 1377331        foreach (var existing in _fieldsList)
 332        {
 243333            if (existing is not string existingField) continue;
 162334            if (string.Compare(existingField, field, StringComparison.OrdinalIgnoreCase) >= 0) break;
 156335            insertIndex++;
 336        }
 447337        _fieldsList.Insert(insertIndex, field);
 447338    }
 339
 340    private void AddListItems(IList list)
 341    {
 348342        var sortedItems = list.Cast<object>()
 316343            .OrderBy(x => x switch
 316344            {
 304345                string s => s,
 6346                QueryBlock q => q.Name,
 6347                _ => x.ToString()
 316348            })
 348349            .ToList();
 350
 1641351        foreach (var item in sortedItems)
 352        {
 474353            HandleAddField(item);
 354        }
 345355    }
 356
 357    private void HandleAddVariable(Variable variable)
 358    {
 1281359        _variables.Add(variable);
 1281360    }
 361
 362    private void HandleAddArgument(string key, object value)
 363    {
 364        // Mirror the case-collision policy enforced for nested dictionary/object argument values
 365        // (which build via Dictionary.Add and throw on an OrdinalIgnoreCase collision): a key that
 366        // differs only by case from an existing one is a distinct GraphQL argument at the wire level,
 367        // so silently overwriting it via the indexer would lose data. Re-setting the exact same key
 368        // is still allowed; only a case-differing collision throws.
 369        //
 370        // This check runs before any mutation of the block (variable extraction, sorting, or the
 371        // dictionary write below) so a throw leaves this block exactly as it was beforehand — no
 372        // orphaned variable declarations, no partially-applied argument.
 39264373        if (CollidesByCase(key))
 374        {
 15375            throw new ArgumentException(
 15376                $"An item with the same key has already been added. Colliding key: '{key}'.",
 15377                nameof(key));
 378        }
 379
 39249380        Helpers.ExtractVariablesFromValue(value, _variables);
 39249381        var sortedValue = Helpers.SortArgumentValue(value);
 40023382        if (_arguments.Count == 0) _firstArgumentKey = key;
 39240383        _arguments[key] = sortedValue!; // SortArgumentValue preserves non-null input
 39240384    }
 385
 386    /// <summary>
 387    /// Returns true when <paramref name="key"/> matches a stored argument key only by case.
 388    /// New keys take the O(log n) membership check alone; the scan runs only when an existing
 389    /// key is set again, to tell an exact re-set (allowed) from a case-differing one (rejected).
 390    /// </summary>
 391    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 392        "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method",
 393        Justification = "Plain foreach over SortedDictionary.Keys short-circuits on the exact match without allocating a
 394    private bool CollidesByCase(string key)
 395    {
 78741396        if (!_arguments.ContainsKey(key)) return false;
 397
 15135398        foreach (var storedKey in _arguments.Keys)
 399        {
 7551400            if (string.Equals(storedKey, key, StringComparison.Ordinal)) return false;
 401        }
 402
 24403        return true;
 15404    }
 405}