Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public sealed class SatisfiabilityOptions
/// <summary>
/// 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 <c>UNREACHABLE_SOURCE_FIELD</c> error.
/// </summary>
public Dictionary<string, List<string>> IgnoredNonAccessibleFields { get; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,10 +14,16 @@ namespace HotChocolate.Fusion.PostMergeValidationRules;
/// <summary>
/// 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 <c>UNREACHABLE_SOURCE_FIELD</c> error.
/// Otherwise, composition fails with an <c>UNREACHABLE_SOURCE_FIELD</c> error, unless the field
/// coordinate is listed in <see cref="SatisfiabilityOptions.IgnoredNonAccessibleFields"/>.
/// </summary>
internal sealed class UnreachableSourceFieldRule : IEventHandler<SchemaEvent>
internal sealed class UnreachableSourceFieldRule(
IReadOnlyDictionary<string, List<string>> ignoredNonAccessibleFields)
: IEventHandler<SchemaEvent>
{
private readonly IReadOnlyDictionary<string, List<string>> _ignoredNonAccessibleFields =
ignoredNonAccessibleFields;

public void Handle(SchemaEvent @event, CompositionContext context)
{
var mergedSchema = @event.Schema;
Expand Down Expand Up @@ -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));
}
}
Expand Down
10 changes: 6 additions & 4 deletions src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ public CompositionResult<MutableSchemaDefinition> Compose()

// Post Merge Validation
var postMergeValidationResult =
new PostMergeValidator(mergedSchema, PostMergeRules, schemas, _log).Validate();
new PostMergeValidator(mergedSchema, CreatePostMergeRules(_schemaComposerOptions), schemas, _log)
.Validate();

if (postMergeValidationResult.IsFailure)
{
Expand Down Expand Up @@ -339,9 +340,10 @@ internal static ImmutableArray<object> CreatePreMergeRules(SchemaComposerOptions
];

/// <summary>
/// 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.
/// </summary>
internal static ImmutableArray<object> PostMergeRules { get; } =
internal static ImmutableArray<object> CreatePostMergeRules(SchemaComposerOptions options) =>
[
new EmptyMergedEnumTypeRule(),
new EmptyMergedInputObjectTypeRule(),
Expand All @@ -363,6 +365,6 @@ internal static ImmutableArray<object> CreatePreMergeRules(SchemaComposerOptions
new ReferenceToInaccessibleTypeRule(),
new ReferenceToInternalTypeRule(),
new RequireInvalidFieldsRule(),
new UnreachableSourceFieldRule()
new UnreachableSourceFieldRule(options.Satisfiability.IgnoredNonAccessibleFields)
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tag>"],
["Category.name"] = ["A:Query.productById<Product> -> A:Product.category<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<Product> -> A:Product.category<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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions website/content/docs/fusion/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -393,7 +393,7 @@ When a field is intentionally non-accessible at the moment, for example during a
}
```

The key is `<TypeName>.<fieldName>`. 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 `<TypeName>.<fieldName>`. 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.

Expand Down
Loading