| | | 1 | | using System.Buffers; |
| | | 2 | | using System.Collections; |
| | | 3 | | using System.Text; |
| | | 4 | | using NGql.Core.Builders; |
| | | 5 | | using NGql.Core.Extensions; |
| | | 6 | | |
| | | 7 | | namespace NGql.Core.Abstractions; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Represents a GraphQL Query Block. |
| | | 11 | | /// </summary> |
| | | 12 | | public 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> |
| | 3054 | 22 | | public IReadOnlyList<object> FieldsList => _fieldsList; |
| | | 23 | | |
| | | 24 | | /// <summary> |
| | | 25 | | /// The collection of arguments related to <see cref="FieldsList"/>. |
| | | 26 | | /// </summary> |
| | 6669 | 27 | | 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 | | |
| | 993 | 34 | | internal SortedDictionary<string, object> ArgumentsInternal => _arguments; |
| | | 35 | | |
| | 1617 | 36 | | internal SortedSet<Variable> VariablesInternal => _variables; |
| | | 37 | | |
| | | 38 | | internal bool TryGetSingleArgument([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? key, [System.Diag |
| | | 39 | | { |
| | 1053 | 40 | | key = _firstArgumentKey; |
| | 1053 | 41 | | value = null; |
| | 1053 | 42 | | 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> |
| | 6387 | 48 | | public IReadOnlyCollection<Variable> Variables => _variables; |
| | | 49 | | |
| | | 50 | | /// <summary> |
| | | 51 | | /// The Query name. |
| | | 52 | | /// </summary> |
| | 3597 | 53 | | public string Name { get; } |
| | | 54 | | |
| | | 55 | | /// <summary> |
| | | 56 | | /// The Query alias. |
| | | 57 | | /// </summary> |
| | 3801 | 58 | | 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) |
| | 30 | 70 | | => 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) |
| | 1251 | 78 | | => 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) |
| | 27 | 88 | | => 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) |
| | 321 | 96 | | => 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) |
| | 606 | 104 | | => 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) |
| | 39261 | 113 | | => HandleAddArgument(key, where); |
| | | 114 | | |
| | | 115 | | /// <summary> |
| | | 116 | | /// Add a dict of key value pairs <string, object> into <see cref="Arguments"/> part of the query. |
| | | 117 | | /// </summary> |
| | | 118 | | /// <param name="dict">An existing Dictionary that takes <string, object></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 | | { |
| | 60 | 142 | | 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). |
| | 12 | 150 | | foreach (var (key, value) in dict) |
| | 3 | 151 | | HandleAddArgument(key, value); |
| | 3 | 152 | | return; |
| | | 153 | | } |
| | | 154 | | |
| | 57 | 155 | | 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). |
| | 39 | 161 | | var staged = new (string Key, object? Value)[dict.Count]; |
| | 39 | 162 | | var i = 0; |
| | 282 | 163 | | foreach (var (key, value) in dict) |
| | | 164 | | { |
| | 105 | 165 | | staged[i++] = (key, Helpers.SortArgumentValue(value)); |
| | | 166 | | } |
| | | 167 | | |
| | 252 | 168 | | foreach (var (key, sortedValue) in staged) |
| | | 169 | | { |
| | 93 | 170 | | Helpers.ExtractVariablesFromValue(sortedValue, _variables); |
| | 120 | 171 | | if (_arguments.Count == 0) _firstArgumentKey = key; |
| | 93 | 172 | | _arguments[key] = sortedValue!; // SortArgumentValue preserves non-null input |
| | | 173 | | } |
| | 33 | 174 | | } |
| | | 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 | | { |
| | 57 | 186 | | var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 366 | 187 | | foreach (var key in dict.Keys) |
| | | 188 | | { |
| | 135 | 189 | | if (!seen.Add(key)) |
| | | 190 | | { |
| | 9 | 191 | | throw new ArgumentException( |
| | 9 | 192 | | $"An item with the same key has already been added. Colliding key: '{key}'.", |
| | 9 | 193 | | nameof(dict)); |
| | | 194 | | } |
| | | 195 | | |
| | 126 | 196 | | if (CollidesByCase(key)) |
| | | 197 | | { |
| | 9 | 198 | | throw new ArgumentException( |
| | 9 | 199 | | $"An item with the same key has already been added. Colliding key: '{key}'.", |
| | 9 | 200 | | nameof(dict)); |
| | | 201 | | } |
| | | 202 | | } |
| | 39 | 203 | | } |
| | | 204 | | |
| | | 205 | | /// <summary> |
| | | 206 | | /// Initializes a new instance of the <see cref="QueryBlock"/> class. |
| | | 207 | | /// </summary> |
| | 1677 | 208 | | public QueryBlock(string name, string prefix = "", string? alias = null, params Variable[]? variables) |
| | | 209 | | { |
| | 1677 | 210 | | _prefix = prefix; |
| | 1677 | 211 | | Name = name; |
| | 1677 | 212 | | Alias = alias; |
| | | 213 | | |
| | 1677 | 214 | | _fieldsList = new List<object>(); |
| | 1818 | 215 | | _variables = variables is null ? [] : [.. variables.DistinctBy(x => x.Name)]; |
| | 1677 | 216 | | _arguments = new SortedDictionary<string, object>(StringComparer.OrdinalIgnoreCase); |
| | 1677 | 217 | | } |
| | | 218 | | |
| | | 219 | | public override string ToString() |
| | | 220 | | { |
| | 1212 | 221 | | var builder = QueryTextBuilder.GetFromPool(); |
| | | 222 | | try |
| | | 223 | | { |
| | 1212 | 224 | | return builder.Build(this, prefix: _prefix); |
| | | 225 | | } |
| | | 226 | | finally |
| | | 227 | | { |
| | 1212 | 228 | | QueryTextBuilder.ReturnToPool(builder); |
| | 1212 | 229 | | } |
| | 1212 | 230 | | } |
| | | 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 | | { |
| | 12 | 241 | | ArgumentNullException.ThrowIfNull(builder); |
| | 6 | 242 | | var textBuilder = QueryTextBuilder.GetFromPool(); |
| | | 243 | | try |
| | | 244 | | { |
| | 6 | 245 | | textBuilder.BuildInto(this, builder, _prefix); |
| | 6 | 246 | | } |
| | | 247 | | finally |
| | | 248 | | { |
| | 6 | 249 | | QueryTextBuilder.ReturnToPool(textBuilder); |
| | 6 | 250 | | } |
| | 6 | 251 | | } |
| | | 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 | | { |
| | 1212 | 262 | | ArgumentNullException.ThrowIfNull(writer); |
| | 1206 | 263 | | var textBuilder = QueryTextBuilder.GetFromPool(); |
| | | 264 | | try |
| | | 265 | | { |
| | 1206 | 266 | | textBuilder.BuildInto(this, writer, _prefix); |
| | 1206 | 267 | | } |
| | | 268 | | finally |
| | | 269 | | { |
| | 1206 | 270 | | QueryTextBuilder.ReturnToPool(textBuilder); |
| | 1206 | 271 | | } |
| | 1206 | 272 | | } |
| | | 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 | | { |
| | 18 | 284 | | ArgumentNullException.ThrowIfNull(bufferWriter); |
| | 12 | 285 | | var textBuilder = QueryTextBuilder.GetFromPool(); |
| | | 286 | | try |
| | | 287 | | { |
| | 12 | 288 | | textBuilder.BuildInto(this, bufferWriter, _prefix); |
| | 12 | 289 | | } |
| | | 290 | | finally |
| | | 291 | | { |
| | 12 | 292 | | QueryTextBuilder.ReturnToPool(textBuilder); |
| | 12 | 293 | | } |
| | 12 | 294 | | } |
| | | 295 | | |
| | | 296 | | private void HandleAddField(object value) |
| | | 297 | | { |
| | | 298 | | switch (value) |
| | | 299 | | { |
| | | 300 | | case QueryBlock subQuery: |
| | 612 | 301 | | AddSubQuery(subQuery); |
| | 612 | 302 | | return; |
| | | 303 | | case string field: |
| | 465 | 304 | | AddStringField(field); |
| | 465 | 305 | | return; |
| | | 306 | | case IList list: |
| | 348 | 307 | | AddListItems(list); |
| | 345 | 308 | | return; |
| | | 309 | | default: |
| | 3 | 310 | | throw new InvalidOperationException("Unsupported Field type found, must be a `string` or `QueryBlock`"); |
| | | 311 | | } |
| | | 312 | | } |
| | | 313 | | |
| | | 314 | | private void AddSubQuery(QueryBlock subQuery) |
| | | 315 | | { |
| | 5220 | 316 | | foreach (var variable in subQuery.Variables) |
| | | 317 | | { |
| | 1998 | 318 | | _variables.Add(variable); |
| | | 319 | | } |
| | 612 | 320 | | _fieldsList.Add(subQuery); |
| | 612 | 321 | | } |
| | | 322 | | |
| | | 323 | | private void AddStringField(string field) |
| | | 324 | | { |
| | 483 | 325 | | 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). |
| | 447 | 330 | | var insertIndex = 0; |
| | 1377 | 331 | | foreach (var existing in _fieldsList) |
| | | 332 | | { |
| | 243 | 333 | | if (existing is not string existingField) continue; |
| | 162 | 334 | | if (string.Compare(existingField, field, StringComparison.OrdinalIgnoreCase) >= 0) break; |
| | 156 | 335 | | insertIndex++; |
| | | 336 | | } |
| | 447 | 337 | | _fieldsList.Insert(insertIndex, field); |
| | 447 | 338 | | } |
| | | 339 | | |
| | | 340 | | private void AddListItems(IList list) |
| | | 341 | | { |
| | 348 | 342 | | var sortedItems = list.Cast<object>() |
| | 316 | 343 | | .OrderBy(x => x switch |
| | 316 | 344 | | { |
| | 304 | 345 | | string s => s, |
| | 6 | 346 | | QueryBlock q => q.Name, |
| | 6 | 347 | | _ => x.ToString() |
| | 316 | 348 | | }) |
| | 348 | 349 | | .ToList(); |
| | | 350 | | |
| | 1641 | 351 | | foreach (var item in sortedItems) |
| | | 352 | | { |
| | 474 | 353 | | HandleAddField(item); |
| | | 354 | | } |
| | 345 | 355 | | } |
| | | 356 | | |
| | | 357 | | private void HandleAddVariable(Variable variable) |
| | | 358 | | { |
| | 1281 | 359 | | _variables.Add(variable); |
| | 1281 | 360 | | } |
| | | 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. |
| | 39264 | 373 | | if (CollidesByCase(key)) |
| | | 374 | | { |
| | 15 | 375 | | throw new ArgumentException( |
| | 15 | 376 | | $"An item with the same key has already been added. Colliding key: '{key}'.", |
| | 15 | 377 | | nameof(key)); |
| | | 378 | | } |
| | | 379 | | |
| | 39249 | 380 | | Helpers.ExtractVariablesFromValue(value, _variables); |
| | 39249 | 381 | | var sortedValue = Helpers.SortArgumentValue(value); |
| | 40023 | 382 | | if (_arguments.Count == 0) _firstArgumentKey = key; |
| | 39240 | 383 | | _arguments[key] = sortedValue!; // SortArgumentValue preserves non-null input |
| | 39240 | 384 | | } |
| | | 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 | | { |
| | 78741 | 396 | | if (!_arguments.ContainsKey(key)) return false; |
| | | 397 | | |
| | 15135 | 398 | | foreach (var storedKey in _arguments.Keys) |
| | | 399 | | { |
| | 7551 | 400 | | if (string.Equals(storedKey, key, StringComparison.Ordinal)) return false; |
| | | 401 | | } |
| | | 402 | | |
| | 24 | 403 | | return true; |
| | 15 | 404 | | } |
| | | 405 | | } |