Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fusion] Added pre-merge validation rule "ProvidesDirectiveInFieldsArgumentRule" #7881

Merged
merged 1 commit into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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."
]
}
};
}
}
Loading