| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Text.Json.Serialization; |
| | | 3 | | using NGql.Core.Extensions; |
| | | 4 | | |
| | | 5 | | namespace NGql.Core.Abstractions; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Represents a GraphQL directive attached to a field — rendered as |
| | | 9 | | /// <c>@Name</c> optionally followed by <c>(argName:value, …)</c> after the field's name and |
| | | 10 | | /// arguments and before its selection set. Examples: <c>@include(if:$show)</c>, |
| | | 11 | | /// <c>@skip(if:$hide)</c>, <c>@deprecated</c>. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <remarks> |
| | | 14 | | /// Directives are immutable value objects. A no-argument directive (<see cref="Arguments"/> |
| | | 15 | | /// is <c>null</c>) carries no dictionary, so the common <c>@deprecated</c>-style case allocates |
| | | 16 | | /// nothing beyond the record itself. Argument values reuse the same formatting as field |
| | | 17 | | /// arguments, so <c>if:$x</c> and complex values render identically. |
| | | 18 | | /// </remarks> |
| | | 19 | | [SuppressMessage("Minor Code Smell", "S2292:Trivial properties should be auto-implemented")] |
| | | 20 | | public sealed record FieldDirective |
| | | 21 | | { |
| | | 22 | | /// <summary> |
| | | 23 | | /// Creates a directive with the given name and optional arguments. |
| | | 24 | | /// </summary> |
| | | 25 | | /// <param name="name">The directive name WITHOUT the leading <c>@</c> (e.g. <c>"include"</c>). |
| | | 26 | | /// Rendered with the <c>@</c> prefix.</param> |
| | | 27 | | /// <param name="arguments">Optional directive arguments; <c>null</c> for a no-argument directive. |
| | | 28 | | /// Argument keys are normalized through the same case-insensitive pipeline as field arguments: |
| | | 29 | | /// keys that collide under <see cref="StringComparer.OrdinalIgnoreCase"/> throw (rather than |
| | | 30 | | /// silently last-win), and the resulting dictionary is sorted for deterministic rendering.</param> |
| | | 31 | | /// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or whitespace, |
| | | 32 | | /// or when <paramref name="arguments"/> contains keys colliding under OrdinalIgnoreCase.</exception> |
| | 9942 | 33 | | public FieldDirective(string name, IReadOnlyDictionary<string, object?>? arguments = null) |
| | | 34 | | { |
| | 9942 | 35 | | if (string.IsNullOrWhiteSpace(name)) |
| | | 36 | | { |
| | 9 | 37 | | throw new ArgumentException("Directive name cannot be null or whitespace.", nameof(name)); |
| | | 38 | | } |
| | | 39 | | |
| | 9933 | 40 | | Name = name; |
| | 9933 | 41 | | Arguments = NormalizeArguments(arguments); |
| | 9930 | 42 | | } |
| | | 43 | | |
| | | 44 | | // Route directive arguments through the same normalization field arguments use |
| | | 45 | | // (Helpers.SortArgumentValue over the dictionary): keys colliding under OrdinalIgnoreCase |
| | | 46 | | // fail fast instead of silently last-winning, nested values are normalized identically, and |
| | | 47 | | // the result is a sorted dictionary so rendering is deterministic — matching field-argument |
| | | 48 | | // behavior exactly. |
| | | 49 | | private static IReadOnlyDictionary<string, object?>? NormalizeArguments(IReadOnlyDictionary<string, object?>? argume |
| | | 50 | | { |
| | 9933 | 51 | | if (arguments is not { Count: > 0 }) |
| | | 52 | | { |
| | 9651 | 53 | | return null; |
| | | 54 | | } |
| | | 55 | | |
| | 282 | 56 | | var source = arguments as IDictionary<string, object?> |
| | 282 | 57 | | ?? new Dictionary<string, object?>(arguments); |
| | 282 | 58 | | return (IReadOnlyDictionary<string, object?>)Helpers.SortArgumentValue(source)!; |
| | | 59 | | } |
| | | 60 | | |
| | | 61 | | /// <summary> |
| | | 62 | | /// The directive name WITHOUT the leading <c>@</c> (stored as <c>"include"</c>, rendered as |
| | | 63 | | /// <c>@include</c>). |
| | | 64 | | /// </summary> |
| | | 65 | | [JsonPropertyName("name")] |
| | 20649 | 66 | | public string Name { get; init; } |
| | | 67 | | |
| | | 68 | | /// <summary> |
| | | 69 | | /// The directive's arguments, or <c>null</c> when the directive takes no arguments. Argument |
| | | 70 | | /// values render with the same formatter used for field arguments. |
| | | 71 | | /// </summary> |
| | | 72 | | [JsonPropertyName("arguments")] |
| | 10398 | 73 | | public IReadOnlyDictionary<string, object?>? Arguments { get; init; } |
| | | 74 | | |
| | | 75 | | /// <summary> |
| | | 76 | | /// Structural equality for dedup on the merge path. The compiler-generated record |
| | | 77 | | /// <see cref="object.Equals(object?)"/> compares <see cref="Arguments"/> by REFERENCE (an |
| | | 78 | | /// <see cref="IReadOnlyDictionary{TKey, TValue}"/> has no value equality), so two independently |
| | | 79 | | /// built <c>@include(if:$x)</c> instances are never record-equal. This compares by |
| | | 80 | | /// <see cref="Name"/> plus argument entries (case-insensitive keys, value-equal values — |
| | | 81 | | /// <see cref="Variable"/> equality is value-based), which is what "the same directive" means |
| | | 82 | | /// for merge deduplication. |
| | | 83 | | /// </summary> |
| | | 84 | | internal bool IsStructurallyEqualTo(FieldDirective other) |
| | | 85 | | { |
| | 81 | 86 | | if (!string.Equals(Name, other.Name, StringComparison.Ordinal)) |
| | | 87 | | { |
| | 3 | 88 | | return false; |
| | | 89 | | } |
| | | 90 | | |
| | 78 | 91 | | var a = Arguments; |
| | 78 | 92 | | var b = other.Arguments; |
| | 78 | 93 | | if (a is null || a.Count == 0) |
| | | 94 | | { |
| | 9 | 95 | | return b is null || b.Count == 0; |
| | | 96 | | } |
| | 69 | 97 | | if (b is null || a.Count != b.Count) |
| | | 98 | | { |
| | 6 | 99 | | return false; |
| | | 100 | | } |
| | | 101 | | |
| | 228 | 102 | | foreach (var (key, value) in a) |
| | | 103 | | { |
| | 63 | 104 | | if (!b.TryGetValue(key, out var otherValue) || !Equals(value, otherValue)) |
| | | 105 | | { |
| | 24 | 106 | | return false; |
| | | 107 | | } |
| | | 108 | | } |
| | 39 | 109 | | return true; |
| | 24 | 110 | | } |
| | | 111 | | } |