diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs index ec03940ad15..ff3125b7dd8 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -14,6 +14,7 @@ public static class LogEntryCodes 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 ProvidesFieldsHasArgs = "PROVIDES_FIELDS_HAS_ARGS"; public const string RootMutationUsed = "ROOT_MUTATION_USED"; public const string RootQueryUsed = "ROOT_QUERY_USED"; public const string RootSubscriptionUsed = "ROOT_SUBSCRIPTION_USED"; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs index 982e81a4a6f..2241ee375ef 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -300,6 +300,29 @@ public static LogEntry ProvidesDirectiveInFieldsArgument( schema); } + public static LogEntry ProvidesFieldsHasArguments( + string providedFieldName, + string providedTypeName, + Directive providesDirective, + string fieldName, + string typeName, + SchemaDefinition schema) + { + var coordinate = new SchemaCoordinate(typeName, fieldName); + + return new LogEntry( + string.Format( + LogEntryHelper_ProvidesFieldsHasArguments, + coordinate, + schema.Name, + new SchemaCoordinate(providedTypeName, providedFieldName)), + LogEntryCodes.ProvidesFieldsHasArgs, + LogSeverity.Error, + coordinate, + providesDirective, + schema); + } + public static LogEntry RootMutationUsed(SchemaDefinition schema) { return new LogEntry( diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Events.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Events.cs index fd1b24b6006..ae9c6a0495f 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Events.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Events.cs @@ -62,6 +62,14 @@ internal record OutputFieldGroupEvent( ImmutableArray FieldGroup, string TypeName) : IEvent; +internal record ProvidesFieldEvent( + OutputFieldDefinition ProvidedField, + ComplexTypeDefinition ProvidedType, + Directive ProvidesDirective, + OutputFieldDefinition Field, + ComplexTypeDefinition Type, + SchemaDefinition Schema) : IEvent; + internal record ProvidesFieldNodeEvent( FieldNode FieldNode, ImmutableArray FieldNamePath, diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs index 2cd3d5bc778..40af55b43f6 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs @@ -263,6 +263,7 @@ private void PublishProvidesEvents( type, providesDirective, [], + field.Type, schema, context); } @@ -278,9 +279,12 @@ private void PublishProvidesFieldEvents( ComplexTypeDefinition type, Directive providesDirective, List fieldNamePath, + ITypeDefinition? parentType, SchemaDefinition schema, CompositionContext context) { + ComplexTypeDefinition? nextParentType = null; + foreach (var selection in selectionSet.Selections) { if (selection is FieldNode fieldNode) @@ -297,6 +301,34 @@ private void PublishProvidesFieldEvents( schema), context); + if (parentType?.NullableType() is ComplexTypeDefinition providedType) + { + if ( + providedType.Fields.TryGetField( + fieldNode.Name.Value, + out var providedField)) + { + PublishEvent( + new ProvidesFieldEvent( + providedField, + providedType, + providesDirective, + field, + type, + schema), + context); + + if (providedField.Type.NullableType() is ComplexTypeDefinition fieldType) + { + nextParentType = fieldType; + } + } + else + { + nextParentType = null; + } + } + if (fieldNode.SelectionSet is not null) { PublishProvidesFieldEvents( @@ -305,6 +337,7 @@ private void PublishProvidesFieldEvents( type, providesDirective, fieldNamePath, + nextParentType, schema, context); } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/ProvidesFieldsHasArgumentsRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/ProvidesFieldsHasArgumentsRule.cs new file mode 100644 index 00000000000..7937fe9ead8 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/ProvidesFieldsHasArgumentsRule.cs @@ -0,0 +1,33 @@ +using HotChocolate.Fusion.Events; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// The @provides directive specifies fields that a resolver provides for the parent type. +/// The fields argument must reference fields that do not have arguments, as fields with +/// arguments introduce variability that is incompatible with the consistent behavior expected of +/// @provides. +/// +/// +/// Specification +/// +internal sealed class ProvidesFieldsHasArgumentsRule : IEventHandler +{ + public void Handle(ProvidesFieldEvent @event, CompositionContext context) + { + var (providedField, providedType, providesDirective, field, type, schema) = @event; + + if (providedField.Arguments.Count != 0) + { + context.Log.Write( + ProvidesFieldsHasArguments( + providedField.Name, + providedType.Name, + providesDirective, + field.Name, + type.Name, + schema)); + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs index b28bde6daf0..3c2c8738fa5 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -11,32 +11,46 @@ namespace HotChocolate.Fusion.Properties { using System; - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [System.Diagnostics.DebuggerNonUserCodeAttribute()] - [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class CompositionResources { - private static System.Resources.ResourceManager resourceMan; + private static global::System.Resources.ResourceManager resourceMan; - private static System.Globalization.CultureInfo resourceCulture; + private static global::System.Globalization.CultureInfo resourceCulture; - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal CompositionResources() { } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - internal static System.Resources.ResourceManager ResourceManager { + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { get { - if (object.Equals(null, resourceMan)) { - System.Resources.ResourceManager temp = new System.Resources.ResourceManager("HotChocolate.Fusion.Properties.CompositionResources", typeof(CompositionResources).Assembly); + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.Fusion.Properties.CompositionResources", typeof(CompositionResources).Assembly); resourceMan = temp; } return resourceMan; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - internal static System.Globalization.CultureInfo Culture { + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -45,120 +59,189 @@ internal static System.Globalization.CultureInfo Culture { } } + /// + /// Looks up a localized string similar to Pre-merge validation failed. View the composition log for details.. + /// internal static string ErrorHelper_PreMergeValidationFailed { get { return ResourceManager.GetString("ErrorHelper_PreMergeValidationFailed", resourceCulture); } } + /// + /// Looks up a localized string similar to The built-in scalar type '{0}' in schema '{1}' is not accessible.. + /// internal static string LogEntryHelper_DisallowedInaccessibleBuiltInScalar { get { return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleBuiltInScalar", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionType { + /// + /// Looks up a localized string similar to The built-in directive argument '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleDirectiveArgument { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionType", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleDirectiveArgument", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionField { + /// + /// Looks up a localized string similar to The introspection argument '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionArgument { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionField", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionArgument", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionArgument { + /// + /// Looks up a localized string similar to The introspection field '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionField { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionArgument", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionField", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleDirectiveArgument { + /// + /// Looks up a localized string similar to The introspection type '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionType { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleDirectiveArgument", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionType", resourceCulture); } } + /// + /// Looks up a localized string similar to The argument with schema coordinate '{0}' has inconsistent default values.. + /// internal static string LogEntryHelper_ExternalArgumentDefaultMismatch { get { return ResourceManager.GetString("LogEntryHelper_ExternalArgumentDefaultMismatch", resourceCulture); } } + /// + /// Looks up a localized string similar to External field '{0}' in schema '{1}' is not defined (non-external) in any other schema.. + /// internal static string LogEntryHelper_ExternalMissingOnBase { get { return ResourceManager.GetString("LogEntryHelper_ExternalMissingOnBase", resourceCulture); } } + /// + /// Looks up a localized string similar to Interface field '{0}' in schema '{1}' must not be marked as external.. + /// internal static string LogEntryHelper_ExternalOnInterface { get { return ResourceManager.GetString("LogEntryHelper_ExternalOnInterface", resourceCulture); } } + /// + /// Looks up a localized string similar to External field '{0}' in schema '{1}' is not referenced by a @provides directive in the schema.. + /// internal static string LogEntryHelper_ExternalUnused { get { return ResourceManager.GetString("LogEntryHelper_ExternalUnused", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not include directive applications.. + /// internal static string LogEntryHelper_KeyDirectiveInFieldsArgument { get { return ResourceManager.GetString("LogEntryHelper_KeyDirectiveInFieldsArgument", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not have arguments.. + /// internal static string LogEntryHelper_KeyFieldsHasArguments { get { return ResourceManager.GetString("LogEntryHelper_KeyFieldsHasArguments", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not be a list, interface, or union type.. + /// internal static string LogEntryHelper_KeyFieldsSelectInvalidType { get { return ResourceManager.GetString("LogEntryHelper_KeyFieldsSelectInvalidType", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which does not exist.. + /// internal static string LogEntryHelper_KeyInvalidFields { get { return ResourceManager.GetString("LogEntryHelper_KeyInvalidFields", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' contains invalid syntax in the 'fields' argument.. + /// internal static string LogEntryHelper_KeyInvalidSyntax { get { return ResourceManager.GetString("LogEntryHelper_KeyInvalidSyntax", resourceCulture); } } + /// + /// Looks up a localized string similar to Field '{0}' has a different type shape in schema '{1}' than it does in schema '{2}'.. + /// internal static string LogEntryHelper_OutputFieldTypesNotMergeable { get { return ResourceManager.GetString("LogEntryHelper_OutputFieldTypesNotMergeable", resourceCulture); } } + /// + /// Looks up a localized string similar to The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications.. + /// internal static string LogEntryHelper_ProvidesDirectiveInFieldsArgument { get { return ResourceManager.GetString("LogEntryHelper_ProvidesDirectiveInFieldsArgument", resourceCulture); } } + /// + /// Looks up a localized string similar to The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not have arguments.. + /// + internal static string LogEntryHelper_ProvidesFieldsHasArguments { + get { + return ResourceManager.GetString("LogEntryHelper_ProvidesFieldsHasArguments", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The root mutation type in schema '{0}' must be named 'Mutation'.. + /// internal static string LogEntryHelper_RootMutationUsed { get { return ResourceManager.GetString("LogEntryHelper_RootMutationUsed", resourceCulture); } } + /// + /// Looks up a localized string similar to The root query type in schema '{0}' must be named 'Query'.. + /// internal static string LogEntryHelper_RootQueryUsed { get { return ResourceManager.GetString("LogEntryHelper_RootQueryUsed", resourceCulture); } } + /// + /// Looks up a localized string similar to The root subscription type in schema '{0}' must be named 'Subscription'.. + /// internal static string LogEntryHelper_RootSubscriptionUsed { get { return ResourceManager.GetString("LogEntryHelper_RootSubscriptionUsed", resourceCulture); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx index a5cf669812b..18c3c09e46c 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -69,6 +69,9 @@ The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications. + + The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not have arguments. + The root mutation type in schema '{0}' must be named 'Mutation'. diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs index 522f73dde0c..f386226e26b 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -58,6 +58,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo new KeyInvalidSyntaxRule(), new OutputFieldTypesMergeableRule(), new ProvidesDirectiveInFieldsArgumentRule(), + new ProvidesFieldsHasArgumentsRule(), new RootMutationUsedRule(), new RootQueryUsedRule(), new RootSubscriptionUsedRule() diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/ProvidesFieldsHasArgumentsRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/ProvidesFieldsHasArgumentsRuleTests.cs new file mode 100644 index 00000000000..bc71f7a9a4a --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/ProvidesFieldsHasArgumentsRuleTests.cs @@ -0,0 +1,127 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.PreMergeValidation.Rules; + +namespace HotChocolate.Composition.PreMergeValidation.Rules; + +public sealed class ProvidesFieldsHasArgumentsRuleTests : CompositionTestBase +{ + private readonly PreMergeValidator _preMergeValidator = + new([new ProvidesFieldsHasArgumentsRule()]); + + [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_FIELDS_HAS_ARGS")); + Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error)); + } + + public static TheoryData ValidExamplesData() + { + return new TheoryData + { + { + [ + """ + type User @key(fields: "id") { + id: ID! + tags: [String] + } + + type Article @key(fields: "id") { + id: ID! + author: User! @provides(fields: "tags") + } + """ + ] + } + }; + } + + public static TheoryData InvalidExamplesData() + { + return new TheoryData + { + // This violates the rule because the "tags" field referenced in the "fields" argument + // of the @provides directive is defined with arguments ("limit: UserType = ADMIN"). + { + [ + """ + type User @key(fields: "id") { + id: ID! + tags(limit: UserType = ADMIN): [String] + } + + enum UserType { + REGULAR + ADMIN + } + + type Article @key(fields: "id") { + id: ID! + author: User! @provides(fields: "tags") + } + """ + ], + [ + "The @provides directive on field 'Article.author' in schema 'A' references " + + "field 'User.tags', which must not have arguments." + ] + }, + // Nested field. + { + [ + """ + type User @key(fields: "id") { + id: ID! + info: UserInfo + } + + type UserInfo { + tags(limit: UserType = ADMIN): [String] + } + + enum UserType { + REGULAR + ADMIN + } + + type Article @key(fields: "id") { + id: ID! + author: User! @provides(fields: "info { tags }") + } + """ + ], + [ + "The @provides directive on field 'Article.author' in schema 'A' references " + + "field 'UserInfo.tags', which must not have arguments." + ] + } + }; + } +}