| | | 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 field definition. |
| | | 9 | | /// </summary> |
| | | 10 | | [SuppressMessage("Minor Code Smell", "S2292:Trivial properties should be auto-implemented")] |
| | | 11 | | public sealed record FieldDefinition |
| | | 12 | | { |
| | | 13 | | // Fields |
| | | 14 | | internal FieldChildren? _children; |
| | | 15 | | internal Dictionary<string, InlineFragmentDefinition>? _fragments; |
| | | 16 | | internal string? _type; |
| | | 17 | | internal string? _alias; |
| | | 18 | | internal string _effectiveName; |
| | | 19 | | internal SortedDictionary<string, object?>? _arguments; |
| | | 20 | | internal Dictionary<string, object?>? _metadata; |
| | 126153 | 21 | | internal string Path { get; init; } = string.Empty; |
| | | 22 | | |
| | | 23 | | /// <summary> |
| | | 24 | | /// Cached result of "does this field's subtree contain any arguments?". |
| | | 25 | | /// Null = not yet computed. Reset to null whenever the subtree mutates. |
| | | 26 | | /// </summary> |
| | | 27 | | internal bool? _subtreeHasAnyArguments; |
| | | 28 | | |
| | | 29 | | private bool? _isArray; |
| | | 30 | | private bool? _isNullable; |
| | | 31 | | |
| | | 32 | | /// <summary> |
| | | 33 | | /// Creates a field definition with a name and optional type and alias. |
| | | 34 | | /// <paramref name="type"/> defaults to <see cref="Constants.DefaultFieldType"/> when null. |
| | | 35 | | /// </summary> |
| | | 36 | | /// <param name="name">Field name as it appears in the rendered GraphQL.</param> |
| | | 37 | | /// <param name="type">Optional type-annotation metadata (rendered nowhere; consumed by tooling).</param> |
| | | 38 | | /// <param name="alias">Optional response-side alias.</param> |
| | | 39 | | public FieldDefinition(string name, string? type = null, string? alias = null) |
| | 27345 | 40 | | : this(name, type ?? Constants.DefaultFieldType, alias, null) |
| | | 41 | | { |
| | 27345 | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Creates a field definition with a pre-sorted argument dictionary and an optional |
| | | 46 | | /// child-field collection. Used internally on the hot path to avoid re-sorting. |
| | | 47 | | /// </summary> |
| | | 48 | | /// <param name="name">Field name.</param> |
| | | 49 | | /// <param name="type">Type-annotation metadata.</param> |
| | | 50 | | /// <param name="alias">Optional response-side alias.</param> |
| | | 51 | | /// <param name="sortedArguments">Pre-sorted argument map (case-insensitive); null/empty stores null.</param> |
| | | 52 | | /// <param name="fields">Optional initial children; null/empty leaves the field as a leaf.</param> |
| | 72192 | 53 | | public FieldDefinition(string name, string type, string? alias, SortedDictionary<string, object?>? sortedArguments = |
| | | 54 | | { |
| | 72192 | 55 | | Name = name; |
| | 72192 | 56 | | _alias = alias; |
| | 72192 | 57 | | _type = type; |
| | 72192 | 58 | | _arguments = sortedArguments?.Count > 0 ? sortedArguments : null; |
| | 72192 | 59 | | _children = AsChildren(fields); |
| | 72192 | 60 | | _effectiveName = !string.IsNullOrEmpty(_alias) ? _alias : Name; |
| | 72192 | 61 | | } |
| | | 62 | | |
| | | 63 | | /// <summary> |
| | | 64 | | /// Creates a field definition from an unsorted argument dictionary (e.g. one produced by |
| | | 65 | | /// callers using collection initializers). The dictionary is copied into a case-insensitive |
| | | 66 | | /// sorted store so output ordering is stable. |
| | | 67 | | /// </summary> |
| | | 68 | | /// <param name="name">Field name.</param> |
| | | 69 | | /// <param name="type">Type-annotation metadata.</param> |
| | | 70 | | /// <param name="alias">Optional response-side alias.</param> |
| | | 71 | | /// <param name="arguments">Unsorted argument map; null/empty stores null.</param> |
| | | 72 | | /// <param name="fields">Optional initial children; null/empty leaves the field as a leaf.</param> |
| | 213 | 73 | | public FieldDefinition(string name, string type, string? alias, IDictionary<string, object?>? arguments, Dictionary< |
| | | 74 | | { |
| | 213 | 75 | | Name = name; |
| | 213 | 76 | | _alias = alias; |
| | 213 | 77 | | _type = type; |
| | 213 | 78 | | _arguments = ToSortedArguments(arguments); |
| | 213 | 79 | | _children = AsChildren(fields); |
| | 213 | 80 | | _effectiveName = !string.IsNullOrEmpty(_alias) ? _alias : Name; |
| | 213 | 81 | | } |
| | | 82 | | |
| | | 83 | | private static SortedDictionary<string, object?>? ToSortedArguments(IDictionary<string, object?>? arguments) |
| | | 84 | | { |
| | 216 | 85 | | if (arguments is null) return null; |
| | 309 | 86 | | if (arguments.Count == 0) return null; |
| | 111 | 87 | | var sorted = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase); |
| | 1608 | 88 | | foreach (var kvp in arguments) sorted[kvp.Key] = kvp.Value; |
| | 111 | 89 | | return sorted; |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | private static FieldChildren? AsChildren(Dictionary<string, FieldDefinition>? fields) |
| | | 93 | | { |
| | 144702 | 94 | | if (fields is null || fields.Count == 0) return null; |
| | 108 | 95 | | var children = new FieldChildren(); |
| | 720 | 96 | | foreach (var kvp in fields) children.Append(kvp.Value); |
| | 108 | 97 | | return children; |
| | | 98 | | } |
| | | 99 | | |
| | | 100 | | // Properties |
| | | 101 | | /// <summary> |
| | | 102 | | /// The name of the field. |
| | | 103 | | /// </summary> |
| | | 104 | | [JsonPropertyName("name")] |
| | 19137837 | 105 | | public string Name { get; init; } |
| | | 106 | | |
| | | 107 | | /// <summary> |
| | | 108 | | /// The type of the field. Defaults to <see cref="Constants.DefaultFieldType"/> if not specified. |
| | | 109 | | /// This is used to define the data type of the field, such as "String", "Int", "Boolean", etc. |
| | | 110 | | /// </summary> |
| | | 111 | | [JsonPropertyName("type")] |
| | | 112 | | public string? Type |
| | | 113 | | { |
| | 1176 | 114 | | get => _type; |
| | 318 | 115 | | init => _type = value; |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | /// <summary> |
| | | 119 | | /// The alias of the field, if any. This is used to provide a more readable or meaningful name for the field in quer |
| | | 120 | | /// If not specified, the field will use its original name. |
| | | 121 | | /// </summary> |
| | | 122 | | [JsonPropertyName("alias")] |
| | | 123 | | public string? Alias |
| | | 124 | | { |
| | 118272 | 125 | | get => _alias; |
| | | 126 | | init |
| | | 127 | | { |
| | 150 | 128 | | _alias = value; |
| | 150 | 129 | | _effectiveName = !string.IsNullOrEmpty(value) ? value : Name; |
| | 150 | 130 | | } |
| | | 131 | | } |
| | | 132 | | |
| | 3 | 133 | | private static readonly IReadOnlyDictionary<string, FieldDefinition> EmptyReadOnlyFields |
| | 3 | 134 | | = new FieldChildren(); |
| | | 135 | | |
| | | 136 | | /// <summary> |
| | | 137 | | /// The collection of fields related to <see cref="FieldDefinition"/>. |
| | | 138 | | /// </summary> |
| | | 139 | | [JsonPropertyName("fields")] |
| | | 140 | | public IReadOnlyDictionary<string, FieldDefinition> Fields |
| | 2829 | 141 | | => _children ?? EmptyReadOnlyFields; |
| | | 142 | | |
| | 3 | 143 | | private static readonly IReadOnlyDictionary<string, InlineFragmentDefinition> EmptyReadOnlyFragments |
| | 3 | 144 | | = new Dictionary<string, InlineFragmentDefinition>(); |
| | | 145 | | |
| | | 146 | | /// <summary> |
| | | 147 | | /// Inline fragments attached to this field, keyed by the fragment's GraphQL type name |
| | | 148 | | /// (case-sensitive). Each fragment renders as <c>... on TypeName { … }</c> after the |
| | | 149 | | /// field's plain children, alphabetical by type name. |
| | | 150 | | /// </summary> |
| | | 151 | | /// <remarks> |
| | | 152 | | /// Used when the field's schema return type is a union or interface and the caller needs |
| | | 153 | | /// type narrowing. See <see cref="NGql.Core.Builders.FieldBuilder.OnType(string, Action{NGql.Core.Builders.FieldBui |
| | | 154 | | /// for the builder-side API. Multiple <c>OnType</c> calls for the same type name merge |
| | | 155 | | /// into one fragment definition. |
| | | 156 | | /// </remarks> |
| | | 157 | | [JsonPropertyName("inlineFragments")] |
| | | 158 | | public IReadOnlyDictionary<string, InlineFragmentDefinition> InlineFragments |
| | 21 | 159 | | => (IReadOnlyDictionary<string, InlineFragmentDefinition>?)_fragments ?? EmptyReadOnlyFragments; |
| | | 160 | | |
| | 3 | 161 | | private static readonly IReadOnlyDictionary<string, object?> EmptyReadOnlyArguments |
| | 3 | 162 | | = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase); |
| | | 163 | | |
| | | 164 | | /// <summary> |
| | | 165 | | /// Sorted, case-insensitive view of the field's GraphQL arguments. Returns an empty |
| | | 166 | | /// dictionary (never null) when the field has no arguments. |
| | | 167 | | /// </summary> |
| | | 168 | | [JsonPropertyName("arguments")] |
| | | 169 | | public IReadOnlyDictionary<string, object?> Arguments |
| | 333 | 170 | | => _arguments ?? EmptyReadOnlyArguments; |
| | | 171 | | |
| | | 172 | | /// <summary> |
| | | 173 | | /// Metadata associated with the field definition. |
| | | 174 | | /// This can include additional information such as descriptions, tags, or any other relevant data. |
| | | 175 | | /// |
| | | 176 | | /// Not used during query text generation but can be useful for documentation or introspection purposes. |
| | | 177 | | /// </summary> |
| | | 178 | | [JsonPropertyName("metadata")] |
| | | 179 | | public Dictionary<string, object?> Metadata |
| | | 180 | | { |
| | 1314 | 181 | | get => _metadata ??= []; |
| | 51 | 182 | | set => _metadata = value; |
| | | 183 | | } |
| | | 184 | | |
| | | 185 | | /// <summary> |
| | | 186 | | /// Gets a value indicating whether this field type is an array. |
| | | 187 | | /// </summary> |
| | | 188 | | [JsonIgnore] |
| | 174 | 189 | | public bool IsArray => _isArray ??= _type.IsArrayType(); |
| | | 190 | | |
| | | 191 | | /// <summary> |
| | | 192 | | /// Gets a value indicating whether this field type is nullable. |
| | | 193 | | /// </summary> |
| | | 194 | | [JsonIgnore] |
| | 144 | 195 | | public bool IsNullable => _isNullable ??= _type.IsNullableType(); |
| | | 196 | | |
| | | 197 | | /// <summary> |
| | | 198 | | /// Gets a value indicating whether this field has child fields. |
| | | 199 | | /// </summary> |
| | | 200 | | [JsonIgnore] |
| | 294 | 201 | | public bool HasFields => _children is { Count: > 0 }; |
| | | 202 | | |
| | | 203 | | /// <summary> |
| | | 204 | | /// Gets a value indicating whether this field has any inline fragments. |
| | | 205 | | /// </summary> |
| | | 206 | | [JsonIgnore] |
| | 6 | 207 | | public bool HasInlineFragments => _fragments is { Count: > 0 }; |
| | | 208 | | |
| | | 209 | | /// <summary> |
| | | 210 | | /// Returns the existing inline fragment for <paramref name="typeName"/>, or appends a new |
| | | 211 | | /// one. Used by the builder to merge multiple <c>OnType("Repository", …)</c> calls on the |
| | | 212 | | /// same parent into a single fragment definition. |
| | | 213 | | /// </summary> |
| | | 214 | | internal InlineFragmentDefinition GetOrAddInlineFragment(string typeName) |
| | | 215 | | { |
| | 51 | 216 | | _fragments ??= new Dictionary<string, InlineFragmentDefinition>(StringComparer.Ordinal); |
| | 51 | 217 | | if (!_fragments.TryGetValue(typeName, out var fragment)) |
| | | 218 | | { |
| | 48 | 219 | | fragment = new InlineFragmentDefinition(typeName); |
| | 48 | 220 | | _fragments[typeName] = fragment; |
| | | 221 | | } |
| | 51 | 222 | | return fragment; |
| | | 223 | | } |
| | | 224 | | |
| | | 225 | | /// <summary> |
| | | 226 | | /// When <c>true</c>, the merger treats this field as opaque — it will not be merged with |
| | | 227 | | /// other fields of the same path; instead it gets aliased (<c>name_1</c>, <c>name_2</c>, …) |
| | | 228 | | /// during <see cref="NGql.Core.Builders.QueryBuilder.Include(NGql.Core.Builders.QueryBuilder)"/>. |
| | | 229 | | /// The setter is internal; the flag is set by <see cref="MergingStrategy.NeverMerge"/>. |
| | | 230 | | /// </summary> |
| | | 231 | | [JsonIgnore] |
| | 3336 | 232 | | public bool IsNeverMerge { get; internal set; } |
| | | 233 | | |
| | | 234 | | // Methods |
| | | 235 | | public bool Equals(FieldDefinition? other) |
| | | 236 | | { |
| | 24789 | 237 | | if (other is null) return false; |
| | 42 | 238 | | if (ReferenceEquals(this, other)) return true; |
| | 12 | 239 | | return string.Equals(Name, other.Name, StringComparison.Ordinal) |
| | 12 | 240 | | && string.Equals(Path, other.Path, StringComparison.Ordinal) |
| | 12 | 241 | | && string.Equals(_type, other._type, StringComparison.OrdinalIgnoreCase) |
| | 12 | 242 | | && string.Equals(_alias, other._alias, StringComparison.OrdinalIgnoreCase) |
| | 12 | 243 | | && IsNeverMerge == other.IsNeverMerge; |
| | | 244 | | } |
| | | 245 | | |
| | | 246 | | // FieldDefinition holds mutable internal state by design (in-place merging in QueryMerger). |
| | | 247 | | // The hash captures identity at evaluation time; callers do not stash hashes across mutations. |
| | | 248 | | #pragma warning disable S2328 |
| | | 249 | | public override int GetHashCode() |
| | 75 | 250 | | => HashCode.Combine(Name, Path, _type?.ToLowerInvariant(), _alias?.ToLowerInvariant(), IsNeverMerge); |
| | | 251 | | #pragma warning restore S2328 |
| | | 252 | | |
| | | 253 | | public override string ToString() |
| | | 254 | | { |
| | 18 | 255 | | if (string.IsNullOrWhiteSpace(Type)) |
| | | 256 | | { |
| | 6 | 257 | | return string.IsNullOrWhiteSpace(Alias) ? Name : $"{Alias}:{Name}"; |
| | | 258 | | } |
| | | 259 | | |
| | 12 | 260 | | return string.IsNullOrWhiteSpace(Alias) ? $"{Type} {Name}" : $"{Type} {Alias}:{Name}"; |
| | | 261 | | } |
| | | 262 | | } |