From 1130fa9853e45163b450ee5f1d26117838ad9649 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 27 May 2026 17:36:03 +0200 Subject: [PATCH 1/3] [Fusion] Extend federation-gateway-audit satisfiability tests --- .../SatisfiabilityValidatorTests.cs | 1357 +++++++++++++++-- 1 file changed, 1272 insertions(+), 85 deletions(-) diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs index 9d1cc3b54e7..0f2a4dde12b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs @@ -1069,6 +1069,121 @@ type Mutation { Assert.True(result.IsSuccess); } + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/mysterious-external + public void MysteriousExternal() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + cheapestProduct: Product + # Federation supplies an implicit reference resolver for the @key; + # the composite schemas spec has no implicit resolution, so the + # equivalent entry point is declared explicitly. + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! @external + price: Float + } + """, + """ + # Schema B + type Query { + products: [Product!]! + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/nested-provides + public void NestedProvides() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type Query { + products: [Product] + @shareable + @provides(fields: "categories { id name subCategories { id name } }") + productById(id: ID!): Product @lookup @inaccessible # Added + categoryById(id: ID!): Category @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + categories: [Category] @external + } + + type Category @key(fields: "id") { + id: ID! + name: String + subCategories: [Category] @external + } + """, + """ + # Schema C + type Query { + productById(id: ID!): Product @lookup @inaccessible # Added + categoryById(id: ID!): Category @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + categories: [Category] @shareable + } + + type Category @key(fields: "id") { + id: ID! + subCategories: [Category] @shareable + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + [Fact] // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/node public void Node() @@ -1084,37 +1199,907 @@ type Query { categoryNode: Node } - interface Node { + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "id") { + id: ID! + } + + type Category implements Node @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type Query { # Added + node(id: ID!): Node @lookup @inaccessible + } + + interface Node { + id: ID! + } + + type Product implements Node @key(fields: "id") @shareable { + id: ID! + name: String! + price: Float! + } + + type Category implements Node @key(fields: "id") { + id: ID! + name: String! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/null-keys + public void NullKeys() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + bookContainers: [BookContainer] + bookByUpc(upc: ID!): Book @lookup @inaccessible # Added + } + + type BookContainer { + book: Book + } + + type Book @key(fields: "upc") { + upc: ID! + } + """, + """ + # Schema B + type Query { + bookById(id: ID!): Book @lookup @inaccessible # Added + bookByUpc(upc: ID!): Book @lookup @inaccessible # Added + } + + type Book @key(fields: "id") @key(fields: "upc") { + id: ID! + upc: ID! + } + """, + """ + # Schema C + type Query { + bookById(id: ID!): Book @lookup @inaccessible # Added + } + + type Book @key(fields: "id") { + id: ID! + author: Author + } + + type Author { + id: ID! + name: String + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/override-type-interface + public void OverrideTypeInterface() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + feed: [Post] + imagePostById(id: ID!): ImagePost @lookup @inaccessible # Added + } + + interface Post { + id: ID! + createdAt: String! + } + + type ImagePost implements Post @key(fields: "id") { + id: ID! + createdAt: String! + } + """, + """ + # Schema B + type Query { + anotherFeed: [AnotherPost] + imagePostById(id: ID!): ImagePost @lookup @inaccessible # Added + textPostById(id: ID!): TextPost @lookup @inaccessible # Added + } + + interface Post { + id: ID! + createdAt: String! + } + + type TextPost implements Post @key(fields: "id") { + id: ID! + createdAt: String! + body: String! + } + + interface AnotherPost { + id: ID! + createdAt: String! + } + + type ImagePost implements AnotherPost @key(fields: "id") { + id: ID! + createdAt: String! @override(from: "A") + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/override-with-requires + public void OverrideWithRequires() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + userInA: User + userById(id: ID!): User @lookup @inaccessible # Added + } + + type User @key(fields: "id") { + id: ID! + name: String! @external + aName(name: String! @require(field: "name")): String! + } + """, + """ + # Schema B + type Query { + userInB: User + userById(id: ID!): User @lookup @inaccessible # Added + } + + type User @key(fields: "id") { + id: ID! + name: String! @override(from: "C") + } + """, + """ + # Schema C + type Query { + userInC: User + userById(id: ID!): User @lookup @inaccessible # Added + } + + type User @key(fields: "id") { + id: ID! + name: String! @external + cName(name: String! @require(field: "name")): String! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact(Skip = "Requires a parent entity call: Category is keyless in schema C " + + "(reachable only via Product.category), so its id/name must be resolved " + + "by re-fetching the parent Product in A/B rather than by a direct " + + "Category lookup. The validator attempts a direct lookup and reports a " + + "cycle on Category.id. Enable once parent entity calls are supported.")] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/parent-entity-call + public void ParentEntityCall() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + products: [Product!]! + productById(id: ID!): Product @lookup @inaccessible # Added + productByIdAndPid(id: ID!, pid: ID!): Product @lookup @inaccessible # Added + categoryById(id: ID!): Category @lookup @inaccessible # Added + } + + type Product @key(fields: "id") @key(fields: "id pid") { + id: ID! + pid: ID! + category: Category @shareable + } + + type Category @key(fields: "id") { + id: ID! + name: String! @shareable + } + """, + """ + # Schema B + type Query { + productByIdAndPid(id: ID!, pid: ID!): Product @lookup @inaccessible # Added + categoryById(id: ID!): Category @lookup @inaccessible # Added + } + + type Product @key(fields: "id pid") { + id: ID! + pid: ID! + category: Category @shareable + } + + type Category @key(fields: "id") { + id: ID! + name: String! @shareable + } + """, + """ + # Schema C + type Query { + productByIdAndPid(id: ID!, pid: ID!): Product @lookup @inaccessible # Added + } + + type Category { + details: CategoryDetails + } + + type Product @key(fields: "id pid") { + id: ID! + pid: ID! + category: Category @shareable + } + + type CategoryDetails { + products: Int + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/parent-entity-call-complex + public void ParentEntityCallComplex() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID @external + category: Category @shareable + } + + type Category { + details: String + } + """, + """ + # Schema B + type Query { + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID @external + category: Category @shareable + } + + type Category { + id: ID @shareable + } + """, + """ + # Schema C + type Query { + categoryById(id: ID!): Category @lookup @inaccessible # Added + } + + type Category @key(fields: "id") { + id: ID + name: String + } + """, + """ + # Schema D + type Query { + productFromD(id: ID!): Product + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID + name: String + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/provides-on-interface + public void ProvidesOnInterface() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + media: Media @shareable + book: Book @provides(fields: "animals { ... on Dog { name } }") + bookById(id: ID!): Book @lookup @inaccessible # Added + dogById(id: ID!): Dog @lookup @inaccessible # Added + catById(id: ID!): Cat @lookup @inaccessible # Added + } + + interface Media { + id: ID! + } + + interface Animal { + id: ID! + } + + type Book implements Media @key(fields: "id") { + id: ID! + animals: [Animal] @shareable + } + + type Dog implements Animal @key(fields: "id") { + id: ID! @external + name: String @external + } + + type Cat implements Animal @key(fields: "id") { + id: ID! @external + } + """, + """ + # Schema B + type Query { + media: Media @shareable @provides(fields: "animals { id name }") + } + + interface Media { + id: ID! + animals: [Animal] + } + + interface Animal { + id: ID! + name: String + } + + type Book implements Media { + id: ID! @shareable + animals: [Animal] @external + } + + type Dog implements Animal { + id: ID! @external + name: String @external + } + + type Cat implements Animal { + id: ID! @external + name: String @external + } + """, + """ + # Schema C + type Query { + bookById(id: ID!): Book @lookup @inaccessible # Added + dogById(id: ID!): Dog @lookup @inaccessible # Added + catById(id: ID!): Cat @lookup @inaccessible # Added + } + + interface Media { + id: ID! + animals: [Animal] + } + + interface Animal { + id: ID! + name: String + } + + type Book implements Media @key(fields: "id") { + id: ID! + animals: [Animal] @shareable + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + name: String @shareable + age: Int + } + + type Cat implements Animal @key(fields: "id") { + id: ID! + name: String @shareable + age: Int + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/provides-on-union + public void ProvidesOnUnion() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + media: [Media] @shareable + bookById(id: ID!): Book @lookup @inaccessible # Added + movieById(id: ID!): Movie @lookup @inaccessible # Added + } + + union Media = Book | Movie + + type Book @key(fields: "id") { + id: ID! + } + + type Movie @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type Query { + media: [Media] @shareable @provides(fields: "... on Book { title }") + bookById(id: ID!): Book @lookup @inaccessible # Added + movieById(id: ID!): Movie @lookup @inaccessible # Added + } + + union Media = Book | Movie + + type Book @key(fields: "id") { + id: ID! + title: String @external + } + + type Movie @key(fields: "id") { + id: ID! + } + """, + """ + # Schema C + type Query { + bookById(id: ID!): Book @lookup @inaccessible # Added + movieById(id: ID!): Movie @lookup @inaccessible # Added + } + + type Book @key(fields: "id") { + id: ID! + title: String @shareable + } + + type Movie @key(fields: "id") { + id: ID! + title: String @shareable + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact(Skip = "Circular @requires whose intermediate field is owned by the " + + "requiring schema is not yet satisfiable. The validator evaluates the " + + "requirement from the entity's origin path and does not hop into the " + + "requiring schema to gather its locally-owned field first. Enable once " + + "multi-hop requirement gathering is supported.")] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/requires-circular + public void RequiresCircular() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + feed: [Post] + postById(id: ID!): Post @lookup @inaccessible # Added + authorById(id: ID!): Author @lookup @inaccessible # Added + } + + type Post @key(fields: "id") { + id: ID! + byNovice: Boolean! @external + byExpert(byNovice: Boolean! @require(field: "byNovice")): Boolean! + } + + type Author @key(fields: "id") { + id: ID! + name: String! + yearsOfExperience: Int! + } + """, + """ + # Schema B + type Query { + postById(id: ID!): Post @lookup @inaccessible # Added + } + + type Post @key(fields: "id") { + id: ID! + author: Author! + byNovice( + yearsOfExperience: Int! @require(field: "author.yearsOfExperience") + ): Boolean! + } + + type Author @key(fields: "id") { + id: ID! + yearsOfExperience: Int! @external + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/requires-interface + public void RequiresInterface() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + a: User + userById(id: ID!): User @lookup @inaccessible # Added + } + + interface Address { + id: ID! + } + + type HomeAddress implements Address @key(fields: "id") { + id: ID! + city: String @shareable + } + + type WorkAddress implements Address @key(fields: "id") { + id: ID! + city: String @shareable + } + + type User @key(fields: "id") { + id: ID! + name: String! @shareable + address: Address @external + city(addressId: ID! @require(field: "address.id")): String + country(workAddressId: ID! @require(field: "address.id")): String + } + """, + """ + # Schema B + type Query { + b: User + userById(id: ID!): User @lookup @inaccessible # Added + } + + interface Address { + id: ID! + } + + type HomeAddress implements Address @key(fields: "id") { + id: ID! + city: String @shareable + } + + type WorkAddress implements Address @key(fields: "id") { + id: ID! + city: String @shareable + } + + type SecondAddress implements Address @key(fields: "id") { + id: ID! + city: String @shareable + } + + type User @key(fields: "id") { + id: ID! + name: String! @shareable + address: Address @shareable + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/blob/main/src/test-suites/requires-requires + public void RequiresRequires() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + price: Float! @inaccessible + } + """, + """ + # Schema B + type Query { + product: Product + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + hasDiscount: Boolean! + } + """, + """ + # Schema C + type Query { + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + price: Float! @external + isExpensive(price: Float! @require(field: "price")): Boolean! + hasDiscount: Boolean! @external + isExpensiveWithDiscount( + hasDiscount: Boolean! @require(field: "hasDiscount") + ): Boolean! + } + """, + """ + # Schema D + type Query { + productById(id: ID!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "id") { + id: ID! + isExpensive: Boolean! @external + canAfford(isExpensive: Boolean! @require(field: "isExpensive")): Boolean! + isExpensiveWithDiscount: Boolean! @external + canAffordWithDiscount( + isExpensiveWithDiscount: Boolean! @require(field: "isExpensiveWithDiscount") + ): Boolean! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/requires-with-fragments + public void RequiresWithFragments() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + a: Entity @shareable + entityById(id: ID!): Entity @lookup @inaccessible # Added + } + + type Entity @key(fields: "id") { id: ID! + data: Foo } - type Product implements Node @key(fields: "id") { - id: ID! + interface Foo { + foo: String! } - type Category implements Node @key(fields: "id") { - id: ID! + interface Bar implements Foo { + foo: String! + bar: String! + } + + type Baz implements Foo & Bar @shareable { + foo: String! + bar: String! + baz: String! + } + + type Qux implements Foo & Bar @shareable { + foo: String! + bar: String! + qux: String! } """, """ # Schema B - type Query { # Added - node(id: ID!): Node @lookup @inaccessible + type Query { + b: Entity @shareable + bb: Entity @shareable + entityById(id: ID!): Entity @lookup @inaccessible # Added } - interface Node { + type Entity @key(fields: "id") { id: ID! + data: Foo @external + requirer( + foo: String! @require(field: "data.foo") + bazBar: String @require(field: "data.bar") + baz: String @require(field: "data.baz") + quxBar: String @require(field: "data.bar") + qux: String @require(field: "data.qux") + ): String! + requirer2(foo: String! @require(field: "data.foo")): String! } - type Product implements Node @key(fields: "id") @shareable { - id: ID! - name: String! - price: Float! + interface Foo { + foo: String! } - type Category implements Node @key(fields: "id") { - id: ID! - name: String! + interface Bar implements Foo { + foo: String! + bar: String! + } + + type Baz implements Foo & Bar @shareable @inaccessible { + foo: String! + bar: String! + baz: String! + } + + type Qux implements Foo & Bar @shareable { + foo: String! + bar: String! + qux: String! } """ ]), @@ -1132,8 +2117,8 @@ type Category implements Node @key(fields: "id") { } [Fact] - // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/requires-interface - public void RequiresInterface() + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/shared-root + public void SharedRoot() { // arrange var merger = new SourceSchemaMerger( @@ -1142,62 +2127,99 @@ public void RequiresInterface() """ # Schema A type Query { - a: User - userById(id: ID!): User @lookup @inaccessible # Added + product: Product! @shareable + products: [Product!]! @shareable } - interface Address { - id: ID! + type Product { + id: ID! @shareable + category: Category! } - type HomeAddress implements Address @key(fields: "id") { + type Category { id: ID! - city: String @shareable + name: String! + } + """, + """ + # Schema B + type Query { + product: Product! @shareable + products: [Product!]! @shareable } - type WorkAddress implements Address @key(fields: "id") { - id: ID! - city: String @shareable + type Product { + id: ID! @shareable + name: Name! } - type User @key(fields: "id") { + type Name { id: ID! - name: String! @shareable - address: Address @external - city(addressId: ID! @require(field: "address.id")): String - country(workAddressId: ID! @require(field: "address.id")): String + brand: String! + model: String! } """, """ - # Schema B + # Schema C type Query { - b: User - userById(id: ID!): User @lookup @inaccessible # Added + product: Product! @shareable + products: [Product!]! @shareable } - interface Address { - id: ID! + type Product { + id: ID! @shareable + price: Price! } - type HomeAddress implements Address @key(fields: "id") { + type Price { id: ID! - city: String @shareable + amount: Int! + currency: String! } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); - type WorkAddress implements Address @key(fields: "id") { - id: ID! - city: String @shareable - } + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); - type SecondAddress implements Address @key(fields: "id") { - id: ID! - city: String @shareable + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/blob/main/src/test-suites/simple-entity-call + public void SimpleEntityCall() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + user: User + userById(id: ID!): User @lookup @inaccessible # Added } type User @key(fields: "id") { id: ID! - name: String! @shareable - address: Address @shareable + email: String! + } + """, + """ + # Schema B + type Query { + userByEmail(email: String!): User @lookup @inaccessible # Added + } + + type User @key(fields: "email") { + email: String! @external + nickname: String! } """ ]), @@ -1215,8 +2237,8 @@ type User @key(fields: "id") { } [Fact] - // https://github.com/graphql-hive/federation-gateway-audit/blob/main/src/test-suites/requires-requires - public void RequiresRequires() + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/simple-inaccessible + public void SimpleInaccessible() { // arrange var merger = new SourceSchemaMerger( @@ -1225,56 +2247,79 @@ public void RequiresRequires() """ # Schema A type Query { - productById(id: ID!): Product @lookup @inaccessible # Added + usersInAge: [User!]! @shareable + userById(id: ID!): User @lookup @inaccessible # Added } - type Product @key(fields: "id") { - id: ID! - price: Float! @inaccessible + type User @key(fields: "id") { + id: ID + age: Int } """, """ # Schema B type Query { - product: Product - productById(id: ID!): Product @lookup @inaccessible # Added + usersInFriends: [User!]! + userById(id: ID!): User @lookup @inaccessible # Added } - type Product @key(fields: "id") { - id: ID! - hasDiscount: Boolean! + type User @key(fields: "id") { + id: ID + friends(type: FriendType = FAMILY @inaccessible): [User!]! + type: FriendType + } + + enum FriendType { + FAMILY @inaccessible + FRIEND } - """, """ - # Schema C + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/simple-override + public void SimpleOverride() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A type Query { - productById(id: ID!): Product @lookup @inaccessible # Added + feed: [Post] @shareable + aFeed: [Post] + postById(id: ID!): Post @lookup @inaccessible # Added } - type Product @key(fields: "id") { + type Post @key(fields: "id") { id: ID! - price: Float! @external - isExpensive(price: Float! @require(field: "price")): Boolean! - hasDiscount: Boolean! @external - isExpensiveWithDiscount( - hasDiscount: Boolean! @require(field: "hasDiscount") - ): Boolean! + createdAt: String! @shareable } """, """ - # Schema D + # Schema B type Query { - productById(id: ID!): Product @lookup @inaccessible # Added + feed: [Post] @shareable + bFeed: [Post] + postById(id: ID!): Post @lookup @inaccessible # Added } - type Product @key(fields: "id") { + type Post @key(fields: "id") { id: ID! - isExpensive: Boolean! @external - canAfford(isExpensive: Boolean! @require(field: "isExpensive")): Boolean! - isExpensiveWithDiscount: Boolean! @external - canAffordWithDiscount( - isExpensiveWithDiscount: Boolean! @require(field: "isExpensiveWithDiscount") - ): Boolean! + createdAt: String! @override(from: "A") @shareable } """ ]), @@ -1292,8 +2337,8 @@ type Product @key(fields: "id") { } [Fact] - // https://github.com/graphql-hive/federation-gateway-audit/blob/main/src/test-suites/simple-entity-call - public void SimpleEntityCall() + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/simple-requires-provides + public void SimpleRequiresProvides() { // arrange var merger = new SourceSchemaMerger( @@ -1302,24 +2347,75 @@ public void SimpleEntityCall() """ # Schema A type Query { - user: User + me: User userById(id: ID!): User @lookup @inaccessible # Added } type User @key(fields: "id") { id: ID! - email: String! + name: String + username: String @shareable } """, """ # Schema B type Query { - userByEmail(email: String!): User @lookup @inaccessible # Added + productByUpc(upc: String!): Product @lookup @inaccessible # Added + } + + type Product @key(fields: "upc") { + upc: String! + weight: Int @external + price: Int @external + inStock: Boolean + shippingEstimate( + price: Int! @require(field: "price") + weight: Int! @require(field: "weight") + ): Int + shippingEstimateTag( + price: Int! @require(field: "price") + weight: Int! @require(field: "weight") + ): String + } + """, + """ + # Schema C + type Query { + products: [Product] + productByUpc(upc: String!): Product @lookup @inaccessible # Added } - type User @key(fields: "email") { - email: String! @external - nickname: String! + type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int + } + """, + """ + # Schema D + type Query { + reviewById(id: ID!): Review @lookup @inaccessible # Added + userById(id: ID!): User @lookup @inaccessible # Added + productByUpc(upc: String!): Product @lookup @inaccessible # Added + } + + type Review @key(fields: "id") { + id: ID! + body: String + author: User @provides(fields: "username") + product: Product + } + + type User @key(fields: "id") { + id: ID! + username: String @external + reviews: [Review] + } + + type Product @key(fields: "upc") { + upc: String! + reviews: [Review] } """ ]), @@ -1397,6 +2493,97 @@ type Oven implements Node @key(fields: "id") { Assert.True(result.IsSuccess); } + [Fact(Skip = "Fails during merge, before satisfiability runs: Query.book is " + + "Book in schema A but the Media union in schema B, and the merger's " + + "LeastRestrictiveType does not widen an object type to a union that " + + "contains it. Enable once the merger supports object-to-union widening.")] + // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/union-intersection + public void UnionIntersection() + { + // arrange + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + media: Media @shareable + aMedia: Media @shareable + book: Book @shareable + song: Media @shareable + viewer: Viewer @shareable + bookById(id: ID!): Book @lookup @inaccessible # Added + songById(id: ID!): Song @lookup @inaccessible # Added + } + + union Media = Book | Song + union ViewerMedia = Book | Song + + type Book @key(fields: "id") { + id: ID! + title: String! @shareable + aTitle: String! + } + + type Song @key(fields: "id") { + id: ID! + title: String! @shareable + aTitle: String! + } + + type Viewer { + media: ViewerMedia @shareable + aMedia: ViewerMedia + book: Book @shareable + song: ViewerMedia @shareable + } + """, + """ + # Schema B + type Query { + media: Media @shareable + bMedia: Media @shareable + book: Media @shareable + viewer: Viewer @shareable + movieById(id: ID!): Movie @lookup @inaccessible # Added + bookById(id: ID!): Book @lookup @inaccessible # Added + } + + union Media = Book | Movie + union ViewerMedia = Book | Movie + + type Movie @key(fields: "id") { + id: ID! + title: String! @shareable + bTitle: String! + } + + type Book @key(fields: "id") { + id: ID! + title: String! @shareable + bTitle: String! + } + + type Viewer { + media: ViewerMedia @shareable + bMedia: ViewerMedia + book: ViewerMedia @shareable + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + // Other tests. [Fact] From b34de37df30219d2df2e165da06821a6400d3437 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 27 May 2026 17:53:49 +0200 Subject: [PATCH 2/3] [Fusion] Fix `@require` wording in RequiresCircular skip reason --- .../Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs index 0f2a4dde12b..1212dd15700 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs @@ -1794,7 +1794,7 @@ type Movie @key(fields: "id") { Assert.True(result.IsSuccess); } - [Fact(Skip = "Circular @requires whose intermediate field is owned by the " + [Fact(Skip = "Circular @require whose intermediate field is owned by the " + "requiring schema is not yet satisfiable. The validator evaluates the " + "requirement from the entity's origin path and does not hop into the " + "requiring schema to gather its locally-owned field first. Enable once " From c173a69f2fd87c9f88d14ff4bdc94aaaa7e1e155 Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 28 May 2026 10:30:00 +0200 Subject: [PATCH 3/3] [Fusion] Remove UnionIntersection test (out of scope) --- .../SatisfiabilityValidatorTests.cs | 91 ------------------- 1 file changed, 91 deletions(-) diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs index 1212dd15700..bc9fe81cd11 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs @@ -2493,97 +2493,6 @@ type Oven implements Node @key(fields: "id") { Assert.True(result.IsSuccess); } - [Fact(Skip = "Fails during merge, before satisfiability runs: Query.book is " - + "Book in schema A but the Media union in schema B, and the merger's " - + "LeastRestrictiveType does not widen an object type to a union that " - + "contains it. Enable once the merger supports object-to-union widening.")] - // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/union-intersection - public void UnionIntersection() - { - // arrange - var merger = new SourceSchemaMerger( - CreateSchemaDefinitions( - [ - """ - # Schema A - type Query { - media: Media @shareable - aMedia: Media @shareable - book: Book @shareable - song: Media @shareable - viewer: Viewer @shareable - bookById(id: ID!): Book @lookup @inaccessible # Added - songById(id: ID!): Song @lookup @inaccessible # Added - } - - union Media = Book | Song - union ViewerMedia = Book | Song - - type Book @key(fields: "id") { - id: ID! - title: String! @shareable - aTitle: String! - } - - type Song @key(fields: "id") { - id: ID! - title: String! @shareable - aTitle: String! - } - - type Viewer { - media: ViewerMedia @shareable - aMedia: ViewerMedia - book: Book @shareable - song: ViewerMedia @shareable - } - """, - """ - # Schema B - type Query { - media: Media @shareable - bMedia: Media @shareable - book: Media @shareable - viewer: Viewer @shareable - movieById(id: ID!): Movie @lookup @inaccessible # Added - bookById(id: ID!): Book @lookup @inaccessible # Added - } - - union Media = Book | Movie - union ViewerMedia = Book | Movie - - type Movie @key(fields: "id") { - id: ID! - title: String! @shareable - bTitle: String! - } - - type Book @key(fields: "id") { - id: ID! - title: String! @shareable - bTitle: String! - } - - type Viewer { - media: ViewerMedia @shareable - bMedia: ViewerMedia - book: ViewerMedia @shareable - } - """ - ]), - new SourceSchemaMergerOptions { AddFusionDefinitions = false }); - - var schema = merger.Merge().Value; - var log = new CompositionLog(); - var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); - - // act - var result = satisfiabilityValidator.Validate(); - - // assert - Assert.True(result.IsSuccess); - } - // Other tests. [Fact]