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 ac296c61562..dcbadd4f986 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs
@@ -9,4 +9,5 @@ public static class LogEntryCodes
public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE";
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 e49dcbf986e..8ced6e05df8 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs
@@ -184,4 +184,14 @@ public static LogEntry RootQueryUsed(SchemaDefinition schema)
member: schema,
schema: schema);
}
+
+ public static LogEntry RootSubscriptionUsed(SchemaDefinition schema)
+ {
+ return new LogEntry(
+ string.Format(LogEntryHelper_RootSubscriptionUsed, schema.Name),
+ LogEntryCodes.RootSubscriptionUsed,
+ severity: LogSeverity.Error,
+ member: schema,
+ schema: schema);
+ }
}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/RootSubscriptionUsedRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/RootSubscriptionUsedRule.cs
new file mode 100644
index 00000000000..75a3be98811
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/RootSubscriptionUsedRule.cs
@@ -0,0 +1,33 @@
+using HotChocolate.Fusion.Events;
+using static HotChocolate.Fusion.Logging.LogEntryHelper;
+
+namespace HotChocolate.Fusion.PreMergeValidation.Rules;
+
+///
+/// This rule enforces that, for any source schema, if a root subscription type is defined, it must
+/// be named Subscription. Defining a root subscription type with a name other than
+/// Subscription or using a differently named type alongside a type explicitly named
+/// Subscription creates inconsistencies in schema design and violates the composite schema
+/// specification.
+///
+///
+/// Specification
+///
+internal sealed class RootSubscriptionUsedRule : IEventHandler
+{
+ public void Handle(SchemaEvent @event, CompositionContext context)
+ {
+ var schema = @event.Schema;
+ var rootSubscription = schema.SubscriptionType;
+
+ if (rootSubscription is not null
+ && rootSubscription.Name != WellKnownTypeNames.Subscription)
+ {
+ context.Log.Write(RootSubscriptionUsed(schema));
+ }
+
+ // An object type named 'Subscription' will be set as the root subscription type if it has
+ // not yet been defined, so it's not necessary to check for this type in the absence of a
+ // root subscription type.
+ }
+}
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 40c2840b77e..08894be8258 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
@@ -166,5 +166,14 @@ internal static string LogEntryHelper_RootQueryUsed {
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 b2bb14f1183..8fb5e617b33 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx
@@ -54,4 +54,7 @@
The root query type in schema '{0}' must be named 'Query'.
+
+ The root subscription type in schema '{0}' must be named 'Subscription'.
+
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs
index f6e05d361e9..01222282b29 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs
@@ -52,6 +52,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo
new ExternalUnusedRule(),
new OutputFieldTypesMergeableRule(),
new RootMutationUsedRule(),
- new RootQueryUsedRule()
+ new RootQueryUsedRule(),
+ new RootSubscriptionUsedRule()
];
}
diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs
index ae8dd6ee97e..c8726c908c3 100644
--- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs
+++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs
@@ -4,4 +4,5 @@ internal static class WellKnownTypeNames
{
public const string Mutation = "Mutation";
public const string Query = "Query";
+ public const string Subscription = "Subscription";
}
diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/RootSubscriptionUsedRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/RootSubscriptionUsedRuleTests.cs
new file mode 100644
index 00000000000..0e8cba67fee
--- /dev/null
+++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/RootSubscriptionUsedRuleTests.cs
@@ -0,0 +1,97 @@
+using HotChocolate.Fusion.Logging;
+using HotChocolate.Fusion.PreMergeValidation;
+using HotChocolate.Fusion.PreMergeValidation.Rules;
+
+namespace HotChocolate.Composition.PreMergeValidation.Rules;
+
+public sealed class RootSubscriptionUsedRuleTests : CompositionTestBase
+{
+ private readonly PreMergeValidator _preMergeValidator = new([new RootSubscriptionUsedRule()]);
+
+ [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 == "ROOT_SUBSCRIPTION_USED"));
+ Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error));
+ }
+
+ public static TheoryData ValidExamplesData()
+ {
+ return new TheoryData
+ {
+ // Valid example.
+ {
+ [
+ """
+ schema {
+ subscription: Subscription
+ }
+
+ type Subscription {
+ productCreated: Product
+ }
+
+ type Product {
+ id: ID!
+ name: String
+ }
+ """
+ ]
+ }
+ };
+ }
+
+ public static TheoryData InvalidExamplesData()
+ {
+ return new TheoryData
+ {
+ // The following example violates the rule because `RootSubscription` is used as the
+ // root subscription type, but a type named `Subscription` is also defined.
+ {
+ [
+ """
+ schema {
+ subscription: RootSubscription
+ }
+
+ type RootSubscription {
+ productCreated: Product
+ }
+
+ type Subscription {
+ deprecatedField: String
+ }
+ """
+ ],
+ [
+ "The root subscription type in schema 'A' must be named 'Subscription'."
+ ]
+ }
+ };
+ }
+}