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."
+ ]
+ }
+ };
+ }
+}