|
| 1 | +using System.Collections.Immutable; |
| 2 | +using HotChocolate.Fusion.Collections; |
| 3 | +using HotChocolate.Fusion.Extensions; |
| 4 | +using HotChocolate.Language; |
| 5 | +using HotChocolate.Types; |
| 6 | +using HotChocolate.Types.Mutable; |
| 7 | +using static HotChocolate.Language.Utf8GraphQLParser.Syntax; |
| 8 | + |
| 9 | +namespace HotChocolate.Fusion.Satisfiability; |
| 10 | + |
| 11 | +/// <summary> |
| 12 | +/// Shared helper for validating that, while traversing the merged schema, we |
| 13 | +/// can move from one source schema to another in order to reach a field. The |
| 14 | +/// satisfiability and requirements validators both face this question and |
| 15 | +/// resolve it the same way: try a direct lookup on the target type, fall back |
| 16 | +/// to a parent entity call via an ancestor on the path, and otherwise check |
| 17 | +/// whether the current path can be traversed one-to-one in the target schema. |
| 18 | +/// </summary> |
| 19 | +internal static class SourceSchemaTransitionHelper |
| 20 | +{ |
| 21 | + /// <summary> |
| 22 | + /// Validates whether a transition to <paramref name="transitionToSchemaName"/> |
| 23 | + /// is possible for <paramref name="type"/>. |
| 24 | + /// </summary> |
| 25 | + /// <param name="schema">The merged schema being validated.</param> |
| 26 | + /// <param name="type"> |
| 27 | + /// The type we need to be holding in <paramref name="transitionToSchemaName"/> |
| 28 | + /// after the transition. |
| 29 | + /// </param> |
| 30 | + /// <param name="transitionToSchemaName">The target source schema.</param> |
| 31 | + /// <param name="pathFromLeaf"> |
| 32 | + /// The current access path enumerated from the leaf (the current position) |
| 33 | + /// toward the root. Used to detect path-traversal and parent entity call |
| 34 | + /// opportunities when a direct lookup on <paramref name="type"/> is |
| 35 | + /// unavailable or unsatisfiable. |
| 36 | + /// </param> |
| 37 | + /// <param name="validateLookupRequirements"> |
| 38 | + /// Callback that validates a candidate lookup's key requirements. It is |
| 39 | + /// invoked with the context type whose lookup is being attempted (either |
| 40 | + /// <paramref name="type"/> for a direct lookup, or an ancestor's type for a |
| 41 | + /// parent entity call) and the path item that anchors the validation |
| 42 | + /// context. Caller-owned so each validator can supply its own state |
| 43 | + /// (cycle detection, excluded schema, etc.). |
| 44 | + /// </param> |
| 45 | + /// <param name="noLookupsFoundForTypeMessageFormat"> |
| 46 | + /// Format string used when no lookup or path-based transition exists. The |
| 47 | + /// caller provides its own resource key so the error reads naturally in |
| 48 | + /// the caller's context. |
| 49 | + /// </param> |
| 50 | + /// <param name="unableToSatisfyRequirementForLookupMessageFormat"> |
| 51 | + /// Format string used when a candidate lookup exists but its key |
| 52 | + /// requirements could not be satisfied. The caller provides its own |
| 53 | + /// resource key. |
| 54 | + /// </param> |
| 55 | + /// <returns> |
| 56 | + /// An empty array if the transition is satisfiable; otherwise the |
| 57 | + /// accumulated errors describing every direct and parent-call lookup that |
| 58 | + /// was tried, plus a "no lookups" error if no lookup was available at all. |
| 59 | + /// </returns> |
| 60 | + public static ImmutableArray<SatisfiabilityError> ValidateSourceSchemaTransition( |
| 61 | + MutableSchemaDefinition schema, |
| 62 | + MutableObjectTypeDefinition type, |
| 63 | + string transitionToSchemaName, |
| 64 | + IReadOnlyList<SatisfiabilityPathItem> pathFromLeaf, |
| 65 | + Func<MutableObjectTypeDefinition, |
| 66 | + SatisfiabilityPathItem?, |
| 67 | + SelectionSetNode, |
| 68 | + ImmutableArray<SatisfiabilityError>> validateLookupRequirements, |
| 69 | + string noLookupsFoundForTypeMessageFormat, |
| 70 | + string unableToSatisfyRequirementForLookupMessageFormat) |
| 71 | + { |
| 72 | + var errors = new List<SatisfiabilityError>(); |
| 73 | + var leafPathItem = pathFromLeaf.Count > 0 ? pathFromLeaf[0] : null; |
| 74 | + |
| 75 | + var lookupDirectives = |
| 76 | + schema.GetPossibleFusionLookupDirectives(type, transitionToSchemaName); |
| 77 | + |
| 78 | + // Direct lookup on `type` in the target schema. |
| 79 | + foreach (var lookupDirective in lookupDirectives) |
| 80 | + { |
| 81 | + if (TryLookup(lookupDirective, type, leafPathItem)) |
| 82 | + { |
| 83 | + return []; |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + // Parent entity call: walk from the leaf upward, find the nearest |
| 88 | + // ancestor on the path whose declaring type has a lookup in the target |
| 89 | + // schema, and try the ancestor's lookups. Accepting one such ancestor |
| 90 | + // is the validator's way of saying the gateway could re-fetch the |
| 91 | + // ancestor in the target schema (via its lookup) and walk the suffix |
| 92 | + // back down to the stuck position; the loop's early break ensures |
| 93 | + // every field on that suffix also exists in the target schema. |
| 94 | + // |
| 95 | + // The loop also tracks whether the entire path's fields exist in the |
| 96 | + // target schema (`pathIsTraversable`); that doubles as the fallback |
| 97 | + // condition below when no lookup-based option succeeded. |
| 98 | + // |
| 99 | + // Known limitation: we do not explicitly replay the suffix fields |
| 100 | + // through the normal field-access rules (@require, @partial, |
| 101 | + // @provides). The main satisfiability loop covers most cases |
| 102 | + // implicitly by visiting every reachable (type, schema) combination |
| 103 | + // and running those checks there. The gap is when this parent-call |
| 104 | + // enables a (type, schema) combination that the forward-from-Query |
| 105 | + // traversal does not otherwise reach (e.g. a target schema whose only |
| 106 | + // entry point for the ancestor type is the @lookup itself, which is |
| 107 | + // @inaccessible and skipped by the main loop). |
| 108 | + var pathIsTraversable = true; |
| 109 | + |
| 110 | + for (var i = 0; i < pathFromLeaf.Count; i++) |
| 111 | + { |
| 112 | + var ancestor = pathFromLeaf[i]; |
| 113 | + |
| 114 | + // The ancestor's field must also exist in the target schema so the |
| 115 | + // suffix can be re-traversed. If it does not, deeper ancestors are |
| 116 | + // also blocked by the same missing field and we can stop here. |
| 117 | + if (!ancestor.Field.ExistsInSchema(transitionToSchemaName)) |
| 118 | + { |
| 119 | + pathIsTraversable = false; |
| 120 | + break; |
| 121 | + } |
| 122 | + |
| 123 | + // Path items are always constructed with object declaring types, |
| 124 | + // but the record property is typed as the broader |
| 125 | + // MutableComplexTypeDefinition; guard for that. |
| 126 | + if (ancestor.Type is not MutableObjectTypeDefinition ancestorObjectType) |
| 127 | + { |
| 128 | + continue; |
| 129 | + } |
| 130 | + |
| 131 | + var ancestorLookups = |
| 132 | + schema.GetPossibleFusionLookupDirectives(ancestorObjectType, transitionToSchemaName); |
| 133 | + |
| 134 | + if (ancestorLookups.Count == 0) |
| 135 | + { |
| 136 | + continue; |
| 137 | + } |
| 138 | + |
| 139 | + // The "prefix leaf" is the path item we held right before the |
| 140 | + // ancestor was accessed (the position at which we were holding |
| 141 | + // ancestor.Type and from which the ancestor's lookup must be |
| 142 | + // satisfiable). |
| 143 | + var prefixLeaf = i + 1 < pathFromLeaf.Count ? pathFromLeaf[i + 1] : null; |
| 144 | + |
| 145 | + foreach (var ancestorLookup in ancestorLookups) |
| 146 | + { |
| 147 | + if (TryLookup(ancestorLookup, ancestorObjectType, prefixLeaf)) |
| 148 | + { |
| 149 | + return []; |
| 150 | + } |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + // One-to-one path fallback: every field on the path exists in the |
| 155 | + // target schema, so the same traversal applies there without any |
| 156 | + // entity call. |
| 157 | + if (pathIsTraversable) |
| 158 | + { |
| 159 | + return []; |
| 160 | + } |
| 161 | + |
| 162 | + // Nothing worked. If no lookup was even tried (no direct lookup on the |
| 163 | + // type, and no ancestor on the path had a lookup either) the |
| 164 | + // accumulated error list is still empty, so emit the canonical |
| 165 | + // "no lookups" error. |
| 166 | + if (errors.Count == 0) |
| 167 | + { |
| 168 | + errors.Add( |
| 169 | + new SatisfiabilityError( |
| 170 | + string.Format( |
| 171 | + noLookupsFoundForTypeMessageFormat, |
| 172 | + type.Name, |
| 173 | + transitionToSchemaName))); |
| 174 | + } |
| 175 | + |
| 176 | + return [.. errors]; |
| 177 | + |
| 178 | + bool TryLookup( |
| 179 | + IDirective lookupDirective, |
| 180 | + MutableObjectTypeDefinition contextType, |
| 181 | + SatisfiabilityPathItem? parentPathItem) |
| 182 | + { |
| 183 | + var lookupKeyArg = (string)lookupDirective.Arguments["key"].Value!; |
| 184 | + var lookupFieldArg = (string)lookupDirective.Arguments["field"].Value!; |
| 185 | + var lookupPathArg = (string?)lookupDirective.Arguments["path"].Value; |
| 186 | + |
| 187 | + var lookupRequirements = ParseSelectionSet($"{{ {lookupKeyArg} }}"); |
| 188 | + var lookupFieldName = ParseFieldDefinition(lookupFieldArg).Name.Value; |
| 189 | + |
| 190 | + var requirementErrors = |
| 191 | + validateLookupRequirements(contextType, parentPathItem, lookupRequirements); |
| 192 | + |
| 193 | + if (requirementErrors.IsEmpty) |
| 194 | + { |
| 195 | + return true; |
| 196 | + } |
| 197 | + |
| 198 | + var lookupName = lookupPathArg is null |
| 199 | + ? lookupFieldName |
| 200 | + : $"{lookupPathArg}.{lookupFieldName}"; |
| 201 | + |
| 202 | + errors.Add( |
| 203 | + new SatisfiabilityError( |
| 204 | + string.Format( |
| 205 | + unableToSatisfyRequirementForLookupMessageFormat, |
| 206 | + lookupRequirements.ToString(indented: false), |
| 207 | + lookupName, |
| 208 | + transitionToSchemaName), |
| 209 | + requirementErrors)); |
| 210 | + |
| 211 | + return false; |
| 212 | + } |
| 213 | + } |
| 214 | +} |
0 commit comments