Skip to content

Commit

Permalink
Merge branch 'main' into dr/lookup-non-list
Browse files Browse the repository at this point in the history
  • Loading branch information
glen-84 committed Jan 2, 2025
2 parents 5d9ef20 + 0f469a5 commit b74840a
Show file tree
Hide file tree
Showing 17 changed files with 937 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ public static class LogEntryCodes
public const string KeyInvalidFields = "KEY_INVALID_FIELDS";
public const string KeyInvalidSyntax = "KEY_INVALID_SYNTAX";
public const string LookupMustNotReturnList = "LOOKUP_MUST_NOT_RETURN_LIST";
public const string LookupShouldHaveNullableReturnType = "LOOKUP_SHOULD_HAVE_NULLABLE_RETURN_TYPE";
public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE";
public const string ProvidesDirectiveInFieldsArg = "PROVIDES_DIRECTIVE_IN_FIELDS_ARG";
public const string ProvidesFieldsHasArgs = "PROVIDES_FIELDS_HAS_ARGS";
public const string ProvidesFieldsMissingExternal = "PROVIDES_FIELDS_MISSING_EXTERNAL";
public const string ProvidesOnNonCompositeField = "PROVIDES_ON_NON_COMPOSITE_FIELD";
public const string QueryRootTypeInaccessible = "QUERY_ROOT_TYPE_INACCESSIBLE";
public const string RequireDirectiveInFieldsArg = "REQUIRE_DIRECTIVE_IN_FIELDS_ARG";
public const string RequireInvalidFieldsType = "REQUIRE_INVALID_FIELDS_TYPE";
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 @@ -276,6 +276,25 @@ public static LogEntry LookupMustNotReturnList(
schema);
}

public static LogEntry LookupShouldHaveNullableReturnType(
OutputFieldDefinition field,
INamedTypeDefinition type,
SchemaDefinition schema)
{
var coordinate = new SchemaCoordinate(type.Name, field.Name);

return new LogEntry(
string.Format(
LogEntryHelper_LookupShouldHaveNullableReturnType,
coordinate,
schema.Name),
LogEntryCodes.LookupShouldHaveNullableReturnType,
LogSeverity.Warning,
coordinate,
field,
schema);
}

public static LogEntry OutputFieldTypesNotMergeable(
OutputFieldDefinition field,
string typeName,
Expand Down Expand Up @@ -365,6 +384,25 @@ public static LogEntry ProvidesFieldsMissingExternal(
schema);
}

public static LogEntry ProvidesOnNonCompositeField(
OutputFieldDefinition field,
INamedTypeDefinition type,
SchemaDefinition schema)
{
var coordinate = new SchemaCoordinate(type.Name, field.Name);

return new LogEntry(
string.Format(
LogEntryHelper_ProvidesOnNonCompositeField,
coordinate,
schema.Name),
LogEntryCodes.ProvidesOnNonCompositeField,
LogSeverity.Error,
coordinate,
field,
schema);
}

public static LogEntry QueryRootTypeInaccessible(
INamedTypeDefinition type,
SchemaDefinition schema)
Expand All @@ -378,6 +416,47 @@ public static LogEntry QueryRootTypeInaccessible(
schema);
}

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

return new LogEntry(
string.Format(
LogEntryHelper_RequireDirectiveInFieldsArgument,
coordinate,
schema.Name,
string.Join(".", fieldNamePath)),
LogEntryCodes.RequireDirectiveInFieldsArg,
LogSeverity.Error,
coordinate,
requireDirective,
schema);
}

public static LogEntry RequireInvalidFieldsType(
Directive requireDirective,
string argumentName,
string fieldName,
string typeName,
SchemaDefinition schema)
{
var coordinate = new SchemaCoordinate(typeName, fieldName, argumentName);

return new LogEntry(
string.Format(LogEntryHelper_RequireInvalidFieldsType, coordinate, schema.Name),
LogEntryCodes.RequireInvalidFieldsType,
LogSeverity.Error,
coordinate,
requireDirective,
schema);
}

public static LogEntry RootMutationUsed(SchemaDefinition schema)
{
return new LogEntry(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ internal record ProvidesFieldNodeEvent(
ComplexTypeDefinition Type,
SchemaDefinition Schema) : IEvent;

internal record RequireFieldNodeEvent(
FieldNode FieldNode,
ImmutableArray<string> FieldNamePath,
Directive RequireDirective,
InputFieldDefinition Argument,
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 @@ -56,6 +56,11 @@ private void PublishEvents(CompositionContext context)
{
PublishEvent(
new FieldArgumentEvent(argument, field, type, schema), context);

if (argument.Directives.ContainsName(WellKnownDirectiveNames.Require))
{
PublishRequireEvents(argument, field, complexType, schema, context);
}
}
}
}
Expand Down Expand Up @@ -347,6 +352,88 @@ private void PublishProvidesFieldEvents(
}
}

private void PublishRequireEvents(
InputFieldDefinition argument,
OutputFieldDefinition field,
ComplexTypeDefinition type,
SchemaDefinition schema,
CompositionContext context)
{
var requireDirective =
argument.Directives.First(d => d.Name == WellKnownDirectiveNames.Require);

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

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

PublishRequireFieldEvents(
selectionSet,
argument,
field,
type,
requireDirective,
[],
schema,
context);
}
catch (SyntaxException)
{
// Ignore.
}
}

