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 ff3125b7dd8..901dd55e299 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -15,6 +15,7 @@ public static class LogEntryCodes 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 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 2241ee375ef..033f5d0a955 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -323,6 +323,29 @@ public static LogEntry ProvidesFieldsHasArguments( schema); } + public static LogEntry ProvidesFieldsMissingExternal( + string providedFieldName, + string providedTypeName, + Directive providesDirective, + string fieldName, + string typeName, + SchemaDefinition schema) + { + var coordinate = new SchemaCoordinate(typeName, fieldName); + + return new LogEntry( + string.Format( + LogEntryHelper_ProvidesFieldsMissingExternal, + coordinate, + schema.Name, + new SchemaCoordinate(providedTypeName, providedFieldName)), + LogEntryCodes.ProvidesFieldsMissingExternal, + 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/Rules/ProvidesFieldsMissingExternalRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/ProvidesFieldsMissingExternalRule.cs new file mode 100644 index 00000000000..586de9d14a1 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/ProvidesFieldsMissingExternalRule.cs @@ -0,0 +1,40 @@ +using HotChocolate.Fusion.Events; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// +/// The @provides directive indicates that an object type field will supply additional fields +/// belonging to the return type in this execution-specific path. Any field listed in the +/// @provides(fields: ...) argument must therefore be external in the local schema, +/// meaning that the local schema itself does not provide it. +/// +/// +/// This rule disallows selecting non-external fields in a @provides selection set. If a +/// field is already provided by the same schema in all execution paths, there is no need to +/// @provide. +/// +/// +/// +/// Specification +/// +internal sealed class ProvidesFieldsMissingExternalRule : IEventHandler +{ + public void Handle(ProvidesFieldEvent @event, CompositionContext context) + { + var (providedField, providedType, providesDirective, field, type, schema) = @event; + + if (!ValidationHelper.IsExternal(providedField)) + { + context.Log.Write( + ProvidesFieldsMissingExternal( + 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 3c2c8738fa5..e7aba369147 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 @@ -221,6 +221,15 @@ internal static string LogEntryHelper_ProvidesFieldsHasArguments { } } + /// + /// Looks up a localized string similar to The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must be marked as external.. + /// + internal static string LogEntryHelper_ProvidesFieldsMissingExternal { + get { + return ResourceManager.GetString("LogEntryHelper_ProvidesFieldsMissingExternal", resourceCulture); + } + } + /// /// Looks up a localized string similar to The root mutation type in schema '{0}' must be named 'Mutation'.. /// 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 18c3c09e46c..41a8ac9b472 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -72,6 +72,9 @@ The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not have arguments. + + The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must be marked as external. + 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 f386226e26b..2b72ad33037 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -59,6 +59,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo new OutputFieldTypesMergeableRule(), new ProvidesDirectiveInFieldsArgumentRule(), new ProvidesFieldsHasArgumentsRule(), + new ProvidesFieldsMissingExternalRule(), new RootMutationUsedRule(), new RootQueryUsedRule(), new RootSubscriptionUsedRule() diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/ProvidesFieldsMissingExternalRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/ProvidesFieldsMissingExternalRuleTests.cs new file mode 100644 index 00000000000..ee7ed635b1e --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/ProvidesFieldsMissingExternalRuleTests.cs @@ -0,0 +1,125 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.PreMergeValidation.Rules; + +namespace HotChocolate.Composition.PreMergeValidation.Rules; + +public sealed class ProvidesFieldsMissingExternalRuleTests : CompositionTestBase +{ + private readonly PreMergeValidator _preMergeValidator = + new([new ProvidesFieldsMissingExternalRule()]); + + [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_MISSING_EXTERNAL")); + Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error)); + } + + public static TheoryData ValidExamplesData() + { + return new TheoryData + { + // Here, the "Order" type from this schema is providing fields on "User" through + // @provides. The "name" field of "User" is not defined in this schema; it is declared + // with @external indicating that the "name" field comes from elsewhere. Thus, + // referencing "name" under @provides(fields: "name") is valid. + { + [ + """ + type Order { + id: ID! + customer: User @provides(fields: "name") + } + + type User @key(fields: "id") { + id: ID! + name: String @external + } + """ + ] + } + }; + } + + public static TheoryData InvalidExamplesData() + { + return new TheoryData + { + // In this example, "User.address" is not marked as @external in the same schema that + // applies @provides. This means the schema already provides the "address" field in all + // possible paths, so using @provides(fields: "address") is invalid. + { + [ + """ + type User { + id: ID! + address: String + } + + type Order { + id: ID! + buyer: User @provides(fields: "address") + } + """ + ], + [ + "The @provides directive on field 'Order.buyer' in schema 'A' references " + + "field 'User.address', which must be marked as external." + ] + }, + // Nested field. + { + [ + """ + type User { + id: ID! + info: UserInfo + } + + type UserInfo { + address: String + } + + type Order { + id: ID! + buyer: User @provides(fields: "info { address }") + } + """ + ], + [ + "The @provides directive on field 'Order.buyer' in schema 'A' references " + + "field 'User.info', which must be marked as external.", + + "The @provides directive on field 'Order.buyer' in schema 'A' references " + + "field 'UserInfo.address', which must be marked as external." + ] + } + }; + } +}