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 db11ba87062..a37f8b4d3a9 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -3,6 +3,7 @@ namespace HotChocolate.Fusion.Logging; public static class LogEntryCodes { public const string DisallowedInaccessible = "DISALLOWED_INACCESSIBLE"; + public const string EnumTypesInconsistent = "ENUM_TYPES_INCONSISTENT"; public const string ExternalArgumentDefaultMismatch = "EXTERNAL_ARGUMENT_DEFAULT_MISMATCH"; public const string ExternalMissingOnBase = "EXTERNAL_MISSING_ON_BASE"; public const string ExternalOnInterface = "EXTERNAL_ON_INTERFACE"; 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 b97f8f61b08..269559d545d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -100,6 +100,24 @@ public static LogEntry DisallowedInaccessibleDirectiveArgument( schema); } + public static LogEntry EnumTypesInconsistent( + EnumTypeDefinition enumType, + string enumValue, + SchemaDefinition schema) + { + return new LogEntry( + string.Format( + LogEntryHelper_EnumTypesInconsistent, + enumType.Name, + schema.Name, + enumValue), + LogEntryCodes.EnumTypesInconsistent, + LogSeverity.Error, + new SchemaCoordinate(enumType.Name), + enumType, + schema); + } + public static LogEntry ExternalArgumentDefaultMismatch( string argumentName, string fieldName, 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 bb4fdd4d60d..e73cd368608 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Events.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Events.cs @@ -15,6 +15,14 @@ internal record DirectiveEvent( DirectiveDefinition Directive, SchemaDefinition Schema) : IEvent; +internal record EnumTypeEvent( + EnumTypeDefinition Type, + SchemaDefinition Schema) : IEvent; + +internal record EnumTypeGroupEvent( + string TypeName, + ImmutableArray TypeGroup) : IEvent; + internal record FieldArgumentEvent( InputFieldDefinition Argument, OutputFieldDefinition Field, diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Info/EnumTypeInfo.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Info/EnumTypeInfo.cs new file mode 100644 index 00000000000..23d9659ac7e --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Info/EnumTypeInfo.cs @@ -0,0 +1,5 @@ +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion.PreMergeValidation.Info; + +internal record EnumTypeInfo(EnumTypeDefinition Type, SchemaDefinition Schema); 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 664e9c1f9ce..105cc78d4e3 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs @@ -65,6 +65,10 @@ private void PublishEvents(CompositionContext context) } } } + else if (type is EnumTypeDefinition enumType) + { + PublishEvent(new EnumTypeEvent(enumType, schema), context); + } } foreach (var directive in schema.DirectiveDefinitions) @@ -84,6 +88,7 @@ private void PublishEvents(CompositionContext context) MultiValueDictionary inputFieldGroupByName = []; MultiValueDictionary outputFieldGroupByName = []; + MultiValueDictionary enumTypeGroupByName = []; foreach (var (type, schema) in typeGroup) { @@ -108,6 +113,10 @@ private void PublishEvents(CompositionContext context) } break; + + case EnumTypeDefinition enumType: + enumTypeGroupByName.Add(enumType.Name, new EnumTypeInfo(enumType, schema)); + break; } } @@ -145,6 +154,11 @@ private void PublishEvents(CompositionContext context) context); } } + + foreach (var (enumName, enumGroup) in enumTypeGroupByName) + { + PublishEvent(new EnumTypeGroupEvent(enumName, [.. enumGroup]), context); + } } } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/EnumTypesInconsistentRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/EnumTypesInconsistentRule.cs new file mode 100644 index 00000000000..85944355fa4 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/EnumTypesInconsistentRule.cs @@ -0,0 +1,53 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Events; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// +/// This rule ensures that enum types with the same name across different source schemas in a +/// composite schema have identical sets of values. Enums must be consistent across source schemas +/// to avoid conflicts and ambiguities in the composite schema. +/// +/// +/// When an enum is defined with differing values, it can lead to confusion and errors in query +/// execution. For instance, a value valid in one schema might be passed to another where it’s +/// unrecognized, leading to unexpected behavior or failures. This rule prevents such +/// inconsistencies by enforcing that all instances of the same named enum across schemas have an +/// exact match in their values. +/// +/// +/// +/// Specification +/// +internal sealed class EnumTypesInconsistentRule : IEventHandler +{ + public void Handle(EnumTypeGroupEvent @event, CompositionContext context) + { + var (_, enumGroup) = @event; + + if (enumGroup.Length < 2) + { + return; + } + + var enumValues = enumGroup + .SelectMany(e => e.Type.Values) + .Where(ValidationHelper.IsAccessible) + .Select(v => v.Name) + .ToImmutableHashSet(); + + foreach (var (enumType, schema) in enumGroup) + { + foreach (var enumValue in enumValues) + { + if (!enumType.Values.ContainsName(enumValue)) + { + context.Log.Write( + EnumTypesInconsistent(enumType, enumValue, 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 a4300a30716..1e7f11d4fa0 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 @@ -113,6 +113,15 @@ internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionType { } } + /// + /// Looks up a localized string similar to The enum type '{0}' in schema '{1}' must define the value '{2}'.. + /// + internal static string LogEntryHelper_EnumTypesInconsistent { + get { + return ResourceManager.GetString("LogEntryHelper_EnumTypesInconsistent", resourceCulture); + } + } + /// /// Looks up a localized string similar to The argument with schema coordinate '{0}' has inconsistent default values.. /// 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 5daa07cb788..fd7394c6db5 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -36,6 +36,9 @@ The built-in directive argument '{0}' in schema '{1}' is not accessible. + + The enum type '{0}' in schema '{1}' must define the value '{2}'. + The argument with schema coordinate '{0}' has inconsistent default values. diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs index 0079a5f0c20..ba89727622d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -47,6 +47,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo private static readonly List _preMergeValidationRules = [ new DisallowedInaccessibleElementsRule(), + new EnumTypesInconsistentRule(), new ExternalArgumentDefaultMismatchRule(), new ExternalMissingOnBaseRule(), new ExternalOnInterfaceRule(), diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/EnumTypesInconsistentRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/EnumTypesInconsistentRuleTests.cs new file mode 100644 index 00000000000..0b59e5bc9fa --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/EnumTypesInconsistentRuleTests.cs @@ -0,0 +1,128 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.PreMergeValidation.Rules; + +namespace HotChocolate.Composition.PreMergeValidation.Rules; + +public sealed class EnumTypesInconsistentRuleTests : CompositionTestBase +{ + private readonly PreMergeValidator _preMergeValidator = new([new EnumTypesInconsistentRule()]); + + [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 == "ENUM_TYPES_INCONSISTENT")); + Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error)); + } + + public static TheoryData ValidExamplesData() + { + return new TheoryData + { + // In this example, both source schemas define "Genre" with the same value "FANTASY", + // satisfying the rule. + { + [ + """ + enum Genre { + FANTASY + } + """, + """ + enum Genre { + FANTASY + } + """ + ] + }, + // Here, the two definitions of "Genre" have shared values and additional values + // declared as @inaccessible, satisfying the rule. + { + [ + """ + enum Genre { + FANTASY + SCIENCE_FICTION @inaccessible + } + """, + """ + enum Genre { + FANTASY + } + """ + ] + }, + // Here, the two definitions of "Genre" have shared values in a differing order. + { + [ + """ + enum Genre { + FANTASY + SCIENCE_FICTION @inaccessible + ANIMATED + } + """, + """ + enum Genre { + ANIMATED + FANTASY + CRIME @inaccessible + } + """ + ] + } + }; + } + + public static TheoryData InvalidExamplesData() + { + return new TheoryData + { + // Here, the two definitions of "Genre" have different values ("FANTASY" and + // "SCIENCE_FICTION"), violating the rule. + { + [ + """ + enum Genre { + FANTASY + } + """, + """ + enum Genre { + SCIENCE_FICTION + } + """ + ], + [ + "The enum type 'Genre' in schema 'A' must define the value 'SCIENCE_FICTION'.", + "The enum type 'Genre' in schema 'B' must define the value 'FANTASY'." + ] + } + }; + } +}