| | | 1 | | using System.Collections; |
| | | 2 | | using System.Reflection; |
| | | 3 | | using NGql.Core.Abstractions; |
| | | 4 | | |
| | | 5 | | namespace NGql.Core.Extensions; |
| | | 6 | | |
| | | 7 | | internal static class QueryBlockObjectExtensions |
| | | 8 | | { |
| | | 9 | | /// <summary> |
| | | 10 | | /// Adds the given type properties into <see cref="QueryBlock.FieldsList"/> part of the query. |
| | | 11 | | /// </summary> |
| | | 12 | | /// <param name="block">The query block</param> |
| | | 13 | | /// <param name="path">The path to use</param> |
| | | 14 | | /// <param name="name">The name to use</param> |
| | | 15 | | /// <param name="alias">The alias to use</param> |
| | | 16 | | /// <typeparam name="T">The type to include</typeparam> |
| | | 17 | | public static void IncludeAtPath<T>(this QueryBlock block, string path, string name, string? alias = null) |
| | | 18 | | { |
| | | 19 | | // Use Span to avoid string allocations during path parsing |
| | 18 | 20 | | var pathSpan = path.AsSpan(); |
| | 18 | 21 | | var currentBlock = block; |
| | | 22 | | |
| | 51 | 23 | | while (!pathSpan.IsEmpty) |
| | | 24 | | { |
| | 33 | 25 | | var dotIndex = pathSpan.IndexOf('.'); |
| | | 26 | | ReadOnlySpan<char> currentSegment; |
| | | 27 | | |
| | 33 | 28 | | if (dotIndex == -1) |
| | | 29 | | { |
| | | 30 | | // Last segment |
| | 12 | 31 | | currentSegment = pathSpan; |
| | 12 | 32 | | pathSpan = ReadOnlySpan<char>.Empty; |
| | | 33 | | } |
| | | 34 | | else |
| | | 35 | | { |
| | | 36 | | // Extract current segment and advance |
| | 21 | 37 | | currentSegment = pathSpan[..dotIndex]; |
| | 21 | 38 | | pathSpan = pathSpan[(dotIndex + 1)..]; |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | // Skip empty segments |
| | 33 | 42 | | if (currentSegment.IsEmpty || currentSegment.IsWhiteSpace()) |
| | | 43 | | { |
| | | 44 | | continue; |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | // Convert to string only when creating the QueryBlock |
| | 27 | 48 | | var segmentString = currentSegment.ToString(); |
| | 27 | 49 | | var subQuery = new QueryBlock(segmentString); |
| | 27 | 50 | | currentBlock.AddField(subQuery); |
| | 27 | 51 | | currentBlock = subQuery; |
| | | 52 | | } |
| | | 53 | | |
| | 18 | 54 | | currentBlock.Include<T>(name, alias); |
| | 18 | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <summary> |
| | | 58 | | /// Adds the given type properties into <see cref="QueryBlock.FieldsList"/> part of the query. |
| | | 59 | | /// </summary> |
| | | 60 | | /// <param name="block">The query block</param> |
| | | 61 | | /// <param name="name">The name to use</param> |
| | | 62 | | /// <param name="alias"></param> |
| | | 63 | | /// <returns>Query</returns> |
| | | 64 | | public static void Include<T>(this QueryBlock block, string name, string? alias = null) |
| | | 65 | | { |
| | 36 | 66 | | var properties = GetSelectableProperties(typeof(T)); |
| | | 67 | | |
| | 36 | 68 | | var subQuery = new QueryBlock(name, alias: alias); |
| | | 69 | | |
| | 36 | 70 | | HandleProperties(subQuery, null, properties); |
| | | 71 | | |
| | 36 | 72 | | block.AddField(subQuery); |
| | 36 | 73 | | } |
| | | 74 | | |
| | | 75 | | /// <summary> |
| | | 76 | | /// Adds the given object properties into <see cref="QueryBlock.FieldsList"/> part of the query. |
| | | 77 | | /// </summary> |
| | | 78 | | /// <param name="block">The query block</param> |
| | | 79 | | /// <param name="obj">A value</param> |
| | | 80 | | /// <returns>Query</returns> |
| | | 81 | | public static void Include(this QueryBlock block, object obj) |
| | | 82 | | { |
| | 21 | 83 | | var type = obj.GetType(); |
| | 21 | 84 | | var properties = GetSelectableProperties(type); |
| | | 85 | | |
| | 21 | 86 | | HandleProperties(block, obj, properties); |
| | 21 | 87 | | } |
| | | 88 | | |
| | | 89 | | /// <summary> |
| | | 90 | | /// Returns the properties of <paramref name="type"/> that map to GraphQL selection fields: |
| | | 91 | | /// indexer properties are excluded (they are not addressable as fields and would throw when |
| | | 92 | | /// read without index arguments), and <c>new</c>-shadowed properties are collapsed to their |
| | | 93 | | /// most-derived declaration so a response name is never emitted twice. Ordering matches the |
| | | 94 | | /// original alphabetical (case-insensitive) sort so normal types render unchanged. |
| | | 95 | | /// </summary> |
| | | 96 | | private static PropertyInfo[] GetSelectableProperties(Type type) |
| | | 97 | | { |
| | | 98 | | // A name may appear more than once when a derived type re-declares a base property with |
| | | 99 | | // `new`; GetProperties() returns every declaration and does not guarantee derived-first |
| | | 100 | | // ordering, so keep the declaration whose DeclaringType is furthest down the hierarchy. |
| | 57 | 101 | | var byName = new Dictionary<string, PropertyInfo>(StringComparer.Ordinal); |
| | | 102 | | |
| | 396 | 103 | | foreach (var property in type.GetProperties()) |
| | | 104 | | { |
| | 141 | 105 | | if (property.GetIndexParameters().Length > 0) |
| | | 106 | | { |
| | | 107 | | continue; |
| | | 108 | | } |
| | | 109 | | |
| | 135 | 110 | | if (!byName.TryGetValue(property.Name, out var existing) || IsMoreDerivedThan(property, existing)) |
| | | 111 | | { |
| | 132 | 112 | | byName[property.Name] = property; |
| | | 113 | | } |
| | | 114 | | } |
| | | 115 | | |
| | 57 | 116 | | return byName.Values |
| | 124 | 117 | | .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) |
| | 57 | 118 | | .ToArray(); |
| | | 119 | | } |
| | | 120 | | |
| | | 121 | | /// <summary> |
| | | 122 | | /// True when <paramref name="candidate"/> is declared further down the hierarchy than |
| | | 123 | | /// <paramref name="current"/>. <c>GetProperties()</c> happens to return most-derived |
| | | 124 | | /// declarations first on the runtimes NGql targets, but that ordering is not contractual, so |
| | | 125 | | /// <see cref="GetSelectableProperties"/> compares rather than trusting position. Internal so |
| | | 126 | | /// both orderings stay directly testable. Both arguments come from |
| | | 127 | | /// <c>Type.GetProperties()</c>, whose results always carry a non-null DeclaringType. |
| | | 128 | | /// </summary> |
| | | 129 | | internal static bool IsMoreDerivedThan(PropertyInfo candidate, PropertyInfo current) |
| | 12 | 130 | | => candidate.DeclaringType != current.DeclaringType |
| | 12 | 131 | | && current.DeclaringType!.IsAssignableFrom(candidate.DeclaringType); |
| | | 132 | | |
| | | 133 | | private static void HandleProperties(QueryBlock block, object? obj, PropertyInfo[] properties) |
| | | 134 | | { |
| | 378 | 135 | | foreach (var property in properties) |
| | | 136 | | { |
| | 132 | 137 | | HandleProperty(block, obj, property); |
| | | 138 | | } |
| | 57 | 139 | | } |
| | | 140 | | |
| | | 141 | | private static void HandleProperty(QueryBlock block, object? obj, PropertyInfo property) |
| | | 142 | | { |
| | 132 | 143 | | var value = obj is null ? null : property.GetValue(obj); |
| | 132 | 144 | | var alias = property.GetAlias(); |
| | | 145 | | |
| | 132 | 146 | | if (value is IDictionary dict) |
| | | 147 | | { |
| | 3 | 148 | | HandleDictionary(block, property.Name, alias, dict); |
| | 3 | 149 | | return; |
| | | 150 | | } |
| | 129 | 151 | | if (value is not null && !IsSimpleType(value.GetType())) |
| | | 152 | | { |
| | 6 | 153 | | var subQuery = new QueryBlock(property.Name, alias: alias); |
| | 6 | 154 | | subQuery.Include(value); |
| | 6 | 155 | | block.AddField(subQuery); |
| | 6 | 156 | | return; |
| | | 157 | | } |
| | 123 | 158 | | AddSimpleProperty(block, property.Name, alias); |
| | 123 | 159 | | } |
| | | 160 | | |
| | | 161 | | private static void AddSimpleProperty(QueryBlock block, string propertyName, string? alias) |
| | | 162 | | { |
| | 123 | 163 | | if (alias == propertyName) |
| | | 164 | | { |
| | 27 | 165 | | block.AddField(propertyName); |
| | 27 | 166 | | return; |
| | | 167 | | } |
| | 96 | 168 | | block.AddField(alias is null |
| | 96 | 169 | | ? new QueryBlock(propertyName) { IsEmpty = true } |
| | 96 | 170 | | : new QueryBlock(alias, alias: propertyName) { IsEmpty = true }); |
| | 96 | 171 | | } |
| | | 172 | | |
| | | 173 | | private static void HandleDictionary(QueryBlock block, string name, string? alias, IDictionary dict) |
| | | 174 | | { |
| | 6 | 175 | | var subQuery = new QueryBlock(name, alias: alias); |
| | 6 | 176 | | var sortedKeys = dict.Keys.Cast<object>() |
| | 18 | 177 | | .OrderBy(k => k.ToString(), StringComparer.OrdinalIgnoreCase); |
| | | 178 | | |
| | 36 | 179 | | foreach (var key in sortedKeys) |
| | | 180 | | { |
| | 12 | 181 | | if (dict[key] is IDictionary nestedDict) |
| | | 182 | | { |
| | 3 | 183 | | HandleDictionary(subQuery, key.ToString()!, null, nestedDict); |
| | | 184 | | } |
| | | 185 | | else |
| | | 186 | | { |
| | 9 | 187 | | subQuery.AddField(key.ToString()!); |
| | | 188 | | } |
| | | 189 | | } |
| | | 190 | | |
| | 6 | 191 | | block.AddField(subQuery); |
| | 6 | 192 | | } |
| | | 193 | | |
| | 3 | 194 | | private static readonly HashSet<Type> SimpleTypes = |
| | 3 | 195 | | [ |
| | 3 | 196 | | typeof(string), |
| | 3 | 197 | | typeof(decimal), |
| | 3 | 198 | | typeof(DateTime), |
| | 3 | 199 | | typeof(DateTimeOffset), |
| | 3 | 200 | | typeof(TimeSpan), |
| | 3 | 201 | | typeof(Guid) |
| | 3 | 202 | | ]; |
| | | 203 | | |
| | | 204 | | private static bool IsSimpleType(Type type) |
| | 33 | 205 | | => type.IsPrimitive || |
| | 33 | 206 | | SimpleTypes.Contains(type) || |
| | 33 | 207 | | Convert.GetTypeCode(type) != TypeCode.Object; |
| | | 208 | | } |