| | | 1 | | using System.Runtime.CompilerServices; |
| | | 2 | | using NGql.Core.Abstractions; |
| | | 3 | | using NGql.Core.Exceptions; |
| | | 4 | | using NGql.Core.Features; |
| | | 5 | | |
| | | 6 | | namespace NGql.Core.Extensions; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Extension methods and utilities for FieldDefinition operations. |
| | | 10 | | /// </summary> |
| | | 11 | | internal static class FieldDefinitionExtensions |
| | | 12 | | { |
| | | 13 | | /// <summary> |
| | | 14 | | /// Determines if two fields can be merged based on their structure, arguments, and |
| | | 15 | | /// conditional-include state (<c>@include</c>/<c>@skip</c> — see <see cref="AreConditionalDirectivesEqual"/>). |
| | | 16 | | /// The recursion is bounded by the field tree's depth — the public API does not allow |
| | | 17 | | /// constructing cyclic trees (FieldDefinition.Fields is get-only, _children is internal), |
| | | 18 | | /// so the recursion always terminates. A library bug introducing a cycle would surface as |
| | | 19 | | /// a StackOverflowException, which is louder and more actionable than a swallowed throw. |
| | | 20 | | /// </summary> |
| | | 21 | | internal static bool CanMergeFields(FieldDefinition existingField, FieldDefinition incomingField) |
| | | 22 | | { |
| | 1035 | 23 | | if (!Helpers.AreArgumentsEqual(existingField._arguments, incomingField._arguments)) |
| | 18 | 24 | | return false; |
| | 1017 | 25 | | if (!AreConditionalDirectivesEqual(existingField, incomingField)) |
| | 12 | 26 | | return false; |
| | 1005 | 27 | | return AreNestedFieldsCompatible(existingField, incomingField); |
| | | 28 | | } |
| | | 29 | | |
| | | 30 | | /// <summary> |
| | | 31 | | /// Compares the <c>@include</c>/<c>@skip</c> conditional state of two fields — the part of a |
| | | 32 | | /// field's directive list that is MERGE-IDENTITY-RELEVANT. Two fragments requesting the same |
| | | 33 | | /// field path under different runtime conditions (different <c>if</c> variables, or one |
| | | 34 | | /// conditional and one not) are not the same field for merge purposes: collapsing them into one |
| | | 35 | | /// node would apply one side's condition to content the other side asked for unconditionally. |
| | | 36 | | /// Custom directives (added via the generic <see cref="Builders.FieldBuilder.Directive(string, System.Collections.G |
| | | 37 | | /// overload, excluding the reserved <c>include</c>/<c>skip</c> names) are intentionally NOT part |
| | | 38 | | /// of this comparison — they stay repeatable/mergeable exactly as before, matching the issue's |
| | | 39 | | /// scope (only <c>@include</c>/<c>@skip</c> are constrained here). |
| | | 40 | | /// </summary> |
| | | 41 | | private static bool AreConditionalDirectivesEqual(FieldDefinition existingField, FieldDefinition incomingField) |
| | | 42 | | { |
| | 3723 | 43 | | var existingInclude = FindConditional(existingField._directives, "include"); |
| | 3723 | 44 | | var incomingInclude = FindConditional(incomingField._directives, "include"); |
| | 3732 | 45 | | if (!AreDirectivesStructurallyEqual(existingInclude, incomingInclude)) return false; |
| | | 46 | | |
| | 3714 | 47 | | var existingSkip = FindConditional(existingField._directives, "skip"); |
| | 3714 | 48 | | var incomingSkip = FindConditional(incomingField._directives, "skip"); |
| | 3714 | 49 | | return AreDirectivesStructurallyEqual(existingSkip, incomingSkip); |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | [System.Diagnostics.CodeAnalysis.SuppressMessage( |
| | | 53 | | "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method", |
| | | 54 | | Justification = "The plain loop short-circuits on first match without allocating an enumerator on the merge-iden |
| | | 55 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 56 | | private static FieldDirective? FindConditional(List<FieldDirective>? directives, string name) |
| | | 57 | | { |
| | 29955 | 58 | | if (directives is not { Count: > 0 }) return null; |
| | 1254 | 59 | | foreach (var directive in directives) |
| | | 60 | | { |
| | 591 | 61 | | if (string.Equals(directive.Name, name, StringComparison.Ordinal)) return directive; |
| | | 62 | | } |
| | 147 | 63 | | return null; |
| | 222 | 64 | | } |
| | | 65 | | |
| | | 66 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 67 | | private static bool AreDirectivesStructurallyEqual(FieldDirective? a, FieldDirective? b) |
| | | 68 | | { |
| | 14853 | 69 | | if (a is null || b is null) return a is null && b is null; |
| | 21 | 70 | | return a.IsStructurallyEqualTo(b); |
| | | 71 | | } |
| | | 72 | | |
| | | 73 | | /// <summary> |
| | | 74 | | /// Whether <paramref name="field"/> carries an <c>@include</c> or <c>@skip</c> directive on its |
| | | 75 | | /// own node. Folded into <see cref="HasAnyArguments"/>/<see cref="SubtreeHasAnyArguments"/> so a |
| | | 76 | | /// child that differs ONLY by conditional-include state (no arguments anywhere) is still treated |
| | | 77 | | /// as merge-identity-significant — otherwise <see cref="IsIncomingChildCompatible"/>/ |
| | | 78 | | /// <see cref="IsExistingExtraCompatible"/>'s argument-free early-out would silently accept a |
| | | 79 | | /// mismatched condition as "absent, therefore compatible". |
| | | 80 | | /// </summary> |
| | | 81 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 82 | | private static bool HasConditionalDirectives(FieldDefinition field) |
| | 9555 | 83 | | => field._directives is { Count: > 0 } directives |
| | 9555 | 84 | | && (FindConditional(directives, "include") is not null || FindConditional(directives, "skip") is not null); |
| | | 85 | | |
| | | 86 | | private static bool AreNestedFieldsCompatible(FieldDefinition existingField, FieldDefinition incomingField) |
| | 3711 | 87 | | => IncomingChildrenCompatible(existingField._children, incomingField._children) |
| | 3711 | 88 | | && ExistingExtrasCompatible(existingField, incomingField._children); |
| | | 89 | | |
| | | 90 | | private static bool IncomingChildrenCompatible(FieldChildren? existingChildren, FieldChildren? incomingChildren) |
| | | 91 | | { |
| | 4521 | 92 | | if (incomingChildren is not { Count: > 0 }) return true; |
| | | 93 | | |
| | 2901 | 94 | | var span = incomingChildren.AsSpan(); |
| | 11682 | 95 | | for (int i = 0; i < span.Length; i++) |
| | | 96 | | { |
| | 2982 | 97 | | if (!IsIncomingChildCompatible(existingChildren, span[i])) |
| | 42 | 98 | | return false; |
| | | 99 | | } |
| | 2859 | 100 | | return true; |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | private static bool IsIncomingChildCompatible(FieldChildren? existingChildren, FieldDefinition incomingChild) |
| | | 104 | | { |
| | 3003 | 105 | | if (existingChildren is null) return !HasAnyArguments(incomingChild); |
| | 2961 | 106 | | var existingChild = FindChildByNameAndAlias(existingChildren, incomingChild); |
| | 3204 | 107 | | if (existingChild is null) return !HasAnyArguments(incomingChild); |
| | 2718 | 108 | | return Helpers.AreArgumentsEqual(existingChild._arguments, incomingChild._arguments) |
| | 2718 | 109 | | && AreConditionalDirectivesEqual(existingChild, incomingChild) |
| | 2718 | 110 | | && AreNestedFieldsCompatible(existingChild, incomingChild); |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | /// <summary> |
| | | 114 | | /// Finds a child matching <paramref name="target"/>'s exact GraphQL identity — (Name, alias), |
| | | 115 | | /// not Name alone. Two children may legitimately share a Name while differing by alias (distinct |
| | | 116 | | /// response keys); a Name-only lookup would wrongly treat one as a match for the other. Thin |
| | | 117 | | /// wrapper over the shared ordinal probe <see cref="Helpers.FindByOrdinalNameAndAlias"/> — unlike |
| | | 118 | | /// <see cref="Helpers.FindExistingField(FieldChildren, FieldDefinition)"/>, an outright miss here |
| | | 119 | | /// stays a plain null (no Path-based fallback): every caller of this method treats null as |
| | | 120 | | /// "no compatible existing child", not "search further by another identity". |
| | | 121 | | /// </summary> |
| | | 122 | | private static FieldDefinition? FindChildByNameAndAlias(FieldChildren children, FieldDefinition target) |
| | 8289 | 123 | | => Helpers.FindByOrdinalNameAndAlias(children, target.Name, target._alias); |
| | | 124 | | |
| | | 125 | | // Skip the existing-not-in-incoming check entirely when nothing in the existing subtree |
| | | 126 | | // could ever fail it — early-out when no field in the existing subtree carries arguments. |
| | | 127 | | private static bool ExistingExtrasCompatible(FieldDefinition existingField, FieldChildren? incomingChildren) |
| | | 128 | | { |
| | 3669 | 129 | | var existingChildren = existingField._children; |
| | 3669 | 130 | | if (existingChildren is not { Count: > 0 } || !SubtreeHasAnyArguments(existingField)) |
| | 1299 | 131 | | return true; |
| | | 132 | | |
| | 2370 | 133 | | var span = existingChildren.AsSpan(); |
| | 9486 | 134 | | for (int i = 0; i < span.Length; i++) |
| | | 135 | | { |
| | 2382 | 136 | | var existingChild = span[i]; |
| | 2382 | 137 | | if (!IsExistingExtraCompatible(existingChild, incomingChildren)) |
| | 9 | 138 | | return false; |
| | | 139 | | } |
| | 2361 | 140 | | return true; |
| | | 141 | | } |
| | | 142 | | |
| | | 143 | | private static bool IsExistingExtraCompatible(FieldDefinition existingChild, FieldChildren? incomingChildren) |
| | | 144 | | { |
| | 2385 | 145 | | if (incomingChildren is null) return !HasAnyArguments(existingChild); |
| | 2379 | 146 | | return FindChildByNameAndAlias(incomingChildren, existingChild) is not null || !HasAnyArguments(existingChild); |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | // Named "HasAnyArguments" historically (pre-directives), but the memoized flag actually answers |
| | | 150 | | // a broader question: "is this subtree merge-identity-significant beyond bare structure?" — |
| | | 151 | | // which now includes conditional-include state, not just arguments. Folding |
| | | 152 | | // HasConditionalDirectives in here (rather than a parallel memoized flag) means every existing |
| | | 153 | | // caller — the fingerprint short-circuit, ExistingExtrasCompatible's early-out, ancestor |
| | | 154 | | // invalidation — picks up directive-significance for free, with the same invalidation contract |
| | | 155 | | // (ClearMergeMemo) that already covers argument mutations. |
| | | 156 | | private static bool SubtreeHasAnyArguments(FieldDefinition field) |
| | | 157 | | { |
| | 61203 | 158 | | if (field._subtreeHasAnyArguments is { } cached) return cached; |
| | | 159 | | |
| | 11871 | 160 | | var result = field._arguments is { Count: > 0 } |
| | 11871 | 161 | | || HasConditionalDirectives(field) |
| | 11871 | 162 | | || AnyChildHasArguments(field._children); |
| | 11871 | 163 | | field._subtreeHasAnyArguments = result; |
| | 11871 | 164 | | return result; |
| | | 165 | | } |
| | | 166 | | |
| | | 167 | | // FNV-style mixing constant shared with Helpers.ComputeArgumentFingerprint's CombineHash — |
| | | 168 | | // duplicated here (rather than exposed) to keep the deep-fingerprint recursion self-contained |
| | | 169 | | // within the file that owns its invalidation contract. |
| | | 170 | | private const ulong DeepFingerprintFnvPrime = 1099511628211UL; |
| | | 171 | | |
| | | 172 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 173 | | private static ulong CombineDeepHash(ulong hash, ulong value) |
| | 34866 | 174 | | => (hash ^ value) * DeepFingerprintFnvPrime; |
| | | 175 | | |
| | | 176 | | /// <summary> |
| | | 177 | | /// Computes (and memoizes on <see cref="FieldDefinition._deepArgumentFingerprint"/>) a |
| | | 178 | | /// conservative fingerprint over the MERGE-RELEVANT subtree of <paramref name="field"/>, for |
| | | 179 | | /// use as a cheap pre-filter bucket key in <c>QueryMerger.FindMergeTarget</c> — NEVER as a |
| | | 180 | | /// merge decision by itself. Strictly refines (never coarsens) the shallow, own-arguments-only |
| | | 181 | | /// fingerprint <see cref="Helpers.ComputeArgumentFingerprint"/> already provides, since this |
| | | 182 | | /// fingerprint folds that same value in at every level plus a commutative combination of every |
| | | 183 | | /// argument-carrying descendant. |
| | | 184 | | /// |
| | | 185 | | /// <para> |
| | | 186 | | /// <b>Conservatism proof sketch</b> (the contract this method must uphold: <c>CanMergeFields(a, b) |
| | | 187 | | /// == true</c> MUST imply <c>ComputeDeepFingerprint(a) == ComputeDeepFingerprint(b)</c>). |
| | | 188 | | /// <c>CanMergeFields</c> requires equal own arguments (already covered — matches |
| | | 189 | | /// <see cref="Helpers.ComputeArgumentFingerprint"/>'s own proven contract) and, recursively for |
| | | 190 | | /// every child on EITHER side whose subtree carries an argument anywhere |
| | | 191 | | /// (<see cref="SubtreeHasAnyArguments"/>), that the OTHER side has a same-named child which is |
| | | 192 | | /// itself pairwise <c>CanMergeFields</c>-compatible. (<see cref="IsIncomingChildCompatible"/>'s |
| | | 193 | | /// and <see cref="IsExistingExtraCompatible"/>'s absent/argument-free branches only accept a |
| | | 194 | | /// missing counterpart when the present side's subtree is ENTIRELY argument-free — so an |
| | | 195 | | /// argument-carrying child always demands an argument-carrying, pairwise-compatible match on |
| | | 196 | | /// the other side.) By induction on subtree depth, a pairwise-compatible child pair has equal |
| | | 197 | | /// deep fingerprints — so folding in EVERY argument-carrying child's (name, deep fingerprint) |
| | | 198 | | /// pair can never observe two <c>CanMergeFields</c>-compatible fields disagree. |
| | | 199 | | /// </para> |
| | | 200 | | /// |
| | | 201 | | /// <para> |
| | | 202 | | /// <b>Argument-free-child invariance.</b> A child whose entire subtree carries no arguments |
| | | 203 | | /// (checked via the existing memoized <see cref="SubtreeHasAnyArguments"/> — NOT the child's |
| | | 204 | | /// own <c>_arguments</c> in isolation) contributes NOTHING to the hash: <c>CanMergeFields</c> |
| | | 205 | | /// tolerates such a child being entirely absent from, or structurally different on, the other |
| | | 206 | | /// side (<see cref="IsIncomingChildCompatible"/> returns true for an incoming argument-free |
| | | 207 | | /// child missing from existing; <see cref="ExistingExtrasCompatible"/> early-outs completely |
| | | 208 | | /// when the existing subtree has no arguments at all). Two otherwise-identical fields differing |
| | | 209 | | /// only in argument-free children — at any depth — MUST hash equal, or a genuine merge would be |
| | | 210 | | /// falsely split. Recursing through <see cref="SubtreeHasAnyArguments"/> (not a shallow check) |
| | | 211 | | /// is what lets a SINGLE argument three levels down still make the whole ancestor chain down to |
| | | 212 | | /// it significant, while an entirely argument-free branch of arbitrary depth vanishes completely. |
| | | 213 | | /// </para> |
| | | 214 | | /// |
| | | 215 | | /// <para> |
| | | 216 | | /// <b>Order independence.</b> Children are matched by name, not position (see |
| | | 217 | | /// <see cref="FieldChildren.Find(string)"/>), so per-child contributions are combined with XOR — |
| | | 218 | | /// commutative and associative — rather than an order-sensitive fold. |
| | | 219 | | /// </para> |
| | | 220 | | /// </summary> |
| | | 221 | | internal static ulong ComputeDeepFingerprint(FieldDefinition field) |
| | | 222 | | { |
| | 21867 | 223 | | if (field._deepArgumentFingerprint is { } cached) return cached; |
| | | 224 | | |
| | | 225 | | // O(1) short-circuit for the common argument-free subtree: when SubtreeHasAnyArguments(field) |
| | | 226 | | // is false, field._arguments is empty/null (so ownArgsFp is the fixed FnvOffsetBasis constant |
| | | 227 | | // — see Helpers.ComputeArgumentFingerprint) AND every descendant's own SubtreeHasAnyArguments |
| | | 228 | | // is also false (that is exactly what AnyChildHasArguments, which SubtreeHasAnyArguments folds |
| | | 229 | | // in, recursively requires). DeepChildrenFingerprint's loop would therefore `continue` past |
| | | 230 | | // every single child without ever computing a contribution, always returning 0UL — so skipping |
| | | 231 | | // straight to CombineDeepHash(ownArgsFp, 0UL) here is provably identical to running the loop, |
| | | 232 | | // without visiting a single child. This is what collapses the O(children)-per-call cost that |
| | | 233 | | // MergeIncomingChildrenInPlace's memo invalidation otherwise forces on every Include for the |
| | | 234 | | // (extremely common) case of a merge-irrelevant, argument-free subtree of arbitrary size. |
| | 13107 | 235 | | if (!SubtreeHasAnyArguments(field)) |
| | | 236 | | { |
| | 660 | 237 | | var constantResult = CombineDeepHash(Helpers.ComputeArgumentFingerprint(null), 0UL); |
| | 660 | 238 | | field._deepArgumentFingerprint = constantResult; |
| | 660 | 239 | | return constantResult; |
| | | 240 | | } |
| | | 241 | | |
| | 12447 | 242 | | var ownArgsFp = CombineDeepHash(Helpers.ComputeArgumentFingerprint(field._arguments), ConditionalDirectiveFinger |
| | 12447 | 243 | | var childrenFp = DeepChildrenFingerprint(field._children); |
| | 12447 | 244 | | var result = CombineDeepHash(ownArgsFp, childrenFp); |
| | | 245 | | |
| | 12447 | 246 | | field._deepArgumentFingerprint = result; |
| | 12447 | 247 | | return result; |
| | | 248 | | } |
| | | 249 | | |
| | | 250 | | // Distinct sentinel folded in when a field carries NEITHER @include nor @skip, so an |
| | | 251 | | // include/skip-bearing field's fingerprint can never coincide with one that reuses the same |
| | | 252 | | // combined value by coincidence of the two directives' own fingerprints XORing to the "absent" |
| | | 253 | | // marker's bit pattern (defense in depth — CombineDeepHash's FNV mixing already makes this |
| | | 254 | | // astronomically unlikely, but a named sentinel makes the "absent" case an explicit branch |
| | | 255 | | // rather than an emergent zero). |
| | | 256 | | private const ulong NoConditionalDirectiveSentinel = 0xA24BAED4963EE889UL; |
| | | 257 | | |
| | | 258 | | /// <summary> |
| | | 259 | | /// Conservative fingerprint contribution for <paramref name="field"/>'s OWN <c>@include</c>/ |
| | | 260 | | /// <c>@skip</c> state, folded into <see cref="ComputeDeepFingerprint"/>'s own-node hash exactly |
| | | 261 | | /// like <see cref="Helpers.ComputeArgumentFingerprint"/> folds in arguments. Must uphold the same |
| | | 262 | | /// conservatism contract: <see cref="AreConditionalDirectivesEqual"/> considering two fields |
| | | 263 | | /// equal MUST imply this returns the same value for both — reuses |
| | | 264 | | /// <see cref="Helpers.ComputeArgumentFingerprint"/> directly on each directive's (already |
| | | 265 | | /// case-insensitively sorted — see <see cref="FieldDirective"/>'s constructor) |
| | | 266 | | /// <see cref="FieldDirective.Arguments"/>, which is already proven conservative for arbitrary |
| | | 267 | | /// argument shapes (nested dictionaries, lists, and unrecognized value types all fold into the |
| | | 268 | | /// shared conservative sentinel rather than risk a false inequality). |
| | | 269 | | /// </summary> |
| | | 270 | | private static ulong ConditionalDirectiveFingerprint(FieldDefinition field) |
| | | 271 | | { |
| | 24795 | 272 | | if (field._directives is not { Count: > 0 } directives) return NoConditionalDirectiveSentinel; |
| | | 273 | | |
| | 99 | 274 | | var include = FindConditional(directives, "include"); |
| | 99 | 275 | | var skip = FindConditional(directives, "skip"); |
| | 102 | 276 | | if (include is null && skip is null) return NoConditionalDirectiveSentinel; |
| | | 277 | | |
| | | 278 | | // "include" and "skip" are hashed under distinct salts so `@include(if:$x)` alone and |
| | | 279 | | // `@skip(if:$x)` alone (same underlying argument shape, different directive name) never |
| | | 280 | | // collide into the same contribution — mirroring how ComputeArgumentFingerprint folds the |
| | | 281 | | // KEY hash in alongside the value hash rather than hashing values alone. |
| | 96 | 282 | | var includeFp = include is { } inc |
| | 96 | 283 | | ? CombineDeepHash((ulong)"include".GetHashCode(StringComparison.Ordinal), ArgumentsFingerprint(inc.Arguments |
| | 96 | 284 | | : 0UL; |
| | 96 | 285 | | var skipFp = skip is { } sk |
| | 96 | 286 | | ? CombineDeepHash((ulong)"skip".GetHashCode(StringComparison.Ordinal), ArgumentsFingerprint(sk.Arguments)) |
| | 96 | 287 | | : 0UL; |
| | | 288 | | |
| | 96 | 289 | | return CombineDeepHash(includeFp, skipFp); |
| | | 290 | | } |
| | | 291 | | |
| | | 292 | | // FieldDirective's constructor always routes arguments through Helpers.SortArgumentValue into a |
| | | 293 | | // case-insensitive SortedDictionary (see FieldDirective.NormalizeArguments) or leaves Arguments |
| | | 294 | | // null for an empty/absent dictionary — there is no public construction path that produces any |
| | | 295 | | // other concrete type, so the cast below is unconditionally safe. |
| | | 296 | | private static ulong ArgumentsFingerprint(IReadOnlyDictionary<string, object?>? arguments) |
| | 96 | 297 | | => Helpers.ComputeArgumentFingerprint((SortedDictionary<string, object?>?)arguments); |
| | | 298 | | |
| | | 299 | | private static ulong DeepChildrenFingerprint(FieldChildren? children) |
| | | 300 | | { |
| | 15522 | 301 | | if (children is not { Count: > 0 }) return 0UL; |
| | | 302 | | |
| | 9372 | 303 | | var span = children.AsSpan(); |
| | 9372 | 304 | | var accumulator = 0UL; |
| | 37596 | 305 | | for (int i = 0; i < span.Length; i++) |
| | | 306 | | { |
| | 9426 | 307 | | var child = span[i]; |
| | | 308 | | |
| | | 309 | | // Argument-free-at-every-depth children are invisible to CanMergeFields (see the |
| | | 310 | | // conservatism proof above) — they must be invisible to the fingerprint too, or a |
| | | 311 | | // genuine merge candidate that only differs by such a child would be falsely split. |
| | 9426 | 312 | | if (!SubtreeHasAnyArguments(child)) continue; |
| | | 313 | | |
| | | 314 | | // XOR (commutative) so sibling iteration order never affects the result — children |
| | | 315 | | // are matched by NAME during the actual merge, never by position. |
| | 9120 | 316 | | var nameHash = (ulong)child.Name.GetHashCode(StringComparison.OrdinalIgnoreCase); |
| | 9120 | 317 | | var childContribution = CombineDeepHash(nameHash, ComputeDeepFingerprint(child)); |
| | 9120 | 318 | | accumulator ^= childContribution; |
| | | 319 | | } |
| | 9372 | 320 | | return accumulator; |
| | | 321 | | } |
| | | 322 | | |
| | | 323 | | private static bool AnyChildHasArguments(FieldChildren? children) |
| | | 324 | | { |
| | 13152 | 325 | | if (children is null || children.Count == 0) return false; |
| | 10812 | 326 | | var span = children.AsSpan(); |
| | 25818 | 327 | | for (int i = 0; i < span.Length; i++) |
| | | 328 | | { |
| | 20205 | 329 | | if (SubtreeHasAnyArguments(span[i])) return true; |
| | | 330 | | } |
| | 1758 | 331 | | return false; |
| | | 332 | | } |
| | | 333 | | |
| | | 334 | | // Un-memoized counterpart of SubtreeHasAnyArguments, used at the "child missing from the other |
| | | 335 | | // side entirely" leaf checks in IsIncomingChildCompatible/IsExistingExtraCompatible. Must apply |
| | | 336 | | // the exact same significance rule (arguments OR conditional directives OR a significant |
| | | 337 | | // descendant) — a child differing only by @include/@skip, with no arguments anywhere in its |
| | | 338 | | // subtree, must not be treated as "absent, therefore compatible" any more than an |
| | | 339 | | // argument-bearing child would be. |
| | | 340 | | private static bool HasAnyArguments(FieldDefinition field) |
| | | 341 | | { |
| | 357 | 342 | | if (field._arguments is { Count: > 0 }) return true; |
| | 342 | 343 | | if (HasConditionalDirectives(field)) return true; |
| | 627 | 344 | | if (field._children is null) return false; |
| | 195 | 345 | | foreach (var child in field._children.AsSpan()) |
| | | 346 | | { |
| | 57 | 347 | | if (HasAnyArguments(child)) return true; |
| | | 348 | | } |
| | 42 | 349 | | return false; |
| | | 350 | | } |
| | | 351 | | |
| | | 352 | | /// <summary> |
| | | 353 | | /// Deep-clones <paramref name="source"/> producing a new <see cref="FieldDefinition"/> that |
| | | 354 | | /// shares no mutable state with the original. This is THE clone boundary — it must copy |
| | | 355 | | /// every per-field store (children, arguments, metadata, inline fragments, spreads); any |
| | | 356 | | /// store it misses aliases the clone to the source and silently drops state on isolation |
| | | 357 | | /// paths (Preserve, Include). Used by <see cref="QueryMerger"/> and the preservation pipeline. |
| | | 358 | | /// </summary> |
| | | 359 | | internal static FieldDefinition DeepClone(this FieldDefinition source) |
| | | 360 | | { |
| | 9129 | 361 | | var clone = new FieldDefinition( |
| | 9129 | 362 | | source.Name, |
| | 9129 | 363 | | source._type!, |
| | 9129 | 364 | | source._alias, |
| | 9129 | 365 | | source._arguments is null ? null : new SortedDictionary<string, object?>(source._arguments, StringComparer.O |
| | 9129 | 366 | | { |
| | 9129 | 367 | | Path = source.Path, |
| | 9129 | 368 | | IsNeverMerge = source.IsNeverMerge, |
| | 9129 | 369 | | }; |
| | | 370 | | |
| | 9129 | 371 | | if (source._children is { Count: > 0 }) |
| | | 372 | | { |
| | 3771 | 373 | | clone._children = new FieldChildren(source._children.Count); |
| | 16518 | 374 | | foreach (var child in source._children.AsSpan()) |
| | 4488 | 375 | | clone._children.Append(child.DeepClone()); |
| | | 376 | | } |
| | | 377 | | |
| | 9129 | 378 | | clone.SetOptionalState( |
| | 9129 | 379 | | DeepCloneFragments(source._fragments), |
| | 9129 | 380 | | source._spreadFragments is { Count: > 0 } ? new List<string>(source._spreadFragments) : null, |
| | 9129 | 381 | | source._directives is { Count: > 0 } ? new List<FieldDirective>(source._directives) : null, |
| | 9129 | 382 | | source._metadata is { Count: > 0 } ? new Dictionary<string, object?>(source._metadata) : null); |
| | | 383 | | |
| | 9129 | 384 | | return clone; |
| | | 385 | | } |
| | | 386 | | |
| | | 387 | | internal static InlineFragmentDefinition DeepClone(this InlineFragmentDefinition source) |
| | | 388 | | { |
| | 42 | 389 | | var clone = new InlineFragmentDefinition(source.TypeName); |
| | 42 | 390 | | CloneFragmentBody(source._fields, source._fragments, source._spreadFragments, |
| | 42 | 391 | | out clone._fields, out clone._fragments, out clone._spreadFragments); |
| | | 392 | | |
| | | 393 | | // FieldDirective is an immutable record — sharing entries is safe; only the LIST needs to |
| | | 394 | | // be a fresh instance so mutating the clone's directives can never reach back into source. |
| | 42 | 395 | | clone._directives = source._directives is { Count: > 0 } |
| | 42 | 396 | | ? new List<FieldDirective>(source._directives) |
| | 42 | 397 | | : null; |
| | | 398 | | |
| | 42 | 399 | | return clone; |
| | | 400 | | } |
| | | 401 | | |
| | | 402 | | internal static NamedFragmentDefinition DeepClone(this NamedFragmentDefinition source) |
| | | 403 | | { |
| | 15 | 404 | | var clone = new NamedFragmentDefinition(source.Name, source.OnType); |
| | 15 | 405 | | CloneFragmentBody(source._fields, source._fragments, source._spreadFragments, |
| | 15 | 406 | | out clone._fields, out clone._fragments, out clone._spreadFragments); |
| | 15 | 407 | | return clone; |
| | | 408 | | } |
| | | 409 | | |
| | | 410 | | internal static Dictionary<string, InlineFragmentDefinition>? DeepCloneFragments(Dictionary<string, InlineFragmentDe |
| | | 411 | | { |
| | 18357 | 412 | | if (fragments is not { Count: > 0 }) return null; |
| | | 413 | | |
| | 15 | 414 | | var clone = new Dictionary<string, InlineFragmentDefinition>(fragments.Count, StringComparer.Ordinal); |
| | 60 | 415 | | foreach (var (typeName, fragment) in fragments) |
| | | 416 | | { |
| | 15 | 417 | | clone[typeName] = fragment.DeepClone(); |
| | | 418 | | } |
| | 15 | 419 | | return clone; |
| | | 420 | | } |
| | | 421 | | |
| | | 422 | | private static void CloneFragmentBody( |
| | | 423 | | FieldChildren? sourceFields, |
| | | 424 | | Dictionary<string, InlineFragmentDefinition>? sourceFragments, |
| | | 425 | | List<string>? sourceSpreads, |
| | | 426 | | out FieldChildren? fields, |
| | | 427 | | out Dictionary<string, InlineFragmentDefinition>? fragments, |
| | | 428 | | out List<string>? spreads) |
| | | 429 | | { |
| | 57 | 430 | | fields = null; |
| | 57 | 431 | | if (sourceFields is { Count: > 0 }) |
| | | 432 | | { |
| | 48 | 433 | | fields = new FieldChildren(sourceFields.Count); |
| | 192 | 434 | | foreach (var child in sourceFields.AsSpan()) |
| | 48 | 435 | | fields.Append(child.DeepClone()); |
| | | 436 | | } |
| | | 437 | | |
| | 57 | 438 | | fragments = DeepCloneFragments(sourceFragments); |
| | 57 | 439 | | spreads = sourceSpreads is { Count: > 0 } ? new List<string>(sourceSpreads) : null; |
| | 57 | 440 | | } |
| | | 441 | | |
| | | 442 | | /// <summary> |
| | | 443 | | /// Merges <paramref name="incoming"/> INTO <paramref name="existing"/>, mutating its child collection |
| | | 444 | | /// and argument dictionary in place. The caller must own <paramref name="existing"/> exclusively |
| | | 445 | | /// (no external aliases) — used by <see cref="QueryMerger"/> when the result will overwrite the |
| | | 446 | | /// dictionary entry that holds <paramref name="existing"/>. |
| | | 447 | | /// </summary> |
| | | 448 | | /// <returns><paramref name="existing"/> after mutation.</returns> |
| | | 449 | | internal static FieldDefinition MergeFieldsInPlace(FieldDefinition existing, FieldDefinition incoming) |
| | | 450 | | { |
| | 3657 | 451 | | ThrowIfTypesConflict(existing, incoming); |
| | 3645 | 452 | | MergeIncomingChildrenInPlace(existing, incoming._children); |
| | 3633 | 453 | | MergeIncomingArgumentsInPlace(existing, incoming._arguments); |
| | 3633 | 454 | | MergeFragmentState(existing, incoming); |
| | 3633 | 455 | | return existing; |
| | | 456 | | } |
| | | 457 | | |
| | | 458 | | /// <summary> |
| | | 459 | | /// Merges <paramref name="incoming"/>'s inline fragments, fragment spreads, and directives INTO |
| | | 460 | | /// <paramref name="existing"/>, mutating its collections in place. Everything carried over is |
| | | 461 | | /// deep-cloned (spreads and directives are copied into fresh lists; inline fragments are cloned |
| | | 462 | | /// via <see cref="DeepClone(InlineFragmentDefinition)"/>) so the merged field never aliases the |
| | | 463 | | /// source's fragment state. Spread insertion order is preserved and duplicate spread names are |
| | | 464 | | /// skipped, matching <see cref="FieldDefinition.AddSpreadFragment"/>. Directives are appended in |
| | | 465 | | /// order, skipping any that are structurally identical to one already present (so merging the |
| | | 466 | | /// same directive on a same-path field does not duplicate it). Shared by every field-level |
| | | 467 | | /// Include path. |
| | | 468 | | /// </summary> |
| | | 469 | | internal static void MergeFragmentState(FieldDefinition existing, FieldDefinition incoming) |
| | | 470 | | { |
| | 4320 | 471 | | MergeInlineFragmentsInPlace(existing, incoming._fragments); |
| | 4320 | 472 | | MergeSpreadsInPlace(existing, incoming._spreadFragments); |
| | 4320 | 473 | | MergeDirectivesInPlace(existing, incoming._directives); |
| | 4320 | 474 | | } |
| | | 475 | | |
| | | 476 | | private static void MergeInlineFragmentsInPlace(FieldDefinition existing, Dictionary<string, InlineFragmentDefinitio |
| | | 477 | | { |
| | 8610 | 478 | | if (incomingFragments is not { Count: > 0 }) return; |
| | | 479 | | |
| | 30 | 480 | | existing._fragments ??= new Dictionary<string, InlineFragmentDefinition>(StringComparer.Ordinal); |
| | 120 | 481 | | foreach (var (typeName, incomingFragment) in incomingFragments) |
| | | 482 | | { |
| | 30 | 483 | | if (existing._fragments.TryGetValue(typeName, out var existingFragment)) |
| | | 484 | | { |
| | 15 | 485 | | MergeInlineFragmentBodyInPlace(existingFragment, incomingFragment); |
| | | 486 | | } |
| | | 487 | | else |
| | | 488 | | { |
| | 15 | 489 | | existing._fragments[typeName] = incomingFragment.DeepClone(); |
| | | 490 | | } |
| | | 491 | | } |
| | 30 | 492 | | } |
| | | 493 | | |
| | | 494 | | // Merges two inline fragments of the same type: their field bodies, nested inline fragments, |
| | | 495 | | // and spreads. Each incoming child/fragment is deep-cloned before entering the target so the |
| | | 496 | | // merged fragment owns its subtree exclusively. |
| | | 497 | | private static void MergeInlineFragmentBodyInPlace(InlineFragmentDefinition existing, InlineFragmentDefinition incom |
| | | 498 | | { |
| | 24 | 499 | | if (incoming._fields is { Count: > 0 }) |
| | | 500 | | { |
| | 18 | 501 | | var targetFields = existing._fields ??= new FieldChildren(); |
| | 72 | 502 | | foreach (var child in incoming._fields.AsSpan()) |
| | | 503 | | { |
| | 18 | 504 | | MergeChildInPlace(targetFields, child); |
| | | 505 | | } |
| | | 506 | | } |
| | | 507 | | |
| | 24 | 508 | | if (incoming._fragments is { Count: > 0 }) |
| | | 509 | | { |
| | 9 | 510 | | existing._fragments ??= new Dictionary<string, InlineFragmentDefinition>(StringComparer.Ordinal); |
| | 36 | 511 | | foreach (var (typeName, nested) in incoming._fragments) |
| | | 512 | | { |
| | 9 | 513 | | if (existing._fragments.TryGetValue(typeName, out var existingNested)) |
| | | 514 | | { |
| | 3 | 515 | | MergeInlineFragmentBodyInPlace(existingNested, nested); |
| | | 516 | | } |
| | | 517 | | else |
| | | 518 | | { |
| | 6 | 519 | | existing._fragments[typeName] = nested.DeepClone(); |
| | | 520 | | } |
| | | 521 | | } |
| | | 522 | | } |
| | | 523 | | |
| | 24 | 524 | | MergeSpreadListInPlace(ref existing._spreadFragments, incoming._spreadFragments); |
| | | 525 | | |
| | 24 | 526 | | if (incoming._directives is { Count: > 0 } incomingDirectives) |
| | | 527 | | { |
| | 12 | 528 | | foreach (var directive in incomingDirectives) |
| | | 529 | | { |
| | 3 | 530 | | existing.AddDirective(directive); |
| | | 531 | | } |
| | | 532 | | } |
| | 24 | 533 | | } |
| | | 534 | | |
| | | 535 | | private static void MergeSpreadsInPlace(FieldDefinition existing, List<string>? incomingSpreads) |
| | | 536 | | { |
| | 4320 | 537 | | var current = existing._spreadFragments; |
| | 4320 | 538 | | var spreads = current; |
| | 4320 | 539 | | MergeSpreadListInPlace(ref spreads, incomingSpreads); |
| | 4341 | 540 | | if (!ReferenceEquals(spreads, current)) existing._spreadFragments = spreads; |
| | 4320 | 541 | | } |
| | | 542 | | |
| | | 543 | | /// <summary> |
| | | 544 | | /// Merges <paramref name="incoming"/> inline fragments into <paramref name="target"/> (a fragment |
| | | 545 | | /// body's inline-fragment map), deep-cloning new entries and recursively merging matching type |
| | | 546 | | /// names. Used for named-fragment bodies, whose store is not a <see cref="FieldDefinition"/>. |
| | | 547 | | /// </summary> |
| | | 548 | | internal static void MergeInlineFragmentsInto( |
| | | 549 | | ref Dictionary<string, InlineFragmentDefinition>? target, |
| | | 550 | | Dictionary<string, InlineFragmentDefinition>? incoming) |
| | | 551 | | { |
| | 78 | 552 | | if (incoming is not { Count: > 0 }) return; |
| | | 553 | | |
| | 12 | 554 | | target ??= new Dictionary<string, InlineFragmentDefinition>(StringComparer.Ordinal); |
| | 48 | 555 | | foreach (var (typeName, incomingFragment) in incoming) |
| | | 556 | | { |
| | 12 | 557 | | if (target.TryGetValue(typeName, out var existingFragment)) |
| | | 558 | | { |
| | 6 | 559 | | MergeInlineFragmentBodyInPlace(existingFragment, incomingFragment); |
| | | 560 | | } |
| | | 561 | | else |
| | | 562 | | { |
| | 6 | 563 | | target[typeName] = incomingFragment.DeepClone(); |
| | | 564 | | } |
| | | 565 | | } |
| | 12 | 566 | | } |
| | | 567 | | |
| | | 568 | | /// <summary> |
| | | 569 | | /// Merges <paramref name="incoming"/> spread names into <paramref name="target"/>, preserving |
| | | 570 | | /// insertion order and skipping duplicates. Public wrapper over the in-place list merge for the |
| | | 571 | | /// named-fragment body path. |
| | | 572 | | /// </summary> |
| | | 573 | | internal static void MergeSpreadNamesInto(ref List<string>? target, List<string>? incoming) |
| | 39 | 574 | | => MergeSpreadListInPlace(ref target, incoming); |
| | | 575 | | |
| | | 576 | | // Appends incoming spread names not already present, preserving insertion order — mirrors the |
| | | 577 | | // dedup contract of FieldDefinition.AddSpreadFragment. Order is user-visible (directives can |
| | | 578 | | // differ per spread site) so it is never sorted. |
| | | 579 | | [System.Diagnostics.CodeAnalysis.SuppressMessage( |
| | | 580 | | "Major Code Smell", "S3267:Loops should be simplified using the \"Where\" LINQ method", |
| | | 581 | | Justification = "List.Contains is the filter AND List.Add the side effect; a Where(Contains) would hide the muta |
| | | 582 | | private static void MergeSpreadListInPlace(ref List<string>? existingSpreads, List<string>? incomingSpreads) |
| | | 583 | | { |
| | 8742 | 584 | | if (incomingSpreads is not { Count: > 0 }) return; |
| | | 585 | | |
| | 24 | 586 | | existingSpreads ??= new List<string>(); |
| | 96 | 587 | | foreach (var name in incomingSpreads) |
| | | 588 | | { |
| | 24 | 589 | | if (!existingSpreads.Contains(name)) |
| | | 590 | | { |
| | 24 | 591 | | existingSpreads.Add(name); |
| | | 592 | | } |
| | | 593 | | } |
| | 24 | 594 | | } |
| | | 595 | | |
| | | 596 | | // Appends incoming directives in order via FieldDefinition.AddDirective — the same entry point |
| | | 597 | | // IncludeIf/SkipIf/Directive use, so merge gets the identical dedup AND last-call-wins collapse |
| | | 598 | | // rules for free: a structurally-identical directive already present is skipped (merging two |
| | | 599 | | // queries that each place the SAME directive on a same-path field must not emit it twice), and |
| | | 600 | | // for the two non-repeatable, spec-constrained names (`include`/`skip`) a differing incoming |
| | | 601 | | // directive REPLACES the existing one in place rather than appending — never emitting the |
| | | 602 | | // spec-invalid `@include(if:$x) @include(if:$x)` shape. Under MergeByFieldPath this branch is |
| | | 603 | | // only reached once CanMergeFields has already confirmed both sides carry the SAME conditional |
| | | 604 | | // state (see CanMergeFields's directive-identity check), so the replace path is unreachable |
| | | 605 | | // there in practice; under MergeByDefault, which merges same-name fields unconditionally, a |
| | | 606 | | // differing incoming @include/@skip legitimately overwrites the existing one — consistent with |
| | | 607 | | // how MergeByDefault already lets incoming field arguments silently override existing ones. |
| | | 608 | | // FieldDirective is an immutable record, so the shared reference is safe to hand to AddDirective |
| | | 609 | | // without cloning. |
| | | 610 | | private static void MergeDirectivesInPlace(FieldDefinition existing, List<FieldDirective>? incomingDirectives) |
| | | 611 | | { |
| | 8622 | 612 | | if (incomingDirectives is not { Count: > 0 }) return; |
| | | 613 | | |
| | 72 | 614 | | foreach (var incoming in incomingDirectives) |
| | | 615 | | { |
| | 18 | 616 | | existing.AddDirective(incoming); |
| | | 617 | | } |
| | 18 | 618 | | } |
| | | 619 | | |
| | | 620 | | private static void ThrowIfTypesConflict(FieldDefinition existing, FieldDefinition incoming) |
| | | 621 | | { |
| | | 622 | | // _type defaults to Constants.DefaultFieldType in every public constructor and is |
| | | 623 | | // never null on the merge path through Include — the field is always touched by |
| | | 624 | | // QueryBuilder.AddField which goes through FieldFactory.CreateFieldDefinition. |
| | 7302 | 625 | | if (existing._type!.AsSpan().Equals(incoming._type!.AsSpan(), StringComparison.OrdinalIgnoreCase)) return; |
| | 12 | 626 | | throw new QueryMergeException($"Type conflict: existing field has type '{existing._type}', incoming field has ty |
| | | 627 | | } |
| | | 628 | | |
| | | 629 | | private static void MergeIncomingChildrenInPlace(FieldDefinition existing, FieldChildren? incomingChildren) |
| | | 630 | | { |
| | 4437 | 631 | | if (incomingChildren is null) return; |
| | | 632 | | |
| | | 633 | | // existing._children can legitimately still be null here: an object-typed field created |
| | | 634 | | // via AddField(name, "Object") with no sub-fields yet (or one whose type was promoted to |
| | | 635 | | // Object by ShouldConvertToObjectType without ever gaining a child) is a leaf-shaped node |
| | | 636 | | // that CanMergeFields already accepted as compatible with ANY incoming children — |
| | | 637 | | // ExistingExtrasCompatible early-outs as soon as existing's own children collection is |
| | | 638 | | // absent/empty, with nothing further to check on that side. So this is not an unenforced |
| | | 639 | | // invariant to trust; it is a real, reachable shape that must allocate on demand rather |
| | | 640 | | // than asserting non-null. |
| | 2853 | 641 | | var existingChildren = existing._children ??= new FieldChildren(); |
| | 2853 | 642 | | var span = incomingChildren.AsSpan(); |
| | 11544 | 643 | | for (int i = 0; i < span.Length; i++) |
| | | 644 | | { |
| | 2931 | 645 | | MergeChildInPlace(existingChildren, span[i]); |
| | | 646 | | } |
| | | 647 | | |
| | | 648 | | // incomingChildren is evaluated for "any arguments anywhere" BEFORE the merge loop mutated |
| | | 649 | | // it into existingChildren's clones — AnyChildHasArguments only reads _arguments/_children, |
| | | 650 | | // which the merge loop does not mutate on the SOURCE side (MergeChildInPlace only clones |
| | | 651 | | // incoming nodes into the target; ExistingNested merges happen on the existing side). |
| | 2841 | 652 | | InvalidateMergeMemoAfterChildrenMerge(existing, AnyChildHasArguments(incomingChildren)); |
| | 2841 | 653 | | } |
| | | 654 | | |
| | | 655 | | private static void MergeIncomingArgumentsInPlace(FieldDefinition existing, SortedDictionary<string, object?>? incom |
| | | 656 | | { |
| | 6558 | 657 | | if (incomingArguments is not { Count: > 0 }) return; |
| | 708 | 658 | | existing.MergeFieldArgumentsInPlace(incomingArguments); |
| | | 659 | | |
| | | 660 | | // No memo update needed here: CanMergeFields (which every MergeFieldsInPlace caller already |
| | | 661 | | // ran) requires AreArgumentsEqual(existing._arguments, incoming._arguments) at this exact |
| | | 662 | | // node, so reaching this line with a non-empty incomingArguments means existing._arguments |
| | | 663 | | // was ALREADY non-empty and equal — i.e. SubtreeHasAnyArguments(existing) was already true |
| | | 664 | | // (and therefore memoized true, or about to memoize true next read regardless). The values |
| | | 665 | | // merged in refine existing keys' nested dictionary contents (see MergeFieldArgumentsInPlace) |
| | | 666 | | // without changing the fingerprint-relevant "has any argument" shape at this node, but the |
| | | 667 | | // fingerprint VALUE can still change (nested value content differs) — so only the fingerprint |
| | | 668 | | // memo needs invalidating, and only when it was actually cached. |
| | 708 | 669 | | if (existing._deepArgumentFingerprint is not null) |
| | | 670 | | { |
| | 657 | 671 | | existing._deepArgumentFingerprint = null; |
| | | 672 | | } |
| | 708 | 673 | | } |
| | | 674 | | |
| | | 675 | | /// <summary> |
| | | 676 | | /// Updates <see cref="FieldDefinition._subtreeHasAnyArguments"/> and |
| | | 677 | | /// <see cref="FieldDefinition._deepArgumentFingerprint"/> after merging incoming CHILDREN into |
| | | 678 | | /// <paramref name="existing"/>'s subtree, exploiting monotonicity instead of always nulling both |
| | | 679 | | /// memos (which forced an O(existing-subtree-size) recompute on every single Include — the |
| | | 680 | | /// O(N²) driver for a chain of N Includes into one shared parent). |
| | | 681 | | /// |
| | | 682 | | /// <para> |
| | | 683 | | /// <b>Monotonicity.</b> Merging NEVER removes an argument or a child — <c>MergeFieldArgumentsInPlace</c> |
| | | 684 | | /// only adds/overwrites keys, <c>MergeChildInPlace</c> only appends new children or recurses into |
| | | 685 | | /// existing ones (itself subject to this same monotonic rule). So once a subtree contains any |
| | | 686 | | /// argument anywhere, it always will — <c>_subtreeHasAnyArguments</c> can only ever transition |
| | | 687 | | /// false/null → true for a given live instance, never true → false. If it was already memoized |
| | | 688 | | /// <c>true</c>, no merge can invalidate that fact — both memos are left untouched (a global no-op, |
| | | 689 | | /// O(1)) beyond the fingerprint invalidation below. |
| | | 690 | | /// </para> |
| | | 691 | | /// |
| | | 692 | | /// <para> |
| | | 693 | | /// <b>The false/unknown case.</b> If it was NOT already known <c>true</c>, whether it becomes true |
| | | 694 | | /// now depends solely on whether THIS merge's incoming children introduced an argument anywhere — |
| | | 695 | | /// i.e. <paramref name="incomingChildrenHaveArguments"/>, which the caller computes over the |
| | | 696 | | /// (typically small, single-fragment) INCOMING side only, never by re-walking the accumulated |
| | | 697 | | /// existing subtree. If nothing was contributed, the subtree provably remains argument-free: |
| | | 698 | | /// <c>_subtreeHasAnyArguments</c> stays false (recorded explicitly, not left null, so a future |
| | | 699 | | /// call is also O(1)) — and per <c>ComputeDeepFingerprint</c>'s own short-circuit, an |
| | | 700 | | /// argument-free subtree's fingerprint is a fixed constant independent of its children, so the |
| | | 701 | | /// OLD fingerprint (if already memoized) is still correct and is likewise left untouched. |
| | | 702 | | /// </para> |
| | | 703 | | /// |
| | | 704 | | /// <para> |
| | | 705 | | /// <b>The third ("was false, incoming introduces a genuinely new argument") case.</b> On the |
| | | 706 | | /// <see cref="QueryMerger"/> path this shape cannot arise: <c>CanMergeFields</c> runs before |
| | | 707 | | /// <see cref="MergeFieldsInPlace"/> there and rejects it outright — <see cref="IsIncomingChildCompatible"/> |
| | | 708 | | /// only tolerates an incoming child ABSENT from existing when that child's whole subtree is |
| | | 709 | | /// argument-free, and any incoming child that DOES exist by name on the existing side must have |
| | | 710 | | /// <c>AreArgumentsEqual</c>-equal own arguments before recursing further. It IS reachable through |
| | | 711 | | /// the inline-fragment path, however: <see cref="MergeInlineFragmentBodyInPlace"/> calls |
| | | 712 | | /// <see cref="MergeChildInPlace"/> — and therefore <see cref="MergeFieldsInPlace"/> — with no |
| | | 713 | | /// <c>CanMergeFields</c> gate at all, so merging two same-typed inline fragments whose shared |
| | | 714 | | /// child gains an argument-bearing descendant lands here. Full invalidation is always safe, so |
| | | 715 | | /// this case simply forgoes the O(1) fast path and recomputes both memos on next read. |
| | | 716 | | /// </para> |
| | | 717 | | /// </summary> |
| | | 718 | | private static void InvalidateMergeMemoAfterChildrenMerge(FieldDefinition existing, bool incomingChildrenHaveArgumen |
| | | 719 | | { |
| | 2841 | 720 | | if (existing._subtreeHasAnyArguments == true) |
| | | 721 | | { |
| | | 722 | | // Already true — monotonicity guarantees it stays true; the exact fingerprint VALUE can |
| | | 723 | | // still change (new argument-bearing content deeper in an already-significant subtree), |
| | | 724 | | // so the fingerprint alone still needs invalidating. |
| | 2358 | 725 | | existing._deepArgumentFingerprint = null; |
| | 2358 | 726 | | return; |
| | | 727 | | } |
| | | 728 | | |
| | 483 | 729 | | if (!incomingChildrenHaveArguments) |
| | | 730 | | { |
| | | 731 | | // Not already known true, and this merge's incoming children contributed nothing new. |
| | | 732 | | // Whether the subtree is argument-free now depends on existing's OWN arguments, not |
| | | 733 | | // just its children — recording a bare `false` here would be actively wrong for a |
| | | 734 | | // field that has its own arguments but had never had SubtreeHasAnyArguments computed |
| | | 735 | | // (memo still null) before this merge. Recompute from existing._arguments directly |
| | | 736 | | // (O(1) — no child recursion needed, since AnyChildHasArguments over existing's |
| | | 737 | | // children is exactly what's already known to be unaffected by this incoming merge). |
| | 480 | 738 | | existing._subtreeHasAnyArguments = existing._arguments is { Count: > 0 }; |
| | 480 | 739 | | return; |
| | | 740 | | } |
| | | 741 | | |
| | | 742 | | // A previously argument-free subtree just gained an argument. Unreachable via QueryMerger |
| | | 743 | | // (CanMergeFields rejects that shape first) but genuinely reachable via the ungated |
| | | 744 | | // inline-fragment merge path — see the third-case remarks above. Drop both memos: full |
| | | 745 | | // invalidation is always correct, it only costs this method's O(1) fast path here. |
| | 3 | 746 | | existing._subtreeHasAnyArguments = null; |
| | 3 | 747 | | existing._deepArgumentFingerprint = null; |
| | 3 | 748 | | } |
| | | 749 | | |
| | | 750 | | private static void MergeChildInPlace(FieldChildren existingChildren, FieldDefinition incomingChild) |
| | | 751 | | { |
| | 2949 | 752 | | var existingNested = FindChildByNameAndAlias(existingChildren, incomingChild); |
| | 2949 | 753 | | if (existingNested != null) |
| | | 754 | | { |
| | 2679 | 755 | | MergeFieldsInPlace(existingNested, incomingChild); |
| | 2667 | 756 | | return; |
| | | 757 | | } |
| | | 758 | | |
| | | 759 | | // Deep-clone so target's tree never references nodes owned by the source builder. Any |
| | | 760 | | // later in-place merges into existingChildren must not propagate back to the source. |
| | 270 | 761 | | var clone = incomingChild.DeepClone(); |
| | | 762 | | |
| | | 763 | | // Effective-name conflict can only occur when the incoming field carries an alias |
| | | 764 | | // distinct from its name; otherwise the Name lookup above would have caught it. |
| | 270 | 765 | | if (!ReferenceEquals(incomingChild._effectiveName, incomingChild.Name) && |
| | 270 | 766 | | HasEffectiveNameConflict(existingChildren, incomingChild._effectiveName)) |
| | | 767 | | { |
| | 6 | 768 | | var uniqueAlias = KeyGenerator.GenerateUniqueKey(incomingChild._effectiveName, existingChildren.AsSpan()); |
| | 6 | 769 | | existingChildren.Append(clone with { Alias = uniqueAlias }); |
| | | 770 | | } |
| | | 771 | | else |
| | | 772 | | { |
| | 264 | 773 | | existingChildren.Append(clone); |
| | | 774 | | } |
| | 264 | 775 | | } |
| | | 776 | | |
| | | 777 | | private static bool HasEffectiveNameConflict(FieldChildren children, string effectiveName) |
| | | 778 | | { |
| | 762 | 779 | | foreach (var f in children.AsSpan()) |
| | | 780 | | { |
| | 297 | 781 | | if (string.Equals(f._effectiveName, effectiveName, StringComparison.OrdinalIgnoreCase)) |
| | 6 | 782 | | return true; |
| | | 783 | | } |
| | 81 | 784 | | return false; |
| | | 785 | | } |
| | | 786 | | |
| | | 787 | | /// <summary> |
| | | 788 | | /// Merges <paramref name="newArguments"/> into <paramref name="existingField"/>'s argument |
| | | 789 | | /// dictionary in place. Caller (MergeFieldsInPlace via CanMergeFields) guarantees that |
| | | 790 | | /// <c>existingField._arguments</c> already has the same keys as <paramref name="newArguments"/>; |
| | | 791 | | /// the merge here only refines values for keys that hold nested dictionaries. |
| | | 792 | | /// </summary> |
| | | 793 | | // Callers (MergeFieldsInPlace via CanMergeFields) guarantee newArguments has Count > 0 |
| | | 794 | | // and existingField._arguments is non-null with the same keys. |
| | | 795 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 796 | | internal static void MergeFieldArgumentsInPlace(this FieldDefinition existingField, IDictionary<string, object?> new |
| | | 797 | | { |
| | 708 | 798 | | var target = existingField._arguments!; |
| | 2844 | 799 | | foreach (var (key, newValue) in newArguments) |
| | | 800 | | { |
| | 714 | 801 | | if (target.TryGetValue(key, out var existingValue) |
| | 714 | 802 | | && existingValue is IDictionary<string, object?> existingDict |
| | 714 | 803 | | && newValue is IDictionary<string, object?> newDict) |
| | | 804 | | { |
| | 18 | 805 | | target[key] = Helpers.MergeNullableDictionaries(existingDict, newDict); |
| | 18 | 806 | | continue; |
| | | 807 | | } |
| | 696 | 808 | | target[key] = newValue; |
| | | 809 | | } |
| | 708 | 810 | | } |
| | | 811 | | |
| | | 812 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | | 813 | | internal static FieldDefinition MergeFieldArguments(this FieldDefinition existingField, IDictionary<string, object?> |
| | | 814 | | { |
| | 876 | 815 | | if (newArguments is not { Count: > 0 }) return existingField; |
| | | 816 | | |
| | 414 | 817 | | existingField.InvalidateMergeIndex(); |
| | | 818 | | |
| | 414 | 819 | | if (existingField._arguments is null || existingField._arguments.Count == 0) |
| | | 820 | | { |
| | 339 | 821 | | return existingField with |
| | 339 | 822 | | { |
| | 339 | 823 | | _arguments = AsSortedCaseInsensitive(newArguments), |
| | 339 | 824 | | _deepArgumentFingerprint = null, |
| | 339 | 825 | | _subtreeHasAnyArguments = null, |
| | 339 | 826 | | }; |
| | | 827 | | } |
| | | 828 | | |
| | 75 | 829 | | var merged = CopyToSortedCaseInsensitive(existingField._arguments); |
| | 75 | 830 | | ApplyArgumentOverrides(merged, newArguments); |
| | 75 | 831 | | return existingField with |
| | 75 | 832 | | { |
| | 75 | 833 | | _arguments = merged, |
| | 75 | 834 | | _deepArgumentFingerprint = null, |
| | 75 | 835 | | _subtreeHasAnyArguments = null, |
| | 75 | 836 | | }; |
| | | 837 | | } |
| | | 838 | | |
| | | 839 | | private static SortedDictionary<string, object?> AsSortedCaseInsensitive(IDictionary<string, object?> source) |
| | 339 | 840 | | => source is SortedDictionary<string, object?> sd |
| | 339 | 841 | | ? sd |
| | 339 | 842 | | : new SortedDictionary<string, object?>(source, StringComparer.OrdinalIgnoreCase); |
| | | 843 | | |
| | | 844 | | private static SortedDictionary<string, object?> CopyToSortedCaseInsensitive(IDictionary<string, object?> source) |
| | | 845 | | { |
| | 75 | 846 | | var copy = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase); |
| | 393 | 847 | | foreach (var (key, value) in source) copy[key] = value; |
| | 75 | 848 | | return copy; |
| | | 849 | | } |
| | | 850 | | |
| | | 851 | | private static void ApplyArgumentOverrides(SortedDictionary<string, object?> target, IDictionary<string, object?> ov |
| | | 852 | | { |
| | 318 | 853 | | foreach (var (key, newValue) in overrides) |
| | | 854 | | { |
| | 84 | 855 | | target[key] = MergedArgumentValue(target, key, newValue); |
| | | 856 | | } |
| | 75 | 857 | | } |
| | | 858 | | |
| | | 859 | | private static object? MergedArgumentValue(SortedDictionary<string, object?> target, string key, object? newValue) |
| | | 860 | | { |
| | 84 | 861 | | if (target.TryGetValue(key, out var existingValue) |
| | 84 | 862 | | && existingValue is IDictionary<string, object?> existingDict |
| | 84 | 863 | | && newValue is IDictionary<string, object?> newDict) |
| | | 864 | | { |
| | 3 | 865 | | return Helpers.MergeNullableDictionaries(existingDict, newDict); |
| | | 866 | | } |
| | 81 | 867 | | return newValue; |
| | | 868 | | } |
| | | 869 | | } |