Skip to content

Commit cb62bfd

Browse files
authored
[Fusion] Added post-merge validation rule "NoQueriesRule" (#7988)
1 parent 15bfd32 commit cb62bfd

File tree

7 files changed

+213
-1
lines changed

7 files changed

+213
-1
lines changed

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public static class LogEntryCodes
2626
public const string KeyInvalidSyntax = "KEY_INVALID_SYNTAX";
2727
public const string LookupReturnsList = "LOOKUP_RETURNS_LIST";
2828
public const string LookupReturnsNonNullableType = "LOOKUP_RETURNS_NON_NULLABLE_TYPE";
29+
public const string NoQueries = "NO_QUERIES";
2930
public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE";
3031
public const string OverrideFromSelf = "OVERRIDE_FROM_SELF";
3132
public const string OverrideOnInterface = "OVERRIDE_ON_INTERFACE";

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,17 @@ public static LogEntry LookupReturnsNonNullableType(
492492
schema);
493493
}
494494

495+
public static LogEntry NoQueries(ObjectTypeDefinition queryType, SchemaDefinition schema)
496+
{
497+
return new LogEntry(
498+
string.Format(LogEntryHelper_NoQueries),
499+
LogEntryCodes.NoQueries,
500+
LogSeverity.Error,
501+
new SchemaCoordinate(queryType.Name),
502+
queryType,
503+
schema);
504+
}
505+
495506
public static LogEntry OutputFieldTypesNotMergeable(
496507
OutputFieldDefinition field,
497508
string typeName,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using HotChocolate.Fusion.Events;
2+
using HotChocolate.Fusion.Events.Contracts;
3+
using HotChocolate.Fusion.Extensions;
4+
using static HotChocolate.Fusion.Logging.LogEntryHelper;
5+
6+
namespace HotChocolate.Fusion.PostMergeValidationRules;
7+
8+
/// <summary>
9+
/// <para>
10+
/// This rule ensures that the composed schema includes at least one accessible field on the root
11+
/// <c>Query</c> type.
12+
/// </para>
13+
/// <para>
14+
/// In GraphQL, the <c>Query</c> type is essential as it defines the entry points for read
15+
/// operations. If none of the composed schemas expose any query fields, the composed schema would
16+
/// lack a root query, making it an invalid GraphQL schema.
17+
/// </para>
18+
/// </summary>
19+
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-No-Queries">
20+
/// Specification
21+
/// </seealso>
22+
internal sealed class NoQueriesRule : IEventHandler<ObjectTypeEvent>
23+
{
24+
public void Handle(ObjectTypeEvent @event, CompositionContext context)
25+
{
26+
var (objectType, schema) = @event;
27+
28+
if (objectType != schema.QueryType)
29+
{
30+
return;
31+
}
32+
33+
var accessibleFields = objectType.Fields.Where(f => !f.HasInaccessibleDirective());
34+
35+
if (!accessibleFields.Any())
36+
{
37+
context.Log.Write(NoQueries(objectType, schema));
38+
}
39+
}
40+
}

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@
114114
<data name="LogEntryHelper_LookupReturnsNonNullableType" xml:space="preserve">
115115
<value>The lookup field '{0}' in schema '{1}' should return a nullable type.</value>
116116
</data>
117+
<data name="LogEntryHelper_NoQueries" xml:space="preserve">
118+
<value>The merged query type has no accessible fields.</value>
119+
</data>
117120
<data name="LogEntryHelper_OutputFieldTypesNotMergeable" xml:space="preserve">
118121
<value>The output field '{0}' has a different type shape in schema '{1}' than it does in schema '{2}'.</value>
119122
</data>

src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public CompositionResult<SchemaDefinition> Compose()
123123
new EmptyMergedEnumTypeRule(),
124124
new EmptyMergedInterfaceTypeRule(),
125125
new EmptyMergedObjectTypeRule(),
126-
new EmptyMergedUnionTypeRule()
126+
new EmptyMergedUnionTypeRule(),
127+
new NoQueriesRule()
127128
];
128129
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using System.Collections.Immutable;
2+
using HotChocolate.Fusion.Logging;
3+
4+
namespace HotChocolate.Fusion.PostMergeValidationRules;
5+
6+
public sealed class NoQueriesRuleTests : CompositionTestBase
7+
{
8+
private static readonly object s_rule = new NoQueriesRule();
9+
private static readonly ImmutableArray<object> s_rules = [s_rule];
10+
private readonly CompositionLog _log = new();
11+
12+
[Theory]
13+
[MemberData(nameof(ValidExamplesData))]
14+
public void Examples_Valid(string[] sdl)
15+
{
16+
// arrange
17+
var schemas = CreateSchemaDefinitions(sdl);
18+
var merger = new SourceSchemaMerger(schemas);
19+
var mergeResult = merger.Merge();
20+
var validator = new PostMergeValidator(mergeResult.Value, s_rules, schemas, _log);
21+
22+
// act
23+
var result = validator.Validate();
24+
25+
// assert
26+
Assert.True(result.IsSuccess);
27+
Assert.True(_log.IsEmpty);
28+
}
29+
30+
[Theory]
31+
[MemberData(nameof(InvalidExamplesData))]
32+
public void Examples_Invalid(string[] sdl, string[] errorMessages)
33+
{
34+
// arrange
35+
var schemas = CreateSchemaDefinitions(sdl);
36+
var merger = new SourceSchemaMerger(schemas);
37+
var mergeResult = merger.Merge();
38+
var validator = new PostMergeValidator(mergeResult.Value, s_rules, schemas, _log);
39+
40+
// act
41+
var result = validator.Validate();
42+
43+
// assert
44+
Assert.True(result.IsFailure);
45+
Assert.Equal(errorMessages, _log.Select(e => e.Message).ToArray());
46+
Assert.True(_log.All(e => e.Code == "NO_QUERIES"));
47+
Assert.True(_log.All(e => e.Severity == LogSeverity.Error));
48+
}
49+
50+
public static TheoryData<string[]> ValidExamplesData()
51+
{
52+
return new TheoryData<string[]>
53+
{
54+
// In this example, at least one schema provides accessible query fields, satisfying the
55+
// rule.
56+
{
57+
[
58+
"""
59+
# Schema A
60+
type Query {
61+
product(id: ID!): Product
62+
}
63+
64+
type Product {
65+
id: ID!
66+
}
67+
""",
68+
"""
69+
# Schema B
70+
type Query {
71+
review(id: ID!): Review
72+
}
73+
74+
type Review {
75+
id: ID!
76+
content: String
77+
rating: Int
78+
}
79+
"""
80+
]
81+
},
82+
// Even if some query fields are marked as @inaccessible, as long as there is at least
83+
// one accessible query field in the composed schema, the rule is satisfied.
84+
{
85+
[
86+
"""
87+
# Schema A
88+
type Query {
89+
internalData: InternalData @inaccessible
90+
}
91+
92+
type InternalData {
93+
secret: String
94+
}
95+
""",
96+
"""
97+
# Schema B
98+
type Query {
99+
product(id: ID!): Product
100+
}
101+
102+
type Product {
103+
id: ID!
104+
name: String
105+
}
106+
"""
107+
]
108+
}
109+
};
110+
}
111+
112+
public static TheoryData<string[], string[]> InvalidExamplesData()
113+
{
114+
return new TheoryData<string[], string[]>
115+
{
116+
// If all query fields in all schemas are marked as @inaccessible, the composed schema
117+
// will lack accessible query fields, violating the rule.
118+
{
119+
[
120+
"""
121+
# Schema A
122+
type Query {
123+
internalData: InternalData @inaccessible
124+
}
125+
126+
type InternalData {
127+
secret: String
128+
}
129+
""",
130+
"""
131+
# Schema B
132+
type Query {
133+
adminStats: AdminStats @inaccessible
134+
}
135+
136+
type AdminStats {
137+
userCount: Int
138+
}
139+
"""
140+
],
141+
[
142+
"The merged query type has no accessible fields."
143+
]
144+
}
145+
};
146+
}
147+
}

0 commit comments

Comments
 (0)