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 0d41e0883a3..db11ba87062 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -8,6 +8,7 @@ public static class LogEntryCodes public const string ExternalOnInterface = "EXTERNAL_ON_INTERFACE"; public const string ExternalUnused = "EXTERNAL_UNUSED"; public const string InputFieldDefaultMismatch = "INPUT_FIELD_DEFAULT_MISMATCH"; + public const string InputFieldTypesNotMergeable = "INPUT_FIELD_TYPES_NOT_MERGEABLE"; public const string KeyDirectiveInFieldsArg = "KEY_DIRECTIVE_IN_FIELDS_ARG"; public const string KeyFieldsHasArgs = "KEY_FIELDS_HAS_ARGS"; public const string KeyFieldsSelectInvalidType = "KEY_FIELDS_SELECT_INVALID_TYPE"; 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 618a169d442..b97f8f61b08 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -187,6 +187,27 @@ public static LogEntry InputFieldDefaultMismatch( schemaA); } + public static LogEntry InputFieldTypesNotMergeable( + InputFieldDefinition field, + string typeName, + SchemaDefinition schemaA, + SchemaDefinition schemaB) + { + var coordinate = new SchemaCoordinate(typeName, field.Name); + + return new LogEntry( + string.Format( + LogEntryHelper_InputFieldTypesNotMergeable, + coordinate, + schemaA.Name, + schemaB.Name), + LogEntryCodes.InputFieldTypesNotMergeable, + LogSeverity.Error, + coordinate, + field, + schemaA); + } + public static LogEntry KeyDirectiveInFieldsArgument( string entityTypeName, Directive keyDirective, diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/InputFieldTypesMergeableRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/InputFieldTypesMergeableRule.cs new file mode 100644 index 00000000000..d7e5b12d4f3 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/InputFieldTypesMergeableRule.cs @@ -0,0 +1,46 @@ +using HotChocolate.Fusion.Events; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// +/// The input fields of input objects with the same name must be mergeable. This rule ensures that +/// input objects with the same name in different source schemas have fields that can be merged +/// consistently without conflicts. +/// +/// +/// Input fields are considered mergeable when they share the same name and have compatible types. +/// The compatibility of types is determined by their structure (e.g., lists), excluding +/// nullability. Mergeable input fields with different nullability are considered mergeable, +/// and the resulting merged field will be the most permissive of the two. +/// +/// +/// +/// Specification +/// +internal sealed class InputFieldTypesMergeableRule : IEventHandler +{ + public void Handle(InputFieldGroupEvent @event, CompositionContext context) + { + var (_, fieldGroup, typeName) = @event; + + for (var i = 0; i < fieldGroup.Length - 1; i++) + { + var fieldInfoA = fieldGroup[i]; + var fieldInfoB = fieldGroup[i + 1]; + var typeA = fieldInfoA.Field.Type; + var typeB = fieldInfoB.Field.Type; + + if (!ValidationHelper.SameTypeShape(typeA, typeB)) + { + context.Log.Write( + InputFieldTypesNotMergeable( + fieldInfoA.Field, + typeName, + fieldInfoA.Schema, + fieldInfoB.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 cd4eba83903..a4300a30716 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 @@ -158,6 +158,15 @@ internal static string LogEntryHelper_InputFieldDefaultMismatch { } } + /// + /// Looks up a localized string similar to The input field '{0}' has a different type shape in schema '{1}' than it does in schema '{2}'.. + /// + internal static string LogEntryHelper_InputFieldTypesNotMergeable { + get { + return ResourceManager.GetString("LogEntryHelper_InputFieldTypesNotMergeable", 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.. /// 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 f41a27f757f..5daa07cb788 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -51,6 +51,9 @@ The default value '{0}' of input field '{1}' in schema '{2}' differs from the default value of '{3}' in schema '{4}'. + + The input field '{0}' has a different type shape in schema '{1}' than it does in schema '{2}'. + A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not include directive applications. diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs index 1b4d745c70e..0079a5f0c20 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 ExternalOnInterfaceRule(), new ExternalUnusedRule(), new InputFieldDefaultMismatchRule(), + new InputFieldTypesMergeableRule(), new KeyDirectiveInFieldsArgumentRule(), new KeyFieldsHasArgumentsRule(), new KeyFieldsSelectInvalidTypeRule(), diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/InputFieldTypesMergeableRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/InputFieldTypesMergeableRuleTests.cs new file mode 100644 index 00000000000..0321689f437 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/InputFieldTypesMergeableRuleTests.cs @@ -0,0 +1,152 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.PreMergeValidation.Rules; + +namespace HotChocolate.Composition.PreMergeValidation.Rules; + +public sealed class InputFieldTypesMergeableRuleTests : CompositionTestBase +{ + private readonly PreMergeValidator _preMergeValidator = + new([new InputFieldTypesMergeableRule()]); + + [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 == "INPUT_FIELD_TYPES_NOT_MERGEABLE")); + Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error)); + } + + public static TheoryData ValidExamplesData() + { + return new TheoryData + { + // In this example, the field "name" in "AuthorInput" has compatible types across source + // schemas, making them mergeable. + { + [ + """ + input AuthorInput { + name: String! + } + """, + """ + input AuthorInput { + name: String + } + """ + ] + }, + // The following example shows that fields are mergeable if they have different + // nullability but the named type is the same and the list structure is the same. + { + [ + """ + input AuthorInput { + tags: [String!] + } + """, + """ + input AuthorInput { + tags: [String]! + } + """, + """ + input AuthorInput { + tags: [String] + } + """ + ] + }, + // Multiple input fields. + { + [ + """ + input AuthorInput { + name: String! + tags: [String!] + birthdate: DateTime + } + """, + """ + input AuthorInput { + name: String + tags: [String]! + birthdate: DateTime! + } + """ + ] + }, + }; + } + + public static TheoryData InvalidExamplesData() + { + return new TheoryData + { + // In this example, the field "birthdate" on "AuthorInput" is not mergeable as the field + // has different named types ("String" and "DateTime") across source schemas. + { + [ + """ + input AuthorInput { + birthdate: String! + } + """, + """ + input AuthorInput { + birthdate: DateTime! + } + """ + ], + [ + "Input field 'AuthorInput.birthdate' has a different type shape in schema " + + "'A' than it does in schema 'B'." + ] + }, + // List versus non-list. + { + [ + """ + input AuthorInput { + birthdate: String! + } + """, + """ + input AuthorInput { + birthdate: [String!] + } + """ + ], + [ + "Input field 'AuthorInput.birthdate' has a different type shape in schema " + + "'A' than it does in schema 'B'." + ] + } + }; + } +}