private void PublishRequireFieldEvents(
SelectionSetNode selectionSet,
InputFieldDefinition argument,
OutputFieldDefinition field,
ComplexTypeDefinition type,
Directive requireDirective,
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 RequireFieldNodeEvent(
fieldNode,
[.. fieldNamePath],
requireDirective,
argument,
field,
type,
schema),
context);

if (fieldNode.SelectionSet is not null)
{
PublishRequireFieldEvents(
fieldNode.SelectionSet,
argument,
field,
type,
requireDirective,
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,28 @@
using HotChocolate.Fusion.Events;
using HotChocolate.Skimmed;
using static HotChocolate.Fusion.Logging.LogEntryHelper;

namespace HotChocolate.Fusion.PreMergeValidation.Rules;

/// <summary>
/// Fields annotated with the <c>@lookup</c> directive are intended to retrieve a single entity
/// based on provided arguments. To properly handle cases where the requested entity does not exist,
/// such fields should have a nullable return type. This allows the field to return null when an
/// entity matching the provided criteria is not found, following the standard GraphQL practices for
/// representing missing data.
/// </summary>
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec--lookup-Should-Have-Nullable-Return-Type">
/// Specification
/// </seealso>
internal sealed class LookupShouldHaveNullableReturnTypeRule : IEventHandler<OutputFieldEvent>
{
public void Handle(OutputFieldEvent @event, CompositionContext context)
{
var (field, type, schema) = @event;

if (ValidationHelper.IsLookup(field) && field.Type is NonNullTypeDefinition)
{
context.Log.Write(LookupShouldHaveNullableReturnType(field, type, schema));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using HotChocolate.Fusion.Events;
using HotChocolate.Fusion.Extensions;
using HotChocolate.Skimmed;
using static HotChocolate.Fusion.Logging.LogEntryHelper;

namespace HotChocolate.Fusion.PreMergeValidation.Rules;

/// <summary>
/// The <c>@provides</c> directive allows a field to “provide” additional nested fields on the
/// composite type it returns. If a field’s base type is not an object or interface type (e.g.,
/// String, Int, Boolean, Enum, Union, or an Input type), it cannot hold nested fields for
/// <c>@provides</c> to select. Consequently, attaching <c>@provides</c> to such a field is
/// invalid and raises a PROVIDES_ON_NON_OBJECT_FIELD error.
/// </summary>
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-on-Non-Composite-Field">
/// Specification
/// </seealso>
internal sealed class ProvidesOnNonCompositeFieldRule : IEventHandler<OutputFieldEvent>
{
public void Handle(OutputFieldEvent @event, CompositionContext context)
{
var (field, type, schema) = @event;

if (ValidationHelper.HasProvidesDirective(field))
{
var fieldType = field.Type.NamedType();

if (fieldType is not ComplexTypeDefinition)
{
context.Log.Write(ProvidesOnNonCompositeField(field, type, schema));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using HotChocolate.Fusion.Events;
using static HotChocolate.Fusion.Logging.LogEntryHelper;

namespace HotChocolate.Fusion.PreMergeValidation.Rules;

/// <summary>
/// The <c>@require</c> directive is used to specify fields on the same type that an argument
/// depends on in order to resolve the annotated field. When using <c>@require(fields: "…")</c>, the
/// <c>fields</c> argument must be a valid selection set string <b>without</b> any additional
/// directive applications. Applying a directive (e.g., <c>@lowercase</c>) inside this selection set
/// is not supported and triggers the <c>REQUIRE_DIRECTIVE_IN_FIELDS_ARG</c> error.
/// </summary>
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Directive-in-Fields-Argument">
/// Specification
/// </seealso>
internal sealed class RequireDirectiveInFieldsArgumentRule : IEventHandler<RequireFieldNodeEvent>
{
public void Handle(RequireFieldNodeEvent @event, CompositionContext context)
{
var (fieldNode, fieldNamePath, requireDirective, argument, field, type, schema) = @event;

if (fieldNode.Directives.Count != 0)
{
context.Log.Write(
RequireDirectiveInFieldsArgument(
fieldNamePath,
requireDirective,
argument.Name,
field.Name,
type.Name,
schema));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using HotChocolate.Fusion.Events;
using HotChocolate.Language;
using static HotChocolate.Fusion.Logging.LogEntryHelper;
using static HotChocolate.Fusion.WellKnownArgumentNames;
using static HotChocolate.Fusion.WellKnownDirectiveNames;

namespace HotChocolate.Fusion.PreMergeValidation.Rules;

/// <summary>
/// When using the <c>@require</c> directive, the <c>fields</c> argument must always be a string
/// that defines a (potentially nested) selection set of fields from the same type. If the
/// <c>fields</c> argument is provided as a type other than a string (such as an integer, boolean,
/// or enum), the directive usage is invalid and will cause schema composition to fail.
/// </summary>
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Fields-Type">
/// Specification
/// </seealso>
internal sealed class RequireInvalidFieldsTypeRule : IEventHandler<FieldArgumentEvent>
{
public void Handle(FieldArgumentEvent @event, CompositionContext context)
{
var (argument, field, type, schema) = @event;

var requireDirective = argument.Directives.FirstOrDefault(Require);

if (
requireDirective is not null
&& requireDirective.Arguments.TryGetValue(Fields, out var fields)
&& fields is not StringValueNode)
{
context.Log.Write(
RequireInvalidFieldsType(
requireDirective,
argument.Name,
field.Name,
type.Name,
schema));
}
}
}
Loading

0 comments on commit b74840a

Please sign in to comment.