Skip to content

Commit

Permalink
[Fusion] Added pre-merge validation rule "ProvidesDirectiveInFieldsAr…
Browse files Browse the repository at this point in the history
…gumentRule"
  • Loading branch information
glen-84 committed Dec 30, 2024
1 parent beebb5e commit 58ee9cc
Show file tree
Hide file tree
Showing 9 changed files with 305 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public static class LogEntryCodes
public const string KeyInvalidFields = "KEY_INVALID_FIELDS";
public const string KeyInvalidSyntax = "KEY_INVALID_SYNTAX";
public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE";
public const string ProvidesDirectiveInFieldsArg = "PROVIDES_DIRECTIVE_IN_FIELDS_ARG";
public const string RootMutationUsed = "ROOT_MUTATION_USED";
public const string RootQueryUsed = "ROOT_QUERY_USED";
public const string RootSubscriptionUsed = "ROOT_SUBSCRIPTION_USED";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,28 @@ public static LogEntry OutputFieldTypesNotMergeable(
schemaA);
}

public static LogEntry ProvidesDirectiveInFieldsArgument(
ImmutableArray<string> fieldNamePath,
Directive providesDirective,
string fieldName,
string typeName,
SchemaDefinition schema)
{
var coordinate = new SchemaCoordinate(typeName, fieldName);

return new LogEntry(
string.Format(
LogEntryHelper_ProvidesDirectiveInFieldsArgument,
coordinate,
schema.Name,
string.Join(".", fieldNamePath)),
LogEntryCodes.ProvidesDirectiveInFieldsArg,
LogSeverity.Error,
coordinate,
providesDirective,
schema);
}

public static LogEntry RootMutationUsed(SchemaDefinition schema)
{
return new LogEntry(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ internal record OutputFieldGroupEvent(
ImmutableArray<OutputFieldInfo> FieldGroup,
string TypeName) : IEvent;

internal record ProvidesFieldNodeEvent(
FieldNode FieldNode,
ImmutableArray<string> FieldNamePath,
Directive ProvidesDirective,
OutputFieldDefinition Field,
ComplexTypeDefinition Type,
SchemaDefinition Schema) : IEvent;

internal record SchemaEvent(SchemaDefinition Schema) : IEvent;

internal record TypeEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ private void PublishEvents(CompositionContext context)
{
PublishEvent(new OutputFieldEvent(field, type, schema), context);

if (field.Directives.ContainsName(WellKnownDirectiveNames.Provides))
{
PublishProvidesEvents(field, complexType, schema, context);
}

foreach (var argument in field.Arguments)
{
PublishEvent(
Expand Down Expand Up @@ -232,6 +237,83 @@ private void PublishKeyFieldEvents(
}
}

private void PublishProvidesEvents(
OutputFieldDefinition field,
ComplexTypeDefinition type,
SchemaDefinition schema,
CompositionContext context)
{
var providesDirective =
field.Directives.First(d => d.Name == WellKnownDirectiveNames.Provides);

if (
!providesDirective.Arguments.TryGetValue(WellKnownArgumentNames.Fields, out var f)
|| f is not StringValueNode fields)
{
return;
}

try
{
var selectionSet = Syntax.ParseSelectionSet($"{{{fields.Value}}}");

PublishProvidesFieldEvents(
selectionSet,
field,
type,
providesDirective,
[],
schema,
context);
}
catch (SyntaxException)
{
// Ignore.
}
}

private void PublishProvidesFieldEvents(
SelectionSetNode selectionSet,
OutputFieldDefinition field,
ComplexTypeDefinition type,
Directive providesDirective,
List<string> fieldNamePath,
SchemaDefinition schema,
CompositionContext context)
{
foreach (var selection in selectionSet.Selections)
{
if (selection is FieldNode fieldNode)
{
fieldNamePath.Add(fieldNode.Name.Value);

PublishEvent(
new ProvidesFieldNodeEvent(
fieldNode,
[.. fieldNamePath],
providesDirective,
field,
type,
schema),
context);

if (fieldNode.SelectionSet is not null)
{
PublishProvidesFieldEvents(
fieldNode.SelectionSet,
field,
type,
providesDirective,
fieldNamePath,
schema,
context);
}

fieldNamePath = [];
}
}
}

private void PublishEvent<TEvent>(TEvent @event, CompositionContext context)
where TEvent : IEvent
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using HotChocolate.Fusion.Events;
using static HotChocolate.Fusion.Logging.LogEntryHelper;

namespace HotChocolate.Fusion.PreMergeValidation.Rules;

/// <summary>
/// The <c>@provides</c> directive is used to specify the set of fields on an object type that a
/// resolver provides for the parent type. The <c>fields</c> argument must consist of a valid
/// GraphQL selection set without any directive applications, as directives within the <c>fields</c>
/// argument are not supported.
/// </summary>
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Directive-in-Fields-Argument">
/// Specification
/// </seealso>
internal sealed class ProvidesDirectiveInFieldsArgumentRule : IEventHandler<ProvidesFieldNodeEvent>
{
public void Handle(ProvidesFieldNodeEvent @event, CompositionContext context)
{
var (fieldNode, fieldNamePath, providesDirective, field, type, schema) = @event;

if (fieldNode.Directives.Count != 0)
{
context.Log.Write(
ProvidesDirectiveInFieldsArgument(
fieldNamePath,
providesDirective,
field.Name,
type.Name,
schema));
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@
<data name="LogEntryHelper_OutputFieldTypesNotMergeable" xml:space="preserve">
<value>Field '{0}' has a different type shape in schema '{1}' than it does in schema '{2}'.</value>
</data>
<data name="LogEntryHelper_ProvidesDirectiveInFieldsArgument" xml:space="preserve">
<value>The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications.</value>
</data>
<data name="LogEntryHelper_RootMutationUsed" xml:space="preserve">
<value>The root mutation type in schema '{0}' must be named 'Mutation'.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ private CompositionResult<SchemaDefinition> MergeSchemaDefinitions(CompositionCo
new KeyInvalidFieldsRule(),
new KeyInvalidSyntaxRule(),
new OutputFieldTypesMergeableRule(),
new ProvidesDirectiveInFieldsArgumentRule(),
new RootMutationUsedRule(),
new RootQueryUsedRule(),
new RootSubscriptionUsedRule()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using HotChocolate.Fusion.Logging;
using HotChocolate.Fusion.PreMergeValidation;
using HotChocolate.Fusion.PreMergeValidation.Rules;

namespace HotChocolate.Composition.PreMergeValidation.Rules;

public sealed class ProvidesDirectiveInFieldsArgumentRuleTests : CompositionTestBase
{
private readonly PreMergeValidator _preMergeValidator =
new([new ProvidesDirectiveInFieldsArgumentRule()]);

[Theory]
[MemberData(nameof(ValidExamplesData))]
public void Examples_Valid(string[] sdl)
{
// arrange
var context = CreateCompositionContext(sdl);

// act
var result = _preMergeValidator.Validate(context);

// assert
Assert.True(result.IsSuccess);
Assert.True(context.Log.IsEmpty);
}

[Theory]
[MemberData(nameof(InvalidExamplesData))]
public void Examples_Invalid(string[] sdl, string[] errorMessages)
{
// arrange
var context = CreateCompositionContext(sdl);

// act
var result = _preMergeValidator.Validate(context);

// assert
Assert.True(result.IsFailure);
Assert.Equal(errorMessages, context.Log.Select(e => e.Message).ToArray());
Assert.True(context.Log.All(e => e.Code == "PROVIDES_DIRECTIVE_IN_FIELDS_ARG"));
Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error));
}

public static TheoryData<string[]> ValidExamplesData()
{
return new TheoryData<string[]>
{
// In this example, the "fields" argument of the @provides directive does not have any
// directive applications, satisfying the rule.
{
[
"""
type User @key(fields: "id name") {
id: ID!
name: String
profile: Profile @provides(fields: "name")
}
type Profile {
id: ID!
name: String
}
"""
]
}
};
}

public static TheoryData<string[], string[]> InvalidExamplesData()
{
return new TheoryData<string[], string[]>
{
// In this example, the "fields" argument of the @provides directive has a directive
// application @lowercase, which is not allowed.
{
[
"""
directive @lowercase on FIELD_DEFINITION
type User @key(fields: "id name") {
id: ID!
name: String
profile: Profile @provides(fields: "name @lowercase")
}
type Profile {
id: ID!
name: String
}
"""
],
[
"The @provides directive on field 'User.profile' in schema 'A' references " +
"field 'name', which must not include directive applications."
]
},
// Nested field.
{
[
"""
directive @lowercase on FIELD_DEFINITION
type User @key(fields: "id name") {
id: ID!
name: String
profile: Profile @provides(fields: "info { name @lowercase }")
}
type Profile {
id: ID!
info: ProfileInfo!
}
type ProfileInfo {
name: String
}
"""
],
[
"The @provides directive on field 'User.profile' in schema 'A' references " +
"field 'info.name', which must not include directive applications."
]
},
// Multiple fields.
{
[
"""
directive @example on FIELD_DEFINITION
type User @key(fields: "id name") {
id: ID!
name: String
profile: Profile @provides(fields: "id @example name @example")
}
"""
],
[
"The @provides directive on field 'User.profile' in schema 'A' references " +
"field 'id', which must not include directive applications.",

"The @provides directive on field 'User.profile' in schema 'A' references " +
"field 'name', which must not include directive applications."
]
}
};
}
}

0 comments on commit 58ee9cc

Please sign in to comment.