diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/TagDirectiveTests.cs b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/TagDirectiveTests.cs new file mode 100644 index 00000000000..a8eccf0e76b --- /dev/null +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/TagDirectiveTests.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Execution; +using HotChocolate.Types; + +namespace HotChocolate.ApolloFederation.Directives; + +public class TagDirectiveTests +{ + [Fact] + public async Task Tag_InApolloFederationMode_ExcludesDirectiveDefinitionLocation() + { + // arrange & act + var schema = await new ServiceCollection() + .AddGraphQL() + .AddApolloFederation() + .AddQueryType() + .BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken); + + // assert + var tag = schema.DirectiveTypes["tag"]; + Assert.False(tag.Locations.HasFlag(DirectiveLocation.DirectiveDefinition)); + } + + public class Query + { + [Tag("public")] + public string Foo() => "bar"; + } +} diff --git a/src/HotChocolate/Caching/test/Caching.Tests/SchemaTests.cs b/src/HotChocolate/Caching/test/Caching.Tests/SchemaTests.cs index b8fb9a40617..6ecdc2beeda 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/SchemaTests.cs +++ b/src/HotChocolate/Caching/test/Caching.Tests/SchemaTests.cs @@ -83,6 +83,7 @@ directive @tag("The name of the tag." name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION """"); } diff --git a/src/HotChocolate/Core/src/Types/Types/Directives/Tag.cs b/src/HotChocolate/Core/src/Types/Types/Directives/Tag.cs index 16bad53acd4..cf6273a5c13 100644 --- a/src/HotChocolate/Core/src/Types/Types/Directives/Tag.cs +++ b/src/HotChocolate/Core/src/Types/Types/Directives/Tag.cs @@ -29,7 +29,8 @@ namespace HotChocolate.Types; | DirectiveLocation.InputFieldDefinition | DirectiveLocation.ArgumentDefinition | DirectiveLocation.EnumValue - | DirectiveLocation.Schema, + | DirectiveLocation.Schema + | DirectiveLocation.DirectiveDefinition, IsRepeatable = true)] [TagDirectiveConfig] [GraphQLDescription( diff --git a/src/HotChocolate/Core/src/Types/Types/Directives/TagAttribute.cs b/src/HotChocolate/Core/src/Types/Types/Directives/TagAttribute.cs index 3bd2d83c16c..6dc1b0e0174 100644 --- a/src/HotChocolate/Core/src/Types/Types/Directives/TagAttribute.cs +++ b/src/HotChocolate/Core/src/Types/Types/Directives/TagAttribute.cs @@ -102,6 +102,10 @@ protected internal override void TryConfigure( desc.Tag(Name); break; + case IDirectiveTypeDescriptor desc: + desc.Tag(Name); + break; + default: throw new SchemaException( SchemaErrorBuilder.New() diff --git a/src/HotChocolate/Core/src/Types/Types/Directives/TagDirectiveExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Directives/TagDirectiveExtensions.cs index 782aed4af03..f14e372b415 100644 --- a/src/HotChocolate/Core/src/Types/Types/Directives/TagDirectiveExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Types/Directives/TagDirectiveExtensions.cs @@ -412,6 +412,36 @@ public static ISchemaTypeDescriptor Tag( return descriptor; } + /// + /// Adds a @tag(name: "your-value") to a . + /// + /// directive @custom @tag(name: "your-value") on OBJECT + /// + /// + /// + /// The on which this directive shall be annotated. + /// + /// + /// The value represents the of the tag. + /// + /// + /// Returns the on which this directive + /// was applied for configuration chaining. + /// + /// + /// is null. + /// + /// + /// is null or . + /// + public static IDirectiveTypeDescriptor Tag( + this IDirectiveTypeDescriptor descriptor, + string name) + { + ApplyTag(descriptor, name); + return descriptor; + } + private static void ApplyTag( this IDescriptor descriptor, string name) @@ -475,6 +505,10 @@ private static void ApplyTag( desc.Directive(new Tag(name)); break; + case IDirectiveTypeDescriptor desc: + desc.Directive(new Tag(name)); + break; + default: throw new NotSupportedException( TypeResources.TagDirective_Descriptor_NotSupported); diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/TagDirectiveTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/TagDirectiveTests.cs index 385f3107a7b..45d72855b4d 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/TagDirectiveTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/TagDirectiveTests.cs @@ -1,4 +1,5 @@ using HotChocolate.Execution; +using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Types.Directives; @@ -6,40 +7,157 @@ namespace HotChocolate.Types.Directives; public class TagDirectiveTests { [Fact] - public async Task EnsureAllLocationsAreApplied() + public async Task SchemaFirst_Tag() { + // arrange & act var schema = await new ServiceCollection() .AddGraphQL() - .AddQueryType() - .AddObjectType() - .AddType() - .SetSchema(d => d.Tag("OnSchema")) + .AddDocumentFromString( + """ + schema @tag(name: "OnSchema") { + query: Query + } + + type Query @tag(name: "OnObjectType") { + foo(a: String! @tag(name: "OnObjectFieldArg")): IFoo! + @tag(name: "OnObjectField") + fooEnum(input: FooInput!): FooEnum! + fooUnion: FooUnion! + } + + type Foo implements IFoo { + bar(baz: String!): String! + } + + interface IFoo @tag(name: "OnInterface") { + bar(baz: String! @tag(name: "OnInterfaceFieldArg")): String! + @tag(name: "OnInterfaceField") + } + + union FooUnion @tag(name: "OnUnion") = FooUnionA | FooUnionB + + type FooUnionA { + id: String! + } + + type FooUnionB { + id: String! + } + + input FooInput @tag(name: "OnInputObjectType") { + bar: String! @tag(name: "OnInputObjectField") + } + + enum FooEnum @tag(name: "OnEnum") { + FOO @tag(name: "OnEnumValue") + BAR + } + + directive @foo(arg: String! @tag(name: "OnDirectiveArgument")) + @tag(name: "OnDirectiveDefinition") on QUERY + + directive @tag("The name of the tag." name: String!) + repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | + ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | + INPUT_OBJECT | INPUT_FIELD_DEFINITION | DIRECTIVE_DEFINITION + """) + .UseField(_ => _ => default) .BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken); + // assert schema.MatchSnapshot(); } [Fact] - public async Task SchemaFirst_Tag() + public async Task CodeFirst_Tag() { + // arrange & act var schema = await new ServiceCollection() .AddGraphQL() - .AddDocumentFromString( - """ - type Query { - field: String @tag(name: "abc") - } - - directive @tag("The name of the tag." name: String!) - repeatable on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | - ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | - INPUT_OBJECT | INPUT_FIELD_DEFINITION - """) + .SetSchema(d => d.Tag("OnSchema")) + .AddQueryType(d => + { + d.Tag("OnObjectType"); + d.Field("foo") + .Type("IFoo!") + .Argument("a", a => a.Type("String!").Tag("OnObjectFieldArg")) + .Tag("OnObjectField"); + d.Field("fooEnum") + .Type("FooEnum!") + .Argument("input", a => a.Type("FooInput!")); + d.Field("fooUnion").Type("FooUnion!"); + }) + .AddInterfaceType(d => + { + d.Name("IFoo"); + d.Tag("OnInterface"); + d.Field("bar") + .Type("String!") + .Argument("baz", a => a.Type("String!").Tag("OnInterfaceFieldArg")) + .Tag("OnInterfaceField"); + }) + .AddObjectType(d => d + .Name("Foo") + .Implements("IFoo") + .Field("bar") + .Type("String!") + .Argument("baz", a => a.Type("String!"))) + .AddUnionType(d => d + .Name("FooUnion") + .Tag("OnUnion") + .Type(new NamedTypeNode("FooUnionA")) + .Type(new NamedTypeNode("FooUnionB"))) + .AddObjectType(d => d.Name("FooUnionA").Field("id").Type("String!")) + .AddObjectType(d => d.Name("FooUnionB").Field("id").Type("String!")) + .AddInputObjectType(d => + { + d.Name("FooInput"); + d.Tag("OnInputObjectType"); + d.Field("bar").Type("String!").Tag("OnInputObjectField"); + }) + .AddEnumType(d => + { + d.Name("FooEnum"); + d.Tag("OnEnum"); + d.Value("FOO").Tag("OnEnumValue"); + d.Value("BAR"); + }) + .AddDirectiveType(d => + { + d.Name("foo"); + d.Location(DirectiveLocation.Query); + d.Tag("OnDirectiveDefinition"); + d.Argument("arg", a => a.Type("String!").Tag("OnDirectiveArgument")); + }) .UseField(_ => _ => default) .BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken); + // assert + schema.MatchSnapshot(); + } + + [Fact] + public async Task ImplementationFirst_Tag() + { + // The SCHEMA position is intentionally omitted here: HotChocolate does not + // apply class-level descriptor attributes to Schema subclasses, so a [Tag] + // on a Schema class has no effect. The schema-first and code-first modes + // cover the SCHEMA position instead. + + // arrange & act + var schema = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddObjectType() + .AddType() + .AddType() + .AddType() + .BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken); + + // assert schema.MatchSnapshot(); } @@ -109,6 +227,8 @@ public class Query public IFoo GetFoo([Tag("OnObjectFieldArg")] string a) => new Foo(); public FooEnum GetFooEnum(FooInput input) => FooEnum.Foo; + + public IFooUnion GetFooUnion() => new FooUnionA(); } [Tag("OnInterface")] @@ -123,6 +243,20 @@ public class Foo : IFoo public string Bar(string baz) => "Bar" + baz; } + [UnionType("FooUnion")] + [Tag("OnUnion")] + public interface IFooUnion; + + public sealed class FooUnionA : IFooUnion + { + public string Id => "a"; + } + + public sealed class FooUnionB : IFooUnion + { + public string Id => "b"; + } + [Tag("OnEnum")] public enum FooEnum { @@ -138,6 +272,7 @@ public class FooInput public required string Bar { get; set; } } + [Tag("OnDirectiveDefinition")] [DirectiveType(DirectiveLocation.Query)] public class FooDirective { diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.EnsureAllLocationsAreApplied.graphql b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.CodeFirst_Tag.graphql similarity index 80% rename from src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.EnsureAllLocationsAreApplied.graphql rename to src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.CodeFirst_Tag.graphql index edd64f204d9..3085faf6f58 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.EnsureAllLocationsAreApplied.graphql +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.CodeFirst_Tag.graphql @@ -6,17 +6,28 @@ type Query @tag(name: "OnObjectType") { foo(a: String! @tag(name: "OnObjectFieldArg")): IFoo! @tag(name: "OnObjectField") fooEnum(input: FooInput!): FooEnum! + fooUnion: FooUnion! } type Foo implements IFoo { bar(baz: String!): String! } +type FooUnionA { + id: String! +} + +type FooUnionB { + id: String! +} + interface IFoo @tag(name: "OnInterface") { bar(baz: String! @tag(name: "OnInterfaceFieldArg")): String! @tag(name: "OnInterfaceField") } +union FooUnion @tag(name: "OnUnion") = FooUnionA | FooUnionB + input FooInput @tag(name: "OnInputObjectType") { bar: String! @tag(name: "OnInputObjectField") } @@ -26,7 +37,8 @@ enum FooEnum @tag(name: "OnEnum") { BAR } -directive @foo(arg: String! @tag(name: "OnDirectiveArgument")) on QUERY +directive @foo(arg: String! @tag(name: "OnDirectiveArgument")) + @tag(name: "OnDirectiveDefinition") on QUERY """ The @tag directive is used to apply arbitrary string @@ -52,3 +64,4 @@ directive @tag("The name of the tag." name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.ImplementationFirst_Tag.graphql b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.ImplementationFirst_Tag.graphql new file mode 100644 index 00000000000..03415d238dd --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.ImplementationFirst_Tag.graphql @@ -0,0 +1,67 @@ +schema { + query: Query +} + +type Query @tag(name: "OnObjectType") { + foo(a: String! @tag(name: "OnObjectFieldArg")): IFoo! + @tag(name: "OnObjectField") + fooEnum(input: FooInput!): FooEnum! + fooUnion: FooUnion! +} + +type Foo implements IFoo { + bar(baz: String!): String! +} + +type FooUnionA { + id: String! +} + +type FooUnionB { + id: String! +} + +interface IFoo @tag(name: "OnInterface") { + bar(baz: String! @tag(name: "OnInterfaceFieldArg")): String! + @tag(name: "OnInterfaceField") +} + +union FooUnion @tag(name: "OnUnion") = FooUnionA | FooUnionB + +input FooInput @tag(name: "OnInputObjectType") { + bar: String! @tag(name: "OnInputObjectField") +} + +enum FooEnum @tag(name: "OnEnum") { + FOO @tag(name: "OnEnumValue") + BAR +} + +directive @foo(arg: String! @tag(name: "OnDirectiveArgument")) + @tag(name: "OnDirectiveDefinition") on QUERY + +""" +The @tag directive is used to apply arbitrary string +metadata to a schema location. Custom tooling can use +this metadata during any step of the schema delivery flow, +including composition, static analysis, and documentation. + +interface Book { + id: ID! @tag(name: "your-value") + title: String! + author: String! +} +""" +directive @tag("The name of the tag." name: String!) repeatable on + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.SchemaFirst_Tag.graphql b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.SchemaFirst_Tag.graphql index a5b97c24ef2..3085faf6f58 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.SchemaFirst_Tag.graphql +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/TagDirectiveTests.SchemaFirst_Tag.graphql @@ -1,11 +1,45 @@ -schema { +schema @tag(name: "OnSchema") { query: Query } -type Query { - field: String @tag(name: "abc") +type Query @tag(name: "OnObjectType") { + foo(a: String! @tag(name: "OnObjectFieldArg")): IFoo! + @tag(name: "OnObjectField") + fooEnum(input: FooInput!): FooEnum! + fooUnion: FooUnion! } +type Foo implements IFoo { + bar(baz: String!): String! +} + +type FooUnionA { + id: String! +} + +type FooUnionB { + id: String! +} + +interface IFoo @tag(name: "OnInterface") { + bar(baz: String! @tag(name: "OnInterfaceFieldArg")): String! + @tag(name: "OnInterfaceField") +} + +union FooUnion @tag(name: "OnUnion") = FooUnionA | FooUnionB + +input FooInput @tag(name: "OnInputObjectType") { + bar: String! @tag(name: "OnInputObjectField") +} + +enum FooEnum @tag(name: "OnEnum") { + FOO @tag(name: "OnEnumValue") + BAR +} + +directive @foo(arg: String! @tag(name: "OnDirectiveArgument")) + @tag(name: "OnDirectiveDefinition") on QUERY + """ The @tag directive is used to apply arbitrary string metadata to a schema location. Custom tooling can use @@ -30,3 +64,4 @@ directive @tag("The name of the tag." name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/TagMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/TagMutableDirectiveDefinition.cs index 7de519c32de..ad6e593446d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/TagMutableDirectiveDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/TagMutableDirectiveDefinition.cs @@ -24,7 +24,8 @@ public TagMutableDirectiveDefinition(MutableScalarTypeDefinition stringType) | DirectiveLocation.InputFieldDefinition | DirectiveLocation.ArgumentDefinition | DirectiveLocation.EnumValue - | DirectiveLocation.Schema; + | DirectiveLocation.Schema + | DirectiveLocation.DirectiveDefinition; } public static TagMutableDirectiveDefinition Create(ISchemaDefinition schema) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveDefinitionCompatibility.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveDefinitionCompatibility.cs new file mode 100644 index 00000000000..3715eb0c32c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveDefinitionCompatibility.cs @@ -0,0 +1,110 @@ +using HotChocolate.Language; + +namespace HotChocolate.Fusion; + +/// +/// Determines whether a source schema's is compatible +/// with a canonical directive definition, ignoring descriptions and treating arguments as an +/// unordered collection. A source definition is compatible when its name and repeatability +/// match the canonical definition exactly, its arguments match the canonical definition's +/// arguments exactly (ignoring order), and every location it declares is also declared by the +/// canonical definition. The canonical definition may declare additional locations that the +/// source definition does not. +/// +internal static class DirectiveDefinitionCompatibility +{ + private static readonly IEqualityComparer s_syntaxComparer = + SyntaxComparer.BySyntaxIgnoreDescriptions; + + public static bool IsSourceCompatibleWithCanonical( + DirectiveDefinitionNode source, + DirectiveDefinitionNode canonical) + { + if (ReferenceEquals(source, canonical)) + { + return true; + } + + return source.Name.Value.Equals(canonical.Name.Value, StringComparison.Ordinal) + && source.IsRepeatable == canonical.IsRepeatable + && ArgumentsEqual(source.Arguments, canonical.Arguments) + && LocationsSubsetOfCanonical(source.Locations, canonical.Locations); + } + + private static bool ArgumentsEqual( + IReadOnlyList sourceArgs, + IReadOnlyList canonicalArgs) + { + if (sourceArgs.Count != canonicalArgs.Count) + { + return false; + } + + if (sourceArgs.Count == 0) + { + return true; + } + + // For each source argument, find the matching canonical argument by name + // and compare them using the syntax comparer (ignoring descriptions). + var matched = new bool[canonicalArgs.Count]; + + foreach (var sourceArg in sourceArgs) + { + var found = false; + + for (var j = 0; j < canonicalArgs.Count; j++) + { + if (matched[j]) + { + continue; + } + + var canonicalArg = canonicalArgs[j]; + + if (sourceArg.Name.Value.Equals(canonicalArg.Name.Value, StringComparison.Ordinal) + && s_syntaxComparer.Equals(sourceArg, canonicalArg)) + { + matched[j] = true; + found = true; + break; + } + } + + if (!found) + { + return false; + } + } + + return true; + } + + private static bool LocationsSubsetOfCanonical( + IReadOnlyList sourceLocations, + IReadOnlyList canonicalLocations) + { + // Every location declared by the source must also be declared by the canonical + // definition. The canonical definition may declare locations the source does not. + foreach (var sourceLocation in sourceLocations) + { + var found = false; + + foreach (var canonicalLocation in canonicalLocations) + { + if (sourceLocation.Value.Equals(canonicalLocation.Value, StringComparison.Ordinal)) + { + found = true; + break; + } + } + + if (!found) + { + return false; + } + } + + return true; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveDefinitionNodeComparer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveDefinitionNodeComparer.cs deleted file mode 100644 index 53338e1f5f4..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveDefinitionNodeComparer.cs +++ /dev/null @@ -1,158 +0,0 @@ -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/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs index e25f9cb44bb..167d639bb2c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs @@ -190,10 +190,11 @@ private void MergeDirectiveDefinitions(MutableSchemaDefinition mergedSchema) var canonicalDirectiveDefinition = directiveMerger.GetCanonicalDirectiveDefinition(mergedSchema); var canonicalDirectiveNode = canonicalDirectiveDefinition.ToSyntaxNode(); - // Ensure that all directive definitions match the canonical definition. + // Ensure that all directive definitions are compatible with the canonical definition. if (!grouping.All( - d => DirectiveDefinitionNodeComparer.Instance - .Equals(d.DirectiveDefinition.ToSyntaxNode(), canonicalDirectiveNode))) + d => DirectiveDefinitionCompatibility.IsSourceCompatibleWithCanonical( + d.DirectiveDefinition.ToSyntaxNode(), + canonicalDirectiveNode))) { // Skip merging if there is a mismatch. continue; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs index 5c370134e1e..4889453ef9a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs @@ -87,8 +87,9 @@ public CompositionResult Preprocess() } /// - /// Removes types, fields, arguments, and enum values that are tagged with any of the specified - /// tags. + /// Removes types, fields, arguments, and enum values that are tagged with any of the + /// specified tags. Also removes directive definitions tagged for exclusion and cascades + /// the removal to their applications. /// private void RemoveTaggedMembers(HashSet excludeByTag) { @@ -212,6 +213,114 @@ private void RemoveTaggedMembers(HashSet excludeByTag) { schema.SubscriptionType = null; } + + var removedDirectiveNames = new HashSet(StringComparer.Ordinal); + + foreach (var directiveDefinition in schema.DirectiveDefinitions.AsEnumerable()) + { + if (directiveDefinition.GetTags().Overlaps(excludeByTag)) + { + removedDirectiveNames.Add(directiveDefinition.Name); + } + } + + if (removedDirectiveNames.Count == 0) + { + return; + } + + foreach (var directiveName in removedDirectiveNames) + { + schema.DirectiveDefinitions.Remove(directiveName); + } + + RemoveDirectiveApplications(removedDirectiveNames); + } + + /// + /// Removes every application of the specified directives from all directive-bearing members of + /// the schema, so that removing the directive definitions leaves a consistent schema without + /// dangling applications. + /// + private void RemoveDirectiveApplications(HashSet directiveNames) + { + RemoveMatchingDirectives(schema.Directives, directiveNames); + + foreach (var type in schema.Types) + { + if (type is IMutableTypeDefinition mutableType) + { + RemoveMatchingDirectives(mutableType.Directives, directiveNames); + } + + switch (type) + { + case MutableComplexTypeDefinition complexType: + foreach (var field in complexType.Fields.AsEnumerable()) + { + RemoveMatchingDirectives(field.Directives, directiveNames); + + foreach (var argument in field.Arguments.AsEnumerable()) + { + RemoveMatchingDirectives(argument.Directives, directiveNames); + } + } + + break; + + case MutableInputObjectTypeDefinition inputObjectType: + foreach (var field in inputObjectType.Fields.AsEnumerable()) + { + RemoveMatchingDirectives(field.Directives, directiveNames); + } + + break; + + case MutableEnumTypeDefinition enumType: + foreach (var value in enumType.Values.AsEnumerable()) + { + RemoveMatchingDirectives(value.Directives, directiveNames); + } + + break; + } + } + + foreach (var directiveDefinition in schema.DirectiveDefinitions.AsEnumerable()) + { + RemoveMatchingDirectives(directiveDefinition.Directives, directiveNames); + + foreach (var argument in directiveDefinition.Arguments.AsEnumerable()) + { + RemoveMatchingDirectives(argument.Directives, directiveNames); + } + } + } + + private static void RemoveMatchingDirectives( + DirectiveCollection directives, + HashSet directiveNames) + { + List? directivesToRemove = null; + + foreach (var directive in directives.AsEnumerable()) + { + if (directiveNames.Contains(directive.Name)) + { + directivesToRemove ??= []; + directivesToRemove.Add(directive); + } + } + + if (directivesToRemove is null) + { + return; + } + + foreach (var directive in directivesToRemove) + { + directives.Remove(directive); + } } /// diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs index 1b80c938ee2..e9ac93aa85c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs @@ -30,6 +30,10 @@ scalar Foo @fusion__type(schema: A) @fusion__type(schema: B) } // Merge @tag directives when the definitions match the canonical definition. + // The custom directive @foo, applied to FooObject.field and with @tag on its own + // definition, is included to show that a custom directive with no dedicated merger + // is dropped from the composed schema: its definition, its application on the field, + // and the @tag on its definition are all absent from the output. [Fact] public void Merge_TagDirectives_MatchesSnapshot() { @@ -46,7 +50,7 @@ type Query { } type FooObject @tag(name: "a") { - field(arg: Int @tag(name: "a")): Int @tag(name: "a") + field(arg: Int @tag(name: "a")): Int @tag(name: "a") @foo(arg: 1) } interface FooInterface @tag(name: "a") { @@ -65,6 +69,8 @@ input FooInput @tag(name: "a") { field: Int @tag(name: "a") } + directive @foo(arg: Int) @tag(name: "a") on FIELD_DEFINITION + directive @tag("Some description" name: String!) repeatable on {{s_tagLocations}} """, $$""" @@ -78,7 +84,7 @@ type Query { } type FooObject @tag(name: "b") { - field(arg: Int @tag(name: "b")): Int @tag(name: "b") + field(arg: Int @tag(name: "b")): Int @tag(name: "b") @foo(arg: 2) } interface FooInterface @tag(name: "b") { @@ -97,6 +103,8 @@ input FooInput @tag(name: "b") { field: Int @tag(name: "b") } + directive @foo(arg: Int) @tag(name: "b") on FIELD_DEFINITION + directive @tag("Some description" name: String!) repeatable on {{s_tagLocations}} """ ], @@ -183,6 +191,77 @@ directive @tag(name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION + """, + options => options.TagMergeBehavior = DirectiveMergeBehavior.Include); + } + + // Merge @tag directives when a source schema's definition predates the + // DIRECTIVE_DEFINITION location added to the canonical @tag definition. + [Fact] + public void Merge_TagDirectivesLocationSubsetOfCanonical_MatchesSnapshot() + { + AssertMatches( + [ + $""" + # Schema A + scalar Foo @tag(name: "a") + + directive @tag(name: String!) repeatable on {s_preDirectiveDefinitionTagLocations} + """, + $""" + # Schema B + scalar Foo @tag(name: "b") + + directive @tag(name: String!) repeatable on {s_preDirectiveDefinitionTagLocations} + """ + ], + """ + scalar Foo + @tag(name: "a") + @tag(name: "b") + @fusion__type(schema: A) + @fusion__type(schema: B) + + directive @tag(name: String!) repeatable on + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION + """, + options => options.TagMergeBehavior = DirectiveMergeBehavior.Include); + } + + // Do not merge @tag directives when a source schema's definition declares a location + // outside the canonical set (a superset), even though its locations are otherwise compatible. + [Fact] + public void Merge_TagDirectivesLocationSupersetOfCanonical_MatchesSnapshot() + { + AssertMatches( + [ + $""" + # Schema A + scalar Foo @tag(name: "a") + + directive @tag(name: String!) repeatable on {s_tagLocations} | FIELD + """, + $""" + # Schema B + scalar Foo @tag(name: "b") + + directive @tag(name: String!) repeatable on {s_tagLocations} | FIELD + """ + ], + """ + scalar Foo @fusion__type(schema: A) @fusion__type(schema: B) """, options => options.TagMergeBehavior = DirectiveMergeBehavior.Include); } @@ -250,6 +329,7 @@ directive @fusion__tag(name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION """, options => options.TagMergeBehavior = DirectiveMergeBehavior.IncludePrivate); } @@ -288,11 +368,29 @@ directive @tag(name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION """, options => options.TagMergeBehavior = DirectiveMergeBehavior.Include); } private static readonly string s_tagLocations = + """ + SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION + """.ReplaceLineEndings(" "); + + // The @tag locations before DIRECTIVE_DEFINITION was added to the canonical definition. + private static readonly string s_preDirectiveDefinitionTagLocations = """ SCHEMA | SCALAR diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs index 4a848993e0c..0b47614c05e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs @@ -74,6 +74,8 @@ VALUE2 @tag(name: "remove") scalar Scalar1 @tag(name: "remove") + directive @directive1 @tag(name: "remove") on FIELD_DEFINITION + directive @tag(name: String!) repeatable on | SCHEMA | SCALAR @@ -86,6 +88,7 @@ directive @tag(name: String!) repeatable on | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION """); var compositionLog = new CompositionLog(); var sourceSchemaParser = new SourceSchemaParser(sourceSchemaText, compositionLog); @@ -101,9 +104,10 @@ directive @tag(name: String!) repeatable on preprocessor.Preprocess(); schema.Types.Remove("FieldSelectionMap"); schema.Types.Remove("FieldSelectionSet"); - schema.DirectiveDefinitions.Clear(); // assert + Assert.False(schema.DirectiveDefinitions.ContainsName("directive1")); + schema.DirectiveDefinitions.Clear(); schema.ToString().MatchSnapshot(extension: ".graphql"); } @@ -249,6 +253,92 @@ directive @tag(name: String!) repeatable on Assert.False(schema.Types.ContainsName("Enum")); } + [Fact] + public void Preprocess_ExcludeByTag_RemovesTaggedDirectiveDefinitionAndCascadesToAllApplicationSites() + { + // arrange + // @drop is tagged for exclusion and applied at every kind of application site: the + // schema, object/enum/input types, an object field, a field argument, an enum value, an + // input field, the surviving @keep directive definition itself, and a @keep argument. + // Removing @drop cascades to all of them, so the preprocessed schema contains no @drop + // definition and no @drop application anywhere, while the untagged @keep survives. + var sourceSchemaText = + new SourceSchemaText( + "A", + // lang=graphql + """ + schema @drop { + query: Query + } + + type Query @drop { + field(argument: Int @drop): Int @drop + } + + enum Enum1 @drop { + VALUE1 + VALUE2 @drop + } + + input Input1 @drop { + field1: ID! + field2: Int @drop + } + + directive @keep(argument: Int @drop) @drop on FIELD_DEFINITION + + directive @drop @tag(name: "remove") on + | SCHEMA + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION + + directive @tag(name: String!) repeatable on + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION + """); + var compositionLog = new CompositionLog(); + var sourceSchemaParser = new SourceSchemaParser(sourceSchemaText, compositionLog); + var schema = sourceSchemaParser.Parse().Value; + var preprocessor = + new SourceSchemaPreprocessor( + schema, + [], + compositionLog, + options: new SourceSchemaPreprocessorOptions { ExcludeByTag = ["remove"] }); + + // act + var result = preprocessor.Preprocess(); + // Trim the Fusion built-in types and directive definitions the parser injects, so the + // snapshot only shows the user-defined @keep and @tag definitions (and proves @drop is + // gone). @keep and @tag are user-defined and therefore retained. + schema.Types.Remove("FieldSelectionMap"); + schema.Types.Remove("FieldSelectionSet"); + foreach (var builtInDirectiveName in FusionBuiltIns.SourceSchemaDirectives.Keys) + { + schema.DirectiveDefinitions.Remove(builtInDirectiveName); + } + + // assert + Assert.True(result.IsSuccess); + schema.ToString().MatchSnapshot(extension: ".graphql"); + } + [Fact] public void Preprocess_InferKeysFromLookupsEnabled_AppliesInferredKeyDirectives() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_ExcludeByTag_RemovesTaggedDirectiveDefinitionAndCascadesToAllApplicationSites.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_ExcludeByTag_RemovesTaggedDirectiveDefinitionAndCascadesToAllApplicationSites.graphql new file mode 100644 index 00000000000..096a328f152 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_ExcludeByTag_RemovesTaggedDirectiveDefinitionAndCascadesToAllApplicationSites.graphql @@ -0,0 +1,33 @@ +schema { + query: Query +} + +type Query { + field(argument: Int): Int +} + +input Input1 { + field1: ID! + field2: Int +} + +enum Enum1 { + VALUE1 + VALUE2 +} + +directive @keep(argument: Int) on FIELD_DEFINITION + +directive @tag(name: String!) repeatable on + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | DIRECTIVE_DEFINITION