Skip to content

Commit 9bb4064

Browse files
authored
Fix duplicate validation errors for reused fragments (#9754)
1 parent f0d02df commit 9bb4064

33 files changed

Lines changed: 362 additions & 76 deletions

File tree

src/HotChocolate/Core/src/Authorization/AuthorizeValidationVisitor.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ protected override ISyntaxVisitorAction VisitChildren(
137137
if (context.Fragments.TryEnter(node, out var fragment))
138138
{
139139
var result = Visit(fragment, node, context);
140-
context.Fragments.Leave(fragment);
141140

142141
if (result.IsBreak())
143142
{

src/HotChocolate/Core/src/Validation/DocumentValidatorContext.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ public bool TryEnter(FragmentSpreadNode spread, [NotNullWhen(true)] out Fragment
289289
return false;
290290
}
291291

292+
// Removes the fragment name from _visited so that sibling spreads of the same
293+
// fragment can re-enter and re-walk the body. Callers that want per-spread
294+
// re-walks (e.g. CostAnalyzer) call this on leave. The base validation walker
295+
// intentionally does NOT call Leave so each fragment is walked at most once per
296+
// operation by default, which is what most validation rules want.
292297
public void Leave(FragmentSpreadNode spread)
293298
=> _visited.Remove(spread.Name.Value);
294299

src/HotChocolate/Core/src/Validation/DocumentValidatorVisitor.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ protected override ISyntaxVisitorAction VisitChildren(
6464
if (context.Fragments.TryEnter(node, out var fragment))
6565
{
6666
var result = Visit(fragment, node, context);
67-
context.Fragments.Leave(fragment);
6867

6968
if (result.IsBreak())
7069
{

src/HotChocolate/Core/src/Validation/Extensions/ErrorBuilderExtensions.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ public static ErrorBuilder SetFragmentName(
3737
this ErrorBuilder errorBuilder,
3838
ISyntaxNode node)
3939
{
40-
if (node.Kind == SyntaxKind.FragmentDefinition)
40+
switch (node.Kind)
4141
{
42-
errorBuilder.SetExtension("fragment", ((FragmentDefinitionNode)node).Name.Value);
42+
case SyntaxKind.FragmentDefinition:
43+
errorBuilder.SetExtension("fragment", ((FragmentDefinitionNode)node).Name.Value);
44+
break;
45+
46+
case SyntaxKind.FragmentSpread:
47+
errorBuilder.SetExtension("fragment", ((FragmentSpreadNode)node).Name.Value);
48+
break;
4349
}
4450
return errorBuilder;
4551
}

src/HotChocolate/Core/src/Validation/Rules/DirectiveVisitor.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,12 @@ private static void ValidateDirectives<T>(
171171
context.ReportError(context.DirectiveMustBeUniqueInLocation(directive));
172172
}
173173

174-
// Defer And Stream Directive Labels Are Unique
175-
// The spec iterates over every directive in the document. Because the
176-
// document walker descends into fragment definitions once per spread,
177-
// the same lexical @defer/@stream directive may be visited multiple
178-
// times. Track the processed directive nodes so each is counted once.
174+
// Defer And Stream Directive Labels Are Unique.
175+
// The rule is document-scoped: a label must be unique across all @defer and
176+
// @stream directives in the document. The same lexical directive can be
177+
// reached from multiple operations (each operation re-walks its spread fragments
178+
// independently), so we must track the directive nodes already counted to avoid
179+
// colliding a label with itself when revisited.
179180
if (node.Kind is Field or InlineFragment or FragmentSpread
180181
&& (directive.Name.Value.Equals(DirectiveNames.Defer.Name, StringComparison.Ordinal)
181182
|| directive.Name.Value.Equals(DirectiveNames.Stream.Name, StringComparison.Ordinal))

src/HotChocolate/Core/src/Validation/Rules/FragmentVisitor.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,6 @@ protected override ISyntaxVisitorAction Enter(
151151
{
152152
if (type.IsCompositeType())
153153
{
154-
ValidateFragmentSpreadIsPossible(
155-
node, context,
156-
context.Types.Peek().NamedType(),
157-
type);
158154
context.Types.Push(type);
159155
return Continue;
160156
}
@@ -171,7 +167,6 @@ protected override ISyntaxVisitorAction Leave(
171167
FragmentDefinitionNode node,
172168
DocumentValidatorContext context)
173169
{
174-
context.Fragments.Leave(node);
175170
return base.Leave(node, context);
176171
}
177172

@@ -214,6 +209,18 @@ protected override ISyntaxVisitorAction Enter(
214209
{
215210
context.ReportError(context.FragmentCycleDetected(node));
216211
}
212+
213+
if (context.Schema.Types.TryGetType<IOutputTypeDefinition>(
214+
fragment.TypeCondition.Name.Value,
215+
out var typeCondition)
216+
&& typeCondition.IsCompositeType())
217+
{
218+
ValidateFragmentSpreadIsPossible(
219+
node,
220+
context,
221+
context.Types.Peek().NamedType(),
222+
typeCondition);
223+
}
217224
}
218225
else
219226
{

src/HotChocolate/Core/test/Validation.Tests/AllVariableUsagesAreAllowedRuleTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,28 @@ query Query($intArg: Int) {
361361
);
362362
}
363363

364+
// The rule must fire once per lexical variable usage, not once per fragment spread.
365+
[Fact]
366+
public void IntNullableToIntWithinReusedFragment()
367+
{
368+
ExpectErrors(
369+
"""
370+
fragment nonNullIntArgFieldFrag on Arguments {
371+
nonNullIntArgField(intArg: $intArg)
372+
}
373+
374+
query Query($intArg: Int) {
375+
arguments {
376+
...nonNullIntArgFieldFrag
377+
...nonNullIntArgFieldFrag
378+
}
379+
}
380+
""",
381+
t => Assert.Equal(
382+
"The variable `intArg` is not compatible with the type of the current location.",
383+
t.Message));
384+
}
385+
364386
[Fact]
365387
public void IntNullableToIntWithinNestedFragment()
366388
{

src/HotChocolate/Core/test/Validation.Tests/ArgumentNamesRuleTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ fragment invalidArgName on Dog {
6565
"The argument `dogCommand` is required.", t.Message));
6666
}
6767

68+
// The rule must fire once per lexical argument, not once per fragment spread.
69+
[Fact]
70+
public void InvalidFieldArgNameInReusedFragment()
71+
{
72+
ExpectErrors(
73+
"""
74+
query {
75+
dog {
76+
... invalidArgName
77+
... invalidArgName
78+
}
79+
}
80+
81+
fragment invalidArgName on Dog {
82+
doesKnowCommand(command: CLEAN_UP_HOUSE)
83+
}
84+
""",
85+
t => Assert.Equal(
86+
"The argument `command` does not exist.", t.Message),
87+
t => Assert.Equal(
88+
"The argument `dogCommand` is required.", t.Message));
89+
}
90+
6891
[Fact]
6992
public void InvalidDirectiveArgName()
7093
{

src/HotChocolate/Core/test/Validation.Tests/FragmentSpreadIsPossibleRuleTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,32 @@ ...on Intelligent { iq }
469469
""");
470470
}
471471

472+
// When a fragment is reused under multiple parent types, the spread-is-possible
473+
// check must run for each spread's parent, not only the first one walked. This
474+
// case is order-dependent: a compatible spread first then an incompatible one.
475+
[Fact]
476+
public void FragmentReusedAcrossCompatibleThenIncompatibleParent()
477+
{
478+
ExpectErrors(
479+
"""
480+
{
481+
dog {
482+
...dogNameFragment
483+
}
484+
human {
485+
...dogNameFragment
486+
}
487+
}
488+
489+
fragment dogNameFragment on Dog {
490+
name
491+
}
492+
""",
493+
t => Assert.Equal(
494+
"The parent type does not match the type condition on the fragment.",
495+
t.Message));
496+
}
497+
472498
[Fact]
473499
public void InterfaceIntoNonOverlappingUnion()
474500
{

src/HotChocolate/Core/test/Validation.Tests/FragmentsOnCompositeTypesRuleTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ fragment fragOnScalar on Int {
165165
t.Message));
166166
}
167167

168+
// The rule must fire once per lexical fragment definition, not once per spread.
169+
[Fact]
170+
public void Fragment_On_Scalar_Is_Invalid_When_Reused()
171+
{
172+
ExpectErrors(
173+
"""
174+
{
175+
dog {
176+
... fragOnScalar
177+
... fragOnScalar
178+
}
179+
}
180+
181+
fragment fragOnScalar on Int {
182+
something
183+
}
184+
""",
185+
t => Assert.Equal(
186+
"Fragments can only be declared on unions, interfaces, and objects.",
187+
t.Message));
188+
}
189+
168190
[Fact]
169191
public void InlineFragment_On_Scalar_Is_Invalid()
170192
{

0 commit comments

Comments
 (0)