diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/FusionIntegrationTests.cs b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/FusionIntegrationTests.cs index b44e744e2e7..ace2f73e2c6 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/FusionIntegrationTests.cs +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/FusionIntegrationTests.cs @@ -196,6 +196,7 @@ private static TestServer CreateSubgraph(ITypeDefinition[]? additionalTypes) var builder = services .AddGraphQLServer() + .AddSourceSchemaDefaults() .AddAuthorization() .AddQueryType() .AddMutationType() diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OpenApi_Includes_Initial_Routes_NET10_0_Fusion.json b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OpenApi_Includes_Initial_Routes_NET10_0_Fusion.json index e3187fdf88a..070e1d06a19 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OpenApi_Includes_Initial_Routes_NET10_0_Fusion.json +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApi/__snapshots__/OpenApiIntegrationTestBase.OpenApi_Includes_Initial_Routes_NET10_0_Fusion.json @@ -47,10 +47,30 @@ "type": "object", "properties": { "any": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "array" + } + ], "description": "The `Any` scalar type represents any valid GraphQL value." }, "base64String": { + "pattern": "\"^(?:[A-Za-z0-9+\\\\/]{4})*(?:[A-Za-z0-9+\\\\/]{2}==|[A-Za-z0-9+\\\\/]{3}=)?$\"", "type": "string", "description": "The `Base64String` scalar type represents an array of bytes encoded as a Base64 string." }, @@ -58,20 +78,24 @@ "type": "boolean" }, "byte": { - "type": "string", - "description": "The `Byte` scalar type represents a signed 8-bit integer." + "type": "integer", + "description": "The `Byte` scalar type represents a signed 8-bit integer.", + "format": "int32" }, "date": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"", "type": "string", "description": "The `Date` scalar type represents a date in UTC." }, "dateTime": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}[Tt]\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?(?:[Zz]|[+-]\\\\d{2}:\\\\d{2})$\"", "type": "string", "description": "The `DateTime` scalar type represents a date and time with time zone offset information." }, "decimal": { - "type": "string", - "description": "The `Decimal` scalar type represents a decimal floating-point number with high precision." + "type": "number", + "description": "The `Decimal` scalar type represents a decimal floating-point number with high precision.", + "format": "float" }, "enum": { "enum": [ @@ -99,7 +123,26 @@ "format": "int32" }, "json": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "array" + } + ], "description": "The `Any` scalar type represents any valid GraphQL value." }, "list": { @@ -109,20 +152,24 @@ } }, "localDate": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"", "type": "string", "description": "The `LocalDate` scalar type represents a date without time or time zone information." }, "localDateTime": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}[Tt]\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?$\"", "type": "string", "description": "The `LocalDateTime` scalar type represents a date and time without time zone information." }, "localTime": { + "pattern": "\"^\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?$\"", "type": "string", "description": "The `LocalTime` scalar type represents a time of day without date or time zone information." }, "long": { - "type": "string", - "description": "The `Long` scalar type represents a signed 64-bit integer." + "type": "integer", + "description": "The `Long` scalar type represents a signed 64-bit integer.", + "format": "int32" }, "object": { "required": [ @@ -143,6 +190,7 @@ "type": "object", "properties": { "field1C": { + "pattern": "\"^\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?$\"", "type": "string", "description": "field1C description" } @@ -155,13 +203,15 @@ } }, "short": { - "type": "string", - "description": "The `Short` scalar type represents a signed 16-bit integer." + "type": "integer", + "description": "The `Short` scalar type represents a signed 16-bit integer.", + "format": "int32" }, "string": { "type": "string" }, "timeSpan": { + "pattern": "\"^-?P(?:\\\\d+W|(?=\\\\d|T(?:\\\\d|$))(?:\\\\d+Y)?(?:\\\\d+M)?(?:\\\\d+D)?(?:T(?:\\\\d+H)?(?:\\\\d+M)?(?:\\\\d+(?:\\\\.\\\\d+)?S)?)?)$\"", "type": "string", "description": "The `TimeSpan` scalar type represents a duration of time." }, @@ -177,6 +227,7 @@ "description": "The `URL` scalar type represents a Uniform Resource Locator (URL) as defined by RFC 3986." }, "uuid": { + "pattern": "\"^[\\\\da-fA-F]{8}-[\\\\da-fA-F]{4}-[\\\\da-fA-F]{4}-[\\\\da-fA-F]{4}-[\\\\da-fA-F]{12}$\"", "type": "string", "description": "The `UUID` scalar type represents a Universally Unique Identifier (UUID) as defined by RFC 9562." } @@ -221,13 +272,30 @@ "type": "object", "properties": { "any": { - "type": [ - "null", - "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "array" + } ], "description": "The `Any` scalar type represents any valid GraphQL value." }, "base64String": { + "pattern": "\"^(?:[A-Za-z0-9+\\\\/]{4})*(?:[A-Za-z0-9+\\\\/]{2}==|[A-Za-z0-9+\\\\/]{3}=)?$\"", "type": [ "null", "string" @@ -243,11 +311,13 @@ "byte": { "type": [ "null", - "string" + "integer" ], - "description": "The `Byte` scalar type represents a signed 8-bit integer." + "description": "The `Byte` scalar type represents a signed 8-bit integer.", + "format": "int32" }, "date": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"", "type": [ "null", "string" @@ -255,6 +325,7 @@ "description": "The `Date` scalar type represents a date in UTC." }, "dateTime": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}[Tt]\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?(?:[Zz]|[+-]\\\\d{2}:\\\\d{2})$\"", "type": [ "null", "string" @@ -264,9 +335,10 @@ "decimal": { "type": [ "null", - "string" + "number" ], - "description": "The `Decimal` scalar type represents a decimal floating-point number with high precision." + "description": "The `Decimal` scalar type represents a decimal floating-point number with high precision.", + "format": "float" }, "enum": { "enum": [ @@ -303,9 +375,25 @@ "format": "int32" }, "json": { - "type": [ - "null", - "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "array" + } ], "description": "The `Any` scalar type represents any valid GraphQL value." }, @@ -322,6 +410,7 @@ } }, "localDate": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"", "type": [ "null", "string" @@ -329,6 +418,7 @@ "description": "The `LocalDate` scalar type represents a date without time or time zone information." }, "localDateTime": { + "pattern": "\"^\\\\d{4}-\\\\d{2}-\\\\d{2}[Tt]\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?$\"", "type": [ "null", "string" @@ -336,6 +426,7 @@ "description": "The `LocalDateTime` scalar type represents a date and time without time zone information." }, "localTime": { + "pattern": "\"^\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?$\"", "type": [ "null", "string" @@ -345,9 +436,10 @@ "long": { "type": [ "null", - "string" + "integer" ], - "description": "The `Long` scalar type represents a signed 64-bit integer." + "description": "The `Long` scalar type represents a signed 64-bit integer.", + "format": "int32" }, "object": { "required": [ @@ -377,6 +469,7 @@ ], "properties": { "field1C": { + "pattern": "\"^\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{1,9})?$\"", "type": [ "null", "string" @@ -392,9 +485,10 @@ "short": { "type": [ "null", - "string" + "integer" ], - "description": "The `Short` scalar type represents a signed 16-bit integer." + "description": "The `Short` scalar type represents a signed 16-bit integer.", + "format": "int32" }, "string": { "type": [ @@ -403,6 +497,7 @@ ] }, "timeSpan": { + "pattern": "\"^-?P(?:\\\\d+W|(?=\\\\d|T(?:\\\\d|$))(?:\\\\d+Y)?(?:\\\\d+M)?(?:\\\\d+D)?(?:T(?:\\\\d+H)?(?:\\\\d+M)?(?:\\\\d+(?:\\\\.\\\\d+)?S)?)?)$\"", "type": [ "null", "string" @@ -423,6 +518,7 @@ "description": "The `URL` scalar type represents a Uniform Resource Locator (URL) as defined by RFC 3986." }, "uuid": { + "pattern": "\"^[\\\\da-fA-F]{8}-[\\\\da-fA-F]{4}-[\\\\da-fA-F]{4}-[\\\\da-fA-F]{4}-[\\\\da-fA-F]{12}$\"", "type": [ "null", "string" diff --git a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApiTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApiTestBase.cs index 6c64feee7e7..6f215297b7f 100644 --- a/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApiTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.OpenApi.Tests/OpenApiTestBase.cs @@ -249,7 +249,9 @@ protected TestServer CreateSourceSchema() IssuerSigningKey = s_tokenKey }); - services.AddGraphQLServer() + services + .AddGraphQLServer() + .AddSourceSchemaDefaults() .AddBasicServer(); }, app => diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/DirectiveDefinitionNodeComparer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/DirectiveDefinitionNodeComparer.cs new file mode 100644 index 00000000000..53338e1f5f4 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/DirectiveDefinitionNodeComparer.cs @@ -0,0 +1,158 @@ +using HotChocolate.Language; + +namespace HotChocolate.Fusion; + +/// +/// Compares two instances for structural equality, +/// ignoring descriptions and treating arguments and locations as unordered collections. +/// +internal sealed class DirectiveDefinitionNodeComparer + : IEqualityComparer +{ + private static readonly IEqualityComparer s_syntaxComparer = + SyntaxComparer.BySyntaxIgnoreDescriptions; + + public static DirectiveDefinitionNodeComparer Instance { get; } = new(); + + public bool Equals(DirectiveDefinitionNode? x, DirectiveDefinitionNode? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x.Name.Value.Equals(y.Name.Value, StringComparison.Ordinal) + && x.IsRepeatable == y.IsRepeatable + && ArgumentsEqual(x.Arguments, y.Arguments) + && LocationsEqual(x.Locations, y.Locations); + } + + public int GetHashCode(DirectiveDefinitionNode obj) + { + var hashCode = new HashCode(); + + hashCode.Add(obj.Name.Value, StringComparer.Ordinal); + hashCode.Add(obj.IsRepeatable); + + // Accumulate full argument hashes additively for order-independent hashing. + var argumentsHash = 0; + foreach (var argument in obj.Arguments) + { + argumentsHash += s_syntaxComparer.GetHashCode(argument); + } + + hashCode.Add(obj.Arguments.Count); + hashCode.Add(argumentsHash); + + // Accumulate location name hashes additively for order-independent hashing. + var locationsHash = 0; + foreach (var location in obj.Locations) + { + locationsHash += StringComparer.Ordinal.GetHashCode(location.Value); + } + + hashCode.Add(obj.Locations.Count); + hashCode.Add(locationsHash); + + return hashCode.ToHashCode(); + } + + private static bool ArgumentsEqual( + IReadOnlyList xArgs, + IReadOnlyList yArgs) + { + if (xArgs.Count != yArgs.Count) + { + return false; + } + + if (xArgs.Count == 0) + { + return true; + } + + // For each argument in x, find the matching argument in y by name + // and compare them using the syntax comparer (ignoring descriptions). + var matched = new bool[yArgs.Count]; + + foreach (var xArg in xArgs) + { + var found = false; + + for (var j = 0; j < yArgs.Count; j++) + { + if (matched[j]) + { + continue; + } + + var yArg = yArgs[j]; + + if (xArg.Name.Value.Equals(yArg.Name.Value, StringComparison.Ordinal) + && s_syntaxComparer.Equals(xArg, yArg)) + { + matched[j] = true; + found = true; + break; + } + } + + if (!found) + { + return false; + } + } + + return true; + } + + private static bool LocationsEqual( + IReadOnlyList xLocations, + IReadOnlyList yLocations) + { + if (xLocations.Count != yLocations.Count) + { + return false; + } + + if (xLocations.Count == 0) + { + return true; + } + + // For each location in x, find the matching location in y by name. + var matched = new bool[yLocations.Count]; + + foreach (var xLocation in xLocations) + { + var found = false; + + for (var j = 0; j < yLocations.Count; j++) + { + if (matched[j]) + { + continue; + } + + if (xLocation.Value.Equals(yLocations[j].Value, StringComparison.Ordinal)) + { + matched[j] = true; + found = true; + break; + } + } + + if (!found) + { + return false; + } + } + + return true; + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs index 90a83330f44..3360088b77d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -166,9 +166,8 @@ private void MergeDirectiveDefinitions(MutableSchemaDefinition mergedSchema) // Ensure that all directive definitions match the canonical definition. if (!grouping.All( - d => d.DirectiveDefinition - .ToSyntaxNode() - .Equals(canonicalDirectiveNode, SyntaxComparison.SyntaxIgnoreDescriptions))) + d => DirectiveDefinitionNodeComparer.Instance + .Equals(d.DirectiveDefinition.ToSyntaxNode(), canonicalDirectiveNode))) { // Skip merging if there is a mismatch. continue; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs index e61b2396916..928530b68a9 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs @@ -1,8 +1,10 @@ using System.Collections.Immutable; using HotChocolate.Fusion.Comparers; using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; using HotChocolate.Types.Mutable; using HotChocolate.Types.Mutable.Serialization; +using static HotChocolate.Fusion.CompositionTestHelper; using static HotChocolate.Fusion.WellKnownTypeNames; namespace HotChocolate.Fusion; @@ -265,4 +267,45 @@ scalar Position Assert.True(result.Value.Types.ContainsName("ProductDimensionInput")); Assert.True(result.Value.Types.ContainsName("Position")); } + + [Fact] + public void Merge_DirectiveDefinitionWithDifferentArgumentOrder_MergesSuccessfully() + { + // arrange + // The canonical @cacheControl definition has arguments in this order: + // maxAge, sharedMaxAge, inheritMaxAge, scope, vary + // and locations: OBJECT | FIELD_DEFINITION | INTERFACE | UNION. + // This source schema defines both in a different order. + var schemas = CreateSchemaDefinitions( + [ + """ + enum CacheControlScope { PUBLIC PRIVATE } + + directive @cacheControl( + vary: [String] + scope: CacheControlScope + inheritMaxAge: Boolean + sharedMaxAge: Int + maxAge: Int + ) on UNION | INTERFACE | FIELD_DEFINITION | OBJECT + + type Foo { + field: Int @cacheControl(maxAge: 500) + } + """ + ]); + var options = new SourceSchemaMergerOptions + { + CacheControlMergeBehavior = DirectiveMergeBehavior.Include, + RemoveUnreferencedDefinitions = false + }; + var merger = new SourceSchemaMerger(schemas, options); + + // act + var result = merger.Merge(); + + // assert + Assert.True(result.IsSuccess); + Assert.True(result.Value.DirectiveDefinitions.ContainsName("cacheControl")); + } }