Skip to content

Commit ea36a50

Browse files
authored
[Fusion] Support parent entity calls in satisfiability validator (#9792)
1 parent 46c5eb7 commit ea36a50

5 files changed

Lines changed: 245 additions & 176 deletions

File tree

src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/PathNodeExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ public bool ContainsItem(SatisfiabilityPathItem item)
2020
return false;
2121
}
2222

23+
public IEnumerable<SatisfiabilityPathItem> EnumerateFromLeaf()
24+
{
25+
for (var node = path; node is not null; node = node.Parent)
26+
{
27+
yield return node.Item;
28+
}
29+
}
30+
2331
public string ToPathString()
2432
{
2533
if (path is null)

src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs

Lines changed: 11 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using HotChocolate.Types;
66
using HotChocolate.Types.Mutable;
77
using static HotChocolate.Fusion.Properties.CompositionResources;
8-
using static HotChocolate.Language.Utf8GraphQLParser.Syntax;
98

109
namespace HotChocolate.Fusion.Satisfiability;
1110

@@ -289,94 +288,20 @@ private ImmutableArray<SatisfiabilityError> ValidateSourceSchemaTransition(
289288
RequirementsValidatorContext context,
290289
string transitionToSchemaName)
291290
{
292-
var errors = new List<SatisfiabilityError>();
293-
294-
var lookupDirectives =
295-
schema.GetPossibleFusionLookupDirectives(type, transitionToSchemaName);
296-
297-
if (!lookupDirectives.Any() && !CanTransitionToSchemaThroughPath(context.Path, transitionToSchemaName))
298-
{
299-
errors.Add(
300-
new SatisfiabilityError(
301-
string.Format(
302-
RequirementsValidator_NoLookupsFoundForType,
303-
type.Name,
304-
transitionToSchemaName)));
305-
306-
return [.. errors];
307-
}
308-
309-
foreach (var lookupDirective in lookupDirectives)
310-
{
311-
var lookupKeyArg = (string)lookupDirective.Arguments["key"].Value!;
312-
var lookupFieldArg = (string)lookupDirective.Arguments["field"].Value!;
313-
var lookupPathArg = (string?)lookupDirective.Arguments["path"].Value;
314-
315-
var lookupRequirements = ParseSelectionSet($"{{ {lookupKeyArg} }}");
316-
var lookupFieldName = ParseFieldDefinition(lookupFieldArg).Name.Value;
317-
318-
// Ensure that lookup requirements are satisfied.
319-
var requirementErrors =
291+
return SourceSchemaTransitionHelper.ValidateSourceSchemaTransition(
292+
schema,
293+
type,
294+
transitionToSchemaName,
295+
[.. context.Path],
296+
(contextType, parentPathItem, lookupRequirements) =>
320297
Validate(
321298
lookupRequirements,
322-
type,
323-
context.Path.Peek(),
299+
contextType,
300+
parentPathItem,
324301
excludeSchemaName: transitionToSchemaName,
325-
context.CycleDetectionPath);
326-
327-
if (requirementErrors.IsEmpty)
328-
{
329-
return [];
330-
}
331-
332-
var lookupName = lookupPathArg is null
333-
? lookupFieldName
334-
: $"{lookupPathArg}.{lookupFieldName}";
335-
336-
errors.Add(
337-
new SatisfiabilityError(
338-
string.Format(
339-
RequirementsValidator_UnableToSatisfyRequirementForLookup,
340-
lookupRequirements.ToString(indented: false),
341-
lookupName,
342-
transitionToSchemaName),
343-
requirementErrors));
344-
}
345-
346-
return [.. errors];
347-
}
348-
349-
/// <summary>
350-
/// We check whether the path we're currently on exists one-to-one
351-
/// on the given schema or whether a type on the path has a lookup
352-
/// on the given schema.
353-
/// </summary>
354-
private bool CanTransitionToSchemaThroughPath(
355-
SatisfiabilityPath path,
356-
string schemaName)
357-
{
358-
foreach (var pathItem in path)
359-
{
360-
var lookupDirectives =
361-
schema.GetPossibleFusionLookupDirectives(
362-
pathItem.Type,
363-
schemaName);
364-
365-
var hasLookups = lookupDirectives.Count > 0;
366-
var fieldExists = pathItem.Field.ExistsInSchema(schemaName);
367-
368-
if (hasLookups && fieldExists)
369-
{
370-
return true;
371-
}
372-
373-
if (!fieldExists)
374-
{
375-
return false;
376-
}
377-
}
378-
379-
return true;
302+
context.CycleDetectionPath),
303+
RequirementsValidator_NoLookupsFoundForType,
304+
RequirementsValidator_UnableToSatisfyRequirementForLookup);
380305
}
381306
}
382307

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)