diff --git a/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.Designer.cs b/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.Designer.cs index e784e19f1df..dd211007a8b 100644 --- a/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.Designer.cs +++ b/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.Designer.cs @@ -68,6 +68,33 @@ internal static string NonNullType_InnerTypeCannotBeNonNull { } } + /// + /// Looks up a localized string similar to The argument '{0}' on field '{1}' was extended with a different default value than the original definition.. + /// + internal static string SchemaParser_ArgumentDefaultValueMismatchInExtension { + get { + return ResourceManager.GetString("SchemaParser_ArgumentDefaultValueMismatchInExtension", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The description on argument '{0}' of field '{1}' in the extension does not match the original definition.. + /// + internal static string SchemaParser_ArgumentDescriptionMismatchInExtension { + get { + return ResourceManager.GetString("SchemaParser_ArgumentDescriptionMismatchInExtension", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The argument '{0}' on field '{1}' was extended with a different type than the original definition.. + /// + internal static string SchemaParser_ArgumentTypeMismatchInExtension { + get { + return ResourceManager.GetString("SchemaParser_ArgumentTypeMismatchInExtension", resourceCulture); + } + } + /// /// Looks up a localized string similar to An argument with the name '{0}' has already been defined on the field '{1}'.. /// @@ -113,6 +140,24 @@ internal static string SchemaParser_DuplicateTypeDefinition { } } + /// + /// Looks up a localized string similar to The description on field '{0}' of type '{1}' in the extension does not match the original definition.. + /// + internal static string SchemaParser_FieldDescriptionMismatchInExtension { + get { + return ResourceManager.GetString("SchemaParser_FieldDescriptionMismatchInExtension", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The field '{0}' on type '{1}' was extended with a different type than the original definition.. + /// + internal static string SchemaParser_FieldTypeMismatchInExtension { + get { + return ResourceManager.GetString("SchemaParser_FieldTypeMismatchInExtension", resourceCulture); + } + } + /// /// Looks up a localized string similar to The argument '{0}' must accept an input type.. /// @@ -148,5 +193,14 @@ internal static string SchemaParser_InvalidUnionMemberType { return ResourceManager.GetString("SchemaParser_InvalidUnionMemberType", resourceCulture); } } + + /// + /// Looks up a localized string similar to The non-repeatable directive '@{0}' was already applied to '{1}' and cannot be applied again by an extension.. + /// + internal static string SchemaParser_NonRepeatableDirectiveAlreadyApplied { + get { + return ResourceManager.GetString("SchemaParser_NonRepeatableDirectiveAlreadyApplied", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.resx b/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.resx index 0f3fad8a039..a5f0adca084 100644 --- a/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.resx +++ b/src/HotChocolate/Mutable/src/Types.Mutable/Properties/MutableResources.resx @@ -21,6 +21,15 @@ The inner type cannot be a non-null type. + + The argument '{0}' on field '{1}' was extended with a different default value than the original definition. + + + The description on argument '{0}' of field '{1}' in the extension does not match the original definition. + + + The argument '{0}' on field '{1}' was extended with a different type than the original definition. + An argument with the name '{0}' has already been defined on the field '{1}'. @@ -36,6 +45,12 @@ A type with the name '{0}' has already been defined. + + The description on field '{0}' of type '{1}' in the extension does not match the original definition. + + + The field '{0}' on type '{1}' was extended with a different type than the original definition. + The argument '{0}' must accept an input type. @@ -48,4 +63,7 @@ The Union type '{0}' cannot include the type '{1}'. Unions can only contain Object types. + + The non-repeatable directive '@{0}' was already applied to '{1}' and cannot be applied again by an extension. + diff --git a/src/HotChocolate/Mutable/src/Types.Mutable/Serialization/SchemaParser.cs b/src/HotChocolate/Mutable/src/Types.Mutable/Serialization/SchemaParser.cs index 0a9eaed3b10..dab54002b4e 100644 --- a/src/HotChocolate/Mutable/src/Types.Mutable/Serialization/SchemaParser.cs +++ b/src/HotChocolate/Mutable/src/Types.Mutable/Serialization/SchemaParser.cs @@ -53,8 +53,8 @@ public static void Parse( DiscoverExtensions(schema, document); BuildTypes(schema, document, skippedNodes); - ExtendTypes(schema, document, skippedNodes); BuildDirectiveTypes(schema, document, skippedNodes); + ExtendTypes(schema, document, skippedNodes); BuildAndExtendSchema(schema, document, skippedNodes); } @@ -413,6 +413,9 @@ private static void BuildComplexType( MutableComplexTypeDefinition type, ComplexTypeDefinitionNodeBase node) { + var isExtension = node is ObjectTypeExtensionNode or InterfaceTypeExtensionNode; + var seenFieldNames = new HashSet(StringComparer.Ordinal); + BuildDirectiveCollection(schema, type.Directives, node.Directives); foreach (var interfaceRef in node.Interfaces) @@ -422,7 +425,7 @@ private static void BuildComplexType( foreach (var fieldNode in node.Fields) { - if (type.Fields.ContainsName(fieldNode.Name.Value)) + if (!seenFieldNames.Add(fieldNode.Name.Value)) { throw new SchemaInitializationException( string.Format( @@ -431,6 +434,21 @@ private static void BuildComplexType( type.Name)); } + if (type.Fields.TryGetField(fieldNode.Name.Value, out var existingField)) + { + if (!isExtension) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_DuplicateFieldDefinition, + fieldNode.Name.Value, + type.Name)); + } + + ExtendOutputField(schema, type, existingField, fieldNode); + continue; + } + var builtFieldType = schema.Types.BuildType(fieldNode.Type); if (builtFieldType is not IOutputType fieldType) @@ -467,36 +485,179 @@ private static void BuildComplexType( $"{type.Name}.{field.Name}")); } - var builtArgumentType = schema.Types.BuildType(argumentNode.Type); + field.Arguments.Add(BuildArgument(schema, field, argumentNode)); + } - if (builtArgumentType is not IInputType argumentType) - { - throw new SchemaInitializationException( - string.Format( - SchemaParser_InvalidArgumentType, - $"{type.Name}.{field.Name}({argumentNode.Name.Value}:)")); - } + type.Fields.Add(field); + } + } - var argument = new MutableInputFieldDefinition(argumentNode.Name.Value) - { - Description = argumentNode.Description?.Value, - Type = argumentType, - DefaultValue = argumentNode.DefaultValue, - DeclaringMember = field - }; + private static MutableInputFieldDefinition BuildArgument( + MutableSchemaDefinition schema, + MutableOutputFieldDefinition field, + InputValueDefinitionNode argumentNode) + { + var builtArgumentType = schema.Types.BuildType(argumentNode.Type); - BuildDirectiveCollection(schema, argument.Directives, argumentNode.Directives); + if (builtArgumentType is not IInputType argumentType) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_InvalidArgumentType, + $"{field.DeclaringMember?.Name}.{field.Name}({argumentNode.Name.Value}:)")); + } - if (IsDeprecated(argument.Directives, out reason)) - { - argument.IsDeprecated = true; - argument.DeprecationReason = reason; - } + var argument = new MutableInputFieldDefinition(argumentNode.Name.Value) + { + Description = argumentNode.Description?.Value, + Type = argumentType, + DefaultValue = argumentNode.DefaultValue, + DeclaringMember = field + }; + + BuildDirectiveCollection(schema, argument.Directives, argumentNode.Directives); + + if (IsDeprecated(argument.Directives, out var reason)) + { + argument.IsDeprecated = true; + argument.DeprecationReason = reason; + } - field.Arguments.Add(argument); + return argument; + } + + private static void ExtendOutputField( + MutableSchemaDefinition schema, + MutableComplexTypeDefinition type, + MutableOutputFieldDefinition existingField, + FieldDefinitionNode fieldNode) + { + var builtFieldType = schema.Types.BuildType(fieldNode.Type); + + if (builtFieldType is not IOutputType fieldType) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_InvalidFieldType, + $"{type.Name}.{fieldNode.Name.Value}")); + } + + if (!existingField.Type.Equals(fieldType, TypeComparison.Structural)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_FieldTypeMismatchInExtension, + fieldNode.Name.Value, + type.Name)); + } + + if (fieldNode.Description is not null + && !string.Equals( + fieldNode.Description.Value, + existingField.Description, + StringComparison.Ordinal)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_FieldDescriptionMismatchInExtension, + fieldNode.Name.Value, + type.Name)); + } + + MergeDirectives( + schema, + existingField.Directives, + fieldNode.Directives, + $"{type.Name}.{existingField.Name}"); + + if (IsDeprecated(existingField.Directives, out var reason)) + { + existingField.IsDeprecated = true; + existingField.DeprecationReason = reason; + } + + var seenArgumentNames = new HashSet(StringComparer.Ordinal); + + foreach (var argumentNode in fieldNode.Arguments) + { + if (!seenArgumentNames.Add(argumentNode.Name.Value)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_DuplicateArgumentDefinition, + argumentNode.Name.Value, + $"{type.Name}.{existingField.Name}")); } - type.Fields.Add(field); + if (existingField.Arguments.TryGetField(argumentNode.Name.Value, out var existingArg)) + { + ExtendArgument(schema, existingField, existingArg, argumentNode); + } + else + { + existingField.Arguments.Add(BuildArgument(schema, existingField, argumentNode)); + } + } + } + + private static void ExtendArgument( + MutableSchemaDefinition schema, + MutableOutputFieldDefinition field, + MutableInputFieldDefinition existingArg, + InputValueDefinitionNode argumentNode) + { + var builtArgumentType = schema.Types.BuildType(argumentNode.Type); + + if (builtArgumentType is not IInputType argumentType) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_InvalidArgumentType, + $"{field.DeclaringMember?.Name}.{field.Name}({argumentNode.Name.Value}:)")); + } + + if (!existingArg.Type.Equals(argumentType, TypeComparison.Structural)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_ArgumentTypeMismatchInExtension, + argumentNode.Name.Value, + $"{field.DeclaringMember?.Name}.{field.Name}")); + } + + if (argumentNode.DefaultValue is not null + && !SyntaxComparer.BySyntax.Equals(existingArg.DefaultValue, argumentNode.DefaultValue)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_ArgumentDefaultValueMismatchInExtension, + argumentNode.Name.Value, + $"{field.DeclaringMember?.Name}.{field.Name}")); + } + + if (argumentNode.Description is not null + && !string.Equals( + argumentNode.Description.Value, + existingArg.Description, + StringComparison.Ordinal)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_ArgumentDescriptionMismatchInExtension, + argumentNode.Name.Value, + $"{field.DeclaringMember?.Name}.{field.Name}")); + } + + MergeDirectives( + schema, + existingArg.Directives, + argumentNode.Directives, + $"{field.DeclaringMember?.Name}.{field.Name}({existingArg.Name}:)"); + + if (IsDeprecated(existingArg.Directives, out var reason)) + { + existingArg.IsDeprecated = true; + existingArg.DeprecationReason = reason; } } @@ -809,45 +970,81 @@ private static void BuildDirectiveCollection( { foreach (var directiveNode in nodes) { - if (!schema.DirectiveDefinitions.TryGetDirective( - directiveNode.Name.Value, - out var directiveType)) - { - switch (directiveNode.Name.Value) - { - case DirectiveNames.Deprecated.Name: - directiveType = BuiltIns.Deprecated.Create(schema); - break; - - case DirectiveNames.OneOf.Name: - directiveType = BuiltIns.OneOf.Create(); - break; - - case DirectiveNames.SpecifiedBy.Name: - directiveType = BuiltIns.SpecifiedBy.Create(schema); - break; + var directiveType = ResolveDirectiveDefinition(schema, directiveNode); - default: - directiveType = new MutableDirectiveDefinition(directiveNode.Name.Value) - { - IsRepeatable = true, - Locations = DirectiveLocation.TypeSystem - }; - - directiveType.Features.Set( - new IncompleteDirectiveDefinitionFeature { IsIncomplete = true }); + var directive = new Directive( + directiveType, + directiveNode.Arguments.Select(t => new ArgumentAssignment(t.Name.Value, t.Value)).ToList()); + directives.Add(directive); + } + } - break; - } + private static void MergeDirectives( + MutableSchemaDefinition schema, + DirectiveCollection target, + IReadOnlyList nodes, + string targetName) + { + foreach (var directiveNode in nodes) + { + var directiveType = ResolveDirectiveDefinition(schema, directiveNode); - schema.DirectiveDefinitions.Add(directiveType); + if (!directiveType.IsRepeatable && target.ContainsName(directiveType.Name)) + { + throw new SchemaInitializationException( + string.Format( + SchemaParser_NonRepeatableDirectiveAlreadyApplied, + directiveType.Name, + targetName)); } var directive = new Directive( directiveType, directiveNode.Arguments.Select(t => new ArgumentAssignment(t.Name.Value, t.Value)).ToList()); - directives.Add(directive); + target.Add(directive); + } + } + + private static MutableDirectiveDefinition ResolveDirectiveDefinition( + MutableSchemaDefinition schema, + DirectiveNode directiveNode) + { + if (schema.DirectiveDefinitions.TryGetDirective( + directiveNode.Name.Value, + out var directiveType)) + { + return directiveType; } + + switch (directiveNode.Name.Value) + { + case DirectiveNames.Deprecated.Name: + directiveType = BuiltIns.Deprecated.Create(schema); + break; + + case DirectiveNames.OneOf.Name: + directiveType = BuiltIns.OneOf.Create(); + break; + + case DirectiveNames.SpecifiedBy.Name: + directiveType = BuiltIns.SpecifiedBy.Create(schema); + break; + + default: + directiveType = new MutableDirectiveDefinition(directiveNode.Name.Value) + { + IsRepeatable = true, + Locations = DirectiveLocation.TypeSystem + }; + + directiveType.Features.Set( + new IncompleteDirectiveDefinitionFeature { IsIncomplete = true }); + + break; + } + + schema.DirectiveDefinitions.Add(directiveType); + return directiveType; } private static bool IsDeprecated(DirectiveCollection directives, out string? reason) diff --git a/src/HotChocolate/Mutable/test/Types.Mutable.Tests/SchemaParserTests.cs b/src/HotChocolate/Mutable/test/Types.Mutable.Tests/SchemaParserTests.cs index eccbd04f38d..bb20d78ea33 100644 --- a/src/HotChocolate/Mutable/test/Types.Mutable.Tests/SchemaParserTests.cs +++ b/src/HotChocolate/Mutable/test/Types.Mutable.Tests/SchemaParserTests.cs @@ -465,4 +465,324 @@ directive @foo on FIELD "A directive with the name '@foo' has already been defined.", Assert.Throws(Action).Message); } + + [Fact] + public void Parse_Should_AddDirectiveToExistingField_When_ExtensionRedeclaresField() + { + // arrange + const string sdl = + """ + type Query { id: String } + + extend type Query { + id: String @deprecated(reason: "Use newId") + } + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + var queryType = Assert.IsType(schema.Types["Query"]); + var field = Assert.Single(queryType.Fields.AsEnumerable()); + Assert.Equal("id", field.Name); + Assert.True(field.IsDeprecated); + Assert.Equal("Use newId", field.DeprecationReason); + Assert.True(field.Directives.ContainsName("deprecated")); + } + + [Fact] + public void Parse_Should_AddArgumentToExistingField_When_ExtensionAddsNewArgument() + { + // arrange + const string sdl = + """ + type Query { + user(id: String): String + } + + extend type Query { + user(name: String): String + } + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + var queryType = Assert.IsType(schema.Types["Query"]); + var field = Assert.Single(queryType.Fields.AsEnumerable()); + Assert.Collection( + field.Arguments.AsEnumerable(), + arg => Assert.Equal("id", arg.Name), + arg => Assert.Equal("name", arg.Name)); + } + + [Fact] + public void Parse_Should_AddDirectiveToExistingArgument_When_ExtensionRedeclaresArgument() + { + // arrange + const string sdl = + """ + type Query { + user(id: String): String + } + + extend type Query { + user(id: String @deprecated(reason: "Use newId")): String + } + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + var queryType = Assert.IsType(schema.Types["Query"]); + var field = Assert.Single(queryType.Fields.AsEnumerable()); + var argument = Assert.Single(field.Arguments.AsEnumerable()); + Assert.Equal("id", argument.Name); + Assert.True(argument.IsDeprecated); + Assert.Equal("Use newId", argument.DeprecationReason); + Assert.True(argument.Directives.ContainsName("deprecated")); + } + + [Fact] + public void Parse_Should_RetainOriginalDescription_When_ExtensionRedeclaresFieldWithoutDescription() + { + // arrange + const string sdl = + """" + type Query { + """desc""" + id: String + } + + extend type Query { + id: String @deprecated + } + """"; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + var queryType = Assert.IsType(schema.Types["Query"]); + var field = Assert.Single(queryType.Fields.AsEnumerable()); + Assert.Equal("desc", field.Description); + Assert.True(field.IsDeprecated); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionRedeclaresFieldWithDifferentDescription() + { + // arrange + const string sdl = + """" + type Query { + """original""" + id: String + } + + extend type Query { + """different""" + id: String + } + """"; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "The description on field 'id' of type 'Query' in the extension does not match the original definition.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionFieldTypeMismatches() + { + // arrange + const string sdl = + """ + type Query { id: String } + + extend type Query { + id: Int + } + """; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "The field 'id' on type 'Query' was extended with a different type than the original definition.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionArgumentDefaultValueMismatches() + { + // arrange + const string sdl = + """ + type Query { + user(id: String = "a"): String + } + + extend type Query { + user(id: String = "b"): String + } + """; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "The argument 'id' on field 'Query.user' was extended with a different default value than the original " + + "definition.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionAppliesNonRepeatableDirectiveAlreadyApplied() + { + // arrange + const string sdl = + """ + type Query { + id: String @deprecated(reason: "first") + } + + extend type Query { + id: String @deprecated(reason: "second") + } + """; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "The non-repeatable directive '@deprecated' was already applied to 'Query.id' " + + "and cannot be applied again by an extension.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_AppendBothDirectives_When_ExtensionAppliesRepeatableDirectiveAlreadyApplied() + { + // arrange + const string sdl = + """ + directive @tag(name: String!) repeatable on FIELD_DEFINITION + + type Query { + id: String @tag(name: "a") + } + + extend type Query { + id: String @tag(name: "b") + } + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + var queryType = Assert.IsType(schema.Types["Query"]); + var field = Assert.Single(queryType.Fields.AsEnumerable()); + Assert.Equal(2, field.Directives["tag"].Count()); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionContainsDuplicateFieldNamesWithinItself() + { + // arrange + const string sdl = + """ + extend type Query { + id: String + id: String + } + """; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "A field with the name 'id' has already been defined on the type 'Query'.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionFieldDeclaresDuplicateArgumentName() + { + // arrange + const string sdl = + """ + type Query { user: String } + + extend type Query { + user(id: String, id: String): String + } + """; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "An argument with the name 'id' has already been defined on the field 'Query.user'.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_Throw_When_ExtensionRedeclaresArgumentTwiceWithinSameFieldExtension() + { + // arrange + const string sdl = + """ + type Query { user(id: String): String } + + extend type Query { + user(id: String, id: String): String + } + """; + + // act + static void Action() => SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + Assert.Equal( + "An argument with the name 'id' has already been defined on the field 'Query.user'.", + Assert.Throws(Action).Message); + } + + [Fact] + public void Parse_Should_AddDirectiveToExistingField_When_InterfaceExtensionRedeclaresField() + { + // arrange + const string sdl = + """ + interface Node { id: ID } + + extend interface Node { + id: ID @deprecated(reason: "Use newId") + } + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + + // assert + var nodeType = Assert.IsType(schema.Types["Node"]); + var field = Assert.Single(nodeType.Fields.AsEnumerable()); + Assert.Equal("id", field.Name); + Assert.True(field.IsDeprecated); + Assert.Equal("Use newId", field.DeprecationReason); + Assert.True(field.Directives.ContainsName("deprecated")); + } }