diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SatisfiabilityOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SatisfiabilityOptions.cs index 03ee555fef6..71510855e78 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SatisfiabilityOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SatisfiabilityOptions.cs @@ -13,7 +13,8 @@ public sealed class SatisfiabilityOptions /// /// A collection of fields that should be ignored during the satisfiability analysis /// because they are known to be non-accessible. The key is the qualified field name (e.g. - /// "TypeName.fieldName") and the value is a list of paths to ignore for that field. + /// "TypeName.fieldName") and the value is a list of paths to ignore for that field. A listed + /// field is also exempt from the UNREACHABLE_SOURCE_FIELD error. /// public Dictionary> IgnoredNonAccessibleFields { get; } = []; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/UnreachableSourceFieldRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/UnreachableSourceFieldRule.cs index 9618216f1a7..defdc1edb92 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/UnreachableSourceFieldRule.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/UnreachableSourceFieldRule.cs @@ -2,6 +2,7 @@ using HotChocolate.Fusion.Events.Contracts; using HotChocolate.Fusion.Extensions; using HotChocolate.Fusion.Features; +using HotChocolate.Fusion.Options; using HotChocolate.Types; using HotChocolate.Types.Mutable; using static HotChocolate.Fusion.Logging.LogEntryHelper; @@ -13,10 +14,16 @@ namespace HotChocolate.Fusion.PostMergeValidationRules; /// /// A field declared on a type that its source schema cannot reach from its root types must be /// resolved by another source schema whenever clients can reach the type in the composite schema. -/// Otherwise, composition fails with an UNREACHABLE_SOURCE_FIELD error. +/// Otherwise, composition fails with an UNREACHABLE_SOURCE_FIELD error, unless the field +/// coordinate is listed in . /// -internal sealed class UnreachableSourceFieldRule : IEventHandler +internal sealed class UnreachableSourceFieldRule( + IReadOnlyDictionary> ignoredNonAccessibleFields) + : IEventHandler { + private readonly IReadOnlyDictionary> _ignoredNonAccessibleFields = + ignoredNonAccessibleFields; + public void Handle(SchemaEvent @event, CompositionContext context) { var mergedSchema = @event.Schema; @@ -65,6 +72,12 @@ public void Handle(SchemaEvent @event, CompositionContext context) continue; } + // An ignored non-accessible field is exempt whatever paths are listed for it. + if (_ignoredNonAccessibleFields.ContainsKey($"{type.Name}.{field.Name}")) + { + continue; + } + context.Log.Write(UnreachableSourceField(field, type, schema)); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs index 24aceba838f..db60e474c00 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs @@ -203,7 +203,8 @@ public CompositionResult Compose() // Post Merge Validation var postMergeValidationResult = - new PostMergeValidator(mergedSchema, PostMergeRules, schemas, _log).Validate(); + new PostMergeValidator(mergedSchema, CreatePostMergeRules(_schemaComposerOptions), schemas, _log) + .Validate(); if (postMergeValidationResult.IsFailure) { @@ -339,9 +340,10 @@ internal static ImmutableArray CreatePreMergeRules(SchemaComposerOptions ]; /// - /// Gets the rules that run against the merged schema, in the order they are applied. + /// Creates the rules that run against the merged schema, in the order they are applied, + /// configured from the given options. /// - internal static ImmutableArray PostMergeRules { get; } = + internal static ImmutableArray CreatePostMergeRules(SchemaComposerOptions options) => [ new EmptyMergedEnumTypeRule(), new EmptyMergedInputObjectTypeRule(), @@ -363,6 +365,6 @@ internal static ImmutableArray CreatePreMergeRules(SchemaComposerOptions new ReferenceToInaccessibleTypeRule(), new ReferenceToInternalTypeRule(), new RequireInvalidFieldsRule(), - new UnreachableSourceFieldRule() + new UnreachableSourceFieldRule(options.Satisfiability.IgnoredNonAccessibleFields) ]; } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/UnreachableSourceFieldRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/UnreachableSourceFieldRuleTests.cs index 53e4ca43cf0..9bb409eb2a2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/UnreachableSourceFieldRuleTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/UnreachableSourceFieldRuleTests.cs @@ -414,6 +414,171 @@ type Product { ]); } + [Fact] + public void Compose_Should_Succeed_When_UnreachableFieldIsIgnored() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! + category: Category + } + + type Category @key(fields: "id") { + id: ID! + } + + type Tag @key(fields: "id") { + id: ID! + name: String! + } + """), + new SourceSchemaText( + "B", + """ + type Query { + tagById(id: ID!): Tag @lookup + } + + type Product @key(fields: "id") { + id: ID! + } + + type Category @key(fields: "id") { + id: ID! + name: String! + } + + type Tag { + id: ID! + } + """) + ], + new SchemaComposerOptions + { + Merger = { AddFusionDefinitions = false }, + Satisfiability = + { + IgnoredNonAccessibleFields = + { + ["Tag.name"] = ["B:Query.tagById"], + ["Category.name"] = ["A:Query.productById -> A:Product.category"] + } + } + }, + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsSuccess); + Assert.Empty(log); + } + + [Fact] + public void Compose_Should_Fail_When_OnlyUnrelatedFieldIsIgnored() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! + category: Category + } + + type Category @key(fields: "id") { + id: ID! + } + + type Tag @key(fields: "id") { + id: ID! + name: String! + } + """), + new SourceSchemaText( + "B", + """ + type Query { + tagById(id: ID!): Tag @lookup + } + + type Product @key(fields: "id") { + id: ID! + } + + type Category @key(fields: "id") { + id: ID! + name: String! + } + + type Tag { + id: ID! + } + """) + ], + new SchemaComposerOptions + { + Merger = { AddFusionDefinitions = false }, + Satisfiability = + { + IgnoredNonAccessibleFields = + { + ["Category.id"] = ["A:Query.productById -> A:Product.category"] + } + } + }, + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsFailure); + log.Select(e => e.ToString()).MatchInlineSnapshots( + [ + """ + { + "message": "The field 'Tag.name' in schema 'A' cannot be resolved, because the type 'Tag' is not reachable from the root types of schema 'A' and no other schema resolves the field.", + "code": "UNREACHABLE_SOURCE_FIELD", + "severity": "Error", + "coordinate": "Tag.name", + "member": "name", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The field 'Category.name' in schema 'B' cannot be resolved, because the type 'Category' is not reachable from the root types of schema 'B' and no other schema resolves the field.", + "code": "UNREACHABLE_SOURCE_FIELD", + "severity": "Error", + "coordinate": "Category.name", + "member": "name", + "schema": "B", + "extensions": {} + } + """ + ]); + } + [Fact] public void Compose_Should_Succeed_When_UnreachableTypeIsAlsoUnreachableInCompositeSchema() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs index 1316cef3d17..9cb88d8d2f4 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs @@ -1192,7 +1192,9 @@ public void PreMergeRules_Should_ContainTheRegisteredRuleSet() public void PostMergeRules_Should_ContainTheRegisteredRuleSet() { // act - var rules = SchemaComposer.PostMergeRules.Select(r => r.GetType().Name); + var rules = SchemaComposer + .CreatePostMergeRules(new SchemaComposerOptions()) + .Select(r => r.GetType().Name); // assert Assert.Equal( diff --git a/website/content/docs/fusion/composition.md b/website/content/docs/fusion/composition.md index 4c81bec6fe5..693cf8bde2d 100644 --- a/website/content/docs/fusion/composition.md +++ b/website/content/docs/fusion/composition.md @@ -311,7 +311,7 @@ The placeholders `{0}`, `{1}`, etc. in the message column are replaced with the | [ROOT_QUERY_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Query-Used) | The root query type in schema '\{0\}' must be named 'Query'. | Fusion requires the root query type to use the canonical name `Query`. Rename the type in the named source schema. A type named `Query` must not exist unless it is the root query type. See [Getting Started](./getting-started.md) for the expected schema layout. | | [ROOT_SUBSCRIPTION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Subscription-Used) | The root subscription type in schema '\{0\}' must be named 'Subscription'. | Fusion requires the root subscription type to use the canonical name `Subscription`. Rename the type in the named source schema. A type named `Subscription` must not exist unless it is the root subscription type. | | [TYPE_KIND_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Type-Kind-Mismatch) | The type '\{0\}' has a different kind in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | The same type name was used for different kinds (for example, an object in one source schema and an interface in another). Decide which kind is correct and update the other source schema, or rename one of the types so they no longer collide. | -| `UNREACHABLE_SOURCE_FIELD` | The field '\{0\}' in schema '\{1\}' cannot be resolved, because the type '\{2\}' is not reachable from the root types of schema '\{1\}' and no other schema resolves the field. | A source schema declares the field on a type that it cannot reach from its root types, while clients reach the type in the composite schema through another source schema. Add a lookup or a field that returns the type to that source schema, or remove the field from it. An interface field counts as resolved when every reachable implementation resolves it. Fields of a type that clients cannot reach in the composite schema, such as an `@inaccessible` type, are removed without a diagnostic. | +| `UNREACHABLE_SOURCE_FIELD` | The field '\{0\}' in schema '\{1\}' cannot be resolved, because the type '\{2\}' is not reachable from the root types of schema '\{1\}' and no other schema resolves the field. | A source schema declares the field on a type that it cannot reach from its root types, while clients reach the type in the composite schema through another source schema. Add a lookup or a field that returns the type to that source schema, or remove the field from it. An interface field counts as resolved when every reachable implementation resolves it. Fields of a type that clients cannot reach in the composite schema, such as an `@inaccessible` type, are removed without a diagnostic. A field coordinate listed under `satisfiability.ignoredNonAccessibleFields` also silences this error. | | [UNSATISFIABLE_QUERY_PATH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Unsatisfiable-Query-Path) | (Message varies. Includes the unreachable field, the path the validator tried, and the lookups it considered.) | Some reachable field cannot be resolved through the available `@lookup` and `@key` paths. This is an error unless the source-schema node-resolution exception described under Warnings applies. See [Diagnosing UNSATISFIABLE_QUERY_PATH Diagnostics](#diagnosing-unsatisfiable_query_path-diagnostics) for how to read the message and fix the underlying gap. | ## Warnings @@ -393,7 +393,7 @@ When a field is intentionally non-accessible at the moment, for example during a } ``` -The key is `.`. Each value is a list of paths (matching exactly what `IncludeSatisfiabilityPaths` would print for that field) where the validator should skip the check. The field name and at least one of its paths must match for the error to be silenced. +The key is `.`. Each value is a list of paths (matching exactly what `IncludeSatisfiabilityPaths` would print for that field) where the validator should skip the check. The field name and at least one of its paths must match for the error to be silenced. A listed field coordinate also silences `UNREACHABLE_SOURCE_FIELD` for that field, whatever paths it lists. This setting lives in the per-source-schema settings file (e.g. `accounts-settings.json`), not in the archive-level composition settings.