diff --git a/src/All.slnx b/src/All.slnx index 0a7248109a4..a4dad5e1730 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -229,6 +229,7 @@ + diff --git a/src/HotChocolate/Core/src/Execution.Operation.Abstractions/ISelectionSet.cs b/src/HotChocolate/Core/src/Execution.Operation.Abstractions/ISelectionSet.cs index f77c192bf47..d497307059c 100644 --- a/src/HotChocolate/Core/src/Execution.Operation.Abstractions/ISelectionSet.cs +++ b/src/HotChocolate/Core/src/Execution.Operation.Abstractions/ISelectionSet.cs @@ -35,9 +35,13 @@ public interface ISelectionSet bool HasIncrementalParts { get; } /// - /// Gets the type that declares this selection set. + /// Gets the complex type that declares this selection set. /// - IObjectTypeDefinition Type { get; } + /// + /// This is typically an object type. It can be an interface type when the concrete runtime + /// type of a selection set is not yet known. + /// + IComplexTypeDefinition Type { get; } /// /// Gets the selections that shall be executed. diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionSet.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionSet.cs index a620ae903b4..14133f55235 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionSet.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/SelectionSet.cs @@ -67,6 +67,8 @@ internal SelectionSet( /// public IObjectTypeDefinition Type { get; } + IComplexTypeDefinition ISelectionSet.Type => Type; + /// /// Gets the declaring operation. /// diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/External.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/External.cs new file mode 100644 index 00000000000..424662b8ab2 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/External.cs @@ -0,0 +1,39 @@ +namespace HotChocolate.Types.Composite; + +/// +/// +/// The @external directive indicates that a field is recognized by the current source schema but is +/// not directly contributed (resolved) by it. Instead, the source schema references the field for +/// specific composition purposes, for example as part of a @requires selection. +/// +/// +/// +/// +/// +/// type User @key(fields: "id") { +/// id: ID! +/// name: String! @external +/// username: String! @requires(fields: "name") +/// } +/// +/// directive @external on FIELD_DEFINITION +/// +/// +[DirectiveType( + DirectiveNames.External.Name, + DirectiveLocation.FieldDefinition, + IsRepeatable = false)] +public sealed class External +{ + private External() + { + } + + /// + public override string ToString() => "@external"; + + /// + /// The singleton instance of the directive. + /// + public static External Instance { get; } = new(); +} diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Implement.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Implement.cs new file mode 100644 index 00000000000..1bd49690595 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Implement.cs @@ -0,0 +1,39 @@ +namespace HotChocolate.Types.Composite; + +/// +/// +/// The @implement directive marks a field as an explicit implementation that intentionally replaces +/// a default field implementation contributed to an interface by an @interfaceObject stand-in. It is +/// required on an implementing type's field, or on a more specific interface's own stand-in field, +/// that collides with an applicable default. +/// +/// +/// +/// +/// +/// type Chair implements Product { +/// id: ID! +/// taxRate: Float! @implement +/// } +/// +/// directive @implement on FIELD_DEFINITION +/// +/// +[DirectiveType( + DirectiveNames.Implement.Name, + DirectiveLocation.FieldDefinition, + IsRepeatable = false)] +public sealed class Implement +{ + private Implement() + { + } + + /// + public override string ToString() => "@implement"; + + /// + /// The singleton instance of the directive. + /// + public static Implement Instance { get; } = new(); +} diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/InterfaceObject.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/InterfaceObject.cs new file mode 100644 index 00000000000..9990591c517 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/InterfaceObject.cs @@ -0,0 +1,39 @@ +namespace HotChocolate.Types.Composite; + +/// +/// +/// The @interfaceObject directive is used within a source schema to declare an object type that +/// acts as a stand-in for an interface defined in another source schema. The stand-in carries the +/// same name as the interface and allows a source schema to contribute fields to that interface +/// without defining any of its implementing types. +/// +/// +/// +/// +/// +/// type Media @interfaceObject @key(fields: "id") { +/// id: ID! +/// reviews: [Review!]! +/// } +/// +/// directive @interfaceObject on OBJECT +/// +/// +[DirectiveType( + DirectiveNames.InterfaceObject.Name, + DirectiveLocation.Object, + IsRepeatable = false)] +public sealed class InterfaceObject +{ + private InterfaceObject() + { + } + + /// + public override string ToString() => "@interfaceObject"; + + /// + /// The singleton instance of the directive. + /// + public static InterfaceObject Instance { get; } = new(); +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs index 7eab7706fdb..e0461879e33 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Processing/OperationCompilerTests.cs @@ -34,6 +34,33 @@ public void Prepare_One_Field() MatchSnapshot(document, operation); } + [Fact] + public void Compile_Should_ExposeObjectSelectionSetThroughPublicContract() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType( + c => c + .Name("Query") + .Field("foo") + .Type() + .Resolve("foo")) + .Create(); + var document = Utf8GraphQLParser.Parse("{ foo }"); + + // act + var operation = OperationCompiler.Compile("opid", document, schema); + ISelectionSet selectionSet = operation.RootSelectionSet; + + // assert + Assert.Equal("Query", selectionSet.Type.Name); + Assert.IsAssignableFrom(selectionSet.Type); + + var selection = Assert.Single(selectionSet.GetSelections()); + Assert.Equal("Query", selection.DeclaringSelectionSet.Type.Name); + Assert.IsAssignableFrom(selection.DeclaringSelectionSet.Type); + } + [Fact] public void Prepare_Duplicate_Field() { diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index b821d7ab275..a7224bb8357 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -23,6 +23,7 @@ + diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs index f3e97de1ad3..e0b4561e064 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs @@ -21,12 +21,7 @@ public static async Task TryComposeAsync( var compositionLog = new CompositionLog(); var environment = settings.EnvironmentName ?? "Aspire"; - var compositionSettings = new CompositionSettings - { - Merger = { EnableGlobalObjectIdentification = settings.EnableGlobalObjectIdentification }, - Satisfiability = { IncludeSatisfiabilityPaths = settings.IncludeSatisfiabilityPaths }, - Preprocessor = { ExcludeByTag = settings.ExcludeByTag?.ToHashSet() } - }; + var compositionSettings = CreateCompositionSettings(settings); var sourceSchemas = newSourceSchemas.ToDictionary( s => s.Name, s => (s.Schema, s.SchemaSettings)); @@ -67,6 +62,26 @@ public static async Task TryComposeAsync( return true; } + internal static CompositionSettings CreateCompositionSettings( + GraphQLCompositionSettings settings) + { + return new CompositionSettings + { + Merger = + { + EnableGlobalObjectIdentification = settings.EnableGlobalObjectIdentification, + NodeResolution = settings.NodeResolution + }, + Satisfiability = { IncludeSatisfiabilityPaths = settings.IncludeSatisfiabilityPaths }, + ApolloFederationCompatibility = + { + AllowNonResolvableInterfaceObjects = settings.AllowNonResolvableInterfaceObjects, + ShareableFieldRuntimeTypeRouting = settings.ShareableFieldRuntimeTypeRouting + }, + Preprocessor = { ExcludeByTag = settings.ExcludeByTag?.ToHashSet() } + }; + } + /// /// Since we're prefixing the message with an emoji and space before printing, /// we need to also indent each line of a multiline message by three spaces to fix the alignment. diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs index e5e2062d448..159ec68dab4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs @@ -10,12 +10,29 @@ public struct GraphQLCompositionSettings /// public bool? EnableGlobalObjectIdentification { get; set; } + /// + /// Gets or sets how the gateway resolves the Query.node field. + /// + public NodeResolution? NodeResolution { get; set; } + /// /// Gets or sets a value indicating whether satisfiability paths should be included in the /// composed schema. /// public bool? IncludeSatisfiabilityPaths { get; set; } + /// + /// Gets or sets whether Apollo Federation non-resolvable interface objects are accepted and + /// unresolved projected fields are reported as field errors at runtime. + /// + public bool? AllowNonResolvableInterfaceObjects { get; set; } + + /// + /// Gets or sets how runtime types are routed for Apollo Federation shareable fields whose + /// result type is abstract. + /// + public ShareableFieldRuntimeTypeRouting? ShareableFieldRuntimeTypeRouting { get; set; } + /// /// Gets or sets the set of tags whose annotated schema elements shall be excluded from /// composition. diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj b/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj index 2b6df36d4e2..dad78802960 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj @@ -7,6 +7,10 @@ false + + + + diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/ApolloFederationCompatibilityMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/ApolloFederationCompatibilityMetadata.cs new file mode 100644 index 00000000000..2980ed86120 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/ApolloFederationCompatibilityMetadata.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Processing-only metadata that records enabled Apollo Federation compatibility +/// capabilities on a source schema while it is composed. +/// +internal sealed class ApolloFederationCompatibilityMetadata +{ + public bool AllowNonResolvableInterfaceObjects { get; init; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs index b8d3262b688..ce9d738db30 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs @@ -19,8 +19,7 @@ internal static class FederationSchemaAnalyzer FederationDirectiveNames.ComposeDirective, FederationDirectiveNames.Authenticated, FederationDirectiveNames.RequiresScopes, - FederationDirectiveNames.Policy, - FederationDirectiveNames.InterfaceObject + FederationDirectiveNames.Policy ]; /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs index 9d06d57cb59..3b94a13a894 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs @@ -92,12 +92,14 @@ public static CompositionResult Transform(string federationSdl) .ToImmutableArray(); } + SourceExternalFieldMetadata.Capture(schema); RemoveFederationInfrastructure.Apply(schema); GenerateLookupFields.Apply(schema); RewriteKeyDirectives.Apply(schema); TransformRequiresToRequire.Apply(schema); RemoveExternalFields.Apply(schema); GenerateNodeLookup.Apply(schema); + SourceExternalFieldMetadata.WriteMarker(schema); return SchemaFormatter.FormatAsString(schema); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/MarkKeyFieldsShareable.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/MarkKeyFieldsShareable.cs index 601034e01fe..dfc55c6fe6d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/MarkKeyFieldsShareable.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/MarkKeyFieldsShareable.cs @@ -30,14 +30,17 @@ public static void Apply( foreach (var type in schema.Types) { - if (type is not MutableComplexTypeDefinition complexType) + // '@shareable' is only meaningful on object type fields. Interface types can carry + // a '@key' (entity interfaces), but their field definitions must never be stamped + // '@shareable' or the composition's shareable-usage validation rejects them. + if (type is not MutableObjectTypeDefinition objectType) { continue; } - foreach (var field in complexType.Fields) + foreach (var field in objectType.Fields) { - if (!keyReferences.Contains((complexType.Name, field.Name))) + if (!keyReferences.Contains((objectType.Name, field.Name))) { continue; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveExternalFields.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveExternalFields.cs index 370f3429c3a..4ec4c3997ae 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveExternalFields.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveExternalFields.cs @@ -72,7 +72,11 @@ public static void Apply(MutableSchemaDefinition schema) continue; } - if (keyReferences.Contains((complexType.Name, field.Name))) + if (keyReferences.Contains((complexType.Name, field.Name)) + || SourceExternalFieldMetadata.Contains( + schema, + complexType.Name, + field.Name)) { var externalDirective = field.Directives.FirstOrDefault( FederationDirectiveNames.External); @@ -375,7 +379,7 @@ private static bool IsReferencedByOutputField( return referenced; } - private static HashSet<(string TypeName, string FieldName)> CollectKeyReferences( + internal static HashSet<(string TypeName, string FieldName)> CollectKeyReferences( MutableSchemaDefinition schema) { var referenced = new HashSet<(string, string)>(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs index 0b47de1675f..14e6d622b51 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs @@ -9,8 +9,9 @@ namespace HotChocolate.Fusion.ApolloFederation; /// internal static class RemoveFederationInfrastructure { - private static readonly HashSet s_federationDirectiveNames = new(StringComparer.Ordinal) - { + private static readonly HashSet s_federationDirectiveNames = + [ + with(StringComparer.Ordinal), FederationDirectiveNames.Key, FederationDirectiveNames.Requires, FederationDirectiveNames.Provides, @@ -20,19 +21,19 @@ internal static class RemoveFederationInfrastructure FederationDirectiveNames.Inaccessible, FederationDirectiveNames.Override, FederationDirectiveNames.Tag, - FederationDirectiveNames.InterfaceObject, FederationDirectiveNames.ComposeDirective, FederationDirectiveNames.Authenticated, FederationDirectiveNames.RequiresScopes, FederationDirectiveNames.Policy - }; + ]; - private static readonly HashSet s_federationScalarNames = new(StringComparer.Ordinal) - { + private static readonly HashSet s_federationScalarNames = + [ + with(StringComparer.Ordinal), FederationTypeNames.Any, FederationTypeNames.FieldSet, FederationTypeNames.LegacyFieldSet - }; + ]; /// /// Applies the transformation to remove federation infrastructure from the schema. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/SourceExternalFieldMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/SourceExternalFieldMetadata.cs new file mode 100644 index 00000000000..5f9cc032e75 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/SourceExternalFieldMetadata.cs @@ -0,0 +1,96 @@ +using HotChocolate.Types; +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Preserves which Apollo Federation key fields were originally marked @external +/// before preprocessing promotes them to full field contributions. +/// +internal sealed class SourceExternalFieldMetadata +{ + private const string MarkerDirectiveName = "fusion__sourceExternal"; + private readonly HashSet<(string TypeName, string FieldName)> _fields; + + private SourceExternalFieldMetadata( + HashSet<(string TypeName, string FieldName)> fields) + { + _fields = fields; + } + + public static void Capture(MutableSchemaDefinition schema) + { + var fields = new HashSet<(string TypeName, string FieldName)>(); + + foreach (var coordinate in RemoveExternalFields.CollectKeyReferences(schema)) + { + if (schema.Types.TryGetType( + coordinate.TypeName, + out var type) + && type.Fields.TryGetField(coordinate.FieldName, out var field) + && field.Directives.ContainsName(FederationDirectiveNames.External)) + { + fields.Add(coordinate); + } + } + + schema.Features.Set(new SourceExternalFieldMetadata(fields)); + } + + public static bool Contains( + MutableSchemaDefinition schema, + string typeName, + string fieldName) + => schema.Features.Get()?._fields.Contains( + (typeName, fieldName)) == true; + + public static void WriteMarker(MutableSchemaDefinition schema) + { + var metadata = schema.Features.Get(); + if (metadata is null || metadata._fields.Count == 0) + { + return; + } + + var markerDefinition = new MutableDirectiveDefinition(MarkerDirectiveName) + { + Locations = DirectiveLocation.FieldDefinition + }; + schema.DirectiveDefinitions.Add(markerDefinition); + + foreach (var (typeName, fieldName) in metadata._fields) + { + if (schema.Types.TryGetType(typeName, out var type) + && type.Fields.TryGetField(fieldName, out var field)) + { + field.Directives.Add(new Directive(markerDefinition)); + } + } + } + + public static void CaptureMarker(MutableSchemaDefinition schema) + { + if (!schema.DirectiveDefinitions.ContainsName(MarkerDirectiveName)) + { + return; + } + + var fields = new HashSet<(string TypeName, string FieldName)>(); + + foreach (var type in schema.Types.OfType()) + { + foreach (var field in type.Fields) + { + var marker = field.Directives.FirstOrDefault(MarkerDirectiveName); + if (marker is not null) + { + fields.Add((type.Name, field.Name)); + field.Directives.Remove(marker); + } + } + } + + schema.DirectiveDefinitions.Remove(MarkerDirectiveName); + schema.Features.Set(new SourceExternalFieldMetadata(fields)); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/SynthesizeImplementDirectives.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/SynthesizeImplementDirectives.cs new file mode 100644 index 00000000000..8c3d9faadbc --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/SynthesizeImplementDirectives.cs @@ -0,0 +1,98 @@ +using HotChocolate.Fusion.Extensions; +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Apollo Federation has no @implement marker: an implementing type simply redeclares a +/// field that an @interfaceObject stand-in also contributes, relying on @shareable +/// to make the field resolvable from both the implementing type's schema and the stand-in's +/// schema. The Composite Schema Spec instead requires such a colliding declaration to carry +/// @implement. This transform bridges the two models by stamping @implement onto a +/// shareable field that collides with a stand-in default, so a valid Apollo subgraph composes +/// without an INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT error. A non-shareable collision is +/// left untouched so the native rule still rejects it, mirroring Apollo's own field-sharing +/// rejection. +/// +internal static class SynthesizeImplementDirectives +{ + /// + /// Applies @implement to shareable fields of that collide with + /// a default contributed by an @interfaceObject stand-in in any source schema. + /// + /// + /// The mutable schema definition to transform in place. + /// + /// + /// All mutable source schema definitions in the composition. + /// + public static void Apply( + MutableSchemaDefinition schema, + IEnumerable schemas) + { + foreach (var objectType in schema.Types.OfType()) + { + // A stand-in's own fields are the defaults; they never replace one. + if (objectType.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject)) + { + continue; + } + + var defaultFieldNames = CollectDefaultFieldNames(objectType, schemas); + + if (defaultFieldNames.Count == 0) + { + continue; + } + + foreach (var field in objectType.Fields) + { + if (!defaultFieldNames.Contains(field.Name) + || field.Directives.ContainsName(WellKnownDirectiveNames.Implement) + || field.Directives.ContainsName(WellKnownDirectiveNames.External)) + { + continue; + } + + // Only a shareable redeclaration is a valid Apollo composition. A non-shareable + // collision is left for the native rule to reject, matching Apollo's rejection. + if (field.Directives.ContainsName(WellKnownDirectiveNames.Shareable)) + { + field.ApplyImplementDirective(); + } + } + } + } + + // The default fields an interface's stand-ins contribute: non-key, non-internal, + // non-inaccessible fields declared on any stand-in of the same name. This mirrors + // InterfaceObjectMetadata.DefaultFields but reads directives directly, because the feature-backed + // field metadata it relies on is only populated after preprocessing. + private static HashSet CollectDefaultFieldNames( + MutableObjectTypeDefinition objectType, + IEnumerable schemas) + { + var schemaList = schemas as IReadOnlyCollection ?? schemas.ToArray(); + var defaultFieldNames = new HashSet(StringComparer.Ordinal); + + foreach (var interfaceType in objectType.Implements) + { + foreach (var (standIn, _) in InterfaceObjectMetadata.GetStandIns(schemaList, interfaceType.Name)) + { + var keyFieldNames = InterfaceObjectMetadata.GetKeyFieldNames(standIn); + + foreach (var field in standIn.Fields) + { + if (!keyFieldNames.Contains(field.Name) + && !field.Directives.ContainsName(WellKnownDirectiveNames.Internal) + && !field.Directives.ContainsName(WellKnownDirectiveNames.Inaccessible)) + { + defaultFieldNames.Add(field.Name); + } + } + } + } + + return defaultFieldNames; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs index 659e6382538..cea4685028b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/CompositionHelper.cs @@ -88,6 +88,8 @@ await archive.GetSourceSchemaNamesAsync(cancellationToken), var sourceSchemaOptionsMap = new Dictionary(); var mergerOptions = mergedCompositionSettings.Merger.ToOptions(); var satisfiabilityOptions = mergedCompositionSettings.Satisfiability.ToOptions(); + var apolloFederationCompatibilityOptions = + mergedCompositionSettings.ApolloFederationCompatibility.ToOptions(); foreach (var (sourceSchemaName, (_, sourceSchemaSettings)) in sourceSchemas) { @@ -105,7 +107,8 @@ await archive.GetSourceSchemaNamesAsync(cancellationToken), { SourceSchemas = sourceSchemaOptionsMap, Merger = mergerOptions, - Satisfiability = satisfiabilityOptions + Satisfiability = satisfiabilityOptions, + ApolloFederationCompatibility = apolloFederationCompatibilityOptions }; var schemaComposer = new SchemaComposer( diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionExecutionMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionExecutionMutableDirectiveDefinition.cs new file mode 100644 index 00000000000..95daa3c3022 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionExecutionMutableDirectiveDefinition.cs @@ -0,0 +1,33 @@ +using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using DirectiveLocation = HotChocolate.Types.DirectiveLocation; + +namespace HotChocolate.Fusion.Definitions; + +internal sealed class FusionExecutionMutableDirectiveDefinition : MutableDirectiveDefinition +{ + public FusionExecutionMutableDirectiveDefinition( + MutableEnumTypeDefinition nodeResolutionType, + MutableEnumTypeDefinition shareableFieldRuntimeTypeRoutingType) + : base(WellKnownDirectiveNames.FusionExecution) + { + Arguments.Add( + new MutableInputFieldDefinition( + WellKnownArgumentNames.NodeResolution, + new NonNullType(nodeResolutionType)) + { + DefaultValue = new EnumValueNode("GATEWAY") + }); + + Arguments.Add( + new MutableInputFieldDefinition( + WellKnownArgumentNames.ShareableFieldRuntimeTypeRouting, + new NonNullType(shareableFieldRuntimeTypeRoutingType)) + { + DefaultValue = new EnumValueNode("SOURCE_LOCAL") + }); + + Locations = DirectiveLocation.Schema; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionFieldMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionFieldMutableDirectiveDefinition.cs index 72ca17868ea..9abe464f7cc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionFieldMutableDirectiveDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionFieldMutableDirectiveDefinition.cs @@ -50,6 +50,17 @@ public FusionFieldMutableDirectiveDefinition( DefaultValue = new BooleanValueNode(false) }); + Arguments.Add( + new MutableInputFieldDefinition( + WellKnownArgumentNames.SourceExternal, + new NonNullType(booleanType)) + { + Description = + "Indicates that the source field was declared as " + + "external before connector preprocessing.", + DefaultValue = new BooleanValueNode(false) + }); + IsRepeatable = true; Locations = DirectiveLocation.FieldDefinition; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionInterfaceObjectMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionInterfaceObjectMutableDirectiveDefinition.cs new file mode 100644 index 00000000000..317b6814137 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionInterfaceObjectMutableDirectiveDefinition.cs @@ -0,0 +1,31 @@ +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Properties.CompositionResources; +using static HotChocolate.Fusion.WellKnownArgumentNames; +using static HotChocolate.Fusion.WellKnownDirectiveNames; + +namespace HotChocolate.Fusion.Definitions; + +/// +/// The @fusion__interfaceObject directive specifies the source schemas that expose an +/// interface as an @interfaceObject stand-in. Values of the interface produced by such a +/// schema are opaque: the schema holds no authoritative concrete type for them. +/// +internal sealed class FusionInterfaceObjectMutableDirectiveDefinition : MutableDirectiveDefinition +{ + public FusionInterfaceObjectMutableDirectiveDefinition( + MutableEnumTypeDefinition schemaMutableEnumType) + : base(FusionInterfaceObject) + { + Description = FusionInterfaceObjectMutableDirectiveDefinition_Description; + + Arguments.Add( + new MutableInputFieldDefinition(Schema, new NonNullType(schemaMutableEnumType)) + { + Description = FusionInterfaceObjectMutableDirectiveDefinition_Argument_Schema_Description + }); + + IsRepeatable = true; + Locations = DirectiveLocation.Interface; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionNodeResolutionMutableEnumTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionNodeResolutionMutableEnumTypeDefinition.cs new file mode 100644 index 00000000000..28e0366f6d8 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionNodeResolutionMutableEnumTypeDefinition.cs @@ -0,0 +1,13 @@ +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.Definitions; + +internal sealed class FusionNodeResolutionMutableEnumTypeDefinition : MutableEnumTypeDefinition +{ + public FusionNodeResolutionMutableEnumTypeDefinition() + : base(WellKnownTypeNames.FusionNodeResolution) + { + Values.Add(new MutableEnumValue("GATEWAY")); + Values.Add(new MutableEnumValue("SOURCE_SCHEMA")); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMetadataMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMetadataMutableDirectiveDefinition.cs index 06e45b95e58..182a1a439fc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMetadataMutableDirectiveDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMetadataMutableDirectiveDefinition.cs @@ -12,7 +12,9 @@ namespace HotChocolate.Fusion.Definitions; /// internal sealed class FusionSchemaMetadataMutableDirectiveDefinition : MutableDirectiveDefinition { - public FusionSchemaMetadataMutableDirectiveDefinition(MutableScalarTypeDefinition stringType) + public FusionSchemaMetadataMutableDirectiveDefinition( + MutableScalarTypeDefinition stringType, + MutableScalarTypeDefinition booleanType) : base(FusionSchemaMetadata) { Description = FusionSchemaMetadataMutableDirectiveDefinition_Description; @@ -24,6 +26,11 @@ public FusionSchemaMetadataMutableDirectiveDefinition(MutableScalarTypeDefinitio Arguments.Add(new MutableInputFieldDefinition(argNames.Kind, stringType)); + Arguments.Add( + new MutableInputFieldDefinition( + argNames.AllowNonResolvableInterfaceObjects, + booleanType)); + Locations = DirectiveLocation.EnumValue; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMutableEnumTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMutableEnumTypeDefinition.cs index 902b2069d97..38bdef0caa4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMutableEnumTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionSchemaMutableEnumTypeDefinition.cs @@ -25,7 +25,8 @@ public FusionSchemaMutableEnumTypeDefinition(FrozenDictionary sc { new Directive( new FusionSchemaMetadataMutableDirectiveDefinition( - BuiltIns.String.Create()), + BuiltIns.String.Create(), + BuiltIns.Boolean.Create()), new ArgumentAssignment(WellKnownArgumentNames.Name, schemaName)) } }; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionShareableFieldRuntimeTypeRoutingMutableEnumTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionShareableFieldRuntimeTypeRoutingMutableEnumTypeDefinition.cs new file mode 100644 index 00000000000..4d0e2def61e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionShareableFieldRuntimeTypeRoutingMutableEnumTypeDefinition.cs @@ -0,0 +1,14 @@ +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.Definitions; + +internal sealed class FusionShareableFieldRuntimeTypeRoutingMutableEnumTypeDefinition + : MutableEnumTypeDefinition +{ + public FusionShareableFieldRuntimeTypeRoutingMutableEnumTypeDefinition() + : base(WellKnownTypeNames.FusionShareableFieldRuntimeTypeRouting) + { + Values.Add(new MutableEnumValue("SOURCE_LOCAL")); + Values.Add(new MutableEnumValue("COMMON_RUNTIME_TYPES")); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionTypeMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionTypeMutableDirectiveDefinition.cs index b7cf3e7e80e..b4fdc46b060 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionTypeMutableDirectiveDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionTypeMutableDirectiveDefinition.cs @@ -12,7 +12,8 @@ namespace HotChocolate.Fusion.Definitions; /// internal sealed class FusionTypeMutableDirectiveDefinition : MutableDirectiveDefinition { - public FusionTypeMutableDirectiveDefinition(MutableEnumTypeDefinition schemaMutableEnumType) + public FusionTypeMutableDirectiveDefinition( + MutableEnumTypeDefinition schemaMutableEnumType) : base(FusionType) { Description = FusionTypeMutableDirectiveDefinition_Description; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ImplementMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ImplementMutableDirectiveDefinition.cs new file mode 100644 index 00000000000..2e72987bc7a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ImplementMutableDirectiveDefinition.cs @@ -0,0 +1,19 @@ +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Properties.CompositionResources; + +namespace HotChocolate.Fusion.Definitions; + +/// +/// The @implement directive marks a field as an explicit implementation that replaces a +/// default field implementation contributed by an @interfaceObject stand-in. +/// +internal sealed class ImplementMutableDirectiveDefinition : MutableDirectiveDefinition +{ + public ImplementMutableDirectiveDefinition() : base(WellKnownDirectiveNames.Implement) + { + Description = ImplementMutableDirectiveDefinition_Description; + + Locations = DirectiveLocation.FieldDefinition; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/InterfaceObjectMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/InterfaceObjectMutableDirectiveDefinition.cs new file mode 100644 index 00000000000..510493db4fb --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/InterfaceObjectMutableDirectiveDefinition.cs @@ -0,0 +1,20 @@ +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Properties.CompositionResources; + +namespace HotChocolate.Fusion.Definitions; + +/// +/// The @interfaceObject directive declares an object type that acts as a stand-in for an +/// interface of the same name defined in one or more other source schemas. It lets a source schema +/// contribute fields to that interface without defining any of its implementing types. +/// +internal sealed class InterfaceObjectMutableDirectiveDefinition : MutableDirectiveDefinition +{ + public InterfaceObjectMutableDirectiveDefinition() : base(WellKnownDirectiveNames.InterfaceObject) + { + Description = InterfaceObjectMutableDirectiveDefinition_Description; + + Locations = DirectiveLocation.Object; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableOutputFieldDefinitionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableOutputFieldDefinitionExtensions.cs index df4d6a8267b..0b472333896 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableOutputFieldDefinitionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableOutputFieldDefinitionExtensions.cs @@ -36,6 +36,17 @@ public void ApplyShareableDirective() } } + public void ApplyImplementDirective() + { + var implementDirectiveExists = + field.Directives.AsEnumerable().Any(d => d.Name == DirectiveNames.Implement); + + if (!implementDirectiveExists) + { + field.Directives.Add(new Directive(FusionBuiltIns.SourceSchemaDirectives[DirectiveNames.Implement])); + } + } + public bool ExistsInSchema(string schemaName) { return field.Directives.AsEnumerable().Any( diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs index 4194b5ed3d6..64683e4a642 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs @@ -40,7 +40,9 @@ public static List GetPossibleFusionLookupDirectives( MutableComplexTypeDefinition type, string? schemaName = null) { - if (!string.IsNullOrEmpty(schemaName) && !type.ExistsInSchema(schemaName)) + if (!string.IsNullOrEmpty(schemaName) + && !type.ExistsInSchema(schemaName) + && !ReachesInterfaceObjectStandIn(schema, type, schemaName)) { return []; } @@ -92,6 +94,34 @@ internal static List GetPossibleFusionLookupDirectivesCore( lookups.AddRange(interfaceLookupsInSchema); } + // Get the interface lookups of any @interfaceObject stand-in for an interface this type + // implements. A stand-in resolves every possible type of its interface by the shared key, + // so its interface-typed lookups are available for this type even in schemas that do not + // define the type. The implements relation is read from the completed set on the type, + // which includes edges derived by transitive closure. + var standInInterfaceNames = new HashSet(StringComparer.Ordinal); + + foreach (var implementedInterface in type.Implements) + { + if (!standInInterfaceNames.Add(implementedInterface.Name) + || !schema.Types.TryGetType( + implementedInterface.Name, + out var standInInterfaceType)) + { + continue; + } + + foreach (var standInSchemaName in GetInterfaceObjectSchemaNames(standInInterfaceType)) + { + if (!string.IsNullOrEmpty(schemaName) && standInSchemaName != schemaName) + { + continue; + } + + lookups.AddRange(GetFusionLookupDirectives(standInInterfaceType, standInSchemaName)); + } + } + // Get the lookups of unions this type is a member of, // if it's an object type. if (type.Kind == TypeKind.Object) @@ -316,6 +346,47 @@ private static void RemoveUnreachableDefinitions( } } + /// + /// Gets the source schema names in which is declared as an + /// @interfaceObject stand-in, read from its @fusion__interfaceObject(schema:) + /// directives on the merged schema. + /// + internal static IEnumerable GetInterfaceObjectSchemaNames( + MutableInterfaceTypeDefinition interfaceType) + { + foreach (var directive in interfaceType.Directives.AsEnumerable()) + { + if (directive.Name == WellKnownDirectiveNames.FusionInterfaceObject) + { + yield return (string)directive.Arguments[WellKnownArgumentNames.Schema].Value!; + } + } + } + + /// + /// Determines whether implements an interface that has an + /// @interfaceObject stand-in in . Such a type can be looked + /// up in that schema through the stand-in even though the schema does not define the type. + /// + internal static bool ReachesInterfaceObjectStandIn( + MutableSchemaDefinition schema, + MutableComplexTypeDefinition type, + string schemaName) + { + foreach (var implementedInterface in type.Implements) + { + if (schema.Types.TryGetType( + implementedInterface.Name, + out var interfaceType) + && GetInterfaceObjectSchemaNames(interfaceType).Contains(schemaName)) + { + return true; + } + } + + return false; + } + private static List GetFusionLookupDirectives( IDirectivesProvider directivesProvider, string? schemaName) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/ObjectTypeDefinitionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/ObjectTypeDefinitionExtensions.cs index cf0cf2287ab..19de7441eae 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/ObjectTypeDefinitionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/ObjectTypeDefinitionExtensions.cs @@ -8,6 +8,9 @@ internal static class ObjectTypeDefinitionExtensions { extension(IObjectTypeDefinition objectType) { + public bool HasInterfaceObjectDirective + => objectType.Features.GetRequired().HasInterfaceObjectDirective; + public bool HasShareableDirective => objectType.Features.GetRequired().HasShareableDirective; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs index 7dde0aeaa62..f604d490f65 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs @@ -33,6 +33,9 @@ public bool HasExternalDirective public bool HasEventCursorDirective => outputField.Features.GetRequired().HasEventCursorDirective; + public bool HasImplementDirective + => outputField.Features.GetRequired().HasImplementDirective; + public bool HasInternalDirective => outputField.Features.GetRequired().HasInternalDirective; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceObjectTypeMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceObjectTypeMetadata.cs index f45eee33309..e6c75c58f20 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceObjectTypeMetadata.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceObjectTypeMetadata.cs @@ -2,6 +2,8 @@ namespace HotChocolate.Fusion.Features; internal sealed class SourceObjectTypeMetadata { + public bool HasInterfaceObjectDirective { get; set; } + public bool HasShareableDirective { get; set; } public bool IsInternal { get; set; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs index b80390d9fc2..e61da3cc6fe 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs @@ -10,6 +10,8 @@ internal sealed class SourceOutputFieldMetadata public bool HasEventCursorDirective { get; set; } + public bool HasImplementDirective { get; set; } + public bool HasInternalDirective { get; set; } public bool HasOverrideDirective { get; set; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs index 027e8817cd2..3ce0ed3a680 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs @@ -20,7 +20,9 @@ internal static class FusionBuiltIns [ new EventCursorMutableDirectiveDefinition(), new ExternalMutableDirectiveDefinition(), + new ImplementMutableDirectiveDefinition(), new InaccessibleMutableDirectiveDefinition(), + new InterfaceObjectMutableDirectiveDefinition(), new InternalMutableDirectiveDefinition(), new IsMutableDirectiveDefinition(s_fieldSelectionMapType), new KeyMutableDirectiveDefinition(s_fieldSelectionSetType), diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj b/src/HotChocolate/Fusion/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj index cf6b9aae6a8..33f1e51550e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj @@ -10,6 +10,7 @@ + diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/InterfaceObjectMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/InterfaceObjectMetadata.cs new file mode 100644 index 00000000000..329f0d5d767 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/InterfaceObjectMetadata.cs @@ -0,0 +1,98 @@ +using HotChocolate.Fusion.Extensions; +using HotChocolate.Language; +using HotChocolate.Types.Mutable; +using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames; + +namespace HotChocolate.Fusion; + +/// +/// Shared helpers for reasoning about @interfaceObject stand-ins across the source schemas. +/// A stand-in is an object type annotated with @interfaceObject whose name matches an +/// interface defined elsewhere; its non-key fields are default implementations for that interface. +/// +internal static class InterfaceObjectMetadata +{ + /// + /// Gets every @interfaceObject stand-in named across the + /// source schemas, together with the schema that declares it. + /// + public static IEnumerable<(MutableObjectTypeDefinition StandIn, MutableSchemaDefinition Schema)> GetStandIns( + IEnumerable schemas, + string interfaceName) + { + foreach (var schema in schemas) + { + if (schema.Types.TryGetType(interfaceName, out MutableObjectTypeDefinition? standIn) + && standIn.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject)) + { + yield return (standIn, schema); + } + } + } + + /// + /// Gets the top-level field names selected by the @key directives on a stand-in. + /// + public static HashSet GetKeyFieldNames(MutableObjectTypeDefinition standIn) + { + var keyFieldNames = new HashSet(StringComparer.Ordinal); + + foreach (var keyDirective in standIn.Directives.AsEnumerable()) + { + if (keyDirective.Name != WellKnownDirectiveNames.Key + || !keyDirective.Arguments.TryGetValue(ArgumentNames.Fields, out var value) + || value is not StringValueNode fields) + { + continue; + } + + try + { + var selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet($"{{ {fields.Value} }}"); + + foreach (var selection in selectionSet.Selections) + { + if (selection is FieldNode fieldNode) + { + keyFieldNames.Add(fieldNode.Name.Value); + } + } + } + catch (SyntaxException) + { + // A malformed key is reported by the key validation rules; ignore it here. + } + } + + return keyFieldNames; + } + + /// + /// Gets the default field names an interface's stand-ins contribute: the non-key, non-internal, + /// non-inaccessible fields declared on any stand-in named . + /// + public static HashSet DefaultFields( + IEnumerable schemas, + string interfaceName) + { + var fieldNames = new HashSet(StringComparer.Ordinal); + var keyFieldNames = new HashSet(StringComparer.Ordinal); + + foreach (var (standIn, _) in GetStandIns(schemas, interfaceName)) + { + keyFieldNames.UnionWith(GetKeyFieldNames(standIn)); + + foreach (var field in standIn.Fields) + { + if (!field.IsInternal && !field.IsInaccessible) + { + fieldNames.Add(field.Name); + } + } + } + + fieldNames.ExceptWith(keyFieldNames); + + return fieldNames; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs index 99f38029085..d2c090ee9d8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -30,14 +30,23 @@ public static class LogEntryCodes public const string FieldArgumentTypesNotMergeable = "FIELD_ARGUMENT_TYPES_NOT_MERGEABLE"; public const string FieldWithMissingRequiredArgument = "FIELD_WITH_MISSING_REQUIRED_ARGUMENT"; public const string ImplementedByInaccessible = "IMPLEMENTED_BY_INACCESSIBLE"; + public const string ImplementWithoutDefault = "IMPLEMENT_WITHOUT_DEFAULT"; public const string InputFieldDefaultMismatch = "INPUT_FIELD_DEFAULT_MISMATCH"; public const string InputFieldTypesNotMergeable = "INPUT_FIELD_TYPES_NOT_MERGEABLE"; public const string InputWithMissingRequiredFields = "INPUT_WITH_MISSING_REQUIRED_FIELDS"; public const string InputWithMissingOneOf = "INPUT_WITH_MISSING_ONEOF"; public const string InterfaceFieldNoImplementation = "INTERFACE_FIELD_NO_IMPLEMENTATION"; + public const string InterfaceObjectFieldRequiresImplement = "INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT"; + public const string InterfaceObjectKeyMismatch = "INTERFACE_OBJECT_KEY_MISMATCH"; + public const string InterfaceObjectKeyMissing = "INTERFACE_OBJECT_KEY_MISSING"; + public const string InterfaceObjectNoInterface = "INTERFACE_OBJECT_NO_INTERFACE"; public const string InvalidFieldSharing = "INVALID_FIELD_SHARING"; public const string InvalidGraphQL = "INVALID_GRAPHQL"; + public const string InvalidNodeResolution = "INVALID_NODE_RESOLUTION"; + public const string InvalidProjectedFieldSharing = "INVALID_PROJECTED_FIELD_SHARING"; public const string InvalidShareableUsage = "INVALID_SHAREABLE_USAGE"; + public const string InvalidShareableFieldRuntimeTypeRouting = + "INVALID_SHAREABLE_FIELD_RUNTIME_TYPE_ROUTING"; public const string IsInvalidFields = "IS_INVALID_FIELDS"; public const string IsInvalidFieldType = "IS_INVALID_FIELD_TYPE"; public const string IsInvalidSyntax = "IS_INVALID_SYNTAX"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs index 4dfa0d20e6f..849066f0bde 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -600,6 +600,105 @@ public static LogEntry InterfaceFieldNoImplementation( .Build(); } + public static LogEntry InterfaceObjectFieldRequiresImplement( + string interfaceName, + string fieldName, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_InterfaceObjectFieldRequiresImplement, + fieldName, + schema.Name, + interfaceName) + .SetCode(LogEntryCodes.InterfaceObjectFieldRequiresImplement) + .SetSeverity(LogSeverity.Error) + .SetSchema(schema) + .Build(); + } + + public static LogEntry ImplementWithoutDefault( + MutableOutputFieldDefinition field, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_ImplementWithoutDefault, + field.Coordinate.ToString(), + schema.Name) + .SetCode(LogEntryCodes.ImplementWithoutDefault) + .SetSeverity(LogSeverity.Error) + .SetTypeSystemMember(field) + .SetSchema(schema) + .Build(); + } + + public static LogEntry InvalidProjectedFieldSharing( + string typeName, + string fieldName, + string interfaceNames, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_InvalidProjectedFieldSharing, + fieldName, + typeName, + interfaceNames) + .SetCode(LogEntryCodes.InvalidProjectedFieldSharing) + .SetSeverity(LogSeverity.Error) + .SetSchema(schema) + .Build(); + } + + public static LogEntry InterfaceObjectKeyMismatch( + MutableObjectTypeDefinition standIn, + string keyFields, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_InterfaceObjectKeyMismatch, + standIn.Name, + schema.Name, + keyFields) + .SetCode(LogEntryCodes.InterfaceObjectKeyMismatch) + .SetSeverity(LogSeverity.Error) + .SetTypeSystemMember(standIn) + .SetSchema(schema) + .Build(); + } + + public static LogEntry InterfaceObjectKeyMissing( + MutableObjectTypeDefinition standIn, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_InterfaceObjectKeyMissing, + standIn.Name, + schema.Name) + .SetCode(LogEntryCodes.InterfaceObjectKeyMissing) + .SetSeverity(LogSeverity.Error) + .SetTypeSystemMember(standIn) + .SetSchema(schema) + .Build(); + } + + public static LogEntry InterfaceObjectNoInterface( + string typeName, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_InterfaceObjectNoInterface, + typeName) + .SetCode(LogEntryCodes.InterfaceObjectNoInterface) + .SetSeverity(LogSeverity.Error) + .SetSchema(schema) + .Build(); + } + public static LogEntry InvalidFieldSharing( MutableOutputFieldDefinition field, MutableSchemaDefinition schema) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ApolloFederationCompatibilityOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ApolloFederationCompatibilityOptions.cs new file mode 100644 index 00000000000..8cb94e37e6f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ApolloFederationCompatibilityOptions.cs @@ -0,0 +1,20 @@ +namespace HotChocolate.Fusion.Options; + +/// +/// Compatibility options for composing Apollo Federation source schemas. +/// +public sealed class ApolloFederationCompatibilityOptions +{ + /// + /// Defines how runtime types are routed for shareable fields whose result type is abstract. + /// + public ShareableFieldRuntimeTypeRouting ShareableFieldRuntimeTypeRouting { get; set; } + = ShareableFieldRuntimeTypeRouting.SourceLocal; + + /// + /// Allows Apollo Federation @interfaceObject stand-ins whose keys are not + /// resolvable. Selections that cannot be routed through such a stand-in produce + /// field errors at runtime. + /// + public bool AllowNonResolvableInterfaceObjects { get; set; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SchemaComposerOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SchemaComposerOptions.cs index 95078c01d72..1ad4c4c3933 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SchemaComposerOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SchemaComposerOptions.cs @@ -5,6 +5,11 @@ namespace HotChocolate.Fusion.Options; /// public sealed class SchemaComposerOptions { + /// + /// Compatibility options for Apollo Federation source schemas. + /// + public ApolloFederationCompatibilityOptions ApolloFederationCompatibility { get; init; } = new(); + /// /// Configuration options for each source schema. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs index 6c9f31cfaec..1462b2df2c5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs @@ -20,6 +20,11 @@ public sealed class SourceSchemaMergerOptions /// public bool EnableGlobalObjectIdentification { get; set; } + /// + /// Defines how the gateway resolves the Query.node field. + /// + public NodeResolution NodeResolution { get; set; } = NodeResolution.Gateway; + /// /// Removes types and directives that are not referenced in the final merged schema. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/ImplementWithoutDefaultRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/ImplementWithoutDefaultRule.cs new file mode 100644 index 00000000000..fe2644486a8 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/ImplementWithoutDefaultRule.cs @@ -0,0 +1,81 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Fusion.Extensions; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PostMergeValidationRules; + +/// +/// The @implement directive only has meaning when a default implementation exists to replace. +/// A field marked with @implement for which no applicable default exists, whether because of a +/// typo, a removed default, or a marker placed on a real interface field, fails composition with an +/// IMPLEMENT_WITHOUT_DEFAULT error. +/// +/// +/// Specification +/// +internal sealed class ImplementWithoutDefaultRule : IEventHandler +{ + public void Handle(SchemaEvent @event, CompositionContext context) + { + var mergedSchema = @event.Schema; + var schemas = context.SchemaDefinitions; + + foreach (var schema in schemas) + { + foreach (var type in schema.Types) + { + if (type is not MutableComplexTypeDefinition complexType) + { + continue; + } + + foreach (var field in complexType.Fields) + { + if (!field.HasImplementDirective) + { + continue; + } + + if (!HasApplicableDefault(complexType, field.Name, mergedSchema, schemas)) + { + context.Log.Write(ImplementWithoutDefault(field, schema)); + } + } + } + } + } + + private static bool HasApplicableDefault( + MutableComplexTypeDefinition sourceType, + string fieldName, + MutableSchemaDefinition mergedSchema, + IReadOnlyList schemas) + { + // @implement on a real interface field can never replace a default. + if (sourceType is MutableInterfaceTypeDefinition) + { + return false; + } + + // A stand-in replaces a less specific interface's default; an ordinary implementing type + // replaces a default contributed to any interface it implements. Both are resolved against + // the completed implements relation on the merged type (an interface for a stand-in, the + // object type otherwise). + if (!mergedSchema.Types.TryGetType(sourceType.Name, out MutableComplexTypeDefinition? mergedType)) + { + return false; + } + + foreach (var ancestor in mergedType.Implements) + { + if (InterfaceObjectMetadata.DefaultFields(schemas, ancestor.Name).Contains(fieldName)) + { + return true; + } + } + + return false; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/InterfaceObjectFieldRequiresImplementRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/InterfaceObjectFieldRequiresImplementRule.cs new file mode 100644 index 00000000000..9787806433f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/InterfaceObjectFieldRequiresImplementRule.cs @@ -0,0 +1,139 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Fusion.Extensions; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PostMergeValidationRules; + +/// +/// A field that replaces a default implementation contributed by an @interfaceObject stand-in +/// must be marked with @implement. This applies to a field declared directly on an +/// implementing object type, and to a field declared on a more specific interface's own stand-in +/// that collides with a less specific interface's default. An unmarked collision fails composition +/// with an INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT error. +/// +/// +/// Specification +/// +internal sealed class InterfaceObjectFieldRequiresImplementRule : IEventHandler +{ + public void Handle(SchemaEvent @event, CompositionContext context) + { + var mergedSchema = @event.Schema; + var schemas = context.SchemaDefinitions; + + // Aggregate per interface + field so the same default collision is not reported once per + // implementing type. + var reported = new HashSet<(string Interface, string Field)>(); + + foreach (var type in mergedSchema.Types) + { + switch (type) + { + case MutableObjectTypeDefinition objectType: + CheckImplementingType(objectType, objectType, schemas, reported, context); + break; + + case MutableInterfaceTypeDefinition interfaceType: + CheckMoreSpecificStandIn(interfaceType, schemas, reported, context); + break; + } + } + } + + private static void CheckImplementingType( + MutableComplexTypeDefinition mergedType, + MutableObjectTypeDefinition objectType, + IReadOnlyList schemas, + HashSet<(string, string)> reported, + CompositionContext context) + { + // fieldName -> the interface supplying the default (first match wins for the diagnostic). + var defaultToInterface = new Dictionary(StringComparer.Ordinal); + + foreach (var interfaceType in mergedType.Implements) + { + foreach (var fieldName in InterfaceObjectMetadata.DefaultFields(schemas, interfaceType.Name)) + { + defaultToInterface.TryAdd(fieldName, interfaceType.Name); + } + } + + if (defaultToInterface.Count == 0) + { + return; + } + + foreach (var schema in schemas) + { + if (!schema.Types.TryGetType(objectType.Name, out MutableObjectTypeDefinition? sourceType)) + { + continue; + } + + foreach (var field in sourceType.Fields) + { + if (field.IsExternal + || field.IsInternal + || field.IsOverridden + || field.HasImplementDirective) + { + continue; + } + + if (defaultToInterface.TryGetValue(field.Name, out var interfaceName) + && reported.Add((interfaceName, field.Name))) + { + context.Log.Write( + InterfaceObjectFieldRequiresImplement(interfaceName, field.Name, schema)); + } + } + } + } + + private static void CheckMoreSpecificStandIn( + MutableInterfaceTypeDefinition interfaceType, + IReadOnlyList schemas, + HashSet<(string, string)> reported, + CompositionContext context) + { + // A more specific interface's own stand-in field that collides with a less specific + // interface's default must carry @implement. + var ownDefaults = InterfaceObjectMetadata.DefaultFields(schemas, interfaceType.Name); + + if (ownDefaults.Count == 0) + { + return; + } + + var ancestorDefaults = new Dictionary(StringComparer.Ordinal); + + foreach (var ancestor in interfaceType.Implements) + { + foreach (var fieldName in InterfaceObjectMetadata.DefaultFields(schemas, ancestor.Name)) + { + ancestorDefaults.TryAdd(fieldName, ancestor.Name); + } + } + + foreach (var (standIn, schema) in InterfaceObjectMetadata.GetStandIns(schemas, interfaceType.Name)) + { + foreach (var field in standIn.Fields) + { + if (!ownDefaults.Contains(field.Name) + || field.HasImplementDirective + || !ancestorDefaults.TryGetValue(field.Name, out var ancestorName)) + { + continue; + } + + if (reported.Add((ancestorName, field.Name))) + { + context.Log.Write( + InterfaceObjectFieldRequiresImplement(ancestorName, field.Name, schema)); + } + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/InvalidProjectedFieldSharingRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/InvalidProjectedFieldSharingRule.cs new file mode 100644 index 00000000000..3a2bd59a900 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/InvalidProjectedFieldSharingRule.cs @@ -0,0 +1,111 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Fusion.Extensions; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PostMergeValidationRules; + +/// +/// A type may implement two or more unrelated interfaces whose @interfaceObject stand-ins each +/// contribute a default for the same field name. There is then no single most specific default. As +/// with INVALID_FIELD_SHARING for directly declared fields, composition fails with an +/// INVALID_PROJECTED_FIELD_SHARING error unless every contributing declaration is marked +/// @shareable. +/// +/// +/// Specification +/// +internal sealed class InvalidProjectedFieldSharingRule : IEventHandler +{ + public void Handle(SchemaEvent @event, CompositionContext context) + { + var mergedSchema = @event.Schema; + var schemas = context.SchemaDefinitions; + + // Report once per conflicting interface pair and field, not once per implementing type. + var reported = new HashSet<(string, string, string)>(); + + foreach (var type in mergedSchema.Types) + { + if (type is not MutableComplexTypeDefinition complexType) + { + continue; + } + + List ancestors = [.. complexType.Implements]; + + if (ancestors.Count < 2) + { + continue; + } + + var candidateFieldNames = new HashSet(StringComparer.Ordinal); + + foreach (var ancestor in ancestors) + { + candidateFieldNames.UnionWith(InterfaceObjectMetadata.DefaultFields(schemas, ancestor.Name)); + } + + foreach (var fieldName in candidateFieldNames) + { + var contributors = MostSpecificContributors(ancestors, fieldName, schemas); + + if (contributors.Count < 2) + { + continue; + } + + if (!AllContributingDeclarationsShareable(contributors, fieldName, schemas)) + { + var names = contributors.Select(c => c.Name).Order(StringComparer.Ordinal).ToArray(); + var key = (names[0], names[1], fieldName); + + if (reported.Add(key)) + { + context.Log.Write( + InvalidProjectedFieldSharing( + complexType.Name, + fieldName, + string.Join(", ", names.Select(n => $"'{n}'")), + mergedSchema)); + } + } + } + } + } + + private static List MostSpecificContributors( + IReadOnlyList ancestors, + string fieldName, + IReadOnlyList schemas) + { + var contributors = ancestors + .Where(a => InterfaceObjectMetadata.DefaultFields(schemas, a.Name).Contains(fieldName)) + .ToList(); + + return contributors + .Where(a => !contributors.Any( + other => other.Name != a.Name && other.Implements.ContainsName(a.Name))) + .ToList(); + } + + private static bool AllContributingDeclarationsShareable( + List contributors, + string fieldName, + IReadOnlyList schemas) + { + foreach (var contributor in contributors) + { + foreach (var (standIn, _) in InterfaceObjectMetadata.GetStandIns(schemas, contributor.Name)) + { + if (standIn.Fields.TryGetField(fieldName, out var field) && !field.IsShareable) + { + return false; + } + } + } + + return true; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/InterfaceObjectKeyMismatchRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/InterfaceObjectKeyMismatchRule.cs new file mode 100644 index 00000000000..cde5ca3fec8 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/InterfaceObjectKeyMismatchRule.cs @@ -0,0 +1,108 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Language; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; +using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames; + +namespace HotChocolate.Fusion.PreMergeValidationRules; + +/// +/// An interface that has a stand-in must declare at least one key with @key, and the stand-in +/// must key on one of the keys of the interface. Each @key on the stand-in must select the +/// same fields as a @key declared on the interface by at least one interface-defining schema. +/// The comparison is structural; the order of the fields and their formatting do not matter. +/// +/// +/// Specification +/// +internal sealed class InterfaceObjectKeyMismatchRule : IEventHandler +{ + public void Handle(TypeGroupEvent @event, CompositionContext context) + { + var (_, typeGroup) = @event; + + var standIns = typeGroup + .Where(i => i.Type is MutableObjectTypeDefinition o + && o.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject)) + .ToArray(); + + if (standIns.Length == 0) + { + return; + } + + // Collect the structural key selections declared on the interface-defining schemas. When the + // interface declares no key, INTERFACE_OBJECT_KEY_MISMATCH also fires, because the stand-in + // can then never key on the interface. + var interfaceKeys = new HashSet(StringComparer.Ordinal); + + foreach (var typeInfo in typeGroup) + { + if (typeInfo.Type is not MutableInterfaceTypeDefinition interfaceType) + { + continue; + } + + foreach (var keyDirective in interfaceType.Directives.AsEnumerable()) + { + if (keyDirective.Name != WellKnownDirectiveNames.Key + || TryNormalizeKey(keyDirective) is not { } key) + { + continue; + } + + interfaceKeys.Add(key); + } + } + + foreach (var (type, schema) in standIns) + { + var standIn = (MutableObjectTypeDefinition)type; + + foreach (var keyDirective in standIn.Directives.AsEnumerable()) + { + if (keyDirective.Name != WellKnownDirectiveNames.Key + || TryNormalizeKey(keyDirective) is not { } key) + { + continue; + } + + if (!interfaceKeys.Contains(key)) + { + context.Log.Write(InterfaceObjectKeyMismatch(standIn, key, schema)); + } + } + } + } + + private static string? TryNormalizeKey(Directive keyDirective) + { + if (!keyDirective.Arguments.TryGetValue(ArgumentNames.Fields, out var value) + || value is not StringValueNode fields) + { + return null; + } + + try + { + return Canonicalize(Utf8GraphQLParser.Syntax.ParseSelectionSet($"{{ {fields.Value} }}")); + } + catch (SyntaxException) + { + return null; + } + } + + private static string Canonicalize(SelectionSetNode selectionSet) + { + var parts = selectionSet.Selections + .OfType() + .Select(f => f.SelectionSet is null + ? f.Name.Value + : $"{f.Name.Value}{{{Canonicalize(f.SelectionSet)}}}") + .OrderBy(p => p, StringComparer.Ordinal); + + return string.Join(" ", parts); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/InterfaceObjectNoInterfaceRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/InterfaceObjectNoInterfaceRule.cs new file mode 100644 index 00000000000..af892699f90 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/InterfaceObjectNoInterfaceRule.cs @@ -0,0 +1,48 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidationRules; + +/// +/// A stand-in binds to the interface of the same name. At least one source schema must define that +/// name as an interface. If every source schema that declares the type name uses +/// @interfaceObject, and none defines it as an interface, the stand-in has no interface to +/// bind to and composition fails with an INTERFACE_OBJECT_NO_INTERFACE error. +/// +/// +/// Specification +/// +internal sealed class InterfaceObjectNoInterfaceRule : IEventHandler +{ + public void Handle(TypeGroupEvent @event, CompositionContext context) + { + var (typeName, typeGroup) = @event; + + var hasStandIn = false; + var firstStandInSchema = default(MutableSchemaDefinition); + + foreach (var typeInfo in typeGroup) + { + if (typeInfo.Type is MutableObjectTypeDefinition o + && o.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject)) + { + hasStandIn = true; + firstStandInSchema ??= typeInfo.Schema; + } + } + + if (!hasStandIn) + { + return; + } + + var hasInterface = typeGroup.Any(i => i.Type is MutableInterfaceTypeDefinition); + + if (!hasInterface) + { + context.Log.Write(InterfaceObjectNoInterface(typeName, firstStandInSchema!)); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/TypeKindMismatchRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/TypeKindMismatchRule.cs index 0bd36ee1cc4..db241ae0d56 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/TypeKindMismatchRule.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/TypeKindMismatchRule.cs @@ -1,5 +1,7 @@ +using System.Collections.Immutable; using HotChocolate.Fusion.Events; using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Types.Mutable; using static HotChocolate.Fusion.Logging.LogEntryHelper; namespace HotChocolate.Fusion.PreMergeValidationRules; @@ -26,10 +28,18 @@ public void Handle(TypeGroupEvent @event, CompositionContext context) { var (_, typeGroup) = @event; - for (var i = 0; i < typeGroup.Length - 1; i++) + // An @interfaceObject stand-in is a deliberate object-for-interface substitution, so it is + // excluded from the kind-consistency check. A same-named object type that is not annotated + // with @interfaceObject remains an ordinary kind mismatch. + var consideredTypes = typeGroup + .Where(i => i.Type is not MutableObjectTypeDefinition o + || !o.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject)) + .ToImmutableArray(); + + for (var i = 0; i < consideredTypes.Length - 1; i++) { - var typeInfoA = typeGroup[i]; - var typeInfoB = typeGroup[i + 1]; + var typeInfoA = consideredTypes[i]; + var typeInfoB = consideredTypes[i + 1]; var typeKindA = typeInfoA.Type.Kind; var typeKindB = typeInfoB.Type.Kind; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs index 2f0652be787..e8e974937d1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -374,6 +374,24 @@ internal static string FusionInputFieldMutableDirectiveDefinition_Description { } } + /// + /// Looks up a localized string similar to The name of the source schema that exposes this interface as an @interfaceObject stand-in.. + /// + internal static string FusionInterfaceObjectMutableDirectiveDefinition_Argument_Schema_Description { + get { + return ResourceManager.GetString("FusionInterfaceObjectMutableDirectiveDefinition_Argument_Schema_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque.. + /// + internal static string FusionInterfaceObjectMutableDirectiveDefinition_Description { + get { + return ResourceManager.GetString("FusionInterfaceObjectMutableDirectiveDefinition_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The assumed size of the list as defined in the source schema.. /// @@ -619,6 +637,15 @@ internal static string FusionUnionMemberMutableDirectiveDefinition_Description { } } + /// + /// Looks up a localized string similar to The @implement directive marks a field as an explicit implementation that replaces a default field implementation contributed by an @interfaceObject stand-in.. + /// + internal static string ImplementMutableDirectiveDefinition_Description { + get { + return ResourceManager.GetString("ImplementMutableDirectiveDefinition_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The @inaccessible directive is used to prevent specific type system members from being accessible through the client-facing composite schema, even if they are accessible in the underlying source schemas.. /// @@ -628,6 +655,15 @@ internal static string InaccessibleMutableDirectiveDefinition_Description { } } + /// + /// Looks up a localized string similar to The @interfaceObject directive declares an object type that acts as a stand-in for an interface of the same name defined in other source schemas, letting a source schema contribute fields to that interface without defining its implementing types.. + /// + internal static string InterfaceObjectMutableDirectiveDefinition_Description { + get { + return ResourceManager.GetString("InterfaceObjectMutableDirectiveDefinition_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The @internal directive is used to mark types and fields as internal within a source schema.. /// @@ -1024,6 +1060,51 @@ internal static string LogEntryHelper_InterfaceFieldNoImplementation { } } + /// + /// Looks up a localized string similar to The field '{0}' is marked with @implement in schema '{1}', but no applicable default implementation exists for it.. + /// + internal static string LogEntryHelper_ImplementWithoutDefault { + get { + return ResourceManager.GetString("LogEntryHelper_ImplementWithoutDefault", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The field '{0}' provided by schema '{1}' replaces the default implementation contributed to interface '{2}'. Mark it with @implement to keep this implementation, or remove it to adopt the default.. + /// + internal static string LogEntryHelper_InterfaceObjectFieldRequiresImplement { + get { + return ResourceManager.GetString("LogEntryHelper_InterfaceObjectFieldRequiresImplement", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The @interfaceObject stand-in '{0}' in schema '{1}' declares the key '{2}', which does not match any key declared on the interface it stands in for.. + /// + internal static string LogEntryHelper_InterfaceObjectKeyMismatch { + get { + return ResourceManager.GetString("LogEntryHelper_InterfaceObjectKeyMismatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The @interfaceObject stand-in '{0}' in schema '{1}' must declare at least one @key directive.. + /// + internal static string LogEntryHelper_InterfaceObjectKeyMissing { + get { + return ResourceManager.GetString("LogEntryHelper_InterfaceObjectKeyMissing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type '{0}' is declared as an @interfaceObject stand-in, but no source schema defines '{0}' as an interface.. + /// + internal static string LogEntryHelper_InterfaceObjectNoInterface { + get { + return ResourceManager.GetString("LogEntryHelper_InterfaceObjectNoInterface", resourceCulture); + } + } + /// /// Looks up a localized string similar to The field '{0}' in schema '{1}' must be shareable.. /// @@ -1033,6 +1114,15 @@ internal static string LogEntryHelper_InvalidFieldSharing { } } + /// + /// Looks up a localized string similar to The default field '{0}' on type '{1}' is contributed by unrelated interfaces {2}; every contributing @interfaceObject declaration must be marked @shareable.. + /// + internal static string LogEntryHelper_InvalidProjectedFieldSharing { + get { + return ResourceManager.GetString("LogEntryHelper_InvalidProjectedFieldSharing", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid GraphQL in source schema. Exception message: {0}.. /// @@ -1663,6 +1753,24 @@ internal static string SatisfiabilityValidator_CycleDetected { } } + /// + /// Looks up a localized string similar to The query path '{0}' cannot be satisfied: values of '{1}' produced by source schema '{2}' are opaque, and no source schema provides a lookup for '{1}' that covers the possible type(s) {3} introduced by source schema(s) {4}.. + /// + internal static string SatisfiabilityValidator_InterfaceObjectNoCoveringLookup { + get { + return ResourceManager.GetString("SatisfiabilityValidator_InterfaceObjectNoCoveringLookup", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Source schema '{0}' contributes fields to '{1}' but provides no lookup to resolve them.. + /// + internal static string SatisfiabilityValidator_InterfaceObjectNoLookup { + get { + return ResourceManager.GetString("SatisfiabilityValidator_InterfaceObjectNoLookup", resourceCulture); + } + } + /// /// Looks up a localized string similar to Type '{0}' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node<Node>' lookup field for this type.. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx index 6484a327e23..49cd583038b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -123,6 +123,12 @@ The @fusion__inputField directive specifies which source schema provides an input field in a composite input type. + + The name of the source schema that exposes this interface as an @interfaceObject stand-in. + + + The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque. + The assumed size of the list as defined in the source schema. @@ -204,9 +210,15 @@ The @fusion__unionMember directive specifies which source schema provides a member type of a union. + + The @implement directive marks a field as an explicit implementation that replaces a default field implementation contributed by an @interfaceObject stand-in. + The @inaccessible directive is used to prevent specific type system members from being accessible through the client-facing composite schema, even if they are accessible in the underlying source schemas. + + The @interfaceObject directive declares an object type that acts as a stand-in for an interface of the same name defined in other source schemas, letting a source schema contribute fields to that interface without defining its implementing types. + The @internal directive is used to mark types and fields as internal within a source schema. @@ -333,12 +345,30 @@ The input type '{0}' in schema '{1}' must define the required field '{2}'. + + The field '{0}' is marked with @implement in schema '{1}', but no applicable default implementation exists for it. + The merged object type '{0}' must implement the field '{1}' on interface '{2}'. + + The field '{0}' provided by schema '{1}' replaces the default implementation contributed to interface '{2}'. Mark it with @implement to keep this implementation, or remove it to adopt the default. + + + The @interfaceObject stand-in '{0}' in schema '{1}' declares the key '{2}', which does not match any key declared on the interface it stands in for. + + + The @interfaceObject stand-in '{0}' in schema '{1}' must declare at least one @key directive. + + + The type '{0}' is declared as an @interfaceObject stand-in, but no source schema defines '{0}' as an interface. + The field '{0}' in schema '{1}' must be shareable. + + The default field '{0}' on type '{1}' is contributed by unrelated interfaces {2}; every contributing @interfaceObject declaration must be marked @shareable. + Invalid GraphQL in source schema. Exception message: {0}. @@ -549,6 +579,12 @@ Cycle detected: {0} -> {1}. + + The query path '{0}' cannot be satisfied: values of '{1}' produced by source schema '{2}' are opaque, and no source schema provides a lookup for '{1}' that covers the possible type(s) {3} introduced by source schema(s) {4}. + + + Source schema '{0}' contributes fields to '{1}' but provides no lookup to resolve them. + Type '{0}' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node<Node>' lookup field for this type. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/FusionLookupDirectiveCache.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/FusionLookupDirectiveCache.cs index 9405c32a1b6..7ec6419ab56 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/FusionLookupDirectiveCache.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/FusionLookupDirectiveCache.cs @@ -49,7 +49,9 @@ public List GetPossibleFusionLookupDirectives( MutableComplexTypeDefinition type, string? schemaName = null) { - if (!string.IsNullOrEmpty(schemaName) && !type.ExistsInSchema(schemaName)) + if (!string.IsNullOrEmpty(schemaName) + && !type.ExistsInSchema(schemaName) + && !MutableSchemaDefinitionExtensions.ReachesInterfaceObjectStandIn(_schema, type, schemaName)) { return []; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.InterfaceObject.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.InterfaceObject.cs new file mode 100644 index 00000000000..9e7fd552f67 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.InterfaceObject.cs @@ -0,0 +1,352 @@ +using HotChocolate.Fusion.Extensions; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Satisfiability; +using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Properties.CompositionResources; +using static HotChocolate.Language.Utf8GraphQLParser.Syntax; +using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames; +using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames; + +namespace HotChocolate.Fusion; + +internal sealed partial class SatisfiabilityValidator +{ + /// + /// Validates the satisfiability obligations that arise from @interfaceObject stand-ins. + /// A value of an interface produced through a stand-in schema is opaque: that schema holds no + /// authoritative concrete type for it. Recovering identity or fetching non-local data for such + /// a value requires a covering interface lookup, and every projected default field must be + /// reachable through one of the stand-in's own lookups. + /// + private void ValidateInterfaceObjectBindings() + { + foreach (var interfaceType in _schema.Types.OfType()) + { + var standInSchemaNames = + MutableSchemaDefinitionExtensions.GetInterfaceObjectSchemaNames(interfaceType).ToArray(); + + if (standInSchemaNames.Length == 0) + { + continue; + } + + var keyFieldNames = GetInterfaceKeyFieldNames(interfaceType); + + foreach (var standInSchemaName in standInSchemaNames) + { + if (AllowsNonResolvableInterfaceObject(standInSchemaName)) + { + continue; + } + + ValidateProjectedFieldsPlannable(interfaceType, standInSchemaName, keyFieldNames); + + var opaquePosition = FindClientAccessibleOpaquePosition(interfaceType, standInSchemaName); + + if (opaquePosition is { } position + && !HasCoveringLookup(interfaceType, keyFieldNames)) + { + ReportNoCoveringLookup(interfaceType, standInSchemaName, position); + } + } + } + } + + private bool AllowsNonResolvableInterfaceObject(string schemaName) + => _apolloFederationCompatibility.AllowNonResolvableInterfaceObjects + && _apolloFederationSchemaNames.Contains(schemaName); + + /// + /// Every non-key field a stand-in contributes is projected onto the implementing types as a + /// default. The stand-in schema must declare a lookup through which the executor can fetch it; + /// without one, no plan can ever reach the field. + /// + private void ValidateProjectedFieldsPlannable( + MutableInterfaceTypeDefinition interfaceType, + string standInSchemaName, + HashSet keyFieldNames) + { + var contributesProjectedField = false; + + foreach (var field in interfaceType.Fields) + { + if (!keyFieldNames.Contains(field.Name) + && FieldHasSource(field, standInSchemaName)) + { + contributesProjectedField = true; + break; + } + } + + if (!contributesProjectedField) + { + return; + } + + if (!HasLookupInSchema(interfaceType, standInSchemaName)) + { + ReportError( + string.Format( + SatisfiabilityValidator_InterfaceObjectNoLookup, + standInSchemaName, + interfaceType.Name)); + } + } + + /// + /// A covering interface lookup is a lookup, in a single source schema, that returns the + /// interface itself, whose inputs are resolvable from the stand-in's key fields, and whose + /// schema-local possible-type set is a superset of the composite possible-type set. + /// + private bool HasCoveringLookup( + MutableInterfaceTypeDefinition interfaceType, + HashSet keyFieldNames) + { + var compositePossibleTypes = GetCompositePossibleTypeNames(interfaceType); + + foreach (var candidateSchemaName in GetInterfaceDefiningSchemaNames(interfaceType)) + { + var localPossibleTypes = interfaceType + .GetPossibleTypes(candidateSchemaName, _schema) + .Select(t => t.Name) + .ToHashSet(StringComparer.Ordinal); + + if (!localPossibleTypes.IsSupersetOf(compositePossibleTypes)) + { + continue; + } + + foreach (var lookup in GetLookupsInSchema(interfaceType, candidateSchemaName)) + { + if (LookupReturnsInterface(lookup, interfaceType) + && GetLookupKeyFieldNames(lookup).IsSubsetOf(keyFieldNames)) + { + return true; + } + } + } + + return false; + } + + private void ReportNoCoveringLookup( + MutableInterfaceTypeDefinition interfaceType, + string standInSchemaName, + OpaquePosition opaquePosition) + { + var compositePossibleTypes = GetCompositePossibleTypeNames(interfaceType); + var coveredTypes = new HashSet(StringComparer.Ordinal); + + foreach (var candidateSchemaName in GetInterfaceDefiningSchemaNames(interfaceType)) + { + var hasInterfaceLookup = + GetLookupsInSchema(interfaceType, candidateSchemaName) + .Any(l => LookupReturnsInterface(l, interfaceType)); + + if (!hasInterfaceLookup) + { + continue; + } + + foreach (var possibleType in interfaceType.GetPossibleTypes(candidateSchemaName, _schema)) + { + coveredTypes.Add(possibleType.Name); + } + } + + var uncoveredTypes = compositePossibleTypes + .Where(t => !coveredTypes.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToArray(); + + var introducingSchemas = uncoveredTypes + .SelectMany(GetTypeDefiningSchemaNames) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToArray(); + + ReportError( + string.Format( + SatisfiabilityValidator_InterfaceObjectNoCoveringLookup, + $"{opaquePosition.ParentTypeName}.{opaquePosition.FieldName}", + interfaceType.Name, + standInSchemaName, + string.Join(", ", uncoveredTypes.Select(t => $"'{t}'")), + string.Join(", ", introducingSchemas.Select(s => $"'{s}'")))); + } + + private OpaquePosition? FindClientAccessibleOpaquePosition( + MutableInterfaceTypeDefinition interfaceType, + string standInSchemaName) + { + foreach (var type in _schema.Types.OfType()) + { + foreach (var field in type.Fields) + { + if (field.HasFusionInaccessibleDirective() + || field.Type.NamedType() is not ITypeDefinition namedType + || namedType.Name != interfaceType.Name) + { + continue; + } + + // The field returns the interface and is resolved by a stand-in schema of it, so + // the value it produces is opaque. + if (FieldHasSource(field, standInSchemaName)) + { + return new OpaquePosition(type.Name, field.Name); + } + } + } + + return null; + } + + private static bool FieldHasSource(MutableOutputFieldDefinition field, string schemaName) + { + foreach (var directive in field.Directives.AsEnumerable()) + { + if (directive.Name == DirectiveNames.FusionField + && directive.Arguments.TryGetValue(ArgumentNames.Schema, out var schema) + && schema is EnumValueNode { Value: var schemaValue } + && schemaValue == schemaName) + { + return true; + } + } + + return false; + } + + private HashSet GetCompositePossibleTypeNames(MutableInterfaceTypeDefinition interfaceType) + => _schema.GetPossibleTypes(interfaceType) + .Select(t => t.Name) + .ToHashSet(StringComparer.Ordinal); + + private static IEnumerable GetInterfaceDefiningSchemaNames( + MutableInterfaceTypeDefinition interfaceType) + { + var standInSchemaNames = MutableSchemaDefinitionExtensions + .GetInterfaceObjectSchemaNames(interfaceType) + .ToHashSet(StringComparer.Ordinal); + + foreach (var directive in interfaceType.Directives.AsEnumerable()) + { + if (directive.Name == DirectiveNames.FusionType + && directive.Arguments[ArgumentNames.Schema] is EnumValueNode { Value: var schemaName } + && !standInSchemaNames.Contains(schemaName)) + { + yield return schemaName; + } + } + } + + private IEnumerable GetTypeDefiningSchemaNames(string typeName) + { + if (!_schema.Types.TryGetType(typeName, out var type) + || type is not MutableComplexTypeDefinition complexType) + { + yield break; + } + + foreach (var directive in complexType.Directives.AsEnumerable()) + { + if (directive.Name == DirectiveNames.FusionType + && directive.Arguments[ArgumentNames.Schema] is EnumValueNode { Value: var schemaName }) + { + yield return schemaName; + } + } + } + + private static HashSet GetInterfaceKeyFieldNames(MutableInterfaceTypeDefinition interfaceType) + { + var keyFieldNames = new HashSet(StringComparer.Ordinal); + + foreach (var directive in interfaceType.Directives.AsEnumerable()) + { + if (directive.Name != DirectiveNames.FusionLookup) + { + continue; + } + + foreach (var fieldName in GetLookupKeyFieldNames(directive)) + { + keyFieldNames.Add(fieldName); + } + } + + return keyFieldNames; + } + + private static IEnumerable GetLookupsInSchema( + MutableInterfaceTypeDefinition interfaceType, + string schemaName) + { + foreach (var directive in interfaceType.Directives.AsEnumerable()) + { + if (directive.Name == DirectiveNames.FusionLookup + && directive.Arguments[ArgumentNames.Schema] is EnumValueNode { Value: var lookupSchema } + && lookupSchema == schemaName) + { + yield return directive; + } + } + } + + private static bool HasLookupInSchema(MutableInterfaceTypeDefinition interfaceType, string schemaName) + => GetLookupsInSchema(interfaceType, schemaName).Any(); + + private static bool LookupReturnsInterface(IDirective lookup, MutableInterfaceTypeDefinition interfaceType) + { + var fieldArgument = (string)lookup.Arguments[ArgumentNames.Field].Value!; + var fieldDefinition = ParseFieldDefinition(fieldArgument); + + return fieldDefinition.Type.NamedType().Name.Value == interfaceType.Name; + } + + private static HashSet GetLookupKeyFieldNames(IDirective lookup) + { + var keyFieldNames = new HashSet(StringComparer.Ordinal); + var key = (string)lookup.Arguments[ArgumentNames.Key].Value!; + var selectionSet = ParseSelectionSet($"{{ {key} }}"); + + CollectTopLevelFieldNames(selectionSet, keyFieldNames); + + return keyFieldNames; + } + + private static void CollectTopLevelFieldNames(SelectionSetNode selectionSet, HashSet fieldNames) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + fieldNames.Add(field.Name.Value); + break; + + case InlineFragmentNode inlineFragment: + CollectTopLevelFieldNames(inlineFragment.SelectionSet, fieldNames); + break; + } + } + } + + private void ReportError(string message) + { + var error = new SatisfiabilityError(message); + + _log.Write( + LogEntryBuilder.New() + .SetMessage(error.ToString()) + .SetCode(LogEntryCodes.UnsatisfiableQueryPath) + .SetSeverity(LogSeverity.Error) + .SetExtension("error", error) + .Build()); + } + + private readonly record struct OpaquePosition(string ParentTypeName, string FieldName); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs index 7597a33a134..b1b38e76886 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs @@ -15,9 +15,12 @@ namespace HotChocolate.Fusion; -internal sealed class SatisfiabilityValidator +internal sealed partial class SatisfiabilityValidator { private readonly SatisfiabilityOptions _options; + private readonly ApolloFederationCompatibilityOptions _apolloFederationCompatibility; + private readonly IReadOnlySet _apolloFederationSchemaNames; + private readonly NodeResolution _nodeResolution; private readonly RequirementsValidator _requirementsValidator; private readonly FusionLookupDirectiveCache _lookupCache; private readonly SatisfiabilityFacts _facts; @@ -27,11 +30,18 @@ internal sealed class SatisfiabilityValidator public SatisfiabilityValidator( MutableSchemaDefinition schema, ICompositionLog log, - SatisfiabilityOptions? options = null) + SatisfiabilityOptions? options = null, + NodeResolution nodeResolution = NodeResolution.Gateway, + ApolloFederationCompatibilityOptions? apolloFederationCompatibility = null, + IReadOnlySet? apolloFederationSchemaNames = null) { _schema = schema; _log = log; _options = options ?? new SatisfiabilityOptions(); + _nodeResolution = nodeResolution; + _apolloFederationCompatibility = + apolloFederationCompatibility ?? new ApolloFederationCompatibilityOptions(); + _apolloFederationSchemaNames = apolloFederationSchemaNames ?? new HashSet(); _lookupCache = new FusionLookupDirectiveCache(schema); _facts = new SatisfiabilityFactsBuilder(schema, _lookupCache).Build(); _requirementsValidator = @@ -71,6 +81,8 @@ public CompositionResult Validate() VisitObjectType(work.ObjectType, work.Path, worklist, visited); } + ValidateInterfaceObjectBindings(); + return _log.HasErrors ? ErrorHelper.SatisfiabilityValidationFailed() : CompositionResult.Success(); @@ -266,6 +278,11 @@ private void VisitOutputField( // (f.e. relatedProduct.relatedProduct.relatedProduct) if (optionCount == 0 && !cycle) { + if (CanReportAtRuntimeForNonResolvableInterfaceObject(type, field)) + { + return; + } + var qualifiedFieldName = $"{type.Name}.{field.Name}"; if (_options.IgnoredNonAccessibleFields.TryGetValue(qualifiedFieldName, out var ignoredPaths) @@ -298,6 +315,61 @@ private void VisitOutputField( } } + private bool CanReportAtRuntimeForNonResolvableInterfaceObject( + MutableObjectTypeDefinition objectType, + MutableOutputFieldDefinition field) + { + if (!_apolloFederationCompatibility.AllowNonResolvableInterfaceObjects) + { + return false; + } + + var hasSource = false; + + foreach (var directive in field.Directives.AsEnumerable()) + { + if (directive.Name != WellKnownDirectiveNames.FusionField + || directive.Arguments[WellKnownArgumentNames.Schema] + is not EnumValueNode { Value: var schemaName }) + { + continue; + } + + hasSource = true; + + if (!_apolloFederationSchemaNames.Contains(schemaName) + || objectType.ExistsInSchema(schemaName) + || !IsProjectedFromInterfaceObject(objectType, field.Name, schemaName)) + { + return false; + } + } + + return hasSource; + } + + private static bool IsProjectedFromInterfaceObject( + MutableObjectTypeDefinition objectType, + string fieldName, + string schemaName) + { + foreach (var interfaceType in objectType.Implements) + { + if (!MutableSchemaDefinitionExtensions + .GetInterfaceObjectSchemaNames(interfaceType) + .Contains(schemaName, StringComparer.Ordinal) + || !interfaceType.Fields.TryGetField(fieldName, out var interfaceField) + || !FieldHasSource(interfaceField, schemaName)) + { + continue; + } + + return true; + } + + return false; + } + private void VisitNodeField( MutableObjectTypeDefinition queryType, MutableOutputFieldDefinition nodeField, @@ -305,6 +377,12 @@ private void VisitNodeField( PathNode? path, Queue worklist) { + if (_nodeResolution is NodeResolution.SourceSchema) + { + VisitSourceSchemaNodeField(queryType, nodeField, nodeType, path, worklist); + return; + } + foreach (var possibleType in _schema.GetPossibleTypes(nodeType)) { var byIdLookups = _lookupCache.GetPossibleFusionLookupDirectivesById(possibleType); @@ -357,6 +435,117 @@ private void VisitNodeField( } } + private void VisitSourceSchemaNodeField( + MutableObjectTypeDefinition queryType, + MutableOutputFieldDefinition nodeField, + IInterfaceTypeDefinition nodeType, + PathNode? path, + Queue worklist) + { + var sourceNodeLookups = new List<(string SchemaName, MutableOutputFieldDefinition Field)>(); + + foreach (var lookup in _lookupCache.GetPossibleFusionLookupDirectivesById( + (MutableComplexTypeDefinition)nodeType)) + { + var fieldDirectiveArgument = (string)lookup.Arguments[WellKnownArgumentNames.Field].Value!; + var lookupFieldDefinition = ParseFieldDefinition(fieldDirectiveArgument); + + if (lookupFieldDefinition.Name.Value != WellKnownFieldNames.Node + || lookupFieldDefinition.Type.NamedType().Name.Value != WellKnownTypeNames.Node + || lookup.Arguments[WellKnownArgumentNames.Path] is not NullValueNode) + { + continue; + } + + var schemaName = (string)lookup.Arguments[WellKnownArgumentNames.Schema].Value!; + var lookupField = new MutableOutputFieldDefinition( + lookupFieldDefinition.Name.Value, + CreateType(lookupFieldDefinition.Type, nodeType).ExpectOutputType()); + + sourceNodeLookups.Add((schemaName, lookupField)); + } + + var possibleTypes = _schema.GetPossibleTypes(nodeType).ToArray(); + + if (sourceNodeLookups.Count == 0) + { + ReportMissingNodeLookups(possibleTypes); + + return; + } + + var coveredTypes = new HashSet(); + var nodePathItem = new SatisfiabilityPathItem(nodeField, queryType, "*"); + var nodePath = new PathNode(nodePathItem, path); + + foreach (var (schemaName, lookupField) in sourceNodeLookups) + { + var lookupPathItem = new SatisfiabilityPathItem(lookupField, queryType, schemaName); + var lookupPath = new PathNode(lookupPathItem, nodePath); + + // Only validate the concrete Node implementations declared by this source. In + // particular, an Apollo interface-object stand-in is not Node lookup coverage. + foreach (var possibleType in nodeType.GetPossibleTypes(schemaName, _schema)) + { + coveredTypes.Add(possibleType); + worklist.Enqueue(new WorkItem(possibleType, lookupPath)); + } + } + + if (coveredTypes.Count == 0) + { + ReportMissingNodeLookups(possibleTypes); + return; + } + + foreach (var possibleType in possibleTypes) + { + if (!coveredTypes.Contains(possibleType)) + { + ReportMissingNodeLookup(possibleType, LogSeverity.Warning); + } + } + } + + private void ReportMissingNodeLookups( + IReadOnlyList possibleTypes) + { + if (possibleTypes.Count == 0) + { + const string message = + "No source schema provides a non-internal 'Query.node' lookup field."; + + _log.Write( + LogEntryBuilder.New() + .SetMessage(message) + .SetCode(LogEntryCodes.UnsatisfiableQueryPath) + .SetSeverity(LogSeverity.Error) + .Build()); + return; + } + + foreach (var possibleType in possibleTypes) + { + ReportMissingNodeLookup(possibleType, LogSeverity.Error); + } + } + + private void ReportMissingNodeLookup( + MutableObjectTypeDefinition possibleType, + LogSeverity severity) + { + var error = new SatisfiabilityError( + string.Format(SatisfiabilityValidator_NodeTypeHasNoNodeLookup, possibleType.Name)); + + _log.Write( + LogEntryBuilder.New() + .SetMessage(error.ToString()) + .SetCode(LogEntryCodes.UnsatisfiableQueryPath) + .SetSeverity(severity) + .SetExtension("error", error) + .Build()); + } + private ImmutableArray ValidateSourceSchemaTransition( MutableObjectTypeDefinition type, PathNode? path, diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs index aed7288e3cd..47640daedfd 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; using HotChocolate.Fusion.Comparers; +using HotChocolate.Fusion.Errors; using HotChocolate.Fusion.Extensions; +using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Logging.Contracts; using HotChocolate.Fusion.Options; using HotChocolate.Fusion.PostMergeValidationRules; @@ -34,6 +36,29 @@ public SchemaComposer( public CompositionResult Compose() { + if (!Enum.IsDefined(_schemaComposerOptions.Merger.NodeResolution)) + { + return InvalidNodeResolution( + $"The node resolution mode '{_schemaComposerOptions.Merger.NodeResolution}' is invalid."); + } + + if (_schemaComposerOptions.Merger.NodeResolution is NodeResolution.SourceSchema + && !_schemaComposerOptions.Merger.EnableGlobalObjectIdentification) + { + return InvalidNodeResolution( + "Source-schema node resolution requires global object identification to be enabled."); + } + + if (!Enum.IsDefined( + _schemaComposerOptions.ApolloFederationCompatibility + .ShareableFieldRuntimeTypeRouting)) + { + return InvalidShareableFieldRuntimeTypeRouting( + "The shareable field runtime type routing mode " + + $"'{_schemaComposerOptions.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting}' " + + "is invalid."); + } + // Parse Source Schemas var parsingResult = _sourceSchemas.Select(schema => @@ -76,6 +101,30 @@ public CompositionResult Compose() return preprocessingResult.Errors; } + var apolloFederationSchemaNames = schemas + .Where(static schema => + schema.Features.Get()?.Kind + == "ApolloFederation") + .Select(static schema => StringUtilities.ToConstantCase(schema.Name)) + .ToHashSet(StringComparer.Ordinal); + + if (_schemaComposerOptions.ApolloFederationCompatibility + .AllowNonResolvableInterfaceObjects) + { + foreach (var schema in schemas) + { + if (schema.Features.Get()?.Kind + == "ApolloFederation") + { + schema.Features.Set( + new ApolloFederation.ApolloFederationCompatibilityMetadata + { + AllowNonResolvableInterfaceObjects = true + }); + } + } + } + // Enrich Source Schemas var enrichmentResult = schemas.Select(schema => new SourceSchemaEnricher(schema, schemas).Enrich()).Combine(); @@ -118,7 +167,11 @@ public CompositionResult Compose() // Merge Source Schemas var sourceSchemaMergerOptions = _schemaComposerOptions.Merger; var (_, isMergeFailure, mergedSchema, mergeErrors) = - new SourceSchemaMerger(schemas, sourceSchemaMergerOptions).Merge(); + new SourceSchemaMerger( + schemas, + sourceSchemaMergerOptions, + _schemaComposerOptions.ApolloFederationCompatibility + .ShareableFieldRuntimeTypeRouting).Merge(); if (isMergeFailure) { @@ -139,7 +192,10 @@ public CompositionResult Compose() new SatisfiabilityValidator( mergedSchema, _log, - _schemaComposerOptions.Satisfiability).Validate(); + _schemaComposerOptions.Satisfiability, + _schemaComposerOptions.Merger.NodeResolution, + _schemaComposerOptions.ApolloFederationCompatibility, + apolloFederationSchemaNames).Validate(); if (satisfiabilityResult.IsFailure) { @@ -149,6 +205,30 @@ public CompositionResult Compose() return mergedSchema; } + private CompositionError InvalidNodeResolution(string message) + { + _log.Write( + LogEntryBuilder.New() + .SetMessage(message) + .SetCode(LogEntryCodes.InvalidNodeResolution) + .SetSeverity(LogSeverity.Error) + .Build()); + + return new CompositionError(message); + } + + private CompositionError InvalidShareableFieldRuntimeTypeRouting(string message) + { + _log.Write( + LogEntryBuilder.New() + .SetMessage(message) + .SetCode(LogEntryCodes.InvalidShareableFieldRuntimeTypeRouting) + .SetSeverity(LogSeverity.Error) + .Build()); + + return new CompositionError(message); + } + private static readonly ImmutableArray s_sourceSchemaRules = [ new DisallowedInaccessibleElementsRule(), @@ -158,6 +238,7 @@ public CompositionResult Compose() new ExternalRequireCollisionRule(), new ExternalUnusedRule(), new EventCursorMarkerRule(), + new InterfaceObjectKeyMissingRule(), new InvalidShareableUsageRule(), new IsInvalidFieldTypeRule(), new IsInvalidSyntaxRule(), @@ -203,6 +284,8 @@ public CompositionResult Compose() new InputFieldTypesMergeableRule(), new InputWithMissingRequiredFieldsRule(), new InputWithMissingOneOfRule(), + new InterfaceObjectKeyMismatchRule(), + new InterfaceObjectNoInterfaceRule(), new InvalidFieldSharingRule(), new MultipleEventStreamSourcesRule(), new OptInFeatureStabilityMismatchRule(), @@ -221,7 +304,10 @@ public CompositionResult Compose() new EnumTypeDefaultValueInaccessibleRule(), new EventStreamMessageAbstractTypeRequiresTypeNameRule(), new ImplementedByInaccessibleRule(), + new ImplementWithoutDefaultRule(), new InterfaceFieldNoImplementationRule(), + new InterfaceObjectFieldRequiresImplementRule(), + new InvalidProjectedFieldSharingRule(), new IsInvalidFieldsRule(), new KeyInvalidFieldsRule(), new NonNullInputFieldIsInaccessibleRule(), diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs index 1a14748aa7a..105753130fc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs @@ -12,11 +12,13 @@ internal sealed record CompositionSettings public CompositionSettings( PreprocessorSettings? preprocessor = null, MergerSettings? merger = null, - SatisfiabilitySettings? satisfiability = null) + SatisfiabilitySettings? satisfiability = null, + ApolloFederationCompatibilitySettings? apolloFederationCompatibility = null) { Preprocessor = preprocessor ?? new(); Merger = merger ?? new(); Satisfiability = satisfiability ?? new(); + ApolloFederationCompatibility = apolloFederationCompatibility ?? new(); } public PreprocessorSettings Preprocessor { get; init; } @@ -25,6 +27,8 @@ public CompositionSettings( public SatisfiabilitySettings Satisfiability { get; init; } + public ApolloFederationCompatibilitySettings ApolloFederationCompatibility { get; init; } + internal sealed record PreprocessorSettings { public HashSet? ExcludeByTag { get; set; } @@ -38,6 +42,8 @@ internal sealed record MergerSettings public bool? EnableGlobalObjectIdentification { get; set; } + public NodeResolution? NodeResolution { get; set; } + public bool? RemoveUnreferencedDefinitions { get; init; } public DirectiveMergeBehavior? TagMergeBehavior { get; set; } @@ -47,4 +53,11 @@ internal sealed record SatisfiabilitySettings { public bool? IncludeSatisfiabilityPaths { get; set; } } + + internal sealed record ApolloFederationCompatibilitySettings + { + public bool? AllowNonResolvableInterfaceObjects { get; set; } + + public ShareableFieldRuntimeTypeRouting? ShareableFieldRuntimeTypeRouting { get; set; } + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs index 267bda917c1..fcc54ff73de 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs @@ -21,6 +21,9 @@ public CompositionSettings MergeInto(CompositionSettings settings) EnableGlobalObjectIdentification = compositionSettings.Merger.EnableGlobalObjectIdentification ?? settings.Merger.EnableGlobalObjectIdentification, + NodeResolution = + compositionSettings.Merger.NodeResolution + ?? settings.Merger.NodeResolution, RemoveUnreferencedDefinitions = compositionSettings.Merger.RemoveUnreferencedDefinitions ?? settings.Merger.RemoveUnreferencedDefinitions, @@ -39,11 +42,45 @@ public CompositionSettings MergeInto(CompositionSettings settings) IncludeSatisfiabilityPaths = compositionSettings.Satisfiability.IncludeSatisfiabilityPaths ?? settings.Satisfiability.IncludeSatisfiabilityPaths - } + }, + ApolloFederationCompatibility = + new CompositionSettings.ApolloFederationCompatibilitySettings + { + AllowNonResolvableInterfaceObjects = + compositionSettings.ApolloFederationCompatibility + .AllowNonResolvableInterfaceObjects + ?? settings.ApolloFederationCompatibility + .AllowNonResolvableInterfaceObjects, + ShareableFieldRuntimeTypeRouting = + compositionSettings.ApolloFederationCompatibility + .ShareableFieldRuntimeTypeRouting + ?? settings.ApolloFederationCompatibility + .ShareableFieldRuntimeTypeRouting + } }; } } + extension(CompositionSettings.ApolloFederationCompatibilitySettings compatibilitySettings) + { + public ApolloFederationCompatibilityOptions ToOptions() + { + var options = new ApolloFederationCompatibilityOptions(); + + if (compatibilitySettings.AllowNonResolvableInterfaceObjects is { } allow) + { + options.AllowNonResolvableInterfaceObjects = allow; + } + + if (compatibilitySettings.ShareableFieldRuntimeTypeRouting is { } routing) + { + options.ShareableFieldRuntimeTypeRouting = routing; + } + + return options; + } + } + extension(CompositionSettings.PreprocessorSettings preprocessorSettings) { public void MergeInto(SourceSchemaPreprocessorOptions preprocessorOptions) @@ -76,6 +113,11 @@ public SourceSchemaMergerOptions ToOptions() mergerOptions.EnableGlobalObjectIdentification = enableGlobalObjectIdentification; } + if (mergerSettings.NodeResolution is { } nodeResolution) + { + mergerOptions.NodeResolution = nodeResolution; + } + if (mergerSettings.RemoveUnreferencedDefinitions is { } removeUnreferencedDefinitions) { mergerOptions.RemoveUnreferencedDefinitions = removeUnreferencedDefinitions; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs index 7041c1fd068..6a440f09501 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs @@ -6,10 +6,18 @@ namespace HotChocolate.Fusion; [JsonSerializable(typeof(CompositionSettings))] [JsonSerializable(typeof(CompositionSettings.PreprocessorSettings), TypeInfoPropertyName = "CompositionPreprocessorSettings")] [JsonSerializable(typeof(CompositionSettings.SatisfiabilitySettings), TypeInfoPropertyName = "CompositionSatisfiabilitySettings")] +[JsonSerializable( + typeof(CompositionSettings.ApolloFederationCompatibilitySettings), + TypeInfoPropertyName = "CompositionApolloFederationCompatibilitySettings")] [JsonSerializable(typeof(SourceSchemaSettings))] [JsonSerializable(typeof(SourceSchemaSettings.PreprocessorSettings), TypeInfoPropertyName = "SourceSchemaPreprocessorSettings")] [JsonSerializable(typeof(SourceSchemaSettings.SatisfiabilitySettings), TypeInfoPropertyName = "SourceSchemaSatisfiabilitySettings")] [JsonSourceGenerationOptions( - Converters = [typeof(JsonStringEnumConverter)], + Converters = + [ + typeof(JsonStringEnumConverter), + typeof(JsonStringEnumConverter), + typeof(JsonStringEnumConverter) + ], PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal partial class SettingsJsonSerializerContext : JsonSerializerContext; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs index 52883a5eb74..ec1b7bbb06d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs @@ -57,6 +57,8 @@ public CompositionResult Enrich() private static void EnrichObjectType(MutableObjectTypeDefinition objectType) { var sourceMetadata = objectType.Features.GetOrSet(); + sourceMetadata.HasInterfaceObjectDirective = + objectType.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject); sourceMetadata.HasShareableDirective = objectType.Directives.ContainsName(Shareable); sourceMetadata.IsInternal = objectType.Directives.ContainsName(WellKnownDirectiveNames.Internal); } @@ -86,6 +88,7 @@ private void EnrichOutputField(MutableOutputFieldDefinition outputField) sourceMetadata.IsLookup = outputField.Directives.ContainsName(Lookup); sourceMetadata.HasExternalDirective = outputField.Directives.ContainsName(External); sourceMetadata.HasEventCursorDirective = outputField.Directives.ContainsName(EventCursor); + sourceMetadata.HasImplementDirective = outputField.Directives.ContainsName(WellKnownDirectiveNames.Implement); sourceMetadata.HasInternalDirective = outputField.Directives.ContainsName(WellKnownDirectiveNames.Internal); sourceMetadata.HasOverrideDirective = outputField.Directives.ContainsName(Override); sourceMetadata.HasProvidesDirective = outputField.Directives.ContainsName(Provides); diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.InterfaceObject.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.InterfaceObject.cs new file mode 100644 index 00000000000..82dc4990985 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.InterfaceObject.cs @@ -0,0 +1,421 @@ +using HotChocolate.Features; +using HotChocolate.Fusion.Extensions; +using HotChocolate.Fusion.Features; +using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames; +using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames; + +namespace HotChocolate.Fusion; + +internal sealed partial class SourceSchemaMerger +{ + /// + /// Projects the fields contributed by @interfaceObject stand-ins onto the interfaces they + /// stand in for and onto every implementing object type. The interface contract fields are + /// already merged in by ; this step completes the implements + /// relation with derived edges and adds each non-key contributed field as a default + /// implementation on the implementing object types, routed to the source schema that owns it. + /// + private void ProjectInterfaceObjectFields(MutableSchemaDefinition mergedSchema) + { + var standInsByName = CollectStandIns(); + + if (standInsByName.Count == 0) + { + return; + } + + // The implements relation was completed by ApplyImplementsClosure before this step, so a + // type reaches every interface it implements even through edges no single source schema + // declared. Projection reads the completed relation. + + // The non-key, non-internal field names each interface's stand-ins contribute as defaults. + var contributedByInterface = new Dictionary>(StringComparer.Ordinal); + + foreach (var (interfaceName, standIns) in standInsByName) + { + var contributed = new HashSet(StringComparer.Ordinal); + + foreach (var standIn in standIns) + { + foreach (var field in standIn.Type.Fields) + { + if (field.IsInternal || standIn.KeyFieldNames.Contains(field.Name)) + { + continue; + } + + contributed.Add(field.Name); + } + } + + contributedByInterface[interfaceName] = contributed; + } + + foreach (var objectType in mergedSchema.Types.OfType()) + { + // The contributed field names reachable through every interface this object implements. + var candidateFieldNames = new HashSet(StringComparer.Ordinal); + + foreach (var interfaceType in objectType.Implements) + { + if (contributedByInterface.TryGetValue(interfaceType.Name, out var contributed)) + { + candidateFieldNames.UnionWith(contributed); + } + } + + foreach (var fieldName in candidateFieldNames) + { + // A direct declaration on the object type wins; a field already projected from a + // more specific interface is likewise kept. + if (objectType.Fields.ContainsName(fieldName)) + { + continue; + } + + var owner = ResolveEffectiveOwner(objectType, fieldName, standInsByName); + + if (owner is null + || !owner.Value.ContributingInterface.Fields.TryGetField(fieldName, out var contractField)) + { + continue; + } + + var projected = new MutableOutputFieldDefinition(fieldName) + { + Description = contractField.Description, + Type = contractField.Type + }; + + foreach (var ownerSchema in owner.Value.Schemas) + { + projected.Directives.Add( + new Directive( + _fusionDirectiveDefinitions[DirectiveNames.FusionField], + new ArgumentAssignment( + ArgumentNames.Schema, + new EnumValueNode(_schemaConstantNames[ownerSchema.Name])))); + } + + projected.DeclaringMember = objectType; + objectType.Fields.Add(projected); + } + } + } + + /// + /// Resolves the source schema(s) that own the default implementation of + /// on . The owner is the unique most specific stand-in that + /// contributes the field; several incomparable minimal contributors are allowed only when every + /// declaration is shareable (validated post-merge by INVALID_PROJECTED_FIELD_SHARING). + /// + private EffectiveOwner? ResolveEffectiveOwner( + MutableObjectTypeDefinition objectType, + string fieldName, + Dictionary> standInsByName) + { + var candidates = new List<(StandInInfo StandIn, MutableInterfaceTypeDefinition Interface)>(); + + foreach (var interfaceType in objectType.Implements) + { + if (!standInsByName.TryGetValue(interfaceType.Name, out var standIns)) + { + continue; + } + + foreach (var standIn in standIns) + { + if (standIn.KeyFieldNames.Contains(fieldName)) + { + continue; + } + + if (standIn.Type.Fields.TryGetField(fieldName, out var field) && !field.IsInternal) + { + candidates.Add((standIn, interfaceType)); + } + } + } + + if (candidates.Count == 0) + { + return null; + } + + // Keep only the most specific contributors: a candidate whose interface is implemented by + // another candidate's (different) interface is less specific and is dropped. + var minimal = candidates + .Where(c => !candidates.Any( + other => !ReferenceEquals(other.Interface, c.Interface) + && other.Interface.Name != c.Interface.Name + && other.Interface.Implements.ContainsName(c.Interface.Name))) + .ToList(); + + var schemas = minimal + .Select(c => c.StandIn.Schema) + .Distinct() + .ToArray(); + + return new EffectiveOwner(minimal[0].Interface, schemas); + } + + /// + /// Completes the implements relation of so that implementation is + /// transitive: whenever a type implements an interface, and that interface implements a parent + /// interface, the type implements the parent as well. Source schemas hold only partial views of + /// the hierarchy; this reconciles them. + /// + private static void ApplyImplementsClosure(MutableSchemaDefinition mergedSchema) + { + var edges = new Dictionary>(StringComparer.Ordinal); + + foreach (var complexType in mergedSchema.Types.OfType()) + { + foreach (var interfaceType in complexType.Implements) + { + AddEdge(edges, complexType.Name, interfaceType.Name); + } + } + + // Transitively close the relation. + var worklist = new Queue<(string Type, string Interface)>(); + + foreach (var (typeName, interfaceNames) in edges) + { + foreach (var interfaceName in interfaceNames) + { + worklist.Enqueue((typeName, interfaceName)); + } + } + + while (worklist.Count > 0) + { + var (typeName, interfaceName) = worklist.Dequeue(); + + if (!edges.TryGetValue(interfaceName, out var parents)) + { + continue; + } + + foreach (var parent in parents) + { + if (AddEdge(edges, typeName, parent)) + { + worklist.Enqueue((typeName, parent)); + } + } + } + + // Apply any derived edges to the merged types. + foreach (var complexType in mergedSchema.Types.OfType()) + { + if (!edges.TryGetValue(complexType.Name, out var interfaceNames)) + { + continue; + } + + foreach (var interfaceName in interfaceNames) + { + if (interfaceName == complexType.Name + || complexType.Implements.ContainsName(interfaceName)) + { + continue; + } + + if (mergedSchema.Types.TryGetType(interfaceName, out MutableInterfaceTypeDefinition? interfaceType)) + { + complexType.Implements.Add(interfaceType); + } + } + } + } + + /// + /// Applies @override declared on @interfaceObject stand-in fields. Before default + /// implementations are resolved, every declaration of the overridden field in the named source + /// schema is dropped across the target interface's implementation closure: on every implementing + /// object type, on every more specific interface's stand-in, and on the named schema's own + /// stand-in. Dropping the named schema's own stand-in declaration lets the default contributor + /// role migrate from one schema to another. + /// + private void ApplyStandInOverrides() + { + var closure = ComputeSourceImplementsClosure(); + + foreach (var schema in _schemas) + { + foreach (var type in schema.Types) + { + if (!IsInterfaceObjectStandIn(type)) + { + continue; + } + + var standIn = (MutableObjectTypeDefinition)type; + + foreach (var field in standIn.Fields) + { + if (GetOverrideFrom(field) is not { } fromName) + { + continue; + } + + var fromSchema = _schemas.FirstOrDefault(s => s.Name == fromName); + + // A dead override or an override naming the declaring schema itself drops + // nothing here; the existing @override rules report the invalid cases. + if (fromSchema is null || ReferenceEquals(fromSchema, schema)) + { + continue; + } + + foreach (var closureTypeName in GetOverrideClosureTypeNames(closure, standIn.Name)) + { + if (fromSchema.Types.TryGetType( + closureTypeName, + out MutableObjectTypeDefinition? localType) + && localType.Fields.TryGetField(field.Name, out var localField)) + { + localField.Features.GetOrSet().IsOverridden = true; + } + } + } + } + } + } + + private Dictionary> ComputeSourceImplementsClosure() + { + var edges = new Dictionary>(StringComparer.Ordinal); + + foreach (var schema in _schemas) + { + foreach (var type in schema.Types) + { + if (type is MutableComplexTypeDefinition complexType) + { + foreach (var interfaceType in complexType.Implements) + { + AddEdge(edges, complexType.Name, interfaceType.Name); + } + } + } + } + + var worklist = new Queue<(string Type, string Interface)>(); + + foreach (var (typeName, interfaceNames) in edges) + { + foreach (var interfaceName in interfaceNames) + { + worklist.Enqueue((typeName, interfaceName)); + } + } + + while (worklist.Count > 0) + { + var (typeName, interfaceName) = worklist.Dequeue(); + + if (!edges.TryGetValue(interfaceName, out var parents)) + { + continue; + } + + foreach (var parent in parents) + { + if (AddEdge(edges, typeName, parent)) + { + worklist.Enqueue((typeName, parent)); + } + } + } + + return edges; + } + + private static IEnumerable GetOverrideClosureTypeNames( + Dictionary> closure, + string interfaceName) + { + // The interface name itself covers the named schema's own stand-in for the interface. + yield return interfaceName; + + foreach (var (typeName, interfaceNames) in closure) + { + if (interfaceNames.Contains(interfaceName)) + { + yield return typeName; + } + } + } + + private static string? GetOverrideFrom(MutableOutputFieldDefinition field) + { + foreach (var directive in field.Directives.AsEnumerable()) + { + if (directive.Name == DirectiveNames.Override + && directive.Arguments.TryGetValue(ArgumentNames.From, out var value) + && value is StringValueNode from) + { + return from.Value; + } + } + + return null; + } + + private Dictionary> CollectStandIns() + { + var standInsByName = new Dictionary>(StringComparer.Ordinal); + + foreach (var schema in _schemas) + { + foreach (var type in schema.Types) + { + if (!IsInterfaceObjectStandIn(type)) + { + continue; + } + + var standIn = (MutableObjectTypeDefinition)type; + + if (!standInsByName.TryGetValue(standIn.Name, out var list)) + { + list = []; + standInsByName[standIn.Name] = list; + } + + list.Add( + new StandInInfo( + standIn, + schema, + InterfaceObjectMetadata.GetKeyFieldNames(standIn))); + } + } + + return standInsByName; + } + + private static bool AddEdge(Dictionary> edges, string type, string @interface) + { + if (!edges.TryGetValue(type, out var interfaces)) + { + interfaces = new HashSet(StringComparer.Ordinal); + edges[type] = interfaces; + } + + return interfaces.Add(@interface); + } + + private readonly record struct StandInInfo( + MutableObjectTypeDefinition Type, + MutableSchemaDefinition Schema, + HashSet KeyFieldNames); + + private readonly record struct EffectiveOwner( + MutableInterfaceTypeDefinition ContributingInterface, + MutableSchemaDefinition[] Schemas); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs index d2953dddb92..b62781a0de1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs @@ -30,12 +30,13 @@ namespace HotChocolate.Fusion; -internal sealed class SourceSchemaMerger +internal sealed partial class SourceSchemaMerger { private static readonly FusionFieldDefinitionSyntaxRewriter s_fieldDefinitionRewriter = new(); private readonly ImmutableSortedSet _schemas; private readonly FrozenDictionary _schemaConstantNames; private readonly SourceSchemaMergerOptions _options; + private readonly ShareableFieldRuntimeTypeRouting _shareableFieldRuntimeTypeRouting; private readonly FrozenDictionary _fusionTypeDefinitions; private readonly FrozenDictionary _fusionDirectiveDefinitions; @@ -48,10 +49,19 @@ private readonly Dictionary public SourceSchemaMerger( ImmutableSortedSet schemas, SourceSchemaMergerOptions? options = null) + : this(schemas, options, ShareableFieldRuntimeTypeRouting.SourceLocal) + { + } + + public SourceSchemaMerger( + ImmutableSortedSet schemas, + SourceSchemaMergerOptions? options, + ShareableFieldRuntimeTypeRouting shareableFieldRuntimeTypeRouting) { _schemas = schemas; _schemaConstantNames = schemas.ToFrozenDictionary(s => s.Name, s => ToConstantCase(s.Name)); _options = options ?? new SourceSchemaMergerOptions(); + _shareableFieldRuntimeTypeRouting = shareableFieldRuntimeTypeRouting; _fusionTypeDefinitions = CreateFusionTypeDefinitions(); _fusionDirectiveDefinitions = CreateFusionDirectiveDefinitions(); _directiveMergers = @@ -108,9 +118,12 @@ public CompositionResult Merge() { var mergedSchema = new MutableSchemaDefinition(); + ApplyStandInOverrides(); MergeTypes(mergedSchema); MergeDirectiveDefinitions(mergedSchema); ApplyDirectives(); + ApplyImplementsClosure(mergedSchema); + ProjectInterfaceObjectFields(mergedSchema); SetOperationTypes(mergedSchema); AddFusionLookupDirectives(mergedSchema); AddNodeField(mergedSchema); @@ -149,19 +162,37 @@ private void MergeTypes(MutableSchemaDefinition mergedSchema) foreach (var (_, typeGroup) in typeGroupByName) { var typeGroupArr = typeGroup.ToImmutableArray(); - var kind = typeGroupArr[0].Type.Kind; - Assert(typeGroupArr.All(i => i.Type.Kind == kind)); + // An @interfaceObject stand-in is merged into the interface of the same name and never + // appears in the composed schema, so it is excluded from the group before the kind is + // determined. Its contributed fields are projected onto the interface and its + // implementing types by ProjectInterfaceObjectFields. When every declaration of the name + // is a stand-in, INTERFACE_OBJECT_NO_INTERFACE has already failed pre-merge. + var consideredTypes = typeGroupArr + .Where(i => !IsInterfaceObjectStandIn(i.Type)) + .ToImmutableArray(); + + if (consideredTypes.Length == 0) + { + continue; + } + + var kind = consideredTypes[0].Type.Kind; + + Assert(consideredTypes.All(i => i.Type.Kind == kind)); // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault ITypeDefinition? mergedType = kind switch { - TypeKind.Enum => MergeEnumTypes(typeGroupArr, mergedSchema), - TypeKind.InputObject => MergeInputTypes(typeGroupArr, mergedSchema), + TypeKind.Enum => MergeEnumTypes(consideredTypes, mergedSchema), + TypeKind.InputObject => MergeInputTypes(consideredTypes, mergedSchema), + // The full group (including @interfaceObject stand-ins) is merged into the + // interface, so each stand-in's contract fields flow through the ordinary output + // field merge and are attributed to their source schema. TypeKind.Interface => MergeInterfaceTypes(typeGroupArr, mergedSchema), - TypeKind.Object => MergeObjectTypes(typeGroupArr, mergedSchema), - TypeKind.Scalar => MergeScalarTypes(typeGroupArr, mergedSchema), - TypeKind.Union => MergeUnionTypes(typeGroupArr, mergedSchema), + TypeKind.Object => MergeObjectTypes(consideredTypes, mergedSchema), + TypeKind.Scalar => MergeScalarTypes(consideredTypes, mergedSchema), + TypeKind.Union => MergeUnionTypes(consideredTypes, mergedSchema), _ => throw new InvalidOperationException() }; @@ -709,10 +740,12 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( interfaceType.Description = description; - // [InterfaceName: [{InterfaceType, Schema}, ...], ...]. + // [InterfaceName: [{InterfaceType, Schema}, ...], ...]. The group may include + // @interfaceObject stand-ins, which are object types, so implements edges are read from the + // common complex-type base rather than the interface type. var interfaceGroupByName = typeGroup .SelectMany( - i => ((MutableInterfaceTypeDefinition)i.Type).Implements.AsEnumerable(), + i => ((MutableComplexTypeDefinition)i.Type).Implements.AsEnumerable(), (i, it) => new InterfaceInfo(it, i.Schema)) .GroupBy(i => i.InterfaceType.Name) .Where(g => !g.Any(i => i.InterfaceType.HasInaccessibleDirective())) @@ -734,6 +767,7 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( _directiveMergers[DirectiveNames.Tag].MergeDirectives(interfaceType, memberDefinitions, mergedSchema); AddFusionTypeDirectives(interfaceType, typeGroup); + AddFusionInterfaceObjectDirectives(interfaceType, typeGroup); AddFusionImplementsDirectives(interfaceType, [.. interfaceGroupByName.SelectMany(g => g)]); if (typeGroup.Any(i => i.Type.HasInaccessibleDirective())) @@ -743,10 +777,11 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( } }); - // [FieldName: [{Field, Type, Schema}, ...], ...]. + // [FieldName: [{Field, Type, Schema}, ...], ...]. Stand-in contract fields are merged in + // alongside the interface-declared fields via the ordinary output field merge. var fieldGroupByName = typeGroup .SelectMany( - i => ((MutableInterfaceTypeDefinition)i.Type).Fields.AsEnumerable(), + i => ((MutableComplexTypeDefinition)i.Type).Fields.AsEnumerable(), (i, f) => new OutputFieldInfo(f, (MutableComplexTypeDefinition)i.Type, i.Schema)) .GroupBy(i => i.Field.Name) .ToImmutableArray(); @@ -1236,8 +1271,12 @@ MissingType m => new MissingType(m.Name), NonNullType n => GetOrCreateType(mergedSchema, n.NullableType), + // A reference to an @interfaceObject stand-in is a reference to the interface it stands + // in for, which is what the merged schema exposes. MutableObjectTypeDefinition o - => GetOrCreateType(mergedSchema, o.Name), + => IsInterfaceObjectStandIn(o) + ? GetOrCreateType(mergedSchema, o.Name) + : GetOrCreateType(mergedSchema, o.Name), MutableScalarTypeDefinition s => GetOrCreateType(mergedSchema, s.Name), MutableUnionTypeDefinition u @@ -1322,6 +1361,14 @@ private void AddFusionFieldDirectives( arguments.Add(new ArgumentAssignment(ArgumentNames.Partial, true)); } + if (SourceExternalFieldMetadata.Contains( + sourceSchema, + sourceField.DeclaringMember!.Name, + sourceField.Name)) + { + arguments.Add(new ArgumentAssignment(ArgumentNames.SourceExternal, true)); + } + field.Directives.Add( new Directive(_fusionDirectiveDefinitions[DirectiveNames.FusionField], arguments)); } @@ -1794,6 +1841,32 @@ private void AddFusionTypeDirectives( } } + /// + /// Emits a @fusion__interfaceObject(schema:) directive for every source schema that + /// exposes as an @interfaceObject stand-in. Values of + /// the interface produced by such a schema are opaque, so the executor recovers their concrete + /// type through a covering interface lookup. + /// + private void AddFusionInterfaceObjectDirectives( + MutableInterfaceTypeDefinition interfaceType, + ImmutableArray typeGroup) + { + foreach (var (sourceType, sourceSchema) in typeGroup) + { + if (!IsInterfaceObjectStandIn(sourceType)) + { + continue; + } + + interfaceType.Directives.Add( + new Directive( + _fusionDirectiveDefinitions[DirectiveNames.FusionInterfaceObject], + new ArgumentAssignment( + ArgumentNames.Schema, + new EnumValueNode(_schemaConstantNames[sourceSchema.Name])))); + } + } + private void AddFusionUnionMemberDirectives( MutableUnionTypeDefinition unionType, ImmutableArray unionMemberGroup) @@ -1832,9 +1905,17 @@ private FrozenDictionary CreateFusionTypeDefinitions() new FusionFieldSelectionSetMutableScalarTypeDefinition() }, // Enum type definitions. + { + TypeNames.FusionNodeResolution, + new FusionNodeResolutionMutableEnumTypeDefinition() + }, { TypeNames.FusionSchema, new FusionSchemaMutableEnumTypeDefinition(_schemaConstantNames) + }, + { + TypeNames.FusionShareableFieldRuntimeTypeRouting, + new FusionShareableFieldRuntimeTypeRoutingMutableEnumTypeDefinition() } }.ToFrozenDictionary(); } @@ -1843,6 +1924,11 @@ private FrozenDictionary CreateFusionDirecti { var schemaEnumType = (MutableEnumTypeDefinition)_fusionTypeDefinitions[TypeNames.FusionSchema]; + var nodeResolutionType = + (MutableEnumTypeDefinition)_fusionTypeDefinitions[TypeNames.FusionNodeResolution]; + var shareableFieldRuntimeTypeRoutingType = + (MutableEnumTypeDefinition)_fusionTypeDefinitions[ + TypeNames.FusionShareableFieldRuntimeTypeRouting]; var fieldDefinitionType = (MutableScalarTypeDefinition)_fusionTypeDefinitions[TypeNames.FusionFieldDefinition]; var fieldSelectionMapType = @@ -1865,6 +1951,12 @@ private FrozenDictionary CreateFusionDirecti DirectiveNames.FusionEnumValue, new FusionEnumValueMutableDirectiveDefinition(schemaEnumType) }, + { + DirectiveNames.FusionExecution, + new FusionExecutionMutableDirectiveDefinition( + nodeResolutionType, + shareableFieldRuntimeTypeRoutingType) + }, { DirectiveNames.FusionField, new FusionFieldMutableDirectiveDefinition( @@ -1889,6 +1981,10 @@ private FrozenDictionary CreateFusionDirecti DirectiveNames.FusionInputField, new FusionInputFieldMutableDirectiveDefinition(schemaEnumType, stringType) }, + { + DirectiveNames.FusionInterfaceObject, + new FusionInterfaceObjectMutableDirectiveDefinition(schemaEnumType) + }, { DirectiveNames.FusionListSize, new FusionListSizeMutableDirectiveDefinition( @@ -1917,7 +2013,7 @@ private FrozenDictionary CreateFusionDirecti }, { DirectiveNames.FusionSchemaMetadata, - new FusionSchemaMetadataMutableDirectiveDefinition(stringType) + new FusionSchemaMetadataMutableDirectiveDefinition(stringType, booleanType) }, { DirectiveNames.FusionEventStream, @@ -1948,6 +2044,32 @@ private void AddFusionDefinitions(MutableSchemaDefinition mergedSchema) { mergedSchema.DirectiveDefinitions.Add(definition); } + + mergedSchema.Directives.Add( + new Directive( + _fusionDirectiveDefinitions[DirectiveNames.FusionExecution], + new ArgumentAssignment( + ArgumentNames.NodeResolution, + new EnumValueNode( + _options.NodeResolution switch + { + NodeResolution.Gateway => "GATEWAY", + NodeResolution.SourceSchema => "SOURCE_SCHEMA", + _ => throw new InvalidOperationException( + $"The node resolution mode '{_options.NodeResolution}' is invalid.") + })), + new ArgumentAssignment( + ArgumentNames.ShareableFieldRuntimeTypeRouting, + new EnumValueNode( + _shareableFieldRuntimeTypeRouting switch + { + ShareableFieldRuntimeTypeRouting.SourceLocal => "SOURCE_LOCAL", + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes => + "COMMON_RUNTIME_TYPES", + _ => throw new InvalidOperationException( + "The shareable field runtime type routing mode " + + $"'{_shareableFieldRuntimeTypeRouting}' is invalid.") + })))); } private void LiftConnectorKindOntoSchemaMetadata(MutableSchemaDefinition mergedSchema) @@ -1983,6 +2105,17 @@ private void LiftConnectorKindOntoSchemaMetadata(MutableSchemaDefinition mergedS arguments.Add(new ArgumentAssignment(ArgumentNames.Kind, connectorKind.Kind)); } + if (schema.Features.Get() + is { AllowNonResolvableInterfaceObjects: true } + && arguments.All( + static t => t.Name != ArgumentNames.AllowNonResolvableInterfaceObjects)) + { + arguments.Add( + new ArgumentAssignment( + ArgumentNames.AllowNonResolvableInterfaceObjects, + true)); + } + var newMetadata = new Directive(metadataDirective, arguments); if (currentMetadata is null) @@ -2024,6 +2157,10 @@ private MergeSelectionSetRewriter GetMergeSelectionSetRewriter(MutableSchemaDefi return rewriter; } + private static bool IsInterfaceObjectStandIn(ITypeDefinition type) + => type is MutableObjectTypeDefinition objectType + && objectType.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject); + private static void Assert(bool condition) { if (!condition) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs index 5369cd9c6e4..92412554275 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs @@ -35,15 +35,19 @@ public CompositionResult Preprocess() { var fusionV1CompatibilityMode = sourceSchemaVersion?.Major == 1; + SourceExternalFieldMetadata.CaptureMarker(schema); + var isFederationSchema = FederationSchemaTransformer.IsFederationSchema(schema) && FederationSchemaAnalyzer.Validate(schema, log); if (isFederationSchema) { + SourceExternalFieldMetadata.Capture(schema); RemoveFederationInfrastructure.Apply(schema); GenerateLookupFields.Apply(schema); RewriteKeyDirectives.Apply(schema); MarkKeyFieldsShareable.Apply(schema, schemas); + SynthesizeImplementDirectives.Apply(schema, schemas); TransformRequiresToRequire.Apply(schema); GenerateNodeLookup.Apply(schema); StampConnectorKind.Apply(schema); diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/InterfaceObjectKeyMissingRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/InterfaceObjectKeyMissingRule.cs new file mode 100644 index 00000000000..5304b684280 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/InterfaceObjectKeyMissingRule.cs @@ -0,0 +1,35 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +/// +/// An object type annotated with @interfaceObject stands in for an interface defined in one +/// or more other source schemas. The composite schema resolves this stand-in as an independently +/// queryable entity, so the type must declare at least one @key, exactly as any other entity +/// type would. A stand-in with no key cannot be targeted by the distributed executor and cannot +/// contribute fields to the interface it stands in for. +/// +/// +/// Specification +/// +internal sealed class InterfaceObjectKeyMissingRule : IEventHandler +{ + public void Handle(ComplexTypeEvent @event, CompositionContext context) + { + var (complexType, schema) = @event; + + if (complexType is not MutableObjectTypeDefinition standIn + || !standIn.Directives.ContainsName(WellKnownDirectiveNames.InterfaceObject)) + { + return; + } + + if (!standIn.Directives.ContainsName(WellKnownDirectiveNames.Key)) + { + context.Log.Write(InterfaceObjectKeyMissing(standIn, schema)); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs index ebdb8788230..2d8f54a3c61 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs @@ -3,6 +3,7 @@ namespace HotChocolate.Fusion; internal static class WellKnownArgumentNames { public const string Apply = "apply"; + public const string AllowNonResolvableInterfaceObjects = "allowNonResolvableInterfaceObjects"; public const string AssumedSize = "assumedSize"; public const string Broker = "broker"; public const string CursorArgument = "cursorArgument"; @@ -24,6 +25,7 @@ internal static class WellKnownArgumentNames public const string Member = "member"; public const string Message = "message"; public const string Name = "name"; + public const string NodeResolution = "nodeResolution"; public const string OpenWorldHint = "openWorldHint"; public const string Partial = "partial"; public const string Path = "path"; @@ -34,11 +36,13 @@ internal static class WellKnownArgumentNames public const string RequireOneSlicingArgument = "requireOneSlicingArgument"; public const string Roles = "roles"; public const string Schema = "schema"; + public const string ShareableFieldRuntimeTypeRouting = "shareableFieldRuntimeTypeRouting"; public const string Scope = "scope"; public const string SharedMaxAge = "sharedMaxAge"; public const string SizedFields = "sizedFields"; public const string SlicingArgumentDefaultValue = "slicingArgumentDefaultValue"; public const string SlicingArguments = "slicingArguments"; + public const string SourceExternal = "sourceExternal"; public const string SourceType = "sourceType"; public const string Topics = "topics"; public const string Type = "type"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs index eca50334e02..6a8fa347cdb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs @@ -11,11 +11,13 @@ internal static class WellKnownDirectiveNames public const string External = DirectiveNames.External.Name; public const string FusionCost = "fusion__cost"; public const string FusionEnumValue = "fusion__enumValue"; + public const string FusionExecution = "fusion__execution"; public const string FusionField = "fusion__field"; public const string FusionGatewayField = "fusion__gateway_field"; public const string FusionImplements = "fusion__implements"; public const string FusionInaccessible = "fusion__inaccessible"; public const string FusionInputField = "fusion__inputField"; + public const string FusionInterfaceObject = "fusion__interfaceObject"; public const string FusionListSize = "fusion__listSize"; public const string FusionLookup = "fusion__lookup"; public const string FusionRequires = "fusion__requires"; @@ -23,7 +25,9 @@ internal static class WellKnownDirectiveNames public const string FusionEventStream = "fusion__eventStream"; public const string FusionType = "fusion__type"; public const string FusionUnionMember = "fusion__unionMember"; + public const string Implement = DirectiveNames.Implement.Name; public const string Inaccessible = DirectiveNames.Inaccessible.Name; + public const string InterfaceObject = DirectiveNames.InterfaceObject.Name; public const string Internal = DirectiveNames.Internal.Name; public const string Is = DirectiveNames.Is.Name; public const string Key = DirectiveNames.Key.Name; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs index f736c66123b..b60b27f01ec 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs @@ -10,7 +10,10 @@ internal static class WellKnownTypeNames public const string FusionFieldSelectionMap = "fusion__FieldSelectionMap"; public const string FusionFieldSelectionPath = "fusion__FieldSelectionPath"; public const string FusionFieldSelectionSet = "fusion__FieldSelectionSet"; + public const string FusionNodeResolution = "fusion__NodeResolution"; public const string FusionSchema = "fusion__Schema"; + public const string FusionShareableFieldRuntimeTypeRouting = + "fusion__ShareableFieldRuntimeTypeRouting"; public const string ID = "ID"; public const string Mutation = "Mutation"; public const string Node = "Node"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/DependencyInjection/InMemoryFusionGatewayBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/DependencyInjection/InMemoryFusionGatewayBuilderExtensions.cs index 52db60d88cc..a62bd09f082 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/DependencyInjection/InMemoryFusionGatewayBuilderExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/DependencyInjection/InMemoryFusionGatewayBuilderExtensions.cs @@ -3,7 +3,9 @@ using HotChocolate.Fusion.Configuration; using HotChocolate.Fusion.Connectors.InMemory; using HotChocolate.Fusion.Execution.Clients; +using HotChocolate.Fusion.Options; using HotChocolate.Transport.Formatters; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection; @@ -12,6 +14,23 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class InMemoryFusionGatewayBuilderExtensions { + /// + /// Registers a callback to modify the options used to compose in-memory source schemas. + /// + /// The fusion gateway builder. + /// A delegate that modifies the schema composer options. + /// The fusion gateway builder for chaining. + public static IFusionGatewayBuilder ModifyInMemoryCompositionOptions( + this IFusionGatewayBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + builder.Services.Configure(configure); + return builder; + } + /// /// Adds an in-memory schema connector that executes operations directly in-process /// against the schema registered by the given . @@ -45,9 +64,11 @@ public static IFusionGatewayBuilder AddInMemorySchema( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(schemaName); + builder.Services.AddOptions(); + if (!builder.Services.Any(d => d.ServiceType == typeof(InMemorySourceSchemaClientFactory))) { - // Remove default HTTP client factory — in-memory mode doesn't need it. + // Remove default HTTP client factory; in-memory mode doesn't need it. for (var i = builder.Services.Count - 1; i >= 0; i--) { if (builder.Services[i].ServiceType == typeof(ISourceSchemaClientFactory)) @@ -77,7 +98,8 @@ public static IFusionGatewayBuilder AddInMemorySchema( return new InMemoryConfigurationProvider( names, sp.GetRequiredService(), - sp.GetRequiredService()); + sp.GetRequiredService(), + sp.GetRequiredService>().Value); }); FusionSetupUtilities.Configure( diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemoryConfigurationProvider.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemoryConfigurationProvider.cs index e286c72be13..431dc933002 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemoryConfigurationProvider.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.InMemory/InMemoryConfigurationProvider.cs @@ -25,6 +25,7 @@ public sealed class InMemoryConfigurationProvider : IFusionConfigurationProvider #endif private readonly string[] _schemaNames; private readonly RequestExecutorProxy[] _proxies; + private readonly SchemaComposerOptions _schemaComposerOptions; private readonly IDisposable? _eventSubscription; private readonly CancellationTokenSource _cts = new(); @@ -50,12 +51,30 @@ public InMemoryConfigurationProvider( string[] schemaNames, IRequestExecutorProvider executorProvider, IRequestExecutorEvents executorEvents) + : this(schemaNames, executorProvider, executorEvents, new SchemaComposerOptions()) + { + } + + /// + /// Initializes a new instance of . + /// + /// The names of the source schemas to compose. + /// The request executor provider. + /// The request executor events. + /// The options used to compose the source schemas. + public InMemoryConfigurationProvider( + string[] schemaNames, + IRequestExecutorProvider executorProvider, + IRequestExecutorEvents executorEvents, + SchemaComposerOptions schemaComposerOptions) { ArgumentNullException.ThrowIfNull(schemaNames); ArgumentNullException.ThrowIfNull(executorProvider); ArgumentNullException.ThrowIfNull(executorEvents); + ArgumentNullException.ThrowIfNull(schemaComposerOptions); _schemaNames = schemaNames; + _schemaComposerOptions = schemaComposerOptions; _proxies = new RequestExecutorProxy[schemaNames.Length]; for (var i = 0; i < schemaNames.Length; i++) @@ -141,7 +160,7 @@ private async Task ComposeLoopAsync(CancellationToken ct) var compositionLog = new CompositionLog(); var result = new SchemaComposer( sourceSchemas, - new SchemaComposerOptions(), + _schemaComposerOptions, compositionLog).Compose(); if (result.IsFailure) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs index 2f55b9b1d58..71e5de007f8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs @@ -133,6 +133,7 @@ public static SourceInterfaceTypeCollection CreateSourceInterfaceTypeCollection( var sourceInterfaceType = new SourceInterfaceType[types.Length]; var sourceImplements = ImplementsDirectiveParser.Parse( typeDef.Directives); + var interfaceObjectSchemaKeys = InterfaceObjectDirectiveParser.Parse(typeDef.Directives); for (var i = 0; i < types.Length; i++) { @@ -146,7 +147,8 @@ public static SourceInterfaceTypeCollection CreateSourceInterfaceTypeCollection( typeDef.Name.Value, schemaName, lookups, - implements ?? []); + implements ?? [], + interfaceObjectSchemaKeys.Contains(type.SchemaKey)); } return new SourceInterfaceTypeCollection(sourceInterfaceType); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs index 0437f026f72..150c96fd4ea 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -30,8 +30,13 @@ public static FusionSchemaDefinition Create( services ??= EmptyServiceProvider.Instance; var typeInterceptor = CreateTypeInterceptor(services); var options = FusionSchemaOptions.From(features?.Get()); + var executionSettings = ParseExecutionSettings(schemaDocument); var context = CreateTypes(name, schemaDocument, services, features, options, typeInterceptor); - return CompleteTypes(context, options); + return CompleteTypes( + context, + options, + executionSettings.NodeResolution, + executionSettings.ShareableFieldRuntimeTypeRouting); } private static CompositeSchemaBuilderContext CreateTypes( @@ -547,7 +552,9 @@ private static FusionEnumValueCollection CreateEnumValues( private static FusionSchemaDefinition CompleteTypes( CompositeSchemaBuilderContext context, - FusionSchemaOptions options) + FusionSchemaOptions options, + NodeResolution nodeResolution, + ShareableFieldRuntimeTypeRouting shareableFieldRuntimeTypeRouting) { foreach (var type in context.TypeDefinitions) { @@ -636,6 +643,8 @@ context.SubscriptionType is not null directives, new FusionTypeDefinitionCollection(AsArray(context.TypeDefinitions)!), new FusionDirectiveDefinitionCollection(AsArray(context.DirectiveDefinitions)!), + nodeResolution, + shareableFieldRuntimeTypeRouting, features, context.SourceSchemaLookup); @@ -647,6 +656,91 @@ context.SubscriptionType is not null return schema; } + private static ExecutionSettings ParseExecutionSettings(DocumentNode document) + { + var executionDirectives = document.Definitions + .SelectMany(static definition => definition switch + { + SchemaDefinitionNode schemaDefinition => schemaDefinition.Directives, + SchemaExtensionNode schemaExtension => schemaExtension.Directives, + _ => [] + }) + .Where(static directive => directive.Name.Value.Equals( + FusionBuiltIns.Execution, + StringComparison.Ordinal)) + .Take(2) + .ToArray(); + + if (executionDirectives.Length == 0) + { + return new ExecutionSettings( + NodeResolution.Gateway, + ShareableFieldRuntimeTypeRouting.SourceLocal); + } + + if (executionDirectives.Length > 1) + { + throw new InvalidOperationException( + "The fusion__execution directive may only be applied once per schema."); + } + + var executionDirective = executionDirectives[0]; + + return new ExecutionSettings( + ParseNodeResolution(executionDirective), + ParseShareableFieldRuntimeTypeRouting(executionDirective)); + } + + private static NodeResolution ParseNodeResolution(DirectiveNode executionDirective) + { + var nodeResolutionArgument = executionDirective.Arguments.FirstOrDefault( + static argument => argument.Name.Value.Equals( + "nodeResolution", + StringComparison.Ordinal)); + + if (nodeResolutionArgument is null) + { + return NodeResolution.Gateway; + } + + return nodeResolutionArgument.Value switch + { + EnumValueNode { Value: "GATEWAY" } => NodeResolution.Gateway, + EnumValueNode { Value: "SOURCE_SCHEMA" } => NodeResolution.SourceSchema, + _ => throw new InvalidOperationException( + "The fusion__execution nodeResolution argument must be GATEWAY or SOURCE_SCHEMA.") + }; + } + + private static ShareableFieldRuntimeTypeRouting ParseShareableFieldRuntimeTypeRouting( + DirectiveNode executionDirective) + { + var argument = executionDirective.Arguments.FirstOrDefault( + static argument => argument.Name.Value.Equals( + "shareableFieldRuntimeTypeRouting", + StringComparison.Ordinal)); + + if (argument is null) + { + return ShareableFieldRuntimeTypeRouting.SourceLocal; + } + + return argument.Value switch + { + EnumValueNode { Value: "SOURCE_LOCAL" } => + ShareableFieldRuntimeTypeRouting.SourceLocal, + EnumValueNode { Value: "COMMON_RUNTIME_TYPES" } => + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + _ => throw new InvalidOperationException( + "The fusion__execution shareableFieldRuntimeTypeRouting argument must be " + + "SOURCE_LOCAL or COMMON_RUNTIME_TYPES.") + }; + } + + private readonly record struct ExecutionSettings( + NodeResolution NodeResolution, + ShareableFieldRuntimeTypeRouting ShareableFieldRuntimeTypeRouting); + private static void CompleteObjectType( FusionObjectTypeDefinition type, ObjectTypeDefinitionNode typeDef, @@ -838,6 +932,7 @@ sourceNamedTypeName is not null requirements, CompleteType(fieldDef.Type, fieldDirective.SourceType, context), fieldDirective.IsExternal, + fieldDirective.IsSourceExternal, fieldDirective.Provides, sourceTypeName, sourceEventStreamDirective)); @@ -1146,9 +1241,15 @@ private static ImmutableDictionary CreateSourceSchemaL { var schemaName = GetSchemaName(sourceSchema); var connectorKind = GetConnectorKind(sourceSchema); + var allowNonResolvableInterfaceObjects = + AllowsNonResolvableInterfaceObjects(sourceSchema); sourceSchemaBuilder.Add( sourceSchema.Name.Value, - new SourceSchemaInfo(sourceSchema.Name.Value, schemaName, connectorKind)); + new SourceSchemaInfo( + sourceSchema.Name.Value, + schemaName, + connectorKind, + allowNonResolvableInterfaceObjects)); } return sourceSchemaBuilder.ToImmutable(); @@ -1169,6 +1270,18 @@ static string GetSchemaName(EnumValueDefinitionNode sourceSchema) return kindArg?.Value is StringValueNode kindValue ? kindValue.Value : null; } + + static bool AllowsNonResolvableInterfaceObjects(EnumValueDefinitionNode sourceSchema) + { + var metadataDirective = sourceSchema.Directives.FirstOrDefault(t => + t.Name.Value.Equals(FusionBuiltIns.SchemaMetadata, StringComparison.Ordinal)); + var compatibilityArg = metadataDirective?.Arguments.FirstOrDefault(t => + t.Name.Value.Equals( + "allowNonResolvableInterfaceObjects", + StringComparison.Ordinal)); + + return compatibilityArg?.Value is BooleanValueNode { Value: true }; + } } private static FusionOptInFeatures CollectOptInFeatures(CompositeSchemaBuilderContext context) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirective.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirective.cs index 32c628bd0be..e3e590bc6a0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirective.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirective.cs @@ -16,7 +16,8 @@ internal class FieldDirective( string? sourceName, ITypeNode? sourceType, SelectionSetNode? provides, - bool isExternal) + bool isExternal, + bool isSourceExternal) { public SchemaKey SchemaKey { get; } = schemaKey; @@ -27,4 +28,6 @@ internal class FieldDirective( public SelectionSetNode? Provides { get; } = provides; public bool IsExternal { get; } = isExternal; + + public bool IsSourceExternal { get; } = isSourceExternal; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirectiveParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirectiveParser.cs index 6d65b1dda39..57b72c5ff43 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirectiveParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/FieldDirectiveParser.cs @@ -16,6 +16,7 @@ public static FieldDirective Parse(DirectiveNode directive) ITypeNode? sourceType = null; SelectionSetNode? provides = null; var isExternal = false; + var isSourceExternal = false; foreach (var argument in directive.Arguments) { @@ -47,6 +48,10 @@ public static FieldDirective Parse(DirectiveNode directive) isExternal = ((BooleanValueNode)argument.Value).Value; break; + case "sourceExternal": + isSourceExternal = ((BooleanValueNode)argument.Value).Value; + break; + default: throw new DirectiveParserException( $"The argument `{argument.Name.Value}` is not supported on @field."); @@ -59,7 +64,13 @@ public static FieldDirective Parse(DirectiveNode directive) "The `schema` argument is required on the @field directive."); } - return new FieldDirective(new SchemaKey(schemaKey), sourceName, sourceType, provides, isExternal); + return new FieldDirective( + new SchemaKey(schemaKey), + sourceName, + sourceType, + provides, + isExternal, + isSourceExternal); } public static SelectionSetNode ParseSelectionSet(string value) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/InterfaceObjectDirectiveParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/InterfaceObjectDirectiveParser.cs new file mode 100644 index 00000000000..b8c2d18d31c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/InterfaceObjectDirectiveParser.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Types.Directives; + +/* +directive @fusion__interfaceObject( + schema: fusion__Schema! +) repeatable on INTERFACE +*/ +internal static class InterfaceObjectDirectiveParser +{ + public static ImmutableHashSet Parse(IReadOnlyList directiveNodes) + { + ImmutableHashSet.Builder? temp = null; + + foreach (var directive in directiveNodes) + { + if (directive.Name.Value.Equals(FusionBuiltIns.InterfaceObject, StringComparison.Ordinal)) + { + temp ??= ImmutableHashSet.CreateBuilder(); + + var schemaValue = directive.Arguments.FirstOrDefault(t => t.Name.Value == "schema")?.Value; + + if (schemaValue is not EnumValueNode { Value: { Length: > 0 } schemaName }) + { + throw new InvalidOperationException( + $"The directive `@fusion__interfaceObject` has an invalid value for `schema`.\r\n{directive}"); + } + + temp.Add(new SchemaKey(schemaName)); + } + } + + return temp?.ToImmutable() ?? []; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs index c5fbe21393d..58c73f41177 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs @@ -8,6 +8,7 @@ internal static class FusionBuiltIns public const string FieldSelectionMap = "fusion__FieldSelectionMap"; public const string FieldSelectionSet = "fusion__FieldSelectionSet"; public const string SelectionPath = "fusion__FieldSelectionPath"; + public const string NodeResolution = "fusion__NodeResolution"; public const string Schema = "fusion__Schema"; public const string Type = "fusion__type"; @@ -16,8 +17,10 @@ internal static class FusionBuiltIns public const string Requires = "fusion__requires"; public const string Lookup = "fusion__lookup"; public const string Implements = "fusion__implements"; + public const string InterfaceObject = "fusion__interfaceObject"; public const string UnionMember = "fusion__unionMember"; public const string EnumValue = "fusion__enumValue"; + public const string Execution = "fusion__execution"; public const string Inaccessible = "fusion__inaccessible"; public const string GatewayField = "fusion__gateway_field"; public const string SchemaMetadata = "fusion__schema_metadata"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs index 100ef39bf3a..d7c02728eac 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaDefinition.cs @@ -40,6 +40,8 @@ internal FusionSchemaDefinition( FusionDirectiveCollection directives, FusionTypeDefinitionCollection types, FusionDirectiveDefinitionCollection directiveDefinitions, + NodeResolution nodeResolution, + ShareableFieldRuntimeTypeRouting shareableFieldRuntimeTypeRouting, IFeatureCollection features, ImmutableDictionary sourceSchemaLookup) { @@ -52,6 +54,8 @@ internal FusionSchemaDefinition( Directives = directives; Types = types; DirectiveDefinitions = directiveDefinitions; + NodeResolution = nodeResolution; + ShareableFieldRuntimeTypeRouting = shareableFieldRuntimeTypeRouting; _features = features; _sourceSchemaLookup = sourceSchemaLookup; } @@ -135,6 +139,16 @@ public static FusionSchemaDefinition Create( /// public FusionDirectiveDefinitionCollection DirectiveDefinitions { get; } + /// + /// Gets how the gateway resolves the Query.node field. + /// + public NodeResolution NodeResolution { get; } + + /// + /// Gets how runtime types are routed for shareable fields whose result type is abstract. + /// + public ShareableFieldRuntimeTypeRouting ShareableFieldRuntimeTypeRouting { get; } + IReadOnlyDirectiveDefinitionCollection ISchemaDefinition.DirectiveDefinitions => DirectiveDefinitions; @@ -166,6 +180,204 @@ IReadOnlyDirectiveDefinitionCollection ISchemaDefinition.DirectiveDefinitions return null; } + /// + /// Gets whether the specified source schema allows non-resolvable Apollo Federation + /// @interfaceObject stand-ins to be handled as field errors at runtime. + /// + public bool AllowsNonResolvableInterfaceObjects(string sourceSchemaName) + { + ArgumentException.ThrowIfNullOrEmpty(sourceSchemaName); + + foreach (var info in _sourceSchemaLookup.Values) + { + if (string.Equals(info.Name, sourceSchemaName, StringComparison.Ordinal)) + { + return info.AllowNonResolvableInterfaceObjects; + } + } + + return false; + } + + internal bool IsAllowedNonResolvableInterfaceObjectSelection( + FusionComplexTypeDefinition type, + SelectionSetNode selectionSet) + { + var hasSelection = false; + + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field when field.Name.Value == IntrospectionFieldNames.TypeName: + hasSelection = true; + + if (!IsBackedOnlyByCompatibleInterfaceObject(type)) + { + return false; + } + break; + + case FieldNode field: + hasSelection = true; + + if (!type.Fields.TryGetField( + field.Name.Value, + allowInaccessibleFields: true, + out var fieldDefinition) + || !IsCompatibleProjectedField(type, fieldDefinition)) + { + return false; + } + break; + + case InlineFragmentNode fragment: + var fragmentType = type; + + if (fragment.TypeCondition is { } typeCondition) + { + if (!Types.TryGetType( + typeCondition.Name.Value, + allowInaccessibleFields: true, + out var conditionType) + || conditionType is not FusionComplexTypeDefinition complexConditionType) + { + return false; + } + + fragmentType = complexConditionType; + } + + if (!IsAllowedNonResolvableInterfaceObjectSelection( + fragmentType, + fragment.SelectionSet)) + { + return false; + } + + hasSelection = true; + break; + } + } + + return hasSelection; + } + + private bool IsCompatibleProjectedField( + FusionComplexTypeDefinition type, + FusionOutputFieldDefinition field) + { + if (field.Sources.Count == 0) + { + return false; + } + + foreach (var source in field.Sources) + { + if (!IsCompatibleInterfaceObjectSource(type, field.Name, source.SchemaName)) + { + return false; + } + } + + return true; + } + + private bool IsBackedOnlyByCompatibleInterfaceObject(FusionComplexTypeDefinition type) + { + var found = false; + + foreach (var sourceSchemaName in GetCompatibleInterfaceObjectSchemaNames(type)) + { + found = true; + } + + return found; + } + + private bool IsCompatibleInterfaceObjectSource( + FusionComplexTypeDefinition type, + string fieldName, + string sourceSchemaName) + { + if (!AllowsNonResolvableInterfaceObjects(sourceSchemaName) + || GetSourceSchemaConnectorKind(sourceSchemaName) + != ConnectorKindNames.ApolloFederation) + { + return false; + } + + if (type is FusionInterfaceTypeDefinition interfaceType) + { + return interfaceType.Sources.TryGetMember(sourceSchemaName, out var interfaceSource) + && interfaceSource.IsInterfaceObject + && interfaceType.Fields.TryGetField( + fieldName, + allowInaccessibleFields: true, + out var interfaceField) + && interfaceField.Sources.ContainsSchema(sourceSchemaName); + } + + if (type is not FusionObjectTypeDefinition objectType + || objectType.Sources.ContainsSchema(sourceSchemaName)) + { + return false; + } + + foreach (var implementedInterface in objectType.Implements) + { + if (implementedInterface.Sources.TryGetMember( + sourceSchemaName, + out var interfaceSource) + && interfaceSource.IsInterfaceObject + && implementedInterface.Fields.TryGetField( + fieldName, + allowInaccessibleFields: true, + out var interfaceField) + && interfaceField.Sources.ContainsSchema(sourceSchemaName)) + { + return true; + } + } + + return false; + } + + private IEnumerable GetCompatibleInterfaceObjectSchemaNames( + FusionComplexTypeDefinition type) + { + if (type is FusionInterfaceTypeDefinition interfaceType) + { + foreach (var source in interfaceType.Sources) + { + if (source.IsInterfaceObject + && AllowsNonResolvableInterfaceObjects(source.SchemaName) + && GetSourceSchemaConnectorKind(source.SchemaName) + == ConnectorKindNames.ApolloFederation) + { + yield return source.SchemaName; + } + } + + yield break; + } + + foreach (var implementedInterface in type.Implements) + { + foreach (var source in implementedInterface.Sources) + { + if (source.IsInterfaceObject + && !type.Sources.ContainsSchema(source.SchemaName) + && AllowsNonResolvableInterfaceObjects(source.SchemaName) + && GetSourceSchemaConnectorKind(source.SchemaName) + == ConnectorKindNames.ApolloFederation) + { + yield return source.SchemaName; + } + } + } + } + public FusionObjectTypeDefinition GetOperationType(OperationType operation) { var type = operation switch @@ -427,6 +639,31 @@ private ImmutableArray GetPossibleLookupsInternal(ITypeDefinition type, } } + if (objectType is not null) + { + // A concrete type has no source of its own in an @interfaceObject stand-in schema, + // yet the stand-in's covering interface lookup resolves every possible type of the + // interface by key. Offer those interface lookups so a field the stand-in owns stays + // reachable under a concrete type condition, where the normal per-source loop above + // never sees the stand-in schema. Gated on the stand-in fact and on the type being + // absent from that schema, so a schema without @interfaceObject is unaffected. + foreach (var interfaceType in complexType.Implements) + { + foreach (var interfaceSource in interfaceType.Sources) + { + if (!interfaceSource.IsInterfaceObject + || (schemaName is not null + && !interfaceSource.SchemaName.Equals(schemaName, StringComparison.Ordinal)) + || complexType.Sources.TryGetMember(interfaceSource.SchemaName, out _)) + { + continue; + } + + lookups.AddRange(interfaceSource.Lookups); + } + } + } + return lookups.ToImmutable(); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceInterfaceType.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceInterfaceType.cs index bbe97918198..f8b3a9f59ce 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceInterfaceType.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceInterfaceType.cs @@ -11,11 +11,13 @@ namespace HotChocolate.Fusion.Types.Metadata; /// The name of the source schema containing this interface type. /// The entity lookups available for this interface type in the source schema. /// The names of interfaces implemented by this interface type in the source schema. +/// Whether the source schema exposes this interface as an @interfaceObject stand-in. public sealed class SourceInterfaceType( string name, string schemaName, ImmutableArray lookups, - ImmutableHashSet implements) + ImmutableHashSet implements, + bool isInterfaceObject = false) : ISourceComplexType { /// @@ -37,4 +39,11 @@ public sealed class SourceInterfaceType( /// Gets the names of interfaces implemented by this interface type in the source schema. /// public ImmutableHashSet Implements { get; } = implements; + + /// + /// Gets a value indicating whether the source schema exposes this interface as an + /// @interfaceObject stand-in, so it holds no authoritative concrete type for the + /// interface's values and covers every possible type through a single interface lookup. + /// + public bool IsInterfaceObject { get; } = isInterfaceObject; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs index 9e0cb757dcc..fbcfa0776c4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceOutputField.cs @@ -4,34 +4,74 @@ namespace HotChocolate.Fusion.Types.Metadata; -public sealed class SourceOutputField( - string name, - string schemaName, - FieldRequirements? requirements, - IType type, - bool isExternal, - SelectionSetNode? provides, - string? sourceTypeName, - EventStreamDirective? eventStreamDirective = null) - : ISourceMember +public sealed class SourceOutputField : ISourceMember { - public string Name { get; } = name; + public SourceOutputField( + string name, + string schemaName, + FieldRequirements? requirements, + IType type, + bool isExternal, + bool isSourceExternal, + SelectionSetNode? provides, + string? sourceTypeName, + EventStreamDirective? eventStreamDirective = null) + { + Name = name; + SchemaName = schemaName; + Requirements = requirements; + Type = type; + IsExternal = isExternal; + IsSourceExternal = isSourceExternal; + Provides = provides; + SourceTypeName = sourceTypeName; + EventStreamDirective = eventStreamDirective; + } - public string SchemaName { get; } = schemaName; + public SourceOutputField( + string name, + string schemaName, + FieldRequirements? requirements, + IType type, + bool isExternal, + SelectionSetNode? provides, + string? sourceTypeName, + EventStreamDirective? eventStreamDirective = null) + : this( + name, + schemaName, + requirements, + type, + isExternal, + false, + provides, + sourceTypeName, + eventStreamDirective) + { + } - public FieldRequirements? Requirements { get; } = requirements; + public string Name { get; } - public IType Type { get; } = type; + public string SchemaName { get; } - public bool IsExternal { get; } = isExternal; + public FieldRequirements? Requirements { get; } - public SelectionSetNode? Provides { get; } = provides; + public IType Type { get; } + + public bool IsExternal { get; } + + /// + /// Gets whether connector preprocessing promoted a source field that was originally external. + /// + public bool IsSourceExternal { get; } + + public SelectionSetNode? Provides { get; } /// /// Gets the named type the source schema actually returns for this field when it differs /// from the composite field's named type, otherwise null. /// - public string? SourceTypeName { get; } = sourceTypeName; + public string? SourceTypeName { get; } - public EventStreamDirective? EventStreamDirective { get; } = eventStreamDirective; + public EventStreamDirective? EventStreamDirective { get; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceSchemaInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceSchemaInfo.cs index 5cb86f37798..ca161d600cf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceSchemaInfo.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/SourceSchemaInfo.cs @@ -1,3 +1,19 @@ namespace HotChocolate.Fusion.Types.Metadata; -public sealed record SourceSchemaInfo(string Key, string Name, string? ConnectorKind); +public sealed record SourceSchemaInfo( + string Key, + string Name, + string? ConnectorKind) +{ + public SourceSchemaInfo( + string key, + string name, + string? connectorKind, + bool allowNonResolvableInterfaceObjects) + : this(key, name, connectorKind) + { + AllowNonResolvableInterfaceObjects = allowNonResolvableInterfaceObjects; + } + + public bool AllowNonResolvableInterfaceObjects { get; init; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/NodeFallbackLookup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/NodeFallbackLookup.cs index c047fee135b..ab17128e40a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/NodeFallbackLookup.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/NodeFallbackLookup.cs @@ -14,6 +14,7 @@ namespace HotChocolate.Fusion.Types; internal sealed class NodeFallbackLookup : INeedsCompletion { private FrozenDictionary _schemaByType = FrozenDictionary.Empty; + private string[] _sourceSchemaNodeLookupSchemas = []; /// /// Tries to determine a possible schema to do a node lookup for the provided . @@ -27,7 +28,7 @@ public bool TryGetNodeLookupSchemaForType(string typeName, [NotNullWhen(true)] o /// public bool TryGetAnyNodeLookupSchema([NotNullWhen(true)] out string? schemaName) { - foreach (var value in _schemaByType.Values) + foreach (var value in _sourceSchemaNodeLookupSchemas) { schemaName = value; return true; @@ -64,5 +65,26 @@ void INeedsCompletion.Complete(FusionSchemaDefinition schema, CompositeSchemaBui } _schemaByType = lookup.ToFrozenDictionary(); + + var sourceSchemaNodeLookupSchemas = new SortedSet(StringComparer.Ordinal); + foreach (var possibleType in schema.GetPossibleTypes(nodeType)) + { + foreach (var nodeLookup in schema.GetPossibleLookups(possibleType)) + { + if (nodeLookup.Fields is [PathNode { PathSegment.FieldName.Value: "id" }] + && nodeLookup is { FieldName: "node", IsInternal: false } + && nodeLookup.FieldType == nodeType + && nodeLookup.Path.IsDefaultOrEmpty + && possibleType.Sources.TryGetMember( + nodeLookup.SchemaName, + out var sourceType) + && sourceType.Implements.Contains(nodeType.Name)) + { + sourceSchemaNodeLookupSchemas.Add(nodeLookup.SchemaName); + } + } + } + + _sourceSchemaNodeLookupSchemas = [.. sourceSchemaNodeLookupSchemas]; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs index 0b2e8641ebe..ceddc55b201 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/PlannerTopologyCache.cs @@ -150,10 +150,8 @@ private static Dictionary BuildTransitions( foreach (var complexType in complexTypes) { - foreach (var toSource in complexType.Sources) + foreach (var toSchema in GetTransitionTargetSchemas(complexType)) { - var toSchema = toSource.SchemaName; - // A direct transition can only target a schema that has lookups for this // type; if there are none, no from-schema can transition here, so skip the sweep. if (schema.GetPossibleLookups(complexType, toSchema).Length == 0) @@ -176,6 +174,36 @@ private static Dictionary BuildTransitions( return directTransitions; } + // The schemas a transition may target for a type: the schemas that declare the type, plus the + // @interfaceObject stand-in schemas of the interfaces it implements. A stand-in holds no source + // for the concrete type yet resolves its projected fields through a single covering interface + // lookup, so a value must be able to transition there under a concrete type condition. Bounded + // to stand-in schemas, this leaves the transition sweep unchanged for schemas without + // interfaceObject. + private static IEnumerable GetTransitionTargetSchemas(FusionComplexTypeDefinition complexType) + { + var targets = new HashSet(StringComparer.Ordinal); + + foreach (var source in complexType.Sources) + { + targets.Add(source.SchemaName); + } + + foreach (var interfaceType in complexType.Implements) + { + foreach (var interfaceSource in interfaceType.Sources) + { + if (interfaceSource.IsInterfaceObject + && !complexType.Sources.TryGetMember(interfaceSource.SchemaName, out _)) + { + targets.Add(interfaceSource.SchemaName); + } + } + } + + return targets; + } + private static Lookup? FindBestDirectLookup( FusionSchemaDefinition schema, KeyTransitionVisitor visitor, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ApolloFederation/LookupEntityQueryRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ApolloFederation/LookupEntityQueryRewriter.cs index 23ca6f60a96..453584e3344 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ApolloFederation/LookupEntityQueryRewriter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ApolloFederation/LookupEntityQueryRewriter.cs @@ -47,7 +47,7 @@ public static RewrittenOperation Rewrite( var lookupField = GetLookupField(operationDefinition); var lookup = ResolveLookup(schema, schemaName, lookupField); var entityTypeName = lookup.FieldType.Name; - var entityType = schema.Types.GetType(entityTypeName); + var entityType = schema.Types.GetType(entityTypeName); // Lift the lookup field's selection set into the inline fragment, stripping // the variable-bound '@require' arguments at any nesting depth. @@ -57,6 +57,17 @@ public static RewrittenOperation Rewrite( entityType, lookupField.SelectionSet); + // When the target schema exposes the entity as an @interfaceObject stand-in it holds no + // concrete implementing types, so a concrete type condition (e.g. `... on Admin`) would + // reference a type the schema does not define. The stand-in resolves its contributed fields + // uniformly for the interface, so such conditions are flattened to the interface level. + if (entityType is FusionInterfaceTypeDefinition entityInterface + && entityInterface.Sources.TryGetMember(schemaName, out var entitySource) + && entitySource.IsInterfaceObject) + { + strippedSelections = FlattenConcreteTypeConditions(strippedSelections); + } + var text = BuildEntitiesDocument( entityTypeName, strippedSelections, @@ -68,6 +79,44 @@ public static RewrittenOperation Rewrite( lookupField); } + /// + /// Flattens the inline fragments of a stand-in's lookup selection into the interface level. + /// A stand-in schema defines no concrete implementing types, so a concrete type condition would + /// reference an unknown type; the contributed fields it wraps are resolved uniformly for the + /// interface. Fields are de-duplicated by response name. + /// + private static SelectionSetNode FlattenConcreteTypeConditions(SelectionSetNode selectionSet) + { + var flattened = new List(); + var seen = new HashSet(StringComparer.Ordinal); + FlattenInto(selectionSet, flattened, seen); + return new SelectionSetNode(flattened); + } + + private static void FlattenInto( + SelectionSetNode selectionSet, + List target, + HashSet seen) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + if (seen.Add(field.Alias?.Value ?? field.Name.Value)) + { + target.Add(field); + } + + break; + + case InlineFragmentNode fragment: + FlattenInto(fragment.SelectionSet, target, seen); + break; + } + } + } + private static FieldNode GetLookupField(OperationDefinitionNode operationDefinition) { var selections = operationDefinition.SelectionSet.Selections; @@ -91,14 +140,16 @@ private static Lookup ResolveLookup( foreach (var type in schema.Types.AsEnumerable(allowInaccessibleFields: true)) { - if (type is not FusionObjectTypeDefinition objectType - || !objectType.IsEntityType - || !objectType.Sources.TryGetMember(schemaName, out var sourceObjectType)) + // Interface entity types are considered too: an @interfaceObject covering lookup + // returns the interface itself and is recorded on the interface's source metadata. + if (type is not FusionComplexTypeDefinition complexType + || !complexType.IsEntityType + || !complexType.Sources.TryGetMember(schemaName, out var sourceComplexType)) { continue; } - foreach (var lookup in sourceObjectType.Lookups) + foreach (var lookup in sourceComplexType.Lookups) { if (!string.Equals(lookup.FieldName, lookupField.Name.Value, StringComparison.Ordinal)) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/OperationPlanContextExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/OperationPlanContextExtensions.cs index 0530557b559..333e7696e79 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/OperationPlanContextExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/OperationPlanContextExtensions.cs @@ -13,7 +13,7 @@ internal static class OperationPlanContextExtensions extension(OperationPlanContext context) { internal NodeResolution NodeResolution - => context.RequestContext.Schema.Features.Get()?.NodeResolution ?? NodeResolution.Gateway; + => ((FusionSchemaDefinition)context.RequestContext.Schema).NodeResolution; internal bool TryGetAnyNodeLookupSchema([NotNullWhen(true)] out string? schemaName) => context.RequestContext.Schema.Features.GetRequired() diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs index d46a7ace4c5..1de8e3fe1a6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs @@ -79,21 +79,6 @@ public int OperationDocumentCacheSize } } = 256; - /// - /// Gets or sets how the gateway resolves the Query.node field. - /// by default. - /// - public NodeResolution NodeResolution - { - get; - set - { - ExpectMutableOptions(); - - field = value; - } - } = NodeResolution.Gateway; - /// /// Gets or sets the initial capacity of the local path segment pool used during result composition. /// 64 by default. 1 is the minimum. @@ -228,7 +213,6 @@ public FusionOptions Clone() OperationExecutionPlanCacheSize = OperationExecutionPlanCacheSize, OperationExecutionPlanCacheDiagnostics = OperationExecutionPlanCacheDiagnostics, OperationDocumentCacheSize = OperationDocumentCacheSize, - NodeResolution = NodeResolution, PathSegmentLocalPoolCapacity = PathSegmentLocalPoolCapacity, LazyInitialization = LazyInitialization, NodeIdSerializerFormat = NodeIdSerializerFormat, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs index 7c4e0ff9bd9..f0a97f89976 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs @@ -157,11 +157,11 @@ public SelectionSet GetSelectionSet(Selection selection) /// /// The specified has no selection set. /// - public SelectionSet GetSelectionSet(Selection selection, IObjectTypeDefinition typeContext) + public SelectionSet GetSelectionSet(Selection selection, IComplexTypeDefinition typeContext) { ArgumentNullException.ThrowIfNull(selection); ArgumentNullException.ThrowIfNull(typeContext); - Debug.Assert(typeContext is FusionObjectTypeDefinition); + Debug.Assert(typeContext is FusionComplexTypeDefinition); var key = (selection.Id, typeContext.Name); @@ -174,7 +174,7 @@ public SelectionSet GetSelectionSet(Selection selection, IObjectTypeDefinition t selectionSet = _compiler.CompileSelectionSet( selection, - (FusionObjectTypeDefinition)typeContext, + (FusionComplexTypeDefinition)typeContext, _includeConditions, _deliveryGroupByFragment, ref _elementsById, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index 5decea0728c..5f19f8a8f88 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -84,7 +84,8 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati fields, rootType, compilationContext, - ref lastId); + ref lastId, + declaringSelection: null); compilationContext.Register(selectionSet, selectionSet.Id); @@ -111,7 +112,7 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati internal SelectionSet CompileSelectionSet( Selection selection, - FusionObjectTypeDefinition objectType, + FusionComplexTypeDefinition objectType, IncludeConditionCollection includeConditions, IReadOnlyDictionary deliveryGroupByFragment, ref object[] elementsById, @@ -152,7 +153,7 @@ internal SelectionSet CompileSelectionSet( } } - var selectionSet = BuildSelectionSet(fields, objectType, compilationContext, ref lastId); + var selectionSet = BuildSelectionSet(fields, objectType, compilationContext, ref lastId, selection); compilationContext.Register(selectionSet, selectionSet.Id); elementsById = compilationContext.ElementsById; return selectionSet; @@ -166,7 +167,7 @@ internal SelectionSet CompileSelectionSet( private void CollectFields( ulong parentIncludeFlags, IReadOnlyList selections, - IObjectTypeDefinition typeContext, + IComplexTypeDefinition typeContext, OrderedDictionary> fields, IncludeConditionCollection includeConditions, IReadOnlyDictionary deliveryGroupByFragment, @@ -228,9 +229,10 @@ private void CollectFields( private SelectionSet BuildSelectionSet( OrderedDictionary> fieldMap, - FusionObjectTypeDefinition typeContext, + FusionComplexTypeDefinition typeContext, CompilationContext compilationContext, - ref int lastId) + ref int lastId, + Selection? declaringSelection) { var i = 0; var selections = new Selection[fieldMap.Count]; @@ -348,7 +350,13 @@ private SelectionSet BuildSelectionSet( } } - return new SelectionSet(selectionSetId, typeContext, selections, isConditional, hasIncrementalParts); + return new SelectionSet( + selectionSetId, + typeContext, + selections, + isConditional, + hasIncrementalParts, + declaringSelection); } private static void CollapseIncludeFlags(List includeFlags) @@ -441,7 +449,7 @@ private static bool IsInternal(List nodes) return true; } - private bool DoesTypeApply(NamedTypeNode? typeCondition, IObjectTypeDefinition typeContext) + private bool DoesTypeApply(NamedTypeNode? typeCondition, IComplexTypeDefinition typeContext) { if (typeCondition is null) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs index cf6b4811ac8..12d0daa58bf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using HotChocolate.Fusion.Types; using HotChocolate.Language; using HotChocolate.Types; @@ -21,6 +22,15 @@ internal abstract class ResultSelectionSet( /// public ReadOnlySpan ResponseNames => allResponseNames; + /// + /// Gets a value indicating whether the objects described by this selection set are opaque + /// interface values produced by an @interfaceObject stand-in. Such values initialize + /// interface-typed and only recover their concrete identity through a covering lookup. + /// + public bool ProducesOpaqueElements { get; private set; } + + private void MarkProducesOpaqueElements() => ProducesOpaqueElements = true; + /// /// Gets the direct selections at this level. /// @@ -44,7 +54,7 @@ internal abstract class ResultSelectionSet( /// Used in TryCompleteObjectValue where the runtime type is known. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ResultSelectionSet? TryGetChild(string responseName, IObjectTypeDefinition objectType) + public ResultSelectionSet? TryGetChild(string responseName, IComplexTypeDefinition objectType) => TryGetDirectChild(responseName, out var selectionSet) ? selectionSet : TryGetFragmentChild(responseName, objectType); @@ -71,7 +81,7 @@ internal abstract class ResultSelectionSet( return null; } - private ResultSelectionSet? TryGetFragmentChild(string responseName, IObjectTypeDefinition objectType) + private ResultSelectionSet? TryGetFragmentChild(string responseName, IComplexTypeDefinition objectType) { for (var i = 0; i < fragments.Length; i++) { @@ -138,7 +148,20 @@ public override string ToString() /// Optional schema used to resolve inline fragment type conditions to /// instances. When null, type conditions are not resolved. /// - public static ResultSelectionSet Create(SelectionSetNode selectionSet, ISchemaDefinition? schema = null) + /// + /// The named type that declares this selection set. When provided together with + /// , it is used to detect fields that return opaque + /// @interfaceObject stand-in values so the corresponding child set can be marked. + /// + /// + /// The source schema that resolves this selection set. When it stands in for an interface a + /// field returns, that field's child set produces opaque interface values. + /// + public static ResultSelectionSet Create( + SelectionSetNode selectionSet, + ISchemaDefinition? schema = null, + ITypeDefinition? parentType = null, + string? sourceSchemaName = null) { var directSelections = new List(); var fragments = new List(); @@ -151,14 +174,23 @@ public static ResultSelectionSet Create(SelectionSetNode selectionSet, ISchemaDe case FieldNode field: var name = field.Alias?.Value ?? field.Name.Value; allResponseNames.Add(name); - directSelections.Add(new ResultSelection( - name, - field.SelectionSet is { } childSet ? Create(childSet, schema) : null)); + + ResultSelectionSet? child = null; + if (field.SelectionSet is { } childSet) + { + var fieldType = ResolveFieldType(parentType, field.Name.Value); + child = Create(childSet, schema, fieldType, sourceSchemaName); + + if (IsOpaqueStandIn(fieldType, sourceSchemaName)) + { + child.MarkProducesOpaqueElements(); + } + } + + directSelections.Add(new ResultSelection(name, child)); break; case InlineFragmentNode inlineFragment: - var body = Create(inlineFragment.SelectionSet, schema); - ITypeDefinition? typeCondition = null; if (inlineFragment.TypeCondition is not null) { @@ -167,6 +199,12 @@ public static ResultSelectionSet Create(SelectionSetNode selectionSet, ISchemaDe out typeCondition); } + var body = Create( + inlineFragment.SelectionSet, + schema, + typeCondition ?? parentType, + sourceSchemaName); + fragments.Add(new ResultFragment(typeCondition, body)); // Add the fragment body's response names to the union. @@ -197,4 +235,16 @@ public static ResultSelectionSet Create(SelectionSetNode selectionSet, ISchemaDe fragmentsArray, responseNamesArray); } + + private static ITypeDefinition? ResolveFieldType(ITypeDefinition? parentType, string fieldName) + => parentType is IComplexTypeDefinition complexType + && complexType.Fields.TryGetField(fieldName, out var field) + ? field.Type.NamedType() + : null; + + private static bool IsOpaqueStandIn(ITypeDefinition? namedType, string? sourceSchemaName) + => sourceSchemaName is not null + && namedType is FusionInterfaceTypeDefinition interfaceType + && interfaceType.Sources.TryGetMember(sourceSchemaName, out var source) + && source.IsInterfaceObject; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs index 73153b7196f..95b83be7a74 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs @@ -125,7 +125,7 @@ public Selection( /// The child selection set, or null when this selection has no child /// selection set. /// - public SelectionSet? GetSelectionSet(IObjectTypeDefinition typeContext) + public SelectionSet? GetSelectionSet(IComplexTypeDefinition typeContext) { if ((_flags & Flags.Leaf) == Flags.Leaf) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs index 48f2154c27c..56d9989d2d5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs @@ -20,10 +20,11 @@ public sealed class SelectionSet : ISelectionSet public SelectionSet( int id, - IObjectTypeDefinition type, + IComplexTypeDefinition type, Selection[] selections, bool isConditional, - bool hasIncrementalParts) + bool hasIncrementalParts, + Selection? declaringSelection) { ArgumentNullException.ThrowIfNull(selections); @@ -36,6 +37,7 @@ public SelectionSet( Type = type; IsConditional = isConditional; HasIncrementalParts = hasIncrementalParts; + DeclaringSelection = declaringSelection; _selections = selections; _responseNameLookup = _selections.ToFrozenDictionary(t => t.ResponseName); _utf8ResponseNameLookup = SelectionLookup.Create(this); @@ -54,7 +56,19 @@ public SelectionSet( /// /// Gets the type that declares this selection set. /// - public IObjectTypeDefinition Type { get; } + /// + /// This is an object type for a fully resolved (concrete) selection set. It can be an + /// interface type while an element produced by an @interfaceObject stand-in still + /// awaits identity recovery through its covering lookup. + /// + public IComplexTypeDefinition Type { get; } + + /// + /// Gets the field selection whose child selection set this is, or null for the + /// operation's root selection set. Used to recompute the concrete selection set when an + /// opaque interface-typed element recovers its identity through a covering lookup. + /// + public Selection? DeclaringSelection { get; } /// /// Gets the declaring operation. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs index d09e4c7fe42..7b2eb0b5a46 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs @@ -544,6 +544,8 @@ private static ParsedOperationNodeInfo ParseOperationNodeInfo( target = SelectionPath.Parse(targetElement.GetString()!); } + var parentType = ResolveResultSelectionSetType(schema, opSource.Type, source ?? SelectionPath.Root); + return new ParsedSingleOperationNodeInfo { Id = id, @@ -553,7 +555,7 @@ private static ParsedOperationNodeInfo ParseOperationNodeInfo( Target = target ?? SelectionPath.Root, Requirements = requirements?.ToArray() ?? [], ForwardedVariables = forwardedVariables ?? [], - ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema), + ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema, parentType, schemaName), Dependencies = dependencies, ParentDependencies = parentDependencies, BatchingGroupId = batchingGroupId, @@ -577,6 +579,8 @@ private static ParsedOperationNodeInfo ParseApolloOperationNodeInfo( target = SelectionPath.Parse(targetElement.GetString()!); } + var parentType = ResolveResultSelectionSetType(schema, opSource.Type, source ?? SelectionPath.Root); + return new ParsedApolloOperationNodeInfo { Id = id, @@ -586,7 +590,7 @@ private static ParsedOperationNodeInfo ParseApolloOperationNodeInfo( Target = target ?? SelectionPath.Root, Requirements = requirements?.ToArray() ?? [], ForwardedVariables = forwardedVariables ?? [], - ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema), + ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema, parentType, schemaName), Dependencies = dependencies, ParentDependencies = parentDependencies, BatchingGroupId = batchingGroupId, @@ -691,6 +695,8 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo( ? targetsElement.EnumerateArray().Select(e => SelectionPath.Parse(e.GetString()!)).ToArray() : []; + var parentType = ResolveResultSelectionSetType(schema, opSource.Type, source ?? SelectionPath.Root); + return new ParsedBatchOperationNodeInfo { Id = id, @@ -700,7 +706,7 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo( Targets = targets, Requirements = requirements?.ToArray() ?? [], ForwardedVariables = forwardedVariables ?? [], - ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema), + ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema, parentType, schemaName), Dependencies = dependencies, ParentDependencies = parentDependencies, BatchingGroupId = batchingGroupId, @@ -710,6 +716,47 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo( }; } + // Reconstructs the type that declares a fetch node's result selection set by walking the + // source path from the operation root type, mirroring how BuildExecutionTree passes + // operationStep.Type into ResultSelectionSet.Create. This is what lets a rehydrated plan + // re-derive @interfaceObject opacity, so a cached plan behaves like a freshly built one. + private static ITypeDefinition? ResolveResultSelectionSetType( + FusionSchemaDefinition schema, + OperationType operationType, + SelectionPath source) + { + ITypeDefinition current = schema.GetOperationType(operationType); + + for (var i = 0; i < source.Length; i++) + { + var segment = source[i]; + + switch (segment.Kind) + { + case SelectionPathSegmentKind.Field: + if (current is not IComplexTypeDefinition complexType + || !complexType.Fields.TryGetField(segment.Name, out var field)) + { + return null; + } + + current = field.Type.NamedType(); + break; + + case SelectionPathSegmentKind.InlineFragment: + if (!schema.Types.TryGetType(segment.Name, out var fragmentType)) + { + return null; + } + + current = fragmentType; + break; + } + } + + return current; + } + private static (string? schemaName, OperationSourceText opSource, SelectionPath? source, List? requirements, string[]? forwardedVariables, SelectionSetNode? resultSelectionSet, int[]? dependencies, int[]? parentDependencies, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs index 8747805af16..bfcdae45bd6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -729,6 +729,9 @@ internal void AddErrors(IError error, ResultSelectionSet resultSelectionSet, Rea } } + internal ImmutableArray GetResultPaths(SelectionPath selectionSet) + => _resultStore.GetResultPaths(selectionSet); + internal PooledArrayWriter CreateRentedBuffer() => _resultStore.CreateRentedBuffer(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs index b9404bada7e..859d5dc441d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.RepresentationValue.cs @@ -39,6 +39,7 @@ public bool AddRepresentationResult( var dataElement = GetDataElement(sourcePath, result.Data); var errors = containsErrors ? result.Errors : null; var errorTrie = containsErrors ? GetErrorTrie(sourcePath, errors?.Trie) : null; + var resultPaths = representation.ResultPaths.AsSpan(); lock (_lock) { @@ -46,17 +47,68 @@ public bool AddRepresentationResult( try { + var hasSourceErrors = errors is not null + && (!errors.RootErrors.IsDefaultOrEmpty + || errorTrie?.FindFirstError() is not null); + + if (dataElement.ValueKind is not JsonValueKind.Array) + { + if (hasSourceErrors) + { + return SaveRepresentationSourceErrors( + _result.Data, + errors!, + errorTrie, + resultPaths, + resultSelectionSet); + } + + return SaveRepresentationProtocolError( + _result.Data, + ThrowHelper.InvalidRepresentationResultKind(sourcePath, dataElement.ValueKind), + resultPaths, + resultSelectionSet); + } + + var resultCount = dataElement.GetArrayLength(); + if (resultPaths.IsEmpty || resultCount != resultPaths.Length) + { + if (hasSourceErrors + && !SaveRepresentationSourceErrors( + _result.Data, + errors!, + errorTrie, + resultPaths, + resultSelectionSet)) + { + return false; + } + + return SaveRepresentationProtocolError( + _result.Data, + ThrowHelper.RepresentationResultCountMismatch( + sourcePath, + resultCount, + resultPaths.Length), + resultPaths, + resultSelectionSet); + } + if (errors?.RootErrors is { Length: > 0 } rootErrors) { _errors ??= []; - _errors.AddRange(rootErrors); + + foreach (var rootError in rootErrors) + { + _errors.Add(_errorHandler.Handle(rootError)); + } } return SaveRepresentationResult( _result.Data, dataElement, errorTrie, - representation.ResultPaths.AsSpan(), + resultPaths, resultSelectionSet); } finally @@ -1383,16 +1435,6 @@ private bool SaveRepresentationResult( ReadOnlySpan resultPaths, ResultSelectionSet resultSelectionSet) { - Debug.Assert( - !resultPaths.IsEmpty, - "Representation result paths should not be empty."); - Debug.Assert( - dataElement.ValueKind is JsonValueKind.Array, - "Representation result data should be an array."); - Debug.Assert( - resultPaths.Length == dataElement.GetArrayLength(), - "Representation result paths length should match the data array length."); - if (errorTrie is null) { var i = 0; @@ -1440,6 +1482,326 @@ private bool SaveRepresentationResult( return true; } + private bool SaveRepresentationSourceErrors( + CompositeResultElement resultData, + SourceSchemaErrors errors, + ErrorTrie? errorTrie, + ReadOnlySpan resultPaths, + ResultSelectionSet resultSelectionSet) + { + if (!errors.RootErrors.IsDefaultOrEmpty) + { + _errors ??= []; + + foreach (var rootError in errors.RootErrors) + { + _errors.Add(_errorHandler.Handle(rootError)); + } + + if (!CompleteRepresentationErrorTargets( + resultData, + resultPaths, + resultSelectionSet)) + { + return false; + } + } + + return errorTrie is null + || AddRepresentationErrors(resultData, errorTrie, resultPaths); + } + + private bool CompleteRepresentationErrorTargets( + CompositeResultElement resultData, + ReadOnlySpan resultPaths, + ResultSelectionSet resultSelectionSet) + { + for (var i = 0; i < resultPaths.Length; i++) + { + ref readonly var resultPath = ref resultPaths[i]; + + if (!Complete(resultPath.Path)) + { + return false; + } + + foreach (var additionalPath in resultPath.AdditionalPaths) + { + if (!Complete(additionalPath)) + { + return false; + } + } + } + + return true; + + bool Complete(CompactPath targetPath) + { + if (resultData.IsInvalidated) + { + return false; + } + + var target = targetPath.IsRoot ? resultData : GetStartObjectResult(targetPath); + return target.IsNullOrInvalidated + || _valueCompletion.CompleteErrorResult(target, resultSelectionSet); + } + } + + private bool AddRepresentationErrors( + CompositeResultElement resultData, + ErrorTrie errorTrie, + ReadOnlySpan resultPaths) + { + if (resultPaths.IsEmpty) + { + AddOriginalRepresentationErrors(errorTrie); + return true; + } + + if (errorTrie.Error is { } rootError) + { + for (var i = 0; i < resultPaths.Length; i++) + { + if (!AddRepresentationError(resultData, rootError, resultPaths[i], null, null)) + { + return false; + } + } + } + + foreach (var (segment, childTrie) in errorTrie) + { + if (segment is int index && (uint)index < (uint)resultPaths.Length) + { + if (!AddRepresentationError(resultData, null, resultPaths[index], null, childTrie)) + { + return false; + } + continue; + } + + if (segment is int) + { + AddOriginalRepresentationErrors(childTrie); + continue; + } + + for (var i = 0; i < resultPaths.Length; i++) + { + if (!AddRepresentationError(resultData, null, resultPaths[i], segment, childTrie)) + { + return false; + } + } + } + + return true; + } + + private void AddOriginalRepresentationErrors(ErrorTrie errorTrie) + { + if (errorTrie.Error is { } error) + { + _errors ??= []; + _errors.Add(_errorHandler.Handle(error)); + } + + foreach (var childTrie in errorTrie.Values) + { + AddOriginalRepresentationErrors(childTrie); + } + } + + private bool AddRepresentationError( + CompositeResultElement resultData, + IError? error, + EntityResultPath resultPath, + object? segment, + ErrorTrie? suffixTrie) + { + var path = resultPath.Path.ToPath(resultData.Operation); + if (segment is not null) + { + path = AppendPathSegment(path, segment); + } + + if (!AddRepresentationError(error, suffixTrie, path)) + { + return false; + } + + foreach (var additionalPath in resultPath.AdditionalPaths) + { + path = additionalPath.ToPath(resultData.Operation); + if (segment is not null) + { + path = AppendPathSegment(path, segment); + } + + if (!AddRepresentationError(error, suffixTrie, path)) + { + return false; + } + } + + return true; + } + + private bool AddRepresentationError(IError? error, ErrorTrie? errorTrie, Path path) + { + if (error is not null && !_valueCompletion.BuildErrorResult(path, error)) + { + return false; + } + + if (errorTrie is null) + { + return true; + } + + if (errorTrie.Error is { } trieError + && !_valueCompletion.BuildErrorResult(path, trieError)) + { + return false; + } + + foreach (var (segment, childTrie) in errorTrie) + { + if (!AddRepresentationError(null, childTrie, AppendPathSegment(path, segment))) + { + return false; + } + } + + return true; + } + + private bool SaveRepresentationError( + CompositeResultElement resultData, + IError error, + ReadOnlySpan resultPaths, + ResultSelectionSet resultSelectionSet) + { + if (resultPaths.IsEmpty) + { + _errors ??= []; + _errors.Add(_errorHandler.Handle(error)); + return true; + } + + for (var i = 0; i < resultPaths.Length; i++) + { + ref readonly var resultPath = ref resultPaths[i]; + + if (!SaveRepresentationError( + resultData, + resultPath.Path, + resultPath.AdditionalPaths.AsSpan(), + error, + resultSelectionSet)) + { + return false; + } + } + + return true; + } + + private bool SaveRepresentationProtocolError( + CompositeResultElement resultData, + Exception exception, + ReadOnlySpan resultPaths, + ResultSelectionSet resultSelectionSet) + { + var error = ErrorBuilder.New() + .SetMessage(exception.Message) + .SetException(exception) + .Build(); + + if (resultPaths.IsEmpty) + { + _errors ??= []; + _errors.Add(_errorHandler.Handle(error)); + return true; + } + + for (var i = 0; i < resultPaths.Length; i++) + { + ref readonly var resultPath = ref resultPaths[i]; + + if (!SaveRepresentationError( + resultData, + resultPath.Path, + resultPath.AdditionalPaths.AsSpan(), + error, + resultSelectionSet)) + { + return false; + } + } + + return true; + } + + private bool SaveRepresentationError( + CompositeResultElement resultData, + CompactPath path, + ReadOnlySpan additionalPaths, + IError error, + ResultSelectionSet resultSelectionSet) + { + if (!AddError(path)) + { + return false; + } + + for (var i = 0; i < additionalPaths.Length; i++) + { + if (!AddError(additionalPaths[i])) + { + return false; + } + } + + return true; + + bool AddError(CompactPath targetPath) + { + if (resultData.IsInvalidated) + { + return false; + } + + var target = targetPath.IsRoot ? resultData : GetStartObjectResult(targetPath); + if (target.IsNullOrInvalidated) + { + return true; + } + + if (_valueCompletion.BuildErrorResult( + target, + resultSelectionSet, + error, + target.CompactPath)) + { + return true; + } + + resultData.Invalidate(); + return false; + } + } + + private static Path AppendPathSegment(Path path, object segment) + => segment switch + { + string name => path.Append(name), + int index => path.Append(index), + _ => throw new UnreachableException() + }; + private void ReturnRepresentationPathSegments(RepresentationValue representation) { if (representation.ResultPaths.IsDefaultOrEmpty) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 2b7e581bf63..fd5c67714f3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -860,8 +860,10 @@ private ReadOnlySpan CollectTargetElements(SelectionPath continue; } - // TODO : Better error - throw new NotSupportedException("Must be list or object."); + throw ThrowHelper.InvalidTargetValueKind( + selectionSet.Slice(i + 1), + value.Path, + valueKind); } } @@ -883,6 +885,36 @@ private ReadOnlySpan CollectTargetElements(SelectionPath return current.AsSpan(0, currentCount); } + public ImmutableArray GetResultPaths(SelectionPath selectionSet) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(selectionSet); + + lock (_lock) + { + if (selectionSet.IsRoot) + { + return [CompactPath.Root]; + } + + var elements = CollectTargetElements(selectionSet); + + if (elements.IsEmpty) + { + return []; + } + + var paths = ImmutableArray.CreateBuilder(elements.Length); + + foreach (var element in elements) + { + paths.Add(element.CompactPath); + } + + return paths.MoveToImmutable(); + } + } + private ImmutableArray BuildVariableValueSetsFromSnapshot( ImmutableArray importedEntries, IReadOnlyList requestVariables, @@ -2210,14 +2242,17 @@ private void InitializeIntermediateObject(CompositeResultElement element) ?? throw new InvalidOperationException( "Cannot initialize an intermediate target object without selection metadata."); - if (selection.Type.NamedType() is not IObjectTypeDefinition objectType) + // An interface-typed named type is an @interfaceObject stand-in position that has not yet + // recovered its concrete identity; it initializes interface-typed against the interface's + // declared fields. Unions carry no such single type context and remain unsupported here. + if (selection.Type.NamedType() is not IComplexTypeDefinition complexType) { throw new InvalidOperationException( "Cannot initialize an intermediate target object for an abstract selection."); } var selectionSet = selection.DeclaringSelectionSet.DeclaringOperation - .GetSelectionSet(selection, objectType); + .GetSelectionSet(selection, complexType); element.SetObjectValue(selectionSet); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs index 7ae5419f999..2e4a8955833 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs @@ -77,6 +77,10 @@ public bool BuildResult( { InitializeTargetObject(source, target); } + else + { + TryUpgradeOpaqueTarget(target, source); + } var objectContext = target.GetObjectContext(); @@ -148,12 +152,36 @@ private void InitializeTargetObject( "Cannot initialize a result object without selection metadata."); } - var objectType = GetType(type, source); + var objectType = GetType(type, source, isOpaque: false); var objectSelectionSet = selection.GetSelectionSet(objectType)!; target.SetObjectValue(objectSelectionSet); } + /// + /// When a covering lookup imports concrete data (carrying a __typename) into an element + /// that is still interface-typed from an @interfaceObject stand-in, upgrades the element + /// to its concrete type so the identity-dependent fields have slots to complete into. + /// + private void TryUpgradeOpaqueTarget(CompositeResultElement target, SourceResultElement source) + { + if (target.SelectionSet is not { Type.Kind: TypeKind.Interface } interfaceSet + || interfaceSet.DeclaringSelection is not { } parentSelection) + { + return; + } + + if (!source.TryGetProperty(IntrospectionFieldNames.TypeNameSpan, out var typeName) + || typeName.ValueKind is not JsonValueKind.String) + { + return; + } + + var concreteType = _schema.Types.GetType(typeName.AssertString()); + var concreteSelectionSet = parentSelection.GetSelectionSet(concreteType)!; + _store.Result.UpgradeObject(target, concreteSelectionSet); + } + /// /// Tries to null and assign the to the path /// of each field selected by . @@ -199,6 +227,56 @@ public bool BuildErrorResult( return true; } + public bool BuildErrorResult(Path path, IError error) + { + var reachablePath = path; + + while (!reachablePath.IsRoot) + { + if (_store.TryGetResult(reachablePath, out var fieldResult) + && fieldResult.Selection is { } selection) + { + return ApplyFieldError(fieldResult, selection, error, path); + } + + reachablePath = reachablePath.Parent; + } + + var errorWithPath = ErrorBuilder.FromError(error) + .SetPath(path) + .Build(); + _store.AddError(_errorHandler.Handle(errorWithPath)); + return true; + } + + public bool CompleteErrorResult( + CompositeResultElement target, + ResultSelectionSet resultSelectionSet) + { + foreach (var responseName in resultSelectionSet.ResponseNames) + { + if (target.IsNullOrInvalidated) + { + return true; + } + + if (!target.TryGetProperty(responseName, out var fieldResult) + || fieldResult.IsInternal + || fieldResult.Selection is not { Type.Kind: TypeKind.NonNull }) + { + continue; + } + + if (_errorHandlingMode is ErrorHandlingMode.Propagate + && PropagateNullValues(fieldResult)) + { + return false; + } + } + + return true; + } + public void FinalizePocketedErrors(CompositeResultElement resultData) { if (!_store.HasPocketedErrors) @@ -372,7 +450,7 @@ private bool CompleteNullSource( private static bool TryInitializeTargetObject(CompositeResultElement target) { if (!TryGetSelectionContext(target, out var selection, out var type) - || type.NamedType() is not IObjectTypeDefinition objectType) + || type.NamedType() is not IComplexTypeDefinition complexType) { return false; } @@ -382,7 +460,7 @@ private static bool TryInitializeTargetObject(CompositeResultElement target) return false; } - target.SetObjectValue(selection.GetSelectionSet(objectType)!); + target.SetObjectValue(selection.GetSelectionSet(complexType)!); return true; } @@ -877,7 +955,7 @@ private bool TryCompleteObjectValue( CompositeResultElement target, ErrorTrie? errorTrie, Selection parentSelection, - IObjectTypeDefinition objectType, + IComplexTypeDefinition objectType, int depth, ResultSelectionSet? resultSelectionSet) { @@ -949,10 +1027,20 @@ private bool TryCompleteAbstractValue( IType type, int depth, ResultSelectionSet? resultSelectionSet) - => TryCompleteObjectValue(source, target, errorTrie, selection, GetType(type, source), depth, resultSelectionSet); + { + var isOpaque = resultSelectionSet?.ProducesOpaqueElements ?? false; + return TryCompleteObjectValue( + source, + target, + errorTrie, + selection, + GetType(type, source, isOpaque), + depth, + resultSelectionSet); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private IObjectTypeDefinition GetType(IType type, SourceResultElement data) + private IComplexTypeDefinition GetType(IType type, SourceResultElement data, bool isOpaque) { var namedType = type.NamedType(); @@ -961,6 +1049,14 @@ private IObjectTypeDefinition GetType(IType type, SourceResultElement data) return objectType; } + // An opaque @interfaceObject value carries no authoritative __typename in the stand-in's + // result, so it completes interface-typed against the interface's declared fields and only + // recovers its concrete identity through the covering lookup. + if (isOpaque) + { + return (IComplexTypeDefinition)namedType; + } + var typeName = data.GetProperty(IntrospectionFieldNames.TypeNameSpan).AssertString(); return _schema.Types.GetType(typeName); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs index 12132a39dda..0b8a144fc6e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Fusion.Properties; @@ -25,6 +26,11 @@ public static InvalidOperationException MissingBatchResult(int operationId) FusionExecutionResources.OperationBatchExecutionNode_MissingBatchResult, operationId)); + public static InvalidOperationException NodeLookupNotFound(string typeName) + => new(string.Format( + FusionExecutionResources.PlanQueue_NodeLookupNotFound, + typeName)); + public static InvalidOperationException SingleOperationRequired() => new(FusionExecutionResources.JsonOperationPlanParser_SingleOperationRequired); @@ -40,4 +46,32 @@ public static InvalidOperationException VariableIndexOutOfRange(int variableInde public static ArgumentException InvalidClientConfiguration(Type expected, Type actual) => new($"Expected client configuration of type '{expected.Name}' but received '{actual.Name}'."); + + public static InvalidOperationException InvalidTargetValueKind( + SelectionPath selectionPath, + Path resultPath, + JsonValueKind valueKind) + => new(string.Format( + FusionExecutionResources.FetchResultStore_InvalidTargetValueKind, + selectionPath, + resultPath, + valueKind)); + + public static InvalidOperationException InvalidRepresentationResultKind( + SelectionPath sourcePath, + JsonValueKind valueKind) + => new(string.Format( + FusionExecutionResources.FetchResultStore_InvalidRepresentationResultKind, + sourcePath, + valueKind)); + + public static InvalidOperationException RepresentationResultCountMismatch( + SelectionPath sourcePath, + int actualCount, + int expectedCount) + => new(string.Format( + FusionExecutionResources.FetchResultStore_RepresentationResultCountMismatch, + sourcePath, + actualCount, + expectedCount)); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs index ffb87d39498..490400a351d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs @@ -48,6 +48,29 @@ public Backlog Pop(out WorkItem workItem) return new Backlog(items, cost); } + public Backlog BindSourceSchemaNodeCandidate(int candidateGroupId, string schemaName) + { + var workItems = _items.ToArray(); + var items = ImmutableStack.Empty; + + for (var i = workItems.Length - 1; i >= 0; i--) + { + var workItem = workItems[i]; + if (workItem.SourceSchemaNodePolicy is { CandidateGroupId: { } groupId } policy + && groupId == candidateGroupId) + { + workItem = workItem with + { + SourceSchemaNodePolicy = policy.BindCandidate(schemaName) + }; + } + + items = items.Push(workItem); + } + + return new Backlog(items, Cost); + } + /// /// Pushes work items for unresolvable selection sets that need /// their own operation steps on other schemas. @@ -57,7 +80,8 @@ public Backlog PushUnresolvable( string fromSchema, int parentDepth, ImmutableHashSet? dependents = null, - bool allowSourceSchemaReentry = false) + bool allowSourceSchemaReentry = false, + SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy = null) { if (unresolvable.IsEmpty) { @@ -79,7 +103,8 @@ public Backlog PushUnresolvable( ParentDepth = parentDepth, Conditions = entry.Conditions, Dependents = dependents ?? [], - AllowSourceSchemaReentry = allowSourceSchemaReentry + AllowSourceSchemaReentry = allowSourceSchemaReentry, + SourceSchemaNodePolicy = sourceSchemaNodePolicy }; backlog = backlog.Push(workItem); } @@ -94,7 +119,8 @@ public Backlog PushUnresolvable( public Backlog PushRequirements( ImmutableStack fieldsWithRequirements, RequirementConsumer consumer, - int parentDepth) + int parentDepth, + SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy = null) { if (fieldsWithRequirements.IsEmpty) { @@ -108,7 +134,8 @@ public Backlog PushRequirements( var workItem = new FieldRequirementWorkItem(entry.FieldSelection, consumer) { ParentDepth = parentDepth, - Conditions = entry.Conditions + Conditions = entry.Conditions, + SourceSchemaNodePolicy = sourceSchemaNodePolicy }; backlog = backlog.Push(workItem); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs index ab979bb8057..90d2602913b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationDefinitionBuilder.cs @@ -1,4 +1,5 @@ using HotChocolate.Execution; +using HotChocolate.Fusion.Types; using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; using HotChocolate.Types; @@ -80,7 +81,16 @@ public OperationDefinitionBuilder SetSelectionSet(SelectionSetNode selectionSet) selectionPathBuilder.AppendField(_lookup.FieldName); var lookupSelectionSet = selectionSet; - if (_typeToLookup != _lookup.FieldType + var isInterfaceObjectLookup = + _lookup.FieldType is FusionInterfaceTypeDefinition interfaceType + && interfaceType.Sources.TryGetMember(_lookup.SchemaName, out var interfaceSource) + && interfaceSource.IsInterfaceObject; + + // The concrete target condition remains on the plan target path. It must not be sent + // to an @interfaceObject stand-in, which neither defines the concrete type nor owns its + // identity. The preceding concrete-aware lookup has already established that identity. + if (!isInterfaceObjectLookup + && _typeToLookup != _lookup.FieldType && !selectionSet.Selections.All(s => s is InlineFragmentNode inlineFragment && inlineFragment.TypeCondition?.Name.Value == _typeToLookup.Name)) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs index 1142339838e..4d3a3be14e8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -591,8 +591,12 @@ private static ExecutionNode CreateOperationExecutionNode( var operationSource = operation.ToSourceText(); var selectionSetNode = GetSelectionSetNodeFromPath(operationStep.Definition, operationStep.Source); - selectionSetNode = PruneNonValueTypeChildren(selectionSetNode, operationStep.Type, schema); - var resultSelectionSet = ResultSelectionSet.Create(selectionSetNode, schema); + selectionSetNode = PruneNonValueTypeChildren(selectionSetNode, operationStep.Type, schema, operationStep.SchemaName); + var resultSelectionSet = ResultSelectionSet.Create( + selectionSetNode, + schema, + operationStep.Type, + operationStep.SchemaName); // Only synthetic internal key lookups resolve through _entities. Real public // root-field lookups, for example the composed node field, stay native even @@ -657,8 +661,12 @@ private static EventStreamExecutionNode CreateEventStreamExecutionNode( ?? throw new InvalidOperationException("The operation step does not carry event-stream metadata."); var selectionSetNode = GetSelectionSetNodeFromPath(operationStep.Definition, operationStep.Source); - selectionSetNode = PruneNonValueTypeChildren(selectionSetNode, operationStep.Type, schema); - var resultSelectionSet = ResultSelectionSet.Create(selectionSetNode, schema); + selectionSetNode = PruneNonValueTypeChildren(selectionSetNode, operationStep.Type, schema, operationStep.SchemaName); + var resultSelectionSet = ResultSelectionSet.Create( + selectionSetNode, + schema, + operationStep.Type, + operationStep.SchemaName); var node = new EventStreamExecutionNode( operationStep.Id, @@ -1366,7 +1374,7 @@ or ApolloOperationExecutionNode continue; } - // For a standalone operation node, attach dependencies directly. + // For a standalone execution node, attach dependencies directly. foreach (var dependencyId in stepDependencies) { if (!ctx.ExecutionNodes.TryGetValue(dependencyId, out var childEntry) @@ -1858,7 +1866,8 @@ private static SelectionSetNode GetSelectionSetNodeFromPath( private static SelectionSetNode PruneNonValueTypeChildren( SelectionSetNode selectionSet, ITypeDefinition parentType, - FusionSchemaDefinition schema) + FusionSchemaDefinition schema, + string? sourceSchemaName) { if (parentType is not IComplexTypeDefinition complexType) { @@ -1876,15 +1885,14 @@ private static SelectionSetNode PruneNonValueTypeChildren( { case FieldNode field when field.SelectionSet is not null: { - var responseName = field.Alias?.Value ?? field.Name.Value; - - if (complexType.Fields.TryGetField(responseName, out var fieldDef)) + if (complexType.Fields.TryGetField(field.Name.Value, out var fieldDef)) { var fieldNamedType = fieldDef.Type.NamedType(); if (fieldNamedType is FusionComplexTypeDefinition { IsValueType: true } valueType) { - var pruned = PruneNonValueTypeChildren(field.SelectionSet, valueType, schema); + var pruned = PruneNonValueTypeChildren( + field.SelectionSet, valueType, schema, sourceSchemaName); if (!ReferenceEquals(pruned, field.SelectionSet)) { @@ -1894,13 +1902,35 @@ private static SelectionSetNode PruneNonValueTypeChildren( continue; } } - else + else if (!IsOpaqueInterfaceObjectStandIn(fieldNamedType, sourceSchemaName)) { + // A non-value complex field (an entity boundary) is normally stripped + // because it is completed by a separate execution node. When its subtree + // reaches an @interfaceObject stand-in, however, the path to that field + // must be kept so the result selection set can carry the opacity marker; + // recurse to preserve it instead of stripping the whole subtree. + if (fieldNamedType is FusionComplexTypeDefinition complexFieldType + && SubtreeContainsOpaqueStandIn( + field.SelectionSet, complexFieldType, schema, sourceSchemaName)) + { + var pruned = PruneNonValueTypeChildren( + field.SelectionSet, complexFieldType, schema, sourceSchemaName); + + selections[i] = new FieldNode( + field.Name, field.Alias, field.Directives, field.Arguments, pruned); + changed = true; + continue; + } + selections[i] = new FieldNode( field.Name, field.Alias, field.Directives, field.Arguments, null); changed = true; continue; } + + // An @interfaceObject stand-in field keeps its interface-declared child + // selections so the result selection set can carry the opacity marker; the + // opaque value completes interface-typed against exactly those fields. } selections[i] = selection; @@ -1917,7 +1947,8 @@ private static SelectionSetNode PruneNonValueTypeChildren( ? resolvedType : parentType; - var pruned = PruneNonValueTypeChildren(inlineFragment.SelectionSet, fragmentType, schema); + var pruned = PruneNonValueTypeChildren( + inlineFragment.SelectionSet, fragmentType, schema, sourceSchemaName); if (!ReferenceEquals(pruned, inlineFragment.SelectionSet)) { @@ -1943,6 +1974,64 @@ private static SelectionSetNode PruneNonValueTypeChildren( return changed ? new SelectionSetNode(selections) : selectionSet; } + private static bool IsOpaqueInterfaceObjectStandIn(ITypeDefinition namedType, string? sourceSchemaName) + => sourceSchemaName is not null + && namedType is FusionInterfaceTypeDefinition interfaceType + && interfaceType.Sources.TryGetMember(sourceSchemaName, out var source) + && source.IsInterfaceObject; + + // Reports whether a selection set reaches an @interfaceObject stand-in field anywhere in its + // subtree. Used to decide whether a non-value entity boundary must keep its child selections so + // the result selection set can carry the opacity marker for a nested stand-in value. + private static bool SubtreeContainsOpaqueStandIn( + SelectionSetNode selectionSet, + ITypeDefinition parentType, + FusionSchemaDefinition schema, + string? sourceSchemaName) + { + if (parentType is not IComplexTypeDefinition complexType) + { + return false; + } + + foreach (var selection in selectionSet.Selections) + { + if (selection is FieldNode { SelectionSet: not null } field) + { + if (complexType.Fields.TryGetField(field.Name.Value, out var fieldDef)) + { + var fieldNamedType = fieldDef.Type.NamedType(); + + if (IsOpaqueInterfaceObjectStandIn(fieldNamedType, sourceSchemaName) + || (fieldNamedType is FusionComplexTypeDefinition complexFieldType + && SubtreeContainsOpaqueStandIn( + field.SelectionSet, complexFieldType, schema, sourceSchemaName))) + { + return true; + } + } + } + else if (selection is InlineFragmentNode inlineFragment) + { + var fragmentType = inlineFragment.TypeCondition is not null + && schema.Types.TryGetType( + inlineFragment.TypeCondition.Name.Value, + allowInaccessibleFields: true, + out var resolvedType) + ? resolvedType + : parentType; + + if (SubtreeContainsOpaqueStandIn( + inlineFragment.SelectionSet, fragmentType, schema, sourceSchemaName)) + { + return true; + } + } + } + + return false; + } + private static bool DoVariablesContainUploadScalar( IReadOnlyList variables, ISchemaDefinition schema) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 3434d04a5a9..d7ecb214ee1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using HotChocolate.Execution; using HotChocolate.Fusion.Converters; -using HotChocolate.Fusion.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Language; using HotChocolate.Fusion.Planning.Partitioners; @@ -27,6 +26,7 @@ public sealed partial class OperationPlanner private readonly SelectionSetPartitioner _partitioner; private readonly SelectionSetByTypePartitioner _selectionSetByTypePartitioner; private readonly NodeFieldSelectionSetPartitioner _nodeFieldSelectionSetPartitioner; + private readonly SourceSchemaNodeCandidateResolver _sourceSchemaNodeCandidateResolver; private readonly OperationPlannerOptions _options; private bool? _schemaHasDivergentInterfaceFields; @@ -52,6 +52,7 @@ public OperationPlanner( _partitioner = new SelectionSetPartitioner(schema); _selectionSetByTypePartitioner = new SelectionSetByTypePartitioner(schema); _nodeFieldSelectionSetPartitioner = new NodeFieldSelectionSetPartitioner(schema); + _sourceSchemaNodeCandidateResolver = new SourceSchemaNodeCandidateResolver(schema); _options = options; } @@ -1042,7 +1043,8 @@ private void PlanLookupSelections( workItem.EstimatedDepth, backlog, workItem.Conditions, - workItem.AllowSourceSchemaReentry); + workItem.AllowSourceSchemaReentry, + workItem.SourceSchemaNodePolicy); PlanSelections( workItem, current, @@ -1078,14 +1080,27 @@ private void PlanSelections( ? CreateEventStreamProvidedSelectionSet( workItem.SelectionSet.Node, current.EventStreamDirective!) - : null + : null, + TreatSourceExternalAsUnresolvable = workItem.SourceSchemaNodePolicy is not null }; (var resolvable, var unresolvable, var fieldsWithRequirements, index) = _partitioner.Partition(input); + if (resolvable is not { Selections.Count: > 0 } + && workItem.SourceSchemaNodePolicy is not null + && !fieldsWithRequirements.IsEmpty) + { + resolvable = new SelectionSetNode([new FieldNode(IntrospectionFieldNames.TypeName)]); + var strictIndexBuilder = index.ToBuilder(); + strictIndexBuilder.Register(workItem.SelectionSet.Id, resolvable); + index = strictIndexBuilder; + } + // if we cannot resolve any selection with the current source schema then this path // cannot be used to resolve the data for the current operation, and we need to skip it. - if (resolvable is null) + if (resolvable is null + || (workItem.SourceSchemaNodePolicy is not null + && resolvable.Selections.Count == 0)) { return; } @@ -1094,8 +1109,17 @@ private void PlanSelections( unresolvable, current.SchemaName, stepDepth, - allowSourceSchemaReentry: isEventStreamRoot); - backlog = backlog.PushRequirements(fieldsWithRequirements, new StepConsumer(stepId), stepDepth); + allowSourceSchemaReentry: isEventStreamRoot, + sourceSchemaNodePolicy: workItem.SourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant); + backlog = backlog.PushRequirements( + fieldsWithRequirements, + new StepConsumer(stepId), + stepDepth, + workItem.SourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant); // Lookups are always queries. Root work items can also be rewritten to the query root // when walking shared paths (for example the viewer convention in mutations). @@ -1206,13 +1230,17 @@ private PlanNode InlineLookupRequirements( int lookupStepDepth, Backlog backlog, ExecutionNodeCondition[]? conditions = null, - bool allowSourceSchemaReentry = false) + bool allowSourceSchemaReentry = false, + SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy = null) { var processed = new HashSet(); var lookupStepId = current.Steps.NextId(); var steps = current.Steps; var index = current.SelectionSetIndex.ToBuilder(); var selectionSet = lookup.Requirements; + var descendantPolicy = sourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant; if (index.IsRegistered(selectionSet)) { @@ -1266,7 +1294,8 @@ private PlanNode InlineLookupRequirements( { SchemaName = schemaName, SelectionSet = workItemSelectionSet with { Node = selectionSet }, - SelectionSetIndex = index + SelectionSetIndex = index, + TreatSourceExternalAsUnresolvable = sourceSchemaNodePolicy is not null }; var (resolvable, unresolvable, _, _) = _partitioner.Partition(input); @@ -1311,7 +1340,8 @@ private PlanNode InlineLookupRequirements( unresolvable, current.SchemaName, GetOperationStepDepth(current, step.Id), - dependents: ImmutableHashSet.Empty.Add(lookupStepId)); + dependents: ImmutableHashSet.Empty.Add(lookupStepId), + sourceSchemaNodePolicy: descendantPolicy); } } @@ -1340,7 +1370,8 @@ private PlanNode InlineLookupRequirements( lookupStepId, index, ref steps, - out var unresolvable)) + out var unresolvable, + treatSourceExternalAsUnresolvable: sourceSchemaNodePolicy is not null)) { selectionSet = null; @@ -1357,7 +1388,8 @@ private PlanNode InlineLookupRequirements( unresolvable, current.SchemaName, GetOperationStepDepth(current, ancestorMatch.Step.Id), - dependents: ImmutableHashSet.Empty.Add(lookupStepId)); + dependents: ImmutableHashSet.Empty.Add(lookupStepId), + sourceSchemaNodePolicy: descendantPolicy); } } } @@ -1377,7 +1409,8 @@ private PlanNode InlineLookupRequirements( lookup.SchemaName, ImmutableHashSet.Empty.Add(lookupStepId), lookupStepDepth, - conditions ?? []); + conditions ?? [], + sourceSchemaNodePolicy); } var remainingCost = @@ -1475,7 +1508,8 @@ private void PlanInlineFieldWithRequirements( workItem.Selection, current, index, - ref backlog); + ref backlog, + workItem.SourceSchemaNodePolicy); var operation = InlineSelections( @@ -1584,7 +1618,8 @@ private void PlanFieldWithRequirement( lookup, workItem.EstimatedDepth, backlog, - workItem.Conditions); + workItem.Conditions, + sourceSchemaNodePolicy: workItem.SourceSchemaNodePolicy); backlog = current.Backlog; if (current.Steps.ById(stepConsumer.StepId) is not OperationPlanStep updatedCurrentStep) @@ -1631,7 +1666,8 @@ private void PlanFieldWithRequirement( lookup.SchemaName, ImmutableHashSet.Empty.Add(stepId), stepDepth, - workItem.Conditions); + workItem.Conditions, + workItem.SourceSchemaNodePolicy); } var compositeField = workItem.Selection.Field; @@ -1684,7 +1720,8 @@ private void PlanFieldWithRequirement( workItem.Selection, current, indexBuilder, - ref backlog); + ref backlog, + workItem.SourceSchemaNodePolicy); var selectionSetNode = new SelectionSetNode( [workItem.Selection.Node.WithArguments(arguments).WithSelectionSet(childSelections)]); @@ -1894,20 +1931,53 @@ private void PlanNodeLookup( { SchemaName = current.SchemaName, SelectionSet = workItem.SelectionSet, - SelectionSetIndex = index + SelectionSetIndex = index, + TreatSourceExternalAsUnresolvable = workItem.SourceSchemaNodePolicy is not null }; (var resolvable, var unresolvable, var fieldsWithRequirements, index) = _partitioner.Partition(input); + if (resolvable is not { Selections.Count: > 0 } + && workItem.SourceSchemaNodePolicy is { } sourceSchemaNodePolicy) + { + if (!CanSeedSourceSchemaNodeBranch( + current.SchemaName, + fieldsWithRequirements, + unresolvable, + sourceSchemaNodePolicy, + index)) + { + return; + } + + resolvable = new SelectionSetNode([new FieldNode(IntrospectionFieldNames.TypeName)]); + var strictIndexBuilder = index.ToBuilder(); + strictIndexBuilder.Register(workItem.SelectionSet.Id, resolvable); + index = strictIndexBuilder; + } + // if we cannot resolve any selection with the current source schema then this path // cannot be used to resolve the data for the current operation, and we need to skip it. - if (resolvable is null) + if (resolvable is null + || (workItem.SourceSchemaNodePolicy is not null + && resolvable.Selections.Count == 0)) { return; } - backlog = backlog.PushUnresolvable(unresolvable, current.SchemaName, stepDepth); - backlog = backlog.PushRequirements(fieldsWithRequirements, new StepConsumer(stepId), stepDepth); + var descendantPolicy = workItem.SourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant; + backlog = backlog.PushUnresolvable( + unresolvable, + current.SchemaName, + stepDepth, + sourceSchemaNodePolicy: descendantPolicy); + backlog = backlog.PushRequirements( + fieldsWithRequirements, + new StepConsumer(stepId), + stepDepth, + descendantPolicy); var resolvableSelections = resolvable.Selections; if (!resolvableSelections.Any(IsTypeNameSelection)) @@ -2006,6 +2076,61 @@ nodePlanStep with possiblePlans.EnqueueBranches(next); } + private bool CanSeedSourceSchemaNodeBranch( + string schemaName, + ImmutableStack fieldsWithRequirements, + ImmutableStack unresolvable, + SourceSchemaNodePlanningPolicy policy, + ISelectionSetIndex index) + { + if (!fieldsWithRequirements.IsEmpty) + { + foreach (var entry in fieldsWithRequirements) + { + var selection = entry.FieldSelection; + if (!selection.Field.Sources.TryGetMember(schemaName, out var sourceField) + || sourceField.Requirements is not { } sourceRequirements) + { + return false; + } + + var requirementIndex = index.ToBuilder(); + requirementIndex.Register( + selection.SelectionSetId, + sourceRequirements.Requirements); + RegisterRequirementSelectionSets( + sourceRequirements.Requirements, + requirementIndex); + + var input = new SelectionSetPartitionerInput + { + SchemaName = schemaName, + SelectionSet = new SelectionSet( + selection.SelectionSetId, + sourceRequirements.Requirements, + selection.Field.DeclaringType, + selection.Path), + SelectionSetIndex = requirementIndex, + TreatSourceExternalAsUnresolvable = true + }; + + var (resolvable, requirementUnresolvable, nestedRequirements, _) = + _partitioner.Partition(input); + + if (resolvable is not { Selections.Count: > 0 } + || !requirementUnresolvable.IsEmpty + || !nestedRequirements.IsEmpty) + { + return false; + } + } + + return true; + } + + return policy.CandidateGroupId is null && !unresolvable.IsEmpty; + } + private void PlanNode( NodeFieldWorkItem workItem, PlanNode current, @@ -2019,8 +2144,7 @@ private void PlanNode( var nodeField = workItem.NodeField.Field; var responseName = nodeField.Alias?.Value ?? nodeField.Name.Value; var selectionPath = SelectionPath.Root.AppendField(responseName); - var sourceSchemaResolution = - _schema.Features.Get()?.NodeResolution == NodeResolution.SourceSchema; + var sourceSchemaResolution = _schema.NodeResolution == NodeResolution.SourceSchema; var idArgumentValue = nodeField.Arguments.First(a => a.Name.Value == "id").Value; @@ -2034,6 +2158,39 @@ private void PlanNode( (var sharedSelectionSet, var selectionSetsByType, index) = _selectionSetByTypePartitioner.Partition(input); + var hasSharedClientSelections = sharedSelectionSet?.Selections.Any( + selection => !IsTypeNameSelection(selection)) == true; + ImmutableDictionary>? candidateSchemasByType = null; + var branches = selectionSetsByType; + + if (sourceSchemaResolution) + { + var abstractType = (FusionComplexTypeDefinition)selectionSet.Type; + candidateSchemasByType = _sourceSchemaNodeCandidateResolver.Resolve( + nodeField, + abstractType); + + if (hasSharedClientSelections) + { + var branchesByType = selectionSetsByType.ToDictionary( + branch => branch.Type.Name, + StringComparer.Ordinal); + var branchBuilder = ImmutableArray.CreateBuilder(); + + foreach (var possibleType in _schema.GetPossibleTypes( + abstractType, + includeInaccessible: true).OrderBy(type => type.Name, StringComparer.Ordinal)) + { + branchBuilder.Add( + branchesByType.TryGetValue(possibleType.Name, out var branch) + ? branch + : new SelectionSetByType(possibleType, sharedSelectionSet!)); + } + + branches = branchBuilder.ToImmutable(); + } + } + var sharedSelections = sharedSelectionSet?.Selections ?? []; if (sourceSchemaResolution) { @@ -2093,8 +2250,21 @@ private void PlanNode( SourceSchemaResolution = sourceSchemaResolution }; - foreach (var (type, selectionSetNode) in selectionSetsByType) + foreach (var (type, selectionSetNode) in branches) { + SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy = null; + if (candidateSchemasByType is not null) + { + if (!candidateSchemasByType.TryGetValue(type.Name, out var candidateSchemas)) + { + continue; + } + + sourceSchemaNodePolicy = new SourceSchemaNodePlanningPolicy( + candidateSchemas, + hasSharedClientSelections ? stepId : null); + } + var nodeSelectionSet = new SelectionSet( index.GetId(selectionSetNode), selectionSetNode, @@ -2107,7 +2277,8 @@ private void PlanNode( idArgumentValue, nodeSelectionSet) { - ParentDepth = stepDepth + ParentDepth = stepDepth, + SourceSchemaNodePolicy = sourceSchemaNodePolicy }; backlog = backlog.Push(newWorkItem); @@ -2226,7 +2397,8 @@ private static List GetLookupArguments(Lookup lookup, string requi FieldSelection selection, PlanNode current, SelectionSetIndexBuilder index, - ref Backlog backlog) + ref Backlog backlog, + SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy) { if (selection.Node.SelectionSet is null) { @@ -2252,12 +2424,24 @@ private static List GetLookupArguments(Lookup lookup, string requi { SchemaName = current.SchemaName, SelectionSet = selectionSet, - SelectionSetIndex = index + SelectionSetIndex = index, + TreatSourceExternalAsUnresolvable = sourceSchemaNodePolicy is not null }; var (resolvable, unresolvable, fieldsWithRequirements, _) = _partitioner.Partition(input); - backlog = backlog.PushUnresolvable(unresolvable, current.SchemaName, stepDepth); - backlog = backlog.PushRequirements(fieldsWithRequirements, new StepConsumer(stepId), stepDepth); + var descendantPolicy = sourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant; + backlog = backlog.PushUnresolvable( + unresolvable, + current.SchemaName, + stepDepth, + sourceSchemaNodePolicy: descendantPolicy); + backlog = backlog.PushRequirements( + fieldsWithRequirements, + new StepConsumer(stepId), + stepDepth, + descendantPolicy); return resolvable; } @@ -2278,10 +2462,14 @@ private Backlog PushRequirementLeftover( string fromSchema, ImmutableHashSet dependents, int parentDepth, - ExecutionNodeCondition[] conditions) + ExecutionNodeCondition[] conditions, + SourceSchemaNodePlanningPolicy? sourceSchemaNodePolicy) { var complexType = entityType as FusionComplexTypeDefinition; List? directSelections = null; + var descendantPolicy = sourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant; foreach (var selection in leftover.Selections) { @@ -2319,7 +2507,8 @@ private Backlog PushRequirementLeftover( { Dependents = dependents, ParentDepth = parentDepth, - Conditions = conditions + Conditions = conditions, + SourceSchemaNodePolicy = descendantPolicy }); continue; } @@ -2343,7 +2532,8 @@ private Backlog PushRequirementLeftover( { Dependents = dependents, ParentDepth = parentDepth, - Conditions = conditions + Conditions = conditions, + SourceSchemaNodePolicy = descendantPolicy }); } @@ -2457,7 +2647,8 @@ private Backlog PushRequirementLeftover( index, requirementAliases, out var updatedStep, - out var unresolvable)) + out var unresolvable, + treatSourceExternalAsUnresolvable: workItem.SourceSchemaNodePolicy is not null)) { // if we cannot resolve any selection with the current source we cannot inline the // field requirements into this step. @@ -2489,7 +2680,10 @@ private Backlog PushRequirementLeftover( FromSchema: current.SchemaName) { ParentDepth = GetOperationStepDepth(current, step.Id), - Conditions = entry.Conditions + Conditions = entry.Conditions, + SourceSchemaNodePolicy = workItem.SourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant }); } } @@ -2529,7 +2723,8 @@ private Backlog PushRequirementLeftover( if (TryInlineIntoAncestorStep( ancestorMatch, requirements, workItem.Selection.Path, dependentStepId, index, ref steps, out var unresolvable, - resolvableRegistrationId: workItem.Selection.SelectionSetId)) + resolvableRegistrationId: workItem.Selection.SelectionSetId, + treatSourceExternalAsUnresolvable: workItem.SourceSchemaNodePolicy is not null)) { requirements = null; @@ -2551,7 +2746,10 @@ private Backlog PushRequirementLeftover( FromSchema: current.SchemaName) { ParentDepth = GetOperationStepDepth(current, ancestorMatch.Step.Id), - Conditions = entry.Conditions + Conditions = entry.Conditions, + SourceSchemaNodePolicy = workItem.SourceSchemaNodePolicy is null + ? null + : SourceSchemaNodePlanningPolicy.Descendant }); } } @@ -2571,7 +2769,8 @@ private bool TryInlineSelectionSetIntoStep( SelectionSetIndexBuilder index, RequirementAliasContext requirementAliases, out OperationPlanStep updatedStep, - out ImmutableStack unresolvable) + out ImmutableStack unresolvable, + bool treatSourceExternalAsUnresolvable = false) { index.Register(targetSelectionSetId, requirementSelections); @@ -2583,7 +2782,8 @@ private bool TryInlineSelectionSetIntoStep( requirementSelections, selectionSetType, path), - SelectionSetIndex = index + SelectionSetIndex = index, + TreatSourceExternalAsUnresolvable = treatSourceExternalAsUnresolvable }; var (resolvable, partitionUnresolvable, _, _) = _partitioner.Partition(input); @@ -3668,7 +3868,8 @@ private bool TryInlineIntoAncestorStep( SelectionSetIndexBuilder index, ref ImmutableList steps, out ImmutableStack unresolvable, - uint? resolvableRegistrationId = null) + uint? resolvableRegistrationId = null, + bool treatSourceExternalAsUnresolvable = false) { unresolvable = []; @@ -3690,7 +3891,8 @@ private bool TryInlineIntoAncestorStep( wrappedSelections, match.Type, path), - SelectionSetIndex = index + SelectionSetIndex = index, + TreatSourceExternalAsUnresolvable = treatSourceExternalAsUnresolvable }; var (resolvable, partitionUnresolvable, _, _) = _partitioner.Partition(input); @@ -3875,7 +4077,8 @@ public static string LookupOrderingKey(Lookup? lookup) public static IEnumerable<(string SchemaName, double Cost)> GetPossibleSchemas( this FusionSchemaDefinition compositeSchema, - SelectionSet selectionSet) + SelectionSet selectionSet, + IEnumerable? additionalCandidateSchemas = null) { ArgumentNullException.ThrowIfNull(compositeSchema); ArgumentNullException.ThrowIfNull(selectionSet); @@ -3889,6 +4092,11 @@ public static string LookupOrderingKey(Lookup? lookup) selectionSet.Selections, candidateSchemas); + if (additionalCandidateSchemas is not null) + { + candidateSchemas.UnionWith(additionalCandidateSchemas); + } + CollectFieldResolutions( compositeSchema, selectionSet.Type, @@ -3978,6 +4186,24 @@ static void CollectCandidateSchemas( case FieldNode fieldNode: if (fieldNode.Name.Value == IntrospectionFieldNames.TypeName) { + // __typename is resolvable on any schema, so it normally does not + // constrain schema choice. The exception is an @interfaceObject-opaque + // position: the stand-in cannot provide an authoritative concrete + // __typename, so identity recovery requires a concrete-aware source + // (one where the interface is not a stand-in), reached through its + // covering interface lookup. + if (complexType is FusionInterfaceTypeDefinition interfaceType + && interfaceType.Sources.Any(t => t.IsInterfaceObject)) + { + foreach (var source in interfaceType.Sources) + { + if (!source.IsInterfaceObject) + { + candidateSchemas.Add(source.SchemaName); + } + } + } + continue; } @@ -4010,6 +4236,25 @@ static void CollectCandidateSchemas( allowInaccessibleFields: true); } + // Narrowing an @interfaceObject-opaque interface to a concrete possible type + // observes identity: the stand-in cannot authoritatively tell whether a + // value is that concrete type, so a concrete-aware source (one where the + // interface is not a stand-in) must recover it through its covering + // interface lookup. Mirrors the interface-level __typename handling above so + // a fragment that selects only __typename still yields a candidate schema. + if (complexType is FusionInterfaceTypeDefinition fragmentParentInterface + && typeCondition is FusionObjectTypeDefinition + && fragmentParentInterface.Sources.Any(t => t.IsInterfaceObject)) + { + foreach (var source in fragmentParentInterface.Sources) + { + if (!source.IsInterfaceObject) + { + candidateSchemas.Add(source.SchemaName); + } + } + } + CollectCandidateSchemas( compositeSchema, typeCondition, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetByTypePartitioner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetByTypePartitioner.cs index 433ac57387e..941cf0306ba 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetByTypePartitioner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetByTypePartitioner.cs @@ -157,11 +157,6 @@ private void AddSelectionsForConcreteType( } } - // Walks the selection top-down, cloning every nested SelectionSetNode so that - // each clone can be registered against its original in the index. We can't use - // the bottom-up SyntaxRewriter for this, because by the time it reaches an outer - // SelectionSetNode its children have already been replaced, leaving the rewriter - // with a freshly allocated node that isn't tracked in the index. private static ISelectionNode CloneSelection( ISelectionNode selection, SelectionSetIndexBuilder indexBuilder) @@ -169,29 +164,13 @@ private static ISelectionNode CloneSelection( return selection switch { FieldNode field when field.SelectionSet is not null - => field.WithSelectionSet(CloneSelectionSet(field.SelectionSet, indexBuilder)), + => field.WithSelectionSet(SelectionSetCloner.Clone(field.SelectionSet, indexBuilder)), InlineFragmentNode fragment - => fragment.WithSelectionSet(CloneSelectionSet(fragment.SelectionSet, indexBuilder)), + => fragment.WithSelectionSet(SelectionSetCloner.Clone(fragment.SelectionSet, indexBuilder)), _ => selection }; } - private static SelectionSetNode CloneSelectionSet( - SelectionSetNode original, - SelectionSetIndexBuilder indexBuilder) - { - var clonedSelections = new ISelectionNode[original.Selections.Count]; - - for (var i = 0; i < original.Selections.Count; i++) - { - clonedSelections[i] = CloneSelection(original.Selections[i], indexBuilder); - } - - var cloned = new SelectionSetNode(clonedSelections); - indexBuilder.RegisterCloned(original, cloned); - return cloned; - } - private static List GetSelectionsWithPath( Stack fragmentPath, List selections, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs index 403845c2295..440c83bde5b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitioner.cs @@ -3,6 +3,7 @@ using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; using HotChocolate.Types; @@ -18,7 +19,8 @@ public SelectionSetPartitionerResult Partition( SchemaName = input.SchemaName, RootPath = input.SelectionSet.Path, SelectionSetIndex = input.SelectionSetIndex, - PruneUnprovidedAbstractBranches = input.PruneUnprovidedAbstractBranches + PruneUnprovidedAbstractBranches = input.PruneUnprovidedAbstractBranches, + TreatSourceExternalAsUnresolvable = input.TreatSourceExternalAsUnresolvable }; var (resolvable, _) = @@ -33,7 +35,11 @@ public SelectionSetPartitionerResult Partition( // further down during recursion, never here at the entry point. input.ProvidedSelectionSet is not null ? ProvidedCoverage.Complete - : ProvidedCoverage.Partial); + : ProvidedCoverage.Partial, + providerSchemas: schema.ShareableFieldRuntimeTypeRouting + is ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes + ? ImmutableHashSet.Create(StringComparer.Ordinal, input.SchemaName) + : null); return new SelectionSetPartitionerResult( resolvable, @@ -78,12 +84,24 @@ input.ProvidedSelectionSet is not null ITypeDefinition type, SelectionSetNode selectionSetNode, SelectionSetNode? providedSelectionSetNode, - ProvidedCoverage coverage) + ProvidedCoverage coverage, + FusionObjectTypeDefinition? narrowedSourceType = null, + ImmutableHashSet? providerSchemas = null, + ImmutableHashSet? allowedRuntimeTypes = null) { var complexType = type as FusionComplexTypeDefinition; List? resolvableSelections = null; List? unresolvableSelections = null; + // When the selection set type is an @interfaceObject stand-in in this schema, values here + // are opaque: the schema holds no authoritative concrete type. Both __typename and any type + // condition are spilled and recovered through the covering interface lookup on a + // concrete-aware schema rather than resolved from the stand-in. + var isInterfaceObjectContext = + type is FusionInterfaceTypeDefinition interfaceContextType + && interfaceContextType.Sources.TryGetMember(context.SchemaName, out var interfaceContextSource) + && interfaceContextSource.IsInterfaceObject; + providedSelectionSetNode = GetProvidedSelectionSet(type, schema, providedSelectionSetNode); context.Nodes.Push(selectionSetNode); @@ -96,9 +114,18 @@ input.ProvidedSelectionSet is not null { // The __typename field is available on all subgraphs, so we always treat it as resolvable. // We need to check it like this to also handle the union { __typename } case. + // The one exception is an @interfaceObject stand-in context, where __typename is + // opaque and must be recovered through the covering interface lookup. if (fieldNode.Name.Value.Equals(IntrospectionFieldNames.TypeName)) { - CompleteSelection(fieldNode, fieldNode, null, i); + if (isInterfaceObjectContext) + { + CompleteSelection(fieldNode, null, fieldNode, i); + } + else + { + CompleteSelection(fieldNode, fieldNode, null, i); + } } else { @@ -122,7 +149,8 @@ input.ProvidedSelectionSet is not null complexType!, fieldNode, GetProvidedField(fieldNode, providedSelectionSetNode), - coverage); + coverage, + providerSchemas); context.PopConditions(savedCount); @@ -143,7 +171,10 @@ input.ProvidedSelectionSet is not null type, inlineFragmentNode, providedSelectionSetNode, - coverage); + coverage, + narrowedSourceType, + providerSchemas, + allowedRuntimeTypes); CompleteSelection(inlineFragmentNode, resolvable, unresolvable, i); } @@ -191,7 +222,7 @@ input.ProvidedSelectionSet is not null resolvableSelections ??= [.. selectionSetNode.Selections]; - if (isAbstractType && !resolvableSelections.Any(IsTypeNameSelection)) + if (isAbstractType && !isInterfaceObjectContext && !resolvableSelections.Any(IsTypeNameSelection)) { resolvableSelections = [ new FieldNode(IntrospectionFieldNames.TypeName), @@ -463,7 +494,8 @@ private static SelectionSetNode WrapInExtraConditions( FusionComplexTypeDefinition complexType, FieldNode fieldNode, FieldNode? providedFieldNode, - ProvidedCoverage coverage) + ProvidedCoverage coverage, + ImmutableHashSet? providerSchemas) { var field = complexType.Fields.GetField(fieldNode.Name.Value, allowInaccessibleFields: true); field.Sources.TryGetMember(context.SchemaName, out var source); @@ -474,13 +506,18 @@ private static SelectionSetNode WrapInExtraConditions( // With partial coverage (a @provides scope), the provided set only adds fields, so an // uncovered field falls back to native ownership and a non-external source resolves it. var isResolvable = providedFieldNode is not null - || (coverage is ProvidedCoverage.Partial && source is { IsExternal: false }); + || (coverage is ProvidedCoverage.Partial + && source is { IsExternal: false } + && (!context.TreatSourceExternalAsUnresolvable + || !source.IsSourceExternal)); if (!isResolvable) { return (null, fieldNode); } + FusionObjectTypeDefinition? narrowedSourceType = null; + if (source?.SourceTypeName is { } narrowedTypeName && field.Type.NamedType().IsAbstractType()) { @@ -495,39 +532,53 @@ private static SelectionSetNode WrapInExtraConditions( + "does not exist in the composite schema."); } - if (narrowedType is not FusionObjectTypeDefinition narrowedObject) + // An @interfaceObject stand-in narrows to the interface itself and covers every possible + // type through its single opaque source, so there is no per-concrete coverage to check + // and nothing to spill. The concrete __typename is recovered through the covering + // interface lookup on a concrete-aware schema. + var isInterfaceObjectNarrowing = + narrowedType is FusionInterfaceTypeDefinition narrowedInterface + && narrowedInterface.Sources.TryGetMember(context.SchemaName, out var standInSource) + && standInSource.IsInterfaceObject; + + if (!isInterfaceObjectNarrowing) { - throw new NotSupportedException( - $"Supertype narrowing of field '{complexType.Name}.{field.Name}' in source schema " - + $"'{context.SchemaName}' to the abstract type '{narrowedTypeName}' " - + "is not yet supported. Only narrowing to a concrete object type is currently supported."); - } + if (narrowedType is not FusionObjectTypeDefinition narrowedObject) + { + throw new NotSupportedException( + $"Supertype narrowing of field '{complexType.Name}.{field.Name}' in source schema " + + $"'{context.SchemaName}' to the abstract type '{narrowedTypeName}' " + + "is not yet supported. Only narrowing to a concrete object type is currently supported."); + } - var coverageSelectionSet = GetCoverageSelectionSet(context.SelectionSetIndex, fieldNode.SelectionSet); - var fieldNodeForSpill = coverageSelectionSet is not null - && !ReferenceEquals(coverageSelectionSet, fieldNode.SelectionSet) - ? fieldNode.WithSelectionSet(coverageSelectionSet) - : fieldNode; + narrowedSourceType = narrowedObject; - if (!narrowedObject.Sources.TryGetMember(context.SchemaName, out var narrowedObjectSource)) - { - throw new InvalidOperationException( - $"The narrowed source type '{narrowedTypeName}' for field " - + $"'{complexType.Name}.{field.Name}' is not declared in source schema " - + $"'{context.SchemaName}'."); - } + var coverageSelectionSet = GetCoverageSelectionSet(context.SelectionSetIndex, fieldNode.SelectionSet); + var fieldNodeForSpill = coverageSelectionSet is not null + && !ReferenceEquals(coverageSelectionSet, fieldNode.SelectionSet) + ? fieldNode.WithSelectionSet(coverageSelectionSet) + : fieldNode; - foreach (var typeCondition in GetTopLevelTypeConditions(coverageSelectionSet)) - { - var conditionName = typeCondition.Name.Value; - var covered = - string.Equals(conditionName, narrowedTypeName, StringComparison.Ordinal) - || narrowedObjectSource.Implements.Contains(conditionName) - || narrowedObjectSource.MemberOf.Contains(conditionName); + if (!narrowedObject.Sources.TryGetMember(context.SchemaName, out var narrowedObjectSource)) + { + throw new InvalidOperationException( + $"The narrowed source type '{narrowedTypeName}' for field " + + $"'{complexType.Name}.{field.Name}' is not declared in source schema " + + $"'{context.SchemaName}'."); + } - if (!covered) + foreach (var typeCondition in GetTopLevelTypeConditions(coverageSelectionSet)) { - return (null, fieldNodeForSpill); + var conditionName = typeCondition.Name.Value; + var covered = + string.Equals(conditionName, narrowedTypeName, StringComparison.Ordinal) + || narrowedObjectSource.Implements.Contains(conditionName) + || narrowedObjectSource.MemberOf.Contains(conditionName); + + if (!covered) + { + return (null, fieldNodeForSpill); + } } } } @@ -550,6 +601,27 @@ private static SelectionSetNode WrapInExtraConditions( if (selectionSet is not null) { + ImmutableHashSet? childProviderSchemas = null; + ImmutableHashSet? allowedRuntimeTypes = null; + + if (providerSchemas is not null) + { + childProviderSchemas = ResolveViableProviderSchemas( + context, + complexType, + field, + providedFieldNode, + providerSchemas); + + if (field.Type.NamedType().IsAbstractType() + && childProviderSchemas.Count > 1) + { + allowedRuntimeTypes = ResolveCommonRuntimeTypes( + field, + childProviderSchemas); + } + } + context.Nodes.Push(fieldNode); var (resolvable, unresolvable) = RewriteSelectionSet( @@ -564,7 +636,10 @@ private static SelectionSetNode WrapInExtraConditions( // @provides also stays complete (at worst an extra lookup). coverage is ProvidedCoverage.Complete && providedFieldNode is not null ? ProvidedCoverage.Complete - : ProvidedCoverage.Partial); + : ProvidedCoverage.Partial, + narrowedSourceType, + childProviderSchemas, + allowedRuntimeTypes); context.Nodes.Pop(); @@ -651,9 +726,11 @@ static IEnumerable GetTopLevelTypeConditions(SelectionSetNode? se ITypeDefinition type, InlineFragmentNode inlineFragmentNode, SelectionSetNode? providedFieldNode, - ProvidedCoverage coverage) + ProvidedCoverage coverage, + FusionObjectTypeDefinition? narrowedSourceType, + ImmutableHashSet? providerSchemas, + ImmutableHashSet? allowedRuntimeTypes) { - // TODO: we need to implement proper type routing here later. var typeCondition = type; if (inlineFragmentNode.TypeCondition is not null) @@ -661,6 +738,16 @@ static IEnumerable GetTopLevelTypeConditions(SelectionSetNode? se typeCondition = schema.Types.GetType( inlineFragmentNode.TypeCondition.Name.Value, allowInaccessibleFields: true); + + // Abstract selections are cloned into concrete branches during planning. Once the + // parent is concrete, discard sibling concrete fragments that can no longer apply. + // Supertype fragments remain applicable because their possible types include the + // concrete parent. + if (type is FusionObjectTypeDefinition objectType + && !ContainsType(schema.GetPossibleTypes(typeCondition, includeInaccessible: true), objectType)) + { + return (null, null); + } } if (context.PruneUnprovidedAbstractBranches @@ -671,7 +758,38 @@ static IEnumerable GetTopLevelTypeConditions(SelectionSetNode? se return (null, null); } - if (!typeCondition.ExistsInSchema(context.SchemaName)) + var typeConditionExistsInSource = typeCondition.ExistsInSchema(context.SchemaName); + + if (!typeConditionExistsInSource + && IsInterfaceObjectPossibleType(type, typeCondition, context.SchemaName)) + { + // In an @interfaceObject stand-in context the concrete type is opaque and not defined + // in this schema, but the type condition still narrows a possible type of the + // interface. Spill it so the covering interface lookup on a concrete-aware schema + // recovers it, rather than dropping the selection. + return (null, inlineFragmentNode); + } + + if (inlineFragmentNode.TypeCondition is not null + && TryGetSourceTypeExpansion( + type, + typeCondition, + context.SchemaName, + narrowedSourceType, + allowedRuntimeTypes, + out var possibleTypes)) + { + return RewriteExpandedFragmentNode( + context, + inlineFragmentNode, + possibleTypes, + providedFieldNode, + coverage, + narrowedSourceType, + providerSchemas); + } + + if (!typeConditionExistsInSource) { return (null, null); } @@ -684,7 +802,10 @@ static IEnumerable GetTopLevelTypeConditions(SelectionSetNode? se typeCondition, inlineFragmentNode.SelectionSet, providedFieldNode, - coverage); + coverage, + narrowedSourceType, + providerSchemas, + allowedRuntimeTypes); context.Nodes.Pop(); @@ -700,6 +821,397 @@ static IEnumerable GetTopLevelTypeConditions(SelectionSetNode? se ); } + private (InlineFragmentNode?, InlineFragmentNode?) RewriteExpandedFragmentNode( + Context context, + InlineFragmentNode inlineFragmentNode, + IReadOnlyList possibleTypes, + SelectionSetNode? providedFieldNode, + ProvidedCoverage coverage, + FusionObjectTypeDefinition? narrowedSourceType, + ImmutableHashSet? providerSchemas) + { + if (possibleTypes.Count == 0) + { + return (null, null); + } + + List? resolvable = null; + List? unresolvable = null; + + context.Nodes.Push(inlineFragmentNode); + + foreach (var possibleType in possibleTypes) + { + var branchSelectionSet = context.CloneSelectionSet(inlineFragmentNode.SelectionSet); + + var concreteFragment = new InlineFragmentNode( + inlineFragmentNode.Location, + new NamedTypeNode(possibleType.Name), + [], + branchSelectionSet); + + context.Nodes.Push(concreteFragment); + + var unresolvableBefore = context.Unresolvable; + + var (resolvedSelectionSet, unresolvedSelectionSet) = + RewriteSelectionSet( + context, + possibleType, + branchSelectionSet, + providedFieldNode, + coverage, + narrowedSourceType ?? possibleType, + providerSchemas); + + context.Nodes.Pop(); + + if (resolvedSelectionSet is { Selections.Count: 0 } + && !ReferenceEquals(unresolvableBefore, context.Unresolvable)) + { + resolvedSelectionSet = new SelectionSetNode([ + new FieldNode(IntrospectionFieldNames.TypeName)]); + context.Register(branchSelectionSet, resolvedSelectionSet); + } + + if (resolvedSelectionSet is { Selections.Count: > 0 }) + { + resolvable ??= new List(possibleTypes.Count); + resolvable.Add(concreteFragment.WithSelectionSet(resolvedSelectionSet)); + } + + if (unresolvedSelectionSet is { Selections.Count: > 0 }) + { + unresolvable ??= new List(possibleTypes.Count); + unresolvable.Add(concreteFragment.WithSelectionSet(unresolvedSelectionSet)); + } + } + + context.Nodes.Pop(); + + return + ( + WrapExpandedFragments(context, inlineFragmentNode, resolvable), + WrapExpandedFragments(context, inlineFragmentNode, unresolvable) + ); + } + + private static InlineFragmentNode? WrapExpandedFragments( + Context context, + InlineFragmentNode original, + List? fragments) + { + if (fragments is null) + { + return null; + } + + if (fragments.Count == 1) + { + var fragment = fragments[0]; + return new InlineFragmentNode( + original.Location, + fragment.TypeCondition, + original.Directives, + fragment.SelectionSet); + } + + var selectionSet = new SelectionSetNode(fragments); + context.Register(original.SelectionSet, selectionSet); + + return new InlineFragmentNode( + original.Location, + null, + original.Directives, + selectionSet); + } + + private bool TryGetSourceTypeExpansion( + ITypeDefinition parentType, + ITypeDefinition typeCondition, + string schemaName, + FusionObjectTypeDefinition? narrowedSourceType, + ImmutableHashSet? allowedRuntimeTypes, + [NotNullWhen(true)] out IReadOnlyList? possibleTypes) + { + if (narrowedSourceType is null + && parentType is not FusionInterfaceTypeDefinition + and not FusionUnionTypeDefinition + || narrowedSourceType is null && !parentType.ExistsInSchema(schemaName) + || parentType is FusionInterfaceTypeDefinition parentInterface + && parentInterface.Sources.TryGetMember(schemaName, out var parentSource) + && parentSource.IsInterfaceObject) + { + possibleTypes = null; + return false; + } + + var parentPossibleTypes = schema.GetPossibleTypes(parentType, includeInaccessible: true); + var conditionPossibleTypes = schema.GetPossibleTypes(typeCondition, includeInaccessible: true); + + if (allowedRuntimeTypes is not null) + { + var allowedTypes = new List(); + + foreach (var possibleType in parentPossibleTypes) + { + if (allowedRuntimeTypes.Contains(possibleType.Name) + && ContainsType(conditionPossibleTypes, possibleType) + && (narrowedSourceType is null + || ReferenceEquals(narrowedSourceType, possibleType)) + && IsPossibleTypeInSource(parentType, possibleType, schemaName)) + { + allowedTypes.Add(possibleType); + } + } + + possibleTypes = allowedTypes; + return true; + } + + if (narrowedSourceType is not null) + { + if (!ContainsType(parentPossibleTypes, narrowedSourceType) + || !ContainsType(conditionPossibleTypes, narrowedSourceType)) + { + possibleTypes = Array.Empty(); + return true; + } + + if (IsPossibleTypeInSource(typeCondition, narrowedSourceType, schemaName)) + { + possibleTypes = null; + return false; + } + + possibleTypes = new[] { narrowedSourceType }; + return true; + } + + var matchingTypeCount = 0; + var requiresExpansion = false; + + foreach (var possibleType in parentPossibleTypes) + { + if (!IsPossibleTypeInSource(parentType, possibleType, schemaName) + || !ContainsType(conditionPossibleTypes, possibleType)) + { + continue; + } + + matchingTypeCount++; + + if (!IsPossibleTypeInSource(typeCondition, possibleType, schemaName)) + { + requiresExpansion = true; + } + } + + if (matchingTypeCount > 0 && !requiresExpansion) + { + possibleTypes = null; + return false; + } + + if (matchingTypeCount == 0) + { + possibleTypes = Array.Empty(); + return true; + } + + var expandedTypes = new List(matchingTypeCount); + + foreach (var possibleType in parentPossibleTypes) + { + if (IsPossibleTypeInSource(parentType, possibleType, schemaName) + && ContainsType(conditionPossibleTypes, possibleType)) + { + expandedTypes.Add(possibleType); + } + } + + possibleTypes = expandedTypes; + return true; + } + + private static bool IsPossibleTypeInSource( + ITypeDefinition abstractType, + FusionObjectTypeDefinition possibleType, + string schemaName) + { + if (!possibleType.Sources.TryGetMember(schemaName, out var source)) + { + return false; + } + + return abstractType switch + { + FusionObjectTypeDefinition objectType => ReferenceEquals(objectType, possibleType), + FusionInterfaceTypeDefinition interfaceType => source.Implements.Contains(interfaceType.Name), + FusionUnionTypeDefinition unionType => source.MemberOf.Contains(unionType.Name), + _ => false + }; + } + + private ImmutableHashSet ResolveViableProviderSchemas( + Context context, + FusionComplexTypeDefinition parentType, + FusionOutputFieldDefinition field, + FieldNode? providedFieldNode, + ImmutableHashSet providerSchemas) + { + if (providedFieldNode is not null) + { + return ImmutableHashSet.Create(StringComparer.Ordinal, context.SchemaName); + } + + foreach (var providerSchema in providerSchemas) + { + if (!IsApolloFederationSchema(providerSchema)) + { + return ImmutableHashSet.Create(StringComparer.Ordinal, context.SchemaName); + } + } + + foreach (var source in field.Sources) + { + if (!source.IsExternal + && !source.IsSourceExternal + && !IsApolloFederationSchema(source.SchemaName)) + { + return ImmutableHashSet.Create(StringComparer.Ordinal, context.SchemaName); + } + } + + var viableProviders = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); + var isOperationRoot = ReferenceEquals(parentType, schema.QueryType) + || ReferenceEquals(parentType, schema.MutationType) + || ReferenceEquals(parentType, schema.SubscriptionType); + + foreach (var source in field.Sources) + { + if (source.IsExternal || source.IsSourceExternal) + { + continue; + } + + var candidateSchema = source.SchemaName; + if (isOperationRoot || providerSchemas.Contains(candidateSchema)) + { + viableProviders.Add(candidateSchema); + continue; + } + + foreach (var providerSchema in providerSchemas) + { + if (schema.TryGetBestDirectLookup( + parentType, + providerSchema, + candidateSchema, + out _)) + { + viableProviders.Add(candidateSchema); + break; + } + } + } + + return viableProviders.Count == 0 + ? ImmutableHashSet.Create(StringComparer.Ordinal, context.SchemaName) + : viableProviders.ToImmutable(); + } + + private ImmutableHashSet? ResolveCommonRuntimeTypes( + FusionOutputFieldDefinition field, + ImmutableHashSet providerSchemas) + { + var fieldType = field.Type.AsTypeDefinition(); + HashSet? commonRuntimeTypes = null; + + foreach (var providerSchema in providerSchemas.OrderBy( + static name => name, + StringComparer.Ordinal)) + { + var source = field.Sources[providerSchema]; + var providerType = fieldType; + + if (source.SourceTypeName is { } sourceTypeName) + { + providerType = schema.Types.GetType( + sourceTypeName, + allowInaccessibleFields: true); + } + + if (providerType is FusionInterfaceTypeDefinition interfaceType + && interfaceType.Sources.TryGetMember(providerSchema, out var interfaceSource) + && interfaceSource.IsInterfaceObject) + { + continue; + } + + var providerRuntimeTypes = new HashSet(StringComparer.Ordinal); + + if (providerType is FusionObjectTypeDefinition objectType) + { + providerRuntimeTypes.Add(objectType.Name); + } + else + { + foreach (var possibleType in schema.GetPossibleTypes( + providerType, + includeInaccessible: true)) + { + if (IsPossibleTypeInSource(providerType, possibleType, providerSchema)) + { + providerRuntimeTypes.Add(possibleType.Name); + } + } + } + + if (commonRuntimeTypes is null) + { + commonRuntimeTypes = providerRuntimeTypes; + } + else + { + commonRuntimeTypes.IntersectWith(providerRuntimeTypes); + } + } + + return commonRuntimeTypes?.ToImmutableHashSet(StringComparer.Ordinal); + } + + private bool IsApolloFederationSchema(string schemaName) + => schema.GetSourceSchemaConnectorKind(schemaName) + == ConnectorKindNames.ApolloFederation; + + private static bool ContainsType( + ImmutableArray possibleTypes, + FusionObjectTypeDefinition type) + { + foreach (var possibleType in possibleTypes) + { + if (ReferenceEquals(possibleType, type)) + { + return true; + } + } + + return false; + } + + private bool IsInterfaceObjectPossibleType( + ITypeDefinition parentType, + ITypeDefinition typeCondition, + string schemaName) + { + return parentType is FusionInterfaceTypeDefinition interfaceType + && interfaceType.Sources.TryGetMember(schemaName, out var source) + && source.IsInterfaceObject + && typeCondition is FusionObjectTypeDefinition + && interfaceType.IsAssignableFrom(typeCondition); + } + private sealed class Context { public required string SchemaName { get; init; } @@ -710,6 +1222,8 @@ private sealed class Context public bool PruneUnprovidedAbstractBranches { get; init; } + public bool TreatSourceExternalAsUnresolvable { get; init; } + [field: AllowNull, MaybeNull] public SelectionSetIndexBuilder SelectionSetIndexBuilder { @@ -798,6 +1312,9 @@ public void Register(SelectionSetNode original, SelectionSetNode branch) SelectionSetIndexBuilder.Register(original, branch); } + + public SelectionSetNode CloneSelectionSet(SelectionSetNode original) + => SelectionSetCloner.Clone(original, SelectionSetIndexBuilder); } private bool CanMessageShapeProvideType( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitionerInput.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitionerInput.cs index 9aa5eeccff5..6d90d2bfb98 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitionerInput.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Partitioners/SelectionSetPartitionerInput.cs @@ -9,4 +9,5 @@ internal readonly ref struct SelectionSetPartitionerInput public required ISelectionSetIndex SelectionSetIndex { get; init; } public SelectionSetNode? ProvidedSelectionSet { get; init; } public bool PruneUnprovidedAbstractBranches { get; init; } + public bool TreatSourceExternalAsUnresolvable { get; init; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs index 90c972b04ca..bbb5d218fd0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs @@ -4,6 +4,7 @@ using HotChocolate.Language; using HotChocolate.Types; using Lookup = HotChocolate.Fusion.Types.Metadata.Lookup; +using ThrowHelper = HotChocolate.Fusion.Execution.ThrowHelper; namespace HotChocolate.Fusion.Planning; @@ -131,7 +132,12 @@ private void EnqueueLookupPlanNodes( // before falling through to the standard abstract-type lookup path. if (type.Kind is TypeKind.Interface or TypeKind.Union) { - TryEnqueueConcreteTypeLookupPlanNodes(planNodeTemplate, workItem, backlog, allCandidateSchemas, type); + TryEnqueueConcreteTypeLookupPlanNodes( + planNodeTemplate, + workItem, + backlog, + allCandidateSchemas, + type); } // Each branch starts from the same popped template and mutates a local copy @@ -293,6 +299,7 @@ private bool TryEnqueueConcreteTypeLookupPlanNodes( FusionComplexTypeDefinition type) { var enqueued = false; + var hasCoveringAbstractLookup = false; // Phase 1: we try to find a single schema that can resolve all concrete types as // this would allow us to batch all requests to these into a single GraphQL batch request. @@ -304,19 +311,20 @@ private bool TryEnqueueConcreteTypeLookupPlanNodes( continue; } - // if the target schema already has a lookup returning the abstract type, - // let the normal lookup path handle it. - var hasAbstractLookups = schema - .GetPossibleLookupsOrdered(type, toSchema) - .Any(t => t.FieldType.Name.Equals(type.Name, StringComparison.Ordinal)); + var fromSchemas = allCandidateSchemas.Remove(toSchema); - if (hasAbstractLookups) + // If the target schema has a directly resolvable lookup returning the abstract type, + // let the normal lookup path handle it instead of requiring a lookup for every concrete + // type. Interface-object metadata alone is not enough: a non-resolvable key does not + // provide a transition into the target schema. + if (schema.TryGetBestDirectLookup(type, fromSchemas, toSchema, out var abstractLookup) + && abstractLookup.FieldType.Name.Equals(type.Name, StringComparison.Ordinal)) { + hasCoveringAbstractLookup = true; continue; } var branchBacklog = backlog; - var fromSchemas = allCandidateSchemas.Remove(toSchema); var allFound = true; // for each concrete type that implements the abstract type, @@ -370,9 +378,9 @@ private bool TryEnqueueConcreteTypeLookupPlanNodes( enqueued = true; } - if (enqueued) + if (enqueued || hasCoveringAbstractLookup) { - return true; + return enqueued; } // Phase 2: if we do not find a single schema that can can resolve all concrete types we @@ -499,8 +507,16 @@ private void EnqueueNodeLookupPlanNodes( // copy backlog state per branch, then // materialize a new node with the // branch-local lower bound. - foreach (var (schemaName, resolutionCost) in schema.GetPossibleSchemas(workItem.SelectionSet)) + foreach (var (schemaName, resolutionCost) in schema.GetPossibleSchemas( + workItem.SelectionSet, + workItem.SourceSchemaNodePolicy?.AllowedSchemaNames)) { + var policy = workItem.SourceSchemaNodePolicy; + if (policy?.Allows(schemaName) == false) + { + continue; + } + // If we have multiple id lookups in a single schema, // we try to choose one that returns the desired type directly // and not an abstract type. @@ -511,8 +527,21 @@ private void EnqueueNodeLookupPlanNodes( continue; } - var lookupWorkItem = workItem with { Lookup = byIdLookup }; - var branchBacklog = backlog.Push(lookupWorkItem); + var candidateBacklog = backlog; + if (policy is { CandidateGroupId: { } candidateGroupId }) + { + policy = policy.BindCandidate(schemaName); + candidateBacklog = backlog.BindSourceSchemaNodeCandidate( + candidateGroupId, + schemaName); + } + + var lookupWorkItem = workItem with + { + Lookup = byIdLookup, + SourceSchemaNodePolicy = policy + }; + var branchBacklog = candidateBacklog.Push(lookupWorkItem); var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog); Enqueue(planNodeTemplate with { @@ -530,12 +559,53 @@ private void EnqueueNodeLookupPlanNodes( // In this case we enqueue the best matching by id lookup of any source schema. if (!hasEnqueuedLookup) { - var byIdLookup = SelectNodeBranchLookup(schema, type, schemaName: null) - ?? throw new InvalidOperationException( - $"Expected to have at least one lookup with just an 'id' argument for type '{type.Name}'."); + Lookup? byIdLookup; + if (workItem.SourceSchemaNodePolicy?.AllowedSchemaNames is not { } allowedSchemaNames) + { + byIdLookup = SelectNodeBranchLookup(schema, type, schemaName: null); + } + else + { + byIdLookup = null; + var schemaNames = allowedSchemaNames.ToArray(); + Array.Sort(schemaNames, StringComparer.Ordinal); - var lookupWorkItem = workItem with { Lookup = byIdLookup }; - var branchBacklog = backlog.Push(lookupWorkItem); + foreach (var schemaName in schemaNames) + { + byIdLookup = SelectNodeBranchLookup(schema, type, schemaName); + if (byIdLookup is not null) + { + break; + } + } + } + + if (byIdLookup is null) + { + if (workItem.SourceSchemaNodePolicy is not null) + { + return; + } + + throw ThrowHelper.NodeLookupNotFound(type.Name); + } + + var policy = workItem.SourceSchemaNodePolicy; + var candidateBacklog = backlog; + if (policy is { CandidateGroupId: { } candidateGroupId }) + { + policy = policy.BindCandidate(byIdLookup.SchemaName); + candidateBacklog = backlog.BindSourceSchemaNodeCandidate( + candidateGroupId, + byIdLookup.SchemaName); + } + + var lookupWorkItem = workItem with + { + Lookup = byIdLookup, + SourceSchemaNodePolicy = policy + }; + var branchBacklog = candidateBacklog.Push(lookupWorkItem); var branchRemainingCost = EstimateRemainingCost(planNodeTemplate, branchBacklog); Enqueue(planNodeTemplate with { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetCloner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetCloner.cs new file mode 100644 index 00000000000..3943f43d003 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetCloner.cs @@ -0,0 +1,38 @@ +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +internal static class SelectionSetCloner +{ + // Clone top-down so every nested selection set can be registered against the original node. + // A bottom-up syntax rewrite replaces children before the parent is visited and loses that mapping. + public static SelectionSetNode Clone( + SelectionSetNode original, + SelectionSetIndexBuilder indexBuilder) + { + var clonedSelections = new ISelectionNode[original.Selections.Count]; + + for (var i = 0; i < original.Selections.Count; i++) + { + clonedSelections[i] = CloneSelection(original.Selections[i], indexBuilder); + } + + var cloned = new SelectionSetNode(clonedSelections); + indexBuilder.RegisterCloned(original, cloned); + return cloned; + } + + private static ISelectionNode CloneSelection( + ISelectionNode selection, + SelectionSetIndexBuilder indexBuilder) + { + return selection switch + { + FieldNode field when field.SelectionSet is not null + => field.WithSelectionSet(Clone(field.SelectionSet, indexBuilder)), + InlineFragmentNode fragment + => fragment.WithSelectionSet(Clone(fragment.SelectionSet, indexBuilder)), + _ => selection + }; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetIndexBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetIndexBuilder.cs index 07612fff85e..4d9a930a68d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetIndexBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SelectionSetIndexBuilder.cs @@ -69,6 +69,11 @@ public void RegisterCloned(SelectionSetNode original, SelectionSetNode cloned) var clonedId = _selectionSets[cloned]; var originalId = _selectionSets[original]; + while (_clonedToOriginalMap.TryGetValue(originalId, out var parentOriginalId)) + { + originalId = parentOriginalId; + } + _clonedToOriginalMap = _clonedToOriginalMap.SetItem(clonedId, originalId); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SourceSchemaNodeCandidateResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SourceSchemaNodeCandidateResolver.cs new file mode 100644 index 00000000000..08b65842d91 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SourceSchemaNodeCandidateResolver.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +internal sealed class SourceSchemaNodeCandidateResolver(FusionSchemaDefinition schema) +{ + public ImmutableDictionary> Resolve( + FieldNode rootFieldNode, + FusionComplexTypeDefinition abstractType) + { + var candidatesByType = new Dictionary>(StringComparer.Ordinal); + + foreach (var (schemaName, sourceTypeName) in GetCandidates(rootFieldNode, abstractType)) + { + foreach (var typeName in GetSourceLocalRuntimeTypes( + abstractType, + schemaName, + sourceTypeName)) + { + if (!candidatesByType.TryGetValue(typeName, out var candidates)) + { + candidates = new HashSet(StringComparer.Ordinal); + candidatesByType.Add(typeName, candidates); + } + + candidates.Add(schemaName); + } + } + + var result = ImmutableDictionary.CreateBuilder>( + StringComparer.Ordinal); + + foreach (var (typeName, candidateNames) in candidatesByType) + { + var candidates = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); + candidates.UnionWith(candidateNames); + result.Add(typeName, candidates.ToImmutable()); + } + + return result.ToImmutable(); + } + + private SortedDictionary GetCandidates( + FieldNode rootFieldNode, + FusionComplexTypeDefinition abstractType) + { + var rootField = schema.QueryType.Fields.GetField( + rootFieldNode.Name.Value, + allowInaccessibleFields: true); + var candidates = new SortedDictionary(StringComparer.Ordinal); + + foreach (var rootFieldSource in rootField.Sources) + { + if (!rootFieldSource.IsExternal) + { + candidates.TryAdd( + rootFieldSource.SchemaName, + rootFieldSource.SourceTypeName); + } + } + + foreach (var possibleType in schema.GetPossibleTypes( + abstractType, + includeInaccessible: true)) + { + foreach (var lookup in schema.GetPossibleLookupsOrdered(possibleType)) + { + if (!lookup.IsInternal + && lookup.FieldName.Equals(rootField.Name, StringComparison.Ordinal) + && lookup.FieldType == abstractType + && lookup.Path.IsDefaultOrEmpty) + { + candidates.TryAdd(lookup.SchemaName, abstractType.Name); + } + } + } + + return candidates; + } + + private HashSet GetSourceLocalRuntimeTypes( + FusionComplexTypeDefinition abstractType, + string schemaName, + string? sourceTypeName) + { + var applicableTypes = new HashSet(StringComparer.Ordinal); + + if (sourceTypeName is not null + && schema.Types.TryGetType( + sourceTypeName, + allowInaccessibleFields: true, + out var sourceType)) + { + if (sourceType is FusionObjectTypeDefinition objectType) + { + applicableTypes.Add(objectType.Name); + return applicableTypes; + } + + if (sourceType is FusionComplexTypeDefinition sourceAbstractType) + { + abstractType = sourceAbstractType; + } + } + + foreach (var possibleType in schema.GetPossibleTypes( + abstractType, + includeInaccessible: true)) + { + if (IsSourceLocalPossibleType(abstractType, possibleType, schemaName)) + { + applicableTypes.Add(possibleType.Name); + } + } + + return applicableTypes; + } + + private static bool IsSourceLocalPossibleType( + FusionComplexTypeDefinition abstractType, + FusionObjectTypeDefinition possibleType, + string schemaName) + { + if (!possibleType.Sources.TryGetMember(schemaName, out var possibleTypeSource)) + { + return false; + } + + return abstractType is FusionInterfaceTypeDefinition + && possibleTypeSource.Implements.Contains(abstractType.Name); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SourceSchemaNodePlanningPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SourceSchemaNodePlanningPolicy.cs new file mode 100644 index 00000000000..6d9e2364ff3 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/SourceSchemaNodePlanningPolicy.cs @@ -0,0 +1,21 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Planning; + +internal sealed record SourceSchemaNodePlanningPolicy( + ImmutableHashSet? AllowedSchemaNames, + int? CandidateGroupId) +{ + public static SourceSchemaNodePlanningPolicy Descendant { get; } = new(null, null); + + public bool Allows(string schemaName) + => AllowedSchemaNames is null || AllowedSchemaNames.Contains(schemaName); + + public SourceSchemaNodePlanningPolicy BindCandidate(string schemaName) + => this with + { + AllowedSchemaNames = Allows(schemaName) + ? ImmutableHashSet.Create(StringComparer.Ordinal, schemaName) + : [] + }; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/WorkItem.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/WorkItem.cs index ac1bb0b3e09..3e86228172b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/WorkItem.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/WorkItem.cs @@ -4,6 +4,8 @@ namespace HotChocolate.Fusion.Planning; internal abstract record WorkItem { + public SourceSchemaNodePlanningPolicy? SourceSchemaNodePolicy { get; init; } + public ImmutableHashSet Dependents { get; init; } = []; /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs index 5efcb92259a..d2f121b4b96 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs @@ -147,6 +147,18 @@ internal static string HttpSourceSchemaClient_NoResultForNode { } } + internal static string FetchResultStore_InvalidRepresentationResultKind { + get { + return ResourceManager.GetString("FetchResultStore_InvalidRepresentationResultKind", resourceCulture); + } + } + + internal static string FetchResultStore_RepresentationResultCountMismatch { + get { + return ResourceManager.GetString("FetchResultStore_RepresentationResultCountMismatch", resourceCulture); + } + } + internal static string SourceSchemaRequestDispatcher_DuplicateNodeSubmission { get { return ResourceManager.GetString("SourceSchemaRequestDispatcher_DuplicateNodeSubmission", resourceCulture); @@ -189,6 +201,12 @@ internal static string ExecutionNode_MissingBooleanVariable { } } + internal static string PlanQueue_NodeLookupNotFound { + get { + return ResourceManager.GetString("PlanQueue_NodeLookupNotFound", resourceCulture); + } + } + internal static string JsonOperationPlanParser_SingleOperationRequired { get { return ResourceManager.GetString("JsonOperationPlanParser_SingleOperationRequired", resourceCulture); @@ -200,5 +218,11 @@ internal static string NodeFieldExecutionNode_InvalidNodeIdFormat { return ResourceManager.GetString("NodeFieldExecutionNode_InvalidNodeIdFormat", resourceCulture); } } + + internal static string FetchResultStore_InvalidTargetValueKind { + get { + return ResourceManager.GetString("FetchResultStore_InvalidTargetValueKind", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx index 250805ed458..251ee5260cd 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx @@ -69,6 +69,12 @@ The batch response does not contain any result for node '{0}'. + + The representation result at '{0}' must be an array but was '{1}'. + + + The representation result at '{0}' contains {1} entries, but {2} were expected. + Node '{0}' was already submitted to its batching group. @@ -90,10 +96,16 @@ Expected to have a boolean value for variable '${0}'. + + Expected to have at least one lookup with just an 'id' argument for type '{0}'. + There must be exactly one operation definition in the operation document of the operation plan. The node ID string has an invalid format. + + Expected the value at result path '{1}' for selection path '{0}' to be an object or list, but found '{2}'. + diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index e34ab3738b4..d7360c90df8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -398,6 +398,37 @@ internal CompositeResultElement CreateObject(Cursor parent, SelectionSet selecti return new CompositeResultElement(this, startObjectCursor); } + /// + /// Upgrades an interface-typed element produced by an @interfaceObject stand-in to its + /// recovered concrete type. A new object is created with the concrete selection set, the + /// interface-declared fields already completed on the old object are carried over by response + /// name, and is re-pointed at the concrete object so the covering + /// lookup can complete the identity-dependent fields. + /// + internal void UpgradeObject(CompositeResultElement element, SelectionSet concreteSelectionSet) + { + var newObject = CreateObject(element.Cursor, concreteSelectionSet); + + foreach (var property in element.EnumerateObject()) + { + var selection = property.Selection; + + // __typename is synthesized from the concrete selection set; the interface-named + // placeholder on the opaque object must never overwrite it. + if (selection is null || selection.Field.IsIntrospectionField) + { + continue; + } + + if (newObject.TryGetProperty(selection.Utf8ResponseName, out var slot)) + { + AssignCompositeValue(slot, property.Value); + } + } + + AssignCompositeValue(element, newObject); + } + internal CompositeResultElement CreateArray(Cursor parent, int length) { var cursor = WriteStartArray(parent, length); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/NodeResolution.cs b/src/HotChocolate/Fusion/src/Fusion.Utilities/NodeResolution.cs similarity index 94% rename from src/HotChocolate/Fusion/src/Fusion.Execution/Execution/NodeResolution.cs rename to src/HotChocolate/Fusion/src/Fusion.Utilities/NodeResolution.cs index 39d555f27da..8c61a455a86 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/NodeResolution.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Utilities/NodeResolution.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Execution; +namespace HotChocolate.Fusion; /// /// Determines how the gateway resolves the Query.node field, i.e. which participant is diff --git a/src/HotChocolate/Fusion/src/Fusion.Utilities/ShareableFieldRuntimeTypeRouting.cs b/src/HotChocolate/Fusion/src/Fusion.Utilities/ShareableFieldRuntimeTypeRouting.cs new file mode 100644 index 00000000000..aa285b084b6 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Utilities/ShareableFieldRuntimeTypeRouting.cs @@ -0,0 +1,19 @@ +namespace HotChocolate.Fusion; + +/// +/// Determines how runtime types are routed for shareable fields whose result type is abstract. +/// +public enum ShareableFieldRuntimeTypeRouting +{ + /// + /// Routes type-conditioned selections using the runtime types declared by the source schema + /// that resolves the field. This is the default. + /// + SourceLocal, + + /// + /// Routes type-conditioned selections using the runtime types common to every viable source + /// schema that can resolve the field. + /// + CommonRuntimeTypes +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs index 61b00a39d84..3087eefdb24 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AbstractTypeTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Transport; using HotChocolate.Transport.Http; using HotChocolate.Types; @@ -347,7 +348,20 @@ type Discussion implements Votable { TestContext.Current.CancellationToken); // assert - await MatchSnapshotAsync(gateway, request, result); + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal( + """ + {"votable":{"id":"RGlzY3Vzc2lvbjox","viewerCanVote":true}} + """, + response.Data.GetRawText()); + }); } [Fact] @@ -508,7 +522,20 @@ type Discussion implements Votable { TestContext.Current.CancellationToken); // assert - await MatchSnapshotAsync(gateway, request, result); + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal( + """ + {"votable":{"id":"RGlzY3Vzc2lvbjox","totalVotes":123}} + """, + response.Data.GetRawText()); + }); } [Fact] diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.CreateSourceSchema.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.CreateSourceSchema.cs index 79a5397b558..25e63f2bc27 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.CreateSourceSchema.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.CreateSourceSchema.cs @@ -136,6 +136,8 @@ public override IEnumerable RegisterMoreTypes( yield return typeInspector.GetTypeRef(typeof(Composite.Is)); yield return typeInspector.GetTypeRef(typeof(Composite.Provides)); yield return typeInspector.GetTypeRef(typeof(Composite.Shareable)); + yield return typeInspector.GetTypeRef(typeof(Composite.InterfaceObject)); + yield return typeInspector.GetTypeRef(typeof(Composite.External)); _registeredTypes = true; } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs index 68d7dcd3399..6b8f1cc015f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs @@ -21,13 +21,48 @@ namespace HotChocolate.Fusion; public abstract partial class FusionTestBase { - protected async Task MatchSnapshotAsync( + protected Task MatchSnapshotAsync( Gateway gateway, OperationRequest request, GraphQLHttpResponse response, string? postFix = null, RawRequest? rawRequest = null, bool stableStream = false) + => MatchSnapshotCoreAsync( + gateway, + request, + response, + assertResults: null, + postFix, + rawRequest, + stableStream); + + protected Task AssertAndMatchSnapshotAsync( + Gateway gateway, + OperationRequest request, + GraphQLHttpResponse response, + Action> assertResults) + { + ArgumentNullException.ThrowIfNull(assertResults); + + return MatchSnapshotCoreAsync( + gateway, + request, + response, + assertResults, + postFix: null, + rawRequest: null, + stableStream: false); + } + + private async Task MatchSnapshotCoreAsync( + Gateway gateway, + OperationRequest request, + GraphQLHttpResponse response, + Action>? assertResults, + string? postFix, + RawRequest? rawRequest, + bool stableStream) { var snapshot = new Snapshot(postFix, ".yaml"); @@ -62,6 +97,8 @@ protected async Task MatchSnapshotAsync( } } + assertResults?.Invoke(results); + var testServerRegistrations = gateway.Services .GetServices() .ToArray(); diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectNestedTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectNestedTests.cs new file mode 100644 index 00000000000..e9e92981929 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectNestedTests.cs @@ -0,0 +1,93 @@ +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion; + +// Mirrors the interface-object-indirect-extension audit shape natively: an @interfaceObject +// stand-in value reached through a relationship (playlist.media) rather than a root list. +// Source schema A owns the Media interface, its Video/Article implementations, and the covering +// interface lookup. Source schema C owns Playlist and an @interfaceObject Media stand-in; the +// media relationship yields an opaque value whose concrete identity is recovered through A. +public class InterfaceObjectNestedTests : FusionTestBase +{ + private const string SchemaA = + """ + type Query { + media: Media + mediaById(id: ID!): Media @lookup + } + + interface Media { + id: ID! + title: String! + } + + type Video implements Media @key(fields: "id") { + id: ID! + title: String! + duration: Int! + } + + type Article implements Media @key(fields: "id") { + id: ID! + title: String! + wordCount: Int! + } + """; + + private const string SchemaC = + """ + type Query { + playlist: Playlist + } + + type Playlist @key(fields: "id") { + id: ID! + name: String! + media: Media + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + } + """; + + [Fact] + public async Task Playlist_Media_ResolvesConcreteFieldThroughInterfaceObject() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverC = CreateSourceSchema("C", SchemaC); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("C", serverC) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + playlist { + id + name + media { + id + ... on Video { duration } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectRequiresTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectRequiresTests.cs new file mode 100644 index 00000000000..7553744186b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectRequiresTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion; + +// Mirrors the interface-object-with-requires federation-gateway-audit suite natively. +// Source schema A owns the NodeWithName interface, its User implementation (with the +// concrete-only "age" field), and the covering interface lookup. Source schema B is an +// @interfaceObject stand-in whose projected "username" field @requires the "name" field that +// A owns; "name" is declared @external on the stand-in. Values produced by B's root list are +// opaque: their concrete identity and A-owned fields are recovered through A's covering lookup. +public class InterfaceObjectRequiresTests : FusionTestBase +{ + private const string SchemaA = + """ + type Query { + users: [NodeWithName!]! + nodeById(id: ID!): NodeWithName @lookup + } + + interface NodeWithName { + id: ID! + name: String! + } + + type User implements NodeWithName @key(fields: "id") { + id: ID! + name: String! + age: Int! + } + """; + + private const string SchemaB = + """ + type Query { + anotherUsers: [NodeWithName!]! + nodeByKey(id: ID!): NodeWithName @lookup @internal + } + + type NodeWithName @interfaceObject @key(fields: "id") { + id: ID! + name: String! @external + username(name: String @require(field: "name")): String! + } + """; + + private const string SchemaBWithProjectedScore = + SchemaB + + """ + + extend type NodeWithName { + score: Int! + } + """; + + [Fact] + public async Task AnotherUsers_SelectsUserAge() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + anotherUsers { + ... on User { age } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task AnotherUsers_SelectsRequiresUsername() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + anotherUsers { + id + name + username + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + // This deliberately uses an argument-free projected field. Preserving @requires metadata on a + // projected concrete field is a separate concern from routing the concrete fragment back to the + // stand-in without sending the unavailable User type condition. + [Fact] + public async Task AnotherUsers_SelectsConcreteFragmentStandInField() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaBWithProjectedScore); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + anotherUsers { + ... on User { + age + id + name + score + } + id + name + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Collection( + response.Data.GetProperty("anotherUsers").EnumerateArray(), + user => AssertUserWithScore(user, "Tm9kZVdpdGhOYW1lOjE="), + user => AssertUserWithScore(user, "Tm9kZVdpdGhOYW1lOjI="), + user => AssertUserWithScore(user, "Tm9kZVdpdGhOYW1lOjM=")); + }); + } + + private static void AssertUserWithScore(JsonElement user, string id) + { + Assert.Equal(4, user.EnumerateObject().Count()); + Assert.Equal(123, user.GetProperty("age").GetInt32()); + Assert.Equal(id, user.GetProperty("id").GetString()); + Assert.Equal($"User: {id}", user.GetProperty("name").GetString()); + Assert.Equal(123, user.GetProperty("score").GetInt32()); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectTests.cs new file mode 100644 index 00000000000..5ed9d3e1132 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/InterfaceObjectTests.cs @@ -0,0 +1,287 @@ +using System.Text.Json; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion; + +public class InterfaceObjectTests : FusionTestBase +{ + // Source schema A defines the Media interface, its implementing types, and the covering + // interface lookup. Source schema B is an @interfaceObject stand-in contributing "views" and + // a list entry point. Values produced by B are opaque: their concrete identity is recovered + // through A's covering lookup only when the operation observes it. + private const string SchemaA = + """ + type Query { + mediaById(id: ID!): Media @lookup + } + + interface Media { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + """; + + private const string SchemaB = + """ + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + """; + + [Fact] + public async Task Default_Field_Inheritance_Resolves_From_StandIn_Only() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + // The client selects only interface-declared fields the stand-in owns, so identity is + // never observed; the covering lookup is skipped and only schema B is fetched. + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + trendingMedia { + id + views + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Opaque_TypeName_Is_Recovered_Through_Covering_Lookup() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + // Selecting __typename observes identity, so the opaque element is upgraded to its concrete + // type through A's covering lookup; __typename resolves to Book/Movie, never Media. + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + trendingMedia { + __typename + id + views + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Id_Only_Selection_Is_A_Single_Fetch() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + // Only the id is selected, so identity is never observed; the covering lookup is skipped + // and the value is served entirely from the stand-in. + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + trendingMedia { + id + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Fragment_Narrowing_Resolves_Concrete_Fields_Through_Covering_Lookup() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + // Type-conditioned selections observe identity, so the covering lookup keyed by the + // stand-in's id resolves the implementing-type fields and the concrete __typename. + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + trendingMedia { + __typename + id + views + ... on Book { isbn } + ... on Movie { runtime } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Fragment_Narrowing_Resolves_ConcreteOwned_Field_From_Opaque_Root() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + // The concrete fragment selects only "isbn", a field owned by the concrete-aware schema A, + // over an opaque value produced by B. Identity and the field are both recovered through A's + // covering lookup, with no interface-level sibling to anchor the fetch. + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + trendingMedia { + ... on Book { isbn } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Fragment_Narrowing_Resolves_StandIn_Field_Through_Covering_Lookup() + { + // arrange + using var serverA = CreateSourceSchema("A", SchemaA); + using var serverB = CreateSourceSchema("B", SchemaB); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + // act + // The concrete fragment selects "views", a field the stand-in schema B owns. Identity is + // recovered through A's covering lookup, then the projected field is fetched back from B + // through its covering interface lookup keyed by the stand-in's id (a three-hop plan). + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + query testQuery { + trendingMedia { + ... on Book { views } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Collection( + response.Data.GetProperty("trendingMedia").EnumerateArray(), + media => Assert.Equal(123, media.GetProperty("views").GetInt32()), + media => Assert.Equal(123, media.GetProperty("views").GetInt32()), + media => Assert.Equal(123, media.GetProperty("views").GetInt32())); + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs index ff1c0294b03..7c4860e6b52 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/ProvidesTests.cs @@ -13,8 +13,6 @@ public async Task Provides_Simple_Covers_Selection() using var serverReviews = CreateSourceSchema( "reviews", """ - directive @external on FIELD_DEFINITION - schema { query: Query } @@ -89,8 +87,6 @@ public async Task Provides_Partial_Covers_Some() using var serverReviews = CreateSourceSchema( "reviews", """ - directive @external on FIELD_DEFINITION - schema { query: Query } @@ -166,8 +162,6 @@ public async Task Provides_On_Interface() using var serverA = CreateSourceSchema( "a", """ - directive @external on FIELD_DEFINITION - schema { query: Query } @@ -269,8 +263,6 @@ public async Task Provides_External_Without_Cover() using var serverReviews = CreateSourceSchema( "reviews", """ - directive @external on FIELD_DEFINITION - schema { query: Query } @@ -345,8 +337,6 @@ public async Task Provides_Deeply_Nested_Chain() using var serverA = CreateSourceSchema( "a", """ - directive @external on FIELD_DEFINITION - schema { query: Query } @@ -449,8 +439,6 @@ public async Task Planner_Should_Route_To_Owning_Source_When_Local_Field_Is_Orph using var serverReviews = CreateSourceSchema( "reviews", """ - directive @external on FIELD_DEFINITION - schema { query: Query } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml index 01a5454ae99..04d74764cab 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup.yaml @@ -82,9 +82,7 @@ sourceSchemas: query Op_fb4e28df_2($__fusion_1_id: ID!) { votableById(id: $__fusion_1_id) { __typename - ... on Discussion { - viewerCanVote - } + viewerCanVote } } variables: | @@ -114,8 +112,8 @@ operationPlan: } } hash: fb4e28df20f489a10c0f71a560445d04 - searchSpace: 2 - expandedNodes: 3 + searchSpace: 1 + expandedNodes: 2 nodes: - id: 1 type: Operation @@ -134,13 +132,11 @@ operationPlan: query Op_fb4e28df_2($__fusion_1_id: ID!) { votableById(id: $__fusion_1_id) { __typename - ... on Discussion { - viewerCanVote - } + viewerCanVote } } - source: $.votableById - target: $.votable + source: $.votableById + target: $.votable requirements: - name: __fusion_1_id selectionMap: >- diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml index 0f183c8ed52..f7a983fcc60 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/AbstractTypeTests.Interface_Field_Without_Type_Refinements_With_Interface_Lookup_And_Field_From_Specific_Source.yaml @@ -101,9 +101,7 @@ sourceSchemas: query Op_1bcbe788_2($__fusion_1_id: ID!) { votableById(id: $__fusion_1_id) { __typename - ... on Discussion { - totalVotes - } + totalVotes } } variables: | @@ -133,8 +131,8 @@ operationPlan: } } hash: 1bcbe788569dbb79a305829bc99385cf - searchSpace: 2 - expandedNodes: 3 + searchSpace: 1 + expandedNodes: 2 nodes: - id: 1 type: Operation @@ -153,13 +151,11 @@ operationPlan: query Op_1bcbe788_2($__fusion_1_id: ID!) { votableById(id: $__fusion_1_id) { __typename - ... on Discussion { - totalVotes - } + totalVotes } } - source: $.votableById - target: $.votable + source: $.votableById + target: $.votable requirements: - name: __fusion_1_id selectionMap: >- diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectNestedTests.Playlist_Media_ResolvesConcreteFieldThroughInterfaceObject.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectNestedTests.Playlist_Media_ResolvesConcreteFieldThroughInterfaceObject.yaml new file mode 100644 index 00000000000..ffbeb143b4c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectNestedTests.Playlist_Media_ResolvesConcreteFieldThroughInterfaceObject.yaml @@ -0,0 +1,185 @@ +title: Playlist_Media_ResolvesConcreteFieldThroughInterfaceObject +request: + document: | + query testQuery { + playlist { + id + name + media { + id + ... on Video { + duration + } + } + } + } +response: + body: | + { + "data": { + "playlist": { + "id": "UGxheWxpc3Q6MQ==", + "name": "Playlist: UGxheWxpc3Q6MQ==", + "media": { + "id": "TWVkaWE6Mg==", + "duration": 123 + } + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + media: Media + mediaById(id: ID!): Media @lookup + } + + type Article implements Media @key(fields: "id") { + id: ID! + title: String! + wordCount: Int! + } + + type Video implements Media @key(fields: "id") { + id: ID! + title: String! + duration: Int! + } + + interface Media { + id: ID! + title: String! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_a40319aa_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Video { + duration + } + } + } + variables: | + { + "__fusion_1_id": "TWVkaWE6Mg==" + } + response: + results: + - | + { + "data": { + "mediaById": { + "__typename": "Video", + "duration": 123 + } + } + } + - name: C + schema: | + schema { + query: Query + } + + type Query { + playlist: Playlist + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + } + + type Playlist @key(fields: "id") { + id: ID! + name: String! + media: Media + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_a40319aa_1 { + playlist { + id + name + media { + id + } + } + } + response: + results: + - | + { + "data": { + "playlist": { + "id": "UGxheWxpc3Q6MQ==", + "name": "Playlist: UGxheWxpc3Q6MQ==", + "media": { + "id": "TWVkaWE6Mg==" + } + } + } + } +operationPlan: + operation: + - document: | + query testQuery { + playlist { + id + name + media { + __typename @fusion__requirement + id + id @fusion__requirement + ... on Video { + duration + } + } + } + } + name: testQuery + hash: a40319aaa234c8e3c78d03216c9a7a8c + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: C + operation: | + query testQuery_a40319aa_1 { + playlist { + id + name + media { + id + } + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_a40319aa_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Video { + duration + } + } + } + source: $.mediaById + target: $.playlist.media + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsConcreteFragmentStandInField.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsConcreteFragmentStandInField.yaml new file mode 100644 index 00000000000..625688e5131 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsConcreteFragmentStandInField.yaml @@ -0,0 +1,289 @@ +title: AnotherUsers_SelectsConcreteFragmentStandInField +request: + document: | + query testQuery { + anotherUsers { + ... on User { + age + id + name + score + } + id + name + } + } +response: + body: | + { + "data": { + "anotherUsers": [ + { + "id": "Tm9kZVdpdGhOYW1lOjE=", + "name": "User: Tm9kZVdpdGhOYW1lOjE=", + "age": 123, + "score": 123 + }, + { + "id": "Tm9kZVdpdGhOYW1lOjI=", + "name": "User: Tm9kZVdpdGhOYW1lOjI=", + "age": 123, + "score": 123 + }, + { + "id": "Tm9kZVdpdGhOYW1lOjM=", + "name": "User: Tm9kZVdpdGhOYW1lOjM=", + "age": 123, + "score": 123 + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + users: [NodeWithName!]! + nodeById(id: ID!): NodeWithName @lookup + } + + type User implements NodeWithName @key(fields: "id") { + id: ID! + name: String! + age: Int! + } + + interface NodeWithName { + id: ID! + name: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_40a12cd0_2($__fusion_1_id: ID!) { + nodeById(id: $__fusion_1_id) { + __typename + name + ... on User { + age + id + name + } + } + } + variables: | + [ + { + "__fusion_1_id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "__fusion_1_id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "__fusion_1_id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "nodeById": { + "__typename": "User", + "name": "User: Tm9kZVdpdGhOYW1lOjE=", + "age": 123, + "id": "Tm9kZVdpdGhOYW1lOjE=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "name": "User: Tm9kZVdpdGhOYW1lOjI=", + "age": 123, + "id": "Tm9kZVdpdGhOYW1lOjI=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "name": "User: Tm9kZVdpdGhOYW1lOjM=", + "age": 123, + "id": "Tm9kZVdpdGhOYW1lOjM=" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + anotherUsers: [NodeWithName!]! + nodeByKey(id: ID!): NodeWithName @lookup @internal + } + + type NodeWithName @interfaceObject @key(fields: "id") { + id: ID! + name: String! @external + username(name: String @require(field: "name")): String! + score: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_40a12cd0_1 { + anotherUsers { + id + } + } + response: + results: + - | + { + "data": { + "anotherUsers": [ + { + "id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + } + } + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_40a12cd0_3($__fusion_2_id: ID!) { + nodeByKey(id: $__fusion_2_id) { + score + } + } + variables: | + [ + { + "__fusion_2_id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "__fusion_2_id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "__fusion_2_id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "nodeByKey": { + "score": 123 + } + } + } + - | + { + "data": { + "nodeByKey": { + "score": 123 + } + } + } + - | + { + "data": { + "nodeByKey": { + "score": 123 + } + } + } +operationPlan: + operation: + - document: | + query testQuery { + anotherUsers { + __typename @fusion__requirement + id + id @fusion__requirement + name + ... on User { + age + id + id @fusion__requirement + name + score + } + } + } + name: testQuery + hash: 40a12cd0f30c3393c17f5534c07956c6 + searchSpace: 1 + expandedNodes: 3 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_40a12cd0_1 { + anotherUsers { + id + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_40a12cd0_2($__fusion_1_id: ID!) { + nodeById(id: $__fusion_1_id) { + __typename + name + ... on User { + age + id + name + } + } + } + source: $.nodeById + target: $.anotherUsers + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query testQuery_40a12cd0_3($__fusion_2_id: ID!) { + nodeByKey(id: $__fusion_2_id) { + score + } + } + source: $.nodeByKey + target: $.anotherUsers + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsRequiresUsername.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsRequiresUsername.yaml new file mode 100644 index 00000000000..bd6d7135b12 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsRequiresUsername.yaml @@ -0,0 +1,335 @@ +title: AnotherUsers_SelectsRequiresUsername +request: + document: | + query testQuery { + anotherUsers { + id + name + username + } + } +response: + body: | + { + "data": { + "anotherUsers": [ + { + "id": "Tm9kZVdpdGhOYW1lOjE=", + "name": "User: Tm9kZVdpdGhOYW1lOjE=", + "username": "NodeWithName: Tm9kZVdpdGhOYW1lOjM=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjI=", + "name": "User: Tm9kZVdpdGhOYW1lOjI=", + "username": "NodeWithName: Tm9kZVdpdGhOYW1lOjI=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjM=", + "name": "User: Tm9kZVdpdGhOYW1lOjM=", + "username": "NodeWithName: Tm9kZVdpdGhOYW1lOjE=" + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + users: [NodeWithName!]! + nodeById(id: ID!): NodeWithName @lookup + } + + type User implements NodeWithName @key(fields: "id") { + id: ID! + name: String! + age: Int! + } + + interface NodeWithName { + id: ID! + name: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + kind: OperationBatch + items: + - document: | + query testQuery_def11e8e_3($__fusion_3_id: ID!) { + nodeById(id: $__fusion_3_id) { + __typename + name + } + } + variables: | + [ + { + "__fusion_3_id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "__fusion_3_id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "__fusion_3_id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + - document: | + query testQuery_def11e8e_4($__fusion_4_id: ID!) { + nodeById(id: $__fusion_4_id) { + __typename + id + } + } + variables: | + [ + { + "__fusion_4_id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "__fusion_4_id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "__fusion_4_id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "nodeById": { + "__typename": "User", + "id": "Tm9kZVdpdGhOYW1lOjI=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "id": "Tm9kZVdpdGhOYW1lOjM=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "name": "User: Tm9kZVdpdGhOYW1lOjM=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "id": "Tm9kZVdpdGhOYW1lOjE=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "name": "User: Tm9kZVdpdGhOYW1lOjE=" + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "name": "User: Tm9kZVdpdGhOYW1lOjI=" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + anotherUsers: [NodeWithName!]! + nodeByKey(id: ID!): NodeWithName @lookup @internal + } + + type NodeWithName @interfaceObject @key(fields: "id") { + id: ID! + name: String! @external + username(name: String @require(field: "name")): String! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_def11e8e_1 { + anotherUsers { + id + } + } + response: + results: + - | + { + "data": { + "anotherUsers": [ + { + "id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + } + } + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_def11e8e_2($__fusion_1_name: String, $__fusion_2_id: ID!) { + nodeByKey(id: $__fusion_2_id) { + username(name: $__fusion_1_name) + } + } + variables: | + [ + { + "__fusion_1_name": "User: Tm9kZVdpdGhOYW1lOjE=", + "__fusion_2_id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "__fusion_1_name": "User: Tm9kZVdpdGhOYW1lOjI=", + "__fusion_2_id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "__fusion_1_name": "User: Tm9kZVdpdGhOYW1lOjM=", + "__fusion_2_id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "nodeByKey": { + "username": "NodeWithName: Tm9kZVdpdGhOYW1lOjM=" + } + } + } + - | + { + "data": { + "nodeByKey": { + "username": "NodeWithName: Tm9kZVdpdGhOYW1lOjI=" + } + } + } + - | + { + "data": { + "nodeByKey": { + "username": "NodeWithName: Tm9kZVdpdGhOYW1lOjE=" + } + } + } +operationPlan: + operation: + - document: | + query testQuery { + anotherUsers { + __typename @fusion__requirement + id + id @fusion__requirement + name + name @fusion__requirement + username + } + } + name: testQuery + hash: def11e8ef61b4f475c70501ad6f43705 + searchSpace: 2 + expandedNodes: 7 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_def11e8e_1 { + anotherUsers { + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query testQuery_def11e8e_2($__fusion_1_name: String, $__fusion_2_id: ID!) { + nodeByKey(id: $__fusion_2_id) { + username(name: $__fusion_1_name) + } + } + source: $.nodeByKey + target: $.anotherUsers + requirements: + - name: __fusion_1_name + selectionMap: >- + name + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 3 + type: OperationBatch + schema: A + operation: | + query testQuery_def11e8e_3($__fusion_3_id: ID!) { + nodeById(id: $__fusion_3_id) { + __typename + name + } + } + source: $.nodeById + targets: + - $.anotherUsers + - $.anotherUsers + batchingGroupId: 3 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 4 + type: Operation + schema: A + operation: | + query testQuery_def11e8e_4($__fusion_4_id: ID!) { + nodeById(id: $__fusion_4_id) { + __typename + id + } + } + source: $.nodeById + target: $.anotherUsers + batchingGroupId: 3 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsUserAge.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsUserAge.yaml new file mode 100644 index 00000000000..512a2e3997a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectRequiresTests.AnotherUsers_SelectsUserAge.yaml @@ -0,0 +1,192 @@ +title: AnotherUsers_SelectsUserAge +request: + document: | + query testQuery { + anotherUsers { + ... on User { + age + } + } + } +response: + body: | + { + "data": { + "anotherUsers": [ + { + "age": 123 + }, + { + "age": 123 + }, + { + "age": 123 + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + users: [NodeWithName!]! + nodeById(id: ID!): NodeWithName @lookup + } + + type User implements NodeWithName @key(fields: "id") { + id: ID! + name: String! + age: Int! + } + + interface NodeWithName { + id: ID! + name: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_d55b3c84_2($__fusion_1_id: ID!) { + nodeById(id: $__fusion_1_id) { + __typename + ... on User { + age + } + } + } + variables: | + [ + { + "__fusion_1_id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "__fusion_1_id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "__fusion_1_id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "nodeById": { + "__typename": "User", + "age": 123 + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "age": 123 + } + } + } + - | + { + "data": { + "nodeById": { + "__typename": "User", + "age": 123 + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + anotherUsers: [NodeWithName!]! + nodeByKey(id: ID!): NodeWithName @lookup @internal + } + + type NodeWithName @interfaceObject @key(fields: "id") { + id: ID! + name: String! @external + username(name: String @require(field: "name")): String! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_d55b3c84_1 { + anotherUsers { + id + } + } + response: + results: + - | + { + "data": { + "anotherUsers": [ + { + "id": "Tm9kZVdpdGhOYW1lOjE=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjI=" + }, + { + "id": "Tm9kZVdpdGhOYW1lOjM=" + } + ] + } + } +operationPlan: + operation: + - document: | + query testQuery { + anotherUsers { + __typename @fusion__requirement + id @fusion__requirement + ... on User { + age + } + } + } + name: testQuery + hash: d55b3c8475af57a5d29e27bd9ff3c50a + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_d55b3c84_1 { + anotherUsers { + id + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_d55b3c84_2($__fusion_1_id: ID!) { + nodeById(id: $__fusion_1_id) { + __typename + ... on User { + age + } + } + } + source: $.nodeById + target: $.anotherUsers + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Default_Field_Inheritance_Resolves_From_StandIn_Only.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Default_Field_Inheritance_Resolves_From_StandIn_Only.yaml new file mode 100644 index 00000000000..cdcabf7266b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Default_Field_Inheritance_Resolves_From_StandIn_Only.yaml @@ -0,0 +1,127 @@ +title: Default_Field_Inheritance_Resolves_From_StandIn_Only +request: + document: | + query testQuery { + trendingMedia { + id + views + } + } +response: + body: | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==", + "views": 123 + }, + { + "id": "TWVkaWE6Mg==", + "views": 123 + }, + { + "id": "TWVkaWE6Mw==", + "views": 123 + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + mediaById(id: ID!): Media @lookup + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + + interface Media { + id: ID! + title: String! + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_9dd08c29_1 { + trendingMedia { + id + views + } + } + response: + results: + - | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==", + "views": 123 + }, + { + "id": "TWVkaWE6Mg==", + "views": 123 + }, + { + "id": "TWVkaWE6Mw==", + "views": 123 + } + ] + } + } +operationPlan: + operation: + - document: | + query testQuery { + trendingMedia { + __typename @fusion__requirement + id + views + } + } + name: testQuery + hash: 9dd08c295249f39bff1a955dfb9e3fbb + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_9dd08c29_1 { + trendingMedia { + id + views + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_ConcreteOwned_Field_From_Opaque_Root.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_ConcreteOwned_Field_From_Opaque_Root.yaml new file mode 100644 index 00000000000..014d3aa9e82 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_ConcreteOwned_Field_From_Opaque_Root.yaml @@ -0,0 +1,196 @@ +title: Fragment_Narrowing_Resolves_ConcreteOwned_Field_From_Opaque_Root +request: + document: | + query testQuery { + trendingMedia { + ... on Book { + isbn + } + } + } +response: + body: | + { + "data": { + "trendingMedia": [ + { + "isbn": "Book: TWVkaWE6MQ==" + }, + { + "isbn": "Book: TWVkaWE6Mg==" + }, + { + "isbn": "Book: TWVkaWE6Mw==" + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + mediaById(id: ID!): Media @lookup + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + + interface Media { + id: ID! + title: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_ac84cf0c_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + isbn + } + } + } + variables: | + [ + { + "__fusion_1_id": "TWVkaWE6MQ==" + }, + { + "__fusion_1_id": "TWVkaWE6Mg==" + }, + { + "__fusion_1_id": "TWVkaWE6Mw==" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "isbn": "Book: TWVkaWE6MQ==" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "isbn": "Book: TWVkaWE6Mg==" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "isbn": "Book: TWVkaWE6Mw==" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_ac84cf0c_1 { + trendingMedia { + id + } + } + response: + results: + - | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==" + }, + { + "id": "TWVkaWE6Mg==" + }, + { + "id": "TWVkaWE6Mw==" + } + ] + } + } +operationPlan: + operation: + - document: | + query testQuery { + trendingMedia { + __typename @fusion__requirement + id @fusion__requirement + ... on Book { + isbn + } + } + } + name: testQuery + hash: ac84cf0c3518213271a745d56f4ba095 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_ac84cf0c_1 { + trendingMedia { + id + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_ac84cf0c_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + isbn + } + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_Concrete_Fields_Through_Covering_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_Concrete_Fields_Through_Covering_Lookup.yaml new file mode 100644 index 00000000000..474bf5792e1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_Concrete_Fields_Through_Covering_Lookup.yaml @@ -0,0 +1,227 @@ +title: Fragment_Narrowing_Resolves_Concrete_Fields_Through_Covering_Lookup +request: + document: | + query testQuery { + trendingMedia { + __typename + id + views + ... on Book { + isbn + } + ... on Movie { + runtime + } + } + } +response: + body: | + { + "data": { + "trendingMedia": [ + { + "__typename": "Book", + "id": "TWVkaWE6MQ==", + "views": 123, + "isbn": "Book: TWVkaWE6MQ==" + }, + { + "__typename": "Book", + "id": "TWVkaWE6Mg==", + "views": 123, + "isbn": "Book: TWVkaWE6Mg==" + }, + { + "__typename": "Book", + "id": "TWVkaWE6Mw==", + "views": 123, + "isbn": "Book: TWVkaWE6Mw==" + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + mediaById(id: ID!): Media @lookup + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + + interface Media { + id: ID! + title: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_236f64a0_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + isbn + } + ... on Movie { + runtime + } + } + } + variables: | + [ + { + "__fusion_1_id": "TWVkaWE6MQ==" + }, + { + "__fusion_1_id": "TWVkaWE6Mg==" + }, + { + "__fusion_1_id": "TWVkaWE6Mw==" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "isbn": "Book: TWVkaWE6MQ==" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "isbn": "Book: TWVkaWE6Mg==" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "isbn": "Book: TWVkaWE6Mw==" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_236f64a0_1 { + trendingMedia { + id + views + } + } + response: + results: + - | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==", + "views": 123 + }, + { + "id": "TWVkaWE6Mg==", + "views": 123 + }, + { + "id": "TWVkaWE6Mw==", + "views": 123 + } + ] + } + } +operationPlan: + operation: + - document: | + query testQuery { + trendingMedia { + __typename + id + id @fusion__requirement + views + ... on Book { + isbn + } + ... on Movie { + runtime + } + } + } + name: testQuery + hash: 236f64a0eb63ca8efc0a62035aca0f99 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_236f64a0_1 { + trendingMedia { + id + views + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_236f64a0_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + isbn + } + ... on Movie { + runtime + } + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_StandIn_Field_Through_Covering_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_StandIn_Field_Through_Covering_Lookup.yaml new file mode 100644 index 00000000000..e93c030d189 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Fragment_Narrowing_Resolves_StandIn_Field_Through_Covering_Lookup.yaml @@ -0,0 +1,261 @@ +title: Fragment_Narrowing_Resolves_StandIn_Field_Through_Covering_Lookup +request: + document: | + query testQuery { + trendingMedia { + ... on Book { + views + } + } + } +response: + body: | + { + "data": { + "trendingMedia": [ + { + "views": 123 + }, + { + "views": 123 + }, + { + "views": 123 + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + mediaById(id: ID!): Media @lookup + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + + interface Media { + id: ID! + title: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_6bd529b0_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + id + } + } + } + variables: | + [ + { + "__fusion_1_id": "TWVkaWE6MQ==" + }, + { + "__fusion_1_id": "TWVkaWE6Mg==" + }, + { + "__fusion_1_id": "TWVkaWE6Mw==" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "id": "TWVkaWE6MQ==" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "id": "TWVkaWE6Mg==" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book", + "id": "TWVkaWE6Mw==" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_6bd529b0_1 { + trendingMedia { + id + } + } + response: + results: + - | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==" + }, + { + "id": "TWVkaWE6Mg==" + }, + { + "id": "TWVkaWE6Mw==" + } + ] + } + } + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_6bd529b0_3($__fusion_2_id: ID!) { + mediaByKey(id: $__fusion_2_id) { + views + } + } + variables: | + [ + { + "__fusion_2_id": "TWVkaWE6MQ==" + }, + { + "__fusion_2_id": "TWVkaWE6Mg==" + }, + { + "__fusion_2_id": "TWVkaWE6Mw==" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "mediaByKey": { + "views": 123 + } + } + } + - | + { + "data": { + "mediaByKey": { + "views": 123 + } + } + } + - | + { + "data": { + "mediaByKey": { + "views": 123 + } + } + } +operationPlan: + operation: + - document: | + query testQuery { + trendingMedia { + __typename @fusion__requirement + id @fusion__requirement + ... on Book { + views + id @fusion__requirement + } + } + } + name: testQuery + hash: 6bd529b052766ba06fb7a06400d3c8a5 + searchSpace: 1 + expandedNodes: 3 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_6bd529b0_1 { + trendingMedia { + id + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_6bd529b0_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + id + } + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: B + operation: | + query testQuery_6bd529b0_3($__fusion_2_id: ID!) { + mediaByKey(id: $__fusion_2_id) { + views + } + } + source: $.mediaByKey + target: $.trendingMedia + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Id_Only_Selection_Is_A_Single_Fetch.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Id_Only_Selection_Is_A_Single_Fetch.yaml new file mode 100644 index 00000000000..f3ab3d0752e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Id_Only_Selection_Is_A_Single_Fetch.yaml @@ -0,0 +1,117 @@ +title: Id_Only_Selection_Is_A_Single_Fetch +request: + document: | + query testQuery { + trendingMedia { + id + } + } +response: + body: | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==" + }, + { + "id": "TWVkaWE6Mg==" + }, + { + "id": "TWVkaWE6Mw==" + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + mediaById(id: ID!): Media @lookup + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + + interface Media { + id: ID! + title: String! + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_751f491c_1 { + trendingMedia { + id + } + } + response: + results: + - | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==" + }, + { + "id": "TWVkaWE6Mg==" + }, + { + "id": "TWVkaWE6Mw==" + } + ] + } + } +operationPlan: + operation: + - document: | + query testQuery { + trendingMedia { + __typename @fusion__requirement + id + } + } + name: testQuery + hash: 751f491cf027a6724db2222ee37d1b21 + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_751f491c_1 { + trendingMedia { + id + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Opaque_TypeName_Is_Recovered_Through_Covering_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Opaque_TypeName_Is_Recovered_Through_Covering_Lookup.yaml new file mode 100644 index 00000000000..e95d618fcfb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/InterfaceObjectTests.Opaque_TypeName_Is_Recovered_Through_Covering_Lookup.yaml @@ -0,0 +1,197 @@ +title: Opaque_TypeName_Is_Recovered_Through_Covering_Lookup +request: + document: | + query testQuery { + trendingMedia { + __typename + id + views + } + } +response: + body: | + { + "data": { + "trendingMedia": [ + { + "__typename": "Book", + "id": "TWVkaWE6MQ==", + "views": 123 + }, + { + "__typename": "Book", + "id": "TWVkaWE6Mg==", + "views": 123 + }, + { + "__typename": "Book", + "id": "TWVkaWE6Mw==", + "views": 123 + } + ] + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + mediaById(id: ID!): Media @lookup + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + + interface Media { + id: ID! + title: String! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query testQuery_c5671534_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + } + } + variables: | + [ + { + "__fusion_1_id": "TWVkaWE6MQ==" + }, + { + "__fusion_1_id": "TWVkaWE6Mg==" + }, + { + "__fusion_1_id": "TWVkaWE6Mw==" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "mediaById": { + "__typename": "Book" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book" + } + } + } + - | + { + "data": { + "mediaById": { + "__typename": "Book" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query testQuery_c5671534_1 { + trendingMedia { + id + views + } + } + response: + results: + - | + { + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==", + "views": 123 + }, + { + "id": "TWVkaWE6Mg==", + "views": 123 + }, + { + "id": "TWVkaWE6Mw==", + "views": 123 + } + ] + } + } +operationPlan: + operation: + - document: | + query testQuery { + trendingMedia { + __typename + id + id @fusion__requirement + views + } + } + name: testQuery + hash: c56715340e963d08d0911cbe2073e28b + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query testQuery_c5671534_1 { + trendingMedia { + id + views + } + } + - id: 2 + type: Operation + schema: A + operation: | + query testQuery_c5671534_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs new file mode 100644 index 00000000000..c6e4efd37d2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs @@ -0,0 +1,42 @@ +namespace HotChocolate.Fusion.Aspire; + +public sealed class AspireCompositionHelperTests +{ + [Theory] + [InlineData(null)] + [InlineData(NodeResolution.Gateway)] + [InlineData(NodeResolution.SourceSchema)] + public void CreateCompositionSettings_Should_MapNodeResolution( + NodeResolution? nodeResolution) + { + var settings = new GraphQLCompositionSettings + { + EnableGlobalObjectIdentification = true, + NodeResolution = nodeResolution + }; + + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); + + Assert.True(compositionSettings.Merger.EnableGlobalObjectIdentification); + Assert.Equal(nodeResolution, compositionSettings.Merger.NodeResolution); + } + + [Theory] + [InlineData(null)] + [InlineData(ShareableFieldRuntimeTypeRouting.SourceLocal)] + [InlineData(ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes)] + public void CreateCompositionSettings_Should_MapShareableFieldRuntimeTypeRouting( + ShareableFieldRuntimeTypeRouting? routing) + { + var settings = new GraphQLCompositionSettings + { + ShareableFieldRuntimeTypeRouting = routing + }; + + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); + + Assert.Equal( + routing, + compositionSettings.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/HotChocolate.Fusion.Aspire.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/HotChocolate.Fusion.Aspire.Tests.csproj new file mode 100644 index 00000000000..d4f2a6c2de5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/HotChocolate.Fusion.Aspire.Tests.csproj @@ -0,0 +1,15 @@ + + + + + + HotChocolate.Fusion.Aspire.Tests + HotChocolate.Fusion.Aspire + net11.0;net10.0;net9.0 + + + + + + + diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ApolloFederationCompatibilityTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ApolloFederationCompatibilityTests.cs new file mode 100644 index 00000000000..82e8c62de29 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ApolloFederationCompatibilityTests.cs @@ -0,0 +1,74 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Results; +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class ApolloFederationCompatibilityTests +{ + [Fact] + public void Compose_Should_RequireExplicitCompatibility_When_InterfaceObjectKeyIsNonResolvable() + { + // act + var strictResult = Compose(allowNonResolvableInterfaceObjects: false); + var compatibilityResult = Compose(allowNonResolvableInterfaceObjects: true); + + // assert + Assert.True(strictResult.IsFailure); + Assert.True( + compatibilityResult.IsSuccess, + compatibilityResult.IsSuccess ? null : compatibilityResult.Errors[0].Message); + } + + private static CompositionResult Compose( + bool allowNonResolvableInterfaceObjects) + { + var options = new SchemaComposerOptions(); + options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects = + allowNonResolvableInterfaceObjects; + + return new SchemaComposer( + [ + new SourceSchemaText("A", SchemaA), + new SourceSchemaText("B", SchemaB) + ], + options, + new CompositionLog()).Compose(); + } + + private const string SchemaA = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) + + type Query { + a: Node + } + + interface Node @key(fields: "id") { + id: ID! + } + + type NodeImpl implements Node @key(fields: "id") { + id: ID! + } + """; + + private const string SchemaB = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: ["@key", "@interfaceObject"]) + + type Query { + b: Node + } + + type Node @key(fields: "id", resolvable: false) @interfaceObject { + id: ID! + field: String + } + """; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/EntityInterfaceKeyShareableTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/EntityInterfaceKeyShareableTests.cs new file mode 100644 index 00000000000..07b0588b66a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/EntityInterfaceKeyShareableTests.cs @@ -0,0 +1,144 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class EntityInterfaceKeyShareableTests +{ + // An Apollo Federation @key may be declared on an interface (entity interface). The key + // fields on the interface itself must never be stamped @shareable: @shareable is only + // meaningful on object fields, and the composition's shareable-usage validation rejects it + // on interfaces. Object key fields, by contrast, must still be stamped so a key field shared + // across subgraphs composes. + [Fact] + public void Compose_Should_Succeed_When_KeyIsDeclaredOnEntityInterface() + { + // arrange + // A single Apollo subgraph whose entity interface carries a @key; before the fix the + // interface's 'id' field was stamped @shareable and composition failed with + // INVALID_SHAREABLE_USAGE. + const string schema = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) { + query: Query + } + + type Query { + media: [Media!]! + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Article implements Media @key(fields: "id") { + id: ID! + title: String! + } + + type _Service { sdl: String! } + + union _Entity = Article + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + var composer = new SchemaComposer( + [new SourceSchemaText("catalog", schema)], + new SchemaComposerOptions(), + new CompositionLog()); + + // act + var result = composer.Compose(); + + // assert + Assert.True( + result.IsSuccess, + result.IsSuccess ? null : result.Errors[0].Message); + } + + [Fact] + public void Compose_Should_Succeed_When_ObjectKeyFieldIsSharedAcrossSubgraphs() + { + // arrange + // Two Apollo subgraphs both contribute the object entity Product and its key field 'id'. + // This only composes when the object key field is stamped @shareable, so it pins that the + // interface-only skip did not also drop @shareable on object key fields. + const string catalog = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) { + query: Query + } + + type Query { + product: Product + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + + type _Service { sdl: String! } + + union _Entity = Product + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + const string reviews = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) { + query: Query + } + + type Query { + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + type Product @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { body: String! } + + type _Service { sdl: String! } + + union _Entity = Product + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + var composer = new SchemaComposer( + [new SourceSchemaText("catalog", catalog), new SourceSchemaText("reviews", reviews)], + new SchemaComposerOptions(), + new CompositionLog()); + + // act + var result = composer.Compose(); + + // assert + Assert.True( + result.IsSuccess, + result.IsSuccess ? null : result.Errors[0].Message); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs index 81a3ca95036..2521261e46e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs @@ -1,5 +1,6 @@ using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Options; +using HotChocolate.Language; namespace HotChocolate.Fusion.ApolloFederation; @@ -51,5 +52,33 @@ type Thing @key(fields: "k") @shareable { // assert Assert.True(result.IsSuccess); + + var document = result.Value.ToSyntaxNode(); + var thing = document.Definitions + .OfType() + .Single(type => type.Name.Value == "Thing"); + var keyField = thing.Fields.Single(field => field.Name.Value == "k"); + var fieldDirectives = keyField.Directives + .Where(directive => directive.Name.Value == "fusion__field") + .ToDictionary( + directive => ((EnumValueNode)directive.Arguments + .Single(argument => argument.Name.Value == "schema").Value).Value); + + Assert.Single(fieldDirectives["A"].Arguments); + Assert.Equal( + ["schema", "sourceExternal"], + fieldDirectives["B"].Arguments.Select(argument => argument.Name.Value)); + Assert.True( + ((BooleanValueNode)fieldDirectives["B"].Arguments + .Single(argument => argument.Name.Value == "sourceExternal").Value).Value); + + var lookup = thing.Directives.Single( + directive => directive.Name.Value == "fusion__lookup" + && ((EnumValueNode)directive.Arguments + .Single(argument => argument.Name.Value == "schema").Value).Value == "B"); + Assert.Equal( + "fusion__lookup_thingByK(k: Int!): Thing", + ((StringValueNode)lookup.Arguments + .Single(argument => argument.Name.Value == "field").Value).Value); } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs index 73a0651aa5e..22d3ca3ed68 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs @@ -415,6 +415,50 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA .MatchMarkdownSnapshot(); } + [Fact] + public void Transform_Should_PreserveExternalKeyField_When_GeneratingLookup() + { + // arrange + const string federationSdl = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@external"]) { + query: Query + } + + type Product @key(fields: "id") { + id: ID! @external + name: String + } + + type Query { + products: [Product] + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + type _Service { sdl: String! } + + union _Entity = Product + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @external on FIELD_DEFINITION + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); + } + [Fact] public void Transform_NonResolvableKey() { @@ -610,7 +654,7 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA } [Fact] - public void Transform_InterfaceObject_Should_ReturnError() + public void Transform_InterfaceObject_Should_Preserve() { // arrange const string federationSdl = @@ -643,13 +687,16 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA """; // act + // @interfaceObject maps 1:1 to the native construct, so it is translated rather than + // rejected: the transform keeps the directive and its application on the stand-in. var result = FederationSchemaTransformer.Transform(federationSdl); // assert - Assert.True(result.IsFailure); - Assert.Contains( - result.Errors, - e => e.Message.Contains("@interfaceObject")); + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); } [Fact] diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/InterfaceObjectImplementSynthesisTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/InterfaceObjectImplementSynthesisTests.cs new file mode 100644 index 00000000000..9a29066ceca --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/InterfaceObjectImplementSynthesisTests.cs @@ -0,0 +1,121 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Results; +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class InterfaceObjectImplementSynthesisTests +{ + // Apollo Federation has no @implement marker. When an implementing type redeclares a field + // that an @interfaceObject stand-in also contributes, Apollo relies on @shareable to make the + // field resolvable from both subgraphs. The connector bridges this to the Composite Schema + // Spec by synthesizing @implement onto the shareable declaration, so a valid Apollo subgraph + // composes. A non-shareable redeclaration is left untouched: the native + // INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT rule rejects it, mirroring Apollo's own rejection + // of a field resolvable from two subgraphs without @shareable. + [Fact] + public void Compose_Should_Succeed_When_ImplementingFieldIsShareable() + { + // arrange + var log = new CompositionLog(); + var result = Compose(Accounts(isActiveShareable: true), Activity(isActiveShareable: true), log); + + // assert + Assert.True( + result.IsSuccess, + result.IsSuccess ? null : result.Errors[0].Message); + } + + [Fact] + public void Compose_Should_Error_When_ImplementingFieldIsNotShareable() + { + // arrange + var log = new CompositionLog(); + var result = Compose(Accounts(isActiveShareable: false), Activity(isActiveShareable: true), log); + + // assert + Assert.True(result.IsFailure); + Assert.Contains( + log, + e => e.Code == LogEntryCodes.InterfaceObjectFieldRequiresImplement + && e.Message.Contains("isActive")); + } + + private static CompositionResult Compose( + string accounts, + string activity, + CompositionLog log) + => new SchemaComposer( + [ + new SourceSchemaText("accounts", accounts), + new SourceSchemaText("activity", activity) + ], + new SchemaComposerOptions(), + log).Compose(); + + // Subgraph that owns the Account interface and its Admin implementer. Admin redeclares the + // isActive field the activity subgraph contributes through @interfaceObject. + private static string Accounts(bool isActiveShareable) + => $$""" + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@shareable"]) { + query: Query + } + + type Query { + accounts: [Account!]! + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + interface Account @key(fields: "id") { + id: ID! + } + + type Admin implements Account @key(fields: "id") { + id: ID! + isActive: Boolean!{{(isActiveShareable ? " @shareable" : "")}} + } + + type _Service { sdl: String! } + + union _Entity = Admin + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @shareable on OBJECT | FIELD_DEFINITION + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // Stand-in subgraph that contributes isActive to the Account interface via @interfaceObject. + private static string Activity(bool isActiveShareable) + => $$""" + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@interfaceObject", "@shareable"]) { + query: Query + } + + type Query { + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + type Account @key(fields: "id") @interfaceObject { + id: ID! + isActive: Boolean!{{(isActiveShareable ? " @shareable" : "")}} + } + + type _Service { sdl: String! } + + union _Entity = Account + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @interfaceObject on OBJECT + directive @shareable on OBJECT | FIELD_DEFINITION + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_InterfaceObject_Should_Preserve.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_InterfaceObject_Should_Preserve.md new file mode 100644 index 00000000000..acbfbd61dc7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_InterfaceObject_Should_Preserve.md @@ -0,0 +1,51 @@ +# Transform_InterfaceObject_Should_Preserve + +## Apollo Federation SDL + +```graphql +schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@interfaceObject"]) { + query: Query +} + +type Product @key(fields: "id") @interfaceObject { + id: ID! + name: String +} + +type Query { + products: [Product] + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! +} + +type _Service { sdl: String! } + +union _Entity = Product + +scalar FieldSet +scalar _Any + +directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @interfaceObject on OBJECT +directive @link(url: String! import: [String!]) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + fusion__lookup_productById(id: ID!): Product @internal @lookup + products: [Product] +} + +type Product @key(fields: "id") @interfaceObject { + id: ID! + name: String +} + +directive @interfaceObject on OBJECT +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_Should_PreserveExternalKeyField_When_GeneratingLookup.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_Should_PreserveExternalKeyField_When_GeneratingLookup.md new file mode 100644 index 00000000000..caded565941 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_Should_PreserveExternalKeyField_When_GeneratingLookup.md @@ -0,0 +1,51 @@ +# Transform_Should_PreserveExternalKeyField_When_GeneratingLookup + +## Apollo Federation SDL + +```graphql +schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@external"]) { + query: Query +} + +type Product @key(fields: "id") { + id: ID! @external + name: String +} + +type Query { + products: [Product] + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! +} + +type _Service { sdl: String! } + +union _Entity = Product + +scalar FieldSet +scalar _Any + +directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @external on FIELD_DEFINITION +directive @link(url: String! import: [String!]) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + fusion__lookup_productById(id: ID!): Product @internal @lookup + products: [Product] +} + +type Product @key(fields: "id") { + id: ID! @fusion__sourceExternal + name: String +} + +directive @fusion__sourceExternal on FIELD_DEFINITION +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs new file mode 100644 index 00000000000..5f77e774604 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; + +namespace HotChocolate.Fusion; + +public sealed class CompositionSettingsTests +{ + [Fact] + public void ApolloFederationCompatibility_Should_RoundTrip_When_Serialized() + { + // arrange + var settings = new CompositionSettings + { + ApolloFederationCompatibility = + { + AllowNonResolvableInterfaceObjects = true, + ShareableFieldRuntimeTypeRouting = + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes + } + }; + + // act + using var document = JsonSerializer.SerializeToDocument( + settings, + SettingsJsonSerializerContext.Default.CompositionSettings); + var roundTripped = document.Deserialize( + SettingsJsonSerializerContext.Default.CompositionSettings); + + // assert + Assert.True(document.RootElement + .GetProperty("apolloFederationCompatibility") + .GetProperty("allowNonResolvableInterfaceObjects") + .GetBoolean()); + Assert.True(roundTripped!.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects); + Assert.True(roundTripped.ApolloFederationCompatibility + .ToOptions() + .AllowNonResolvableInterfaceObjects); + Assert.Equal( + "CommonRuntimeTypes", + document.RootElement + .GetProperty("apolloFederationCompatibility") + .GetProperty("shareableFieldRuntimeTypeRouting") + .GetString()); + Assert.Equal( + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + roundTripped.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting); + Assert.Equal( + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + roundTripped.ApolloFederationCompatibility + .ToOptions() + .ShareableFieldRuntimeTypeRouting); + } + + [Fact] + public void MergeInto_Should_PreserveExistingCompatibility_When_OverrideIsUnset() + { + // arrange + var existing = new CompositionSettings + { + ApolloFederationCompatibility = + { + AllowNonResolvableInterfaceObjects = true, + ShareableFieldRuntimeTypeRouting = + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes + } + }; + + // act + var merged = new CompositionSettings().MergeInto(existing); + + // assert + Assert.True(merged.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects); + Assert.Equal( + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + merged.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting); + } + + [Fact] + public void NodeResolution_Should_RoundTripAsString_When_Serialized() + { + var settings = new CompositionSettings + { + Merger = + { + EnableGlobalObjectIdentification = true, + NodeResolution = NodeResolution.SourceSchema + } + }; + + using var document = JsonSerializer.SerializeToDocument( + settings, + SettingsJsonSerializerContext.Default.CompositionSettings); + var roundTripped = document.Deserialize( + SettingsJsonSerializerContext.Default.CompositionSettings); + + Assert.Equal( + "SourceSchema", + document.RootElement + .GetProperty("merger") + .GetProperty("nodeResolution") + .GetString()); + Assert.Equal(NodeResolution.SourceSchema, roundTripped!.Merger.NodeResolution); + Assert.Equal(NodeResolution.SourceSchema, roundTripped.Merger.ToOptions().NodeResolution); + } + + [Fact] + public void MergeInto_Should_PreserveExistingNodeResolution_When_OverrideIsUnset() + { + var existing = new CompositionSettings + { + Merger = { NodeResolution = NodeResolution.SourceSchema } + }; + + var merged = new CompositionSettings().MergeInto(existing); + + Assert.Equal(NodeResolution.SourceSchema, merged.Merger.NodeResolution); + } + + [Fact] + public void LegacySettings_Should_DefaultNodeResolutionToGateway() + { + const string json = + """ + { + "merger": { + "enableGlobalObjectIdentification": true + } + } + """; + + var settings = JsonSerializer.Deserialize( + json, + SettingsJsonSerializerContext.Default.CompositionSettings); + + Assert.Null(settings!.Merger.NodeResolution); + Assert.Equal(NodeResolution.Gateway, settings.Merger.ToOptions().NodeResolution); + Assert.Null(settings.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting); + Assert.Equal( + ShareableFieldRuntimeTypeRouting.SourceLocal, + settings.ApolloFederationCompatibility.ToOptions().ShareableFieldRuntimeTypeRouting); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/ImplementWithoutDefaultRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/ImplementWithoutDefaultRuleTests.cs new file mode 100644 index 00000000000..253f98044e8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/ImplementWithoutDefaultRuleTests.cs @@ -0,0 +1,84 @@ +namespace HotChocolate.Fusion.PostMergeValidationRules; + +public sealed class ImplementWithoutDefaultRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new ImplementWithoutDefaultRule(); + + // "Product"'s stand-in contributes a default "weight"; "PhysicalProduct"'s stand-in replaces it + // with @implement. The marker matches an applicable default, so composition succeeds. + [Fact] + public void Validate_ImplementMatchesAncestorDefault_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Product @key(fields: "id") { + id: ID! + name: String! + } + + interface PhysicalProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # Schema B + type Product @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! + } + """, + """ + # Schema C + type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! @implement + } + """ + ]); + } + + // No source schema contributes a default "weight", so @implement on "PhysicalProduct.weight" + // matches no default. + [Fact] + public void Validate_ImplementWithoutDefault_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Product @key(fields: "id") { + id: ID! + name: String! + } + + interface PhysicalProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # Schema B + type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! @implement + } + """ + ], + [ + """ + { + "message": "The field 'PhysicalProduct.weight' is marked with @implement in schema 'B', but no applicable default implementation exists for it.", + "code": "IMPLEMENT_WITHOUT_DEFAULT", + "severity": "Error", + "coordinate": "PhysicalProduct.weight", + "member": "weight", + "schema": "B", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/InterfaceObjectFieldRequiresImplementRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/InterfaceObjectFieldRequiresImplementRuleTests.cs new file mode 100644 index 00000000000..b147feb142e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/InterfaceObjectFieldRequiresImplementRuleTests.cs @@ -0,0 +1,209 @@ +namespace HotChocolate.Fusion.PostMergeValidationRules; + +public sealed class InterfaceObjectFieldRequiresImplementRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new InterfaceObjectFieldRequiresImplementRule(); + + // "Book" declares no "reviews" of its own, so it adopts the default contributed by the stand-in. + [Fact] + public void Validate_ImplementingTypeAdoptsDefault_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! + } + """ + ]); + } + + // "Book" declares its own "reviews" and marks it @implement, so the explicit replacement is + // allowed. + [Fact] + public void Validate_ExplicitImplementMarked_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + reviews: [Review!]! @implement + } + + type Review { + rating: Int! @shareable + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! @shareable + } + """ + ]); + } + + // "Book.reviews" is declared directly but not marked @implement, so it collides with the default + // contributed by the "Media" stand-in. + [Fact] + public void Validate_UnmarkedCollision_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + reviews: [Review!]! + } + + type Review { + rating: Int! @shareable + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! @shareable + } + """ + ], + [ + """ + { + "message": "The field 'reviews' provided by schema 'A' replaces the default implementation contributed to interface 'Media'. Mark it with @implement to keep this implementation, or remove it to adopt the default.", + "code": "INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT", + "severity": "Error", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + // "PhysicalProduct" implements "Product", and both have stand-ins that default "weight". + // "PhysicalProduct"'s stand-in declares "weight" without @implement, colliding with the default + // contributed by "Product"'s stand-in, one level up the interface hierarchy. + [Fact] + public void Validate_MoreSpecificStandInUnmarked_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Product @key(fields: "id") { + id: ID! + name: String! + } + + interface PhysicalProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # Schema B + type Product @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! + } + """, + """ + # Schema C + type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! + } + """ + ], + [ + """ + { + "message": "The field 'weight' provided by schema 'C' replaces the default implementation contributed to interface 'Product'. Mark it with @implement to keep this implementation, or remove it to adopt the default.", + "code": "INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT", + "severity": "Error", + "schema": "C", + "extensions": {} + } + """ + ]); + } + + // The same hierarchy, but "PhysicalProduct"'s stand-in marks its "weight" field @implement, so the + // more-specific default explicitly replaces the less-specific one. + [Fact] + public void Validate_MoreSpecificStandInImplement_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Product @key(fields: "id") { + id: ID! + name: String! + } + + interface PhysicalProduct implements Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # Schema B + type Product @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! + } + """, + """ + # Schema C + type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + weight: Float! @implement + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/InvalidProjectedFieldSharingRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/InvalidProjectedFieldSharingRuleTests.cs new file mode 100644 index 00000000000..51e964aac88 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/InvalidProjectedFieldSharingRuleTests.cs @@ -0,0 +1,109 @@ +namespace HotChocolate.Fusion.PostMergeValidationRules; + +public sealed class InvalidProjectedFieldSharingRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new InvalidProjectedFieldSharingRule(); + + // "Chair" implements two unrelated interfaces whose stand-ins both default "reviews", and both + // declarations are marked @shareable, so the contract is intentionally identical. + [Fact] + public void Validate_UnrelatedDefaultsShareable_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface PhysicalProduct @key(fields: "id") { + id: ID! + } + + interface DigitalProduct @key(fields: "id") { + id: ID! + } + + type Chair implements PhysicalProduct & DigitalProduct @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! @shareable + } + + type Review { + rating: Int! @shareable + } + """, + """ + # Schema C + type DigitalProduct @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! @shareable + } + + type Review { + rating: Int! @shareable + } + """ + ]); + } + + // The same two unrelated defaults, neither marked @shareable, leave no single most-specific + // default for "Chair.reviews". + [Fact] + public void Validate_UnrelatedDefaultsNotShareable_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface PhysicalProduct @key(fields: "id") { + id: ID! + } + + interface DigitalProduct @key(fields: "id") { + id: ID! + } + + type Chair implements PhysicalProduct & DigitalProduct @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! @shareable + } + """, + """ + # Schema C + type DigitalProduct @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! @shareable + } + """ + ], + [ + """ + { + "message": "The default field 'reviews' on type 'Chair' is contributed by unrelated interfaces 'DigitalProduct', 'PhysicalProduct'; every contributing @interfaceObject declaration must be marked @shareable.", + "code": "INVALID_PROJECTED_FIELD_SHARING", + "severity": "Error", + "schema": "default", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/FieldWithMissingRequiredArgumentRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/FieldWithMissingRequiredArgumentRuleTests.cs index c57e3ea2823..a47907253bd 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/FieldWithMissingRequiredArgumentRuleTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/FieldWithMissingRequiredArgumentRuleTests.cs @@ -131,4 +131,60 @@ type Collection { """ ]); } + + // The same rule applies when the contributing schemas are @interfaceObject stand-ins for the + // same interface. Schema B supplies "minRating" from the executor via @require, while schema C + // declares it as a client-supplied non-null argument. The two are not mergeable. + [Fact] + public void Validate_InterfaceObjectStandInWithRequiredArgument_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Media { + id: ID! + title: String! + rating: Int! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + recommended(minRating: Int! @require(field: "rating")): [Review!]! @shareable + } + + type Review { + id: ID! @shareable + rating: Int! @shareable + } + """, + """ + # Schema C + type Media @interfaceObject @key(fields: "id") { + id: ID! + recommended(minRating: Int!): [Review!]! @shareable + } + + type Review { + id: ID! @shareable + rating: Int! @shareable + } + """ + ], + [ + """ + { + "message": "The argument 'Media.recommended(minRating:)' must be defined as required in schema 'B'. Arguments marked with @require are treated as non-required.", + "code": "FIELD_WITH_MISSING_REQUIRED_ARGUMENT", + "severity": "Error", + "coordinate": "Media.recommended(minRating:)", + "member": "recommended", + "schema": "B", + "extensions": {} + } + """ + ]); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/InterfaceObjectKeyMismatchRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/InterfaceObjectKeyMismatchRuleTests.cs new file mode 100644 index 00000000000..728cd37e214 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/InterfaceObjectKeyMismatchRuleTests.cs @@ -0,0 +1,118 @@ +namespace HotChocolate.Fusion.PreMergeValidationRules; + +public sealed class InterfaceObjectKeyMismatchRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new InterfaceObjectKeyMismatchRule(); + + // The stand-in keys on "sku", which is one of the interface's two keys, so this is valid even + // though the stand-in does not repeat the "id" key. + [Fact] + public void Validate_StandInKeyMatchesInterfaceKey_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Media @key(fields: "id") @key(fields: "sku") { + id: ID! + sku: String! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "sku") { + sku: String! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ]); + } + + // The stand-in keys on "upc", but the "Media" interface declares no key with that field. + [Fact] + public void Validate_StandInKeyNotOnInterface_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "upc") { + upc: String! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ], + [ + """ + { + "message": "The @interfaceObject stand-in 'Media' in schema 'B' declares the key 'upc', which does not match any key declared on the interface it stands in for.", + "code": "INTERFACE_OBJECT_KEY_MISMATCH", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + // The "Media" interface declares no @key at all, so a stand-in can never key on it. + [Fact] + public void Validate_InterfaceHasNoKey_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Media { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ], + [ + """ + { + "message": "The @interfaceObject stand-in 'Media' in schema 'B' declares the key 'id', which does not match any key declared on the interface it stands in for.", + "code": "INTERFACE_OBJECT_KEY_MISMATCH", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "B", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/InterfaceObjectNoInterfaceRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/InterfaceObjectNoInterfaceRuleTests.cs new file mode 100644 index 00000000000..4b3b7e219bb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/InterfaceObjectNoInterfaceRuleTests.cs @@ -0,0 +1,78 @@ +namespace HotChocolate.Fusion.PreMergeValidationRules; + +public sealed class InterfaceObjectNoInterfaceRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new InterfaceObjectNoInterfaceRule(); + + // Source schema A defines "Media" as a real interface, so source schema B's stand-in is valid. + [Fact] + public void Validate_StandInHasInterface_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Media { + id: ID! + title: String! + } + + type Book implements Media { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ]); + } + + // Both schemas declare "Media" as a stand-in, but no schema defines it as an interface. + [Fact] + public void Validate_StandInWithoutInterface_Fails() + { + AssertInvalid( + [ + """ + # Schema A + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + averageRating: Float! + } + """ + ], + [ + """ + { + "message": "The type 'Media' is declared as an @interfaceObject stand-in, but no source schema defines 'Media' as an interface.", + "code": "INTERFACE_OBJECT_NO_INTERFACE", + "severity": "Error", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/TypeKindMismatchRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/TypeKindMismatchRuleTests.cs index ad3992944d4..0ad9fd407ed 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/TypeKindMismatchRuleTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/TypeKindMismatchRuleTests.cs @@ -63,4 +63,70 @@ interface User { """ ]); } + + // "Media" is an interface in schema A and an @interfaceObject stand-in (object type) in schema B. + // The stand-in is excluded from the kind check, so this is not a mismatch. + [Fact] + public void Validate_StandInExcludedFromKindCheck_Succeeds() + { + AssertValid( + [ + """ + # Schema A + interface Media { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ]); + } + + // "Media" is an interface in schema A and a plain object type (no @interfaceObject) in schema B. + // The stand-in exception does not apply, so this is a genuine TYPE_KIND_MISMATCH. + [Fact] + public void Validate_PlainObjectVsInterface_Fails() + { + AssertInvalid( + [ + """ + # Schema A + interface Media { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media { + id: ID! + reviewCount: Int! + } + """ + ], + [ + """ + { + "message": "The type 'Media' has a different kind in schema 'A' (Interface) than it does in schema 'B' (Object).", + "code": "TYPE_KIND_MISMATCH", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "A", + "extensions": {} + } + """ + ]); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorInterfaceObjectTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorInterfaceObjectTests.cs new file mode 100644 index 00000000000..084ecd9ed7b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorInterfaceObjectTests.cs @@ -0,0 +1,159 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Results; +using static HotChocolate.Fusion.CompositionTestHelper; + +namespace HotChocolate.Fusion; + +public sealed class SatisfiabilityValidatorInterfaceObjectTests +{ + // Tests from the specification "Validate Satisfiability" additions for @interfaceObject. + // https://graphql.github.io/composite-schemas-spec/draft/#sec-Validate-Satisfiability + + [Fact] + public void Validate_Should_Succeed_When_CoveringLookupResolvesOpaqueValue() + { + // arrange + // Source schema A defines the Media interface and a covering interface lookup; source + // schema B contributes the projected field "reviews" through a stand-in and provides its + // own lookup to resolve it. + var (result, log) = ValidateSatisfiability( + [ + """ + # name: a + interface Media { id: ID! title: String! } + type Book implements Media { id: ID! title: String! author: String! } + type Movie implements Media { id: ID! title: String! director: String! } + type Query { mediaById(id: ID!): Media @lookup } + """, + """ + # name: b + type Media @interfaceObject @key(fields: "id") { id: ID! reviews: [Review!]! } + type Review { body: String! } + type Query { + mediaById(id: ID!): Media @lookup @internal + topReviewed(limit: Int = 10): [Media!]! + } + """ + ]); + + // assert + Assert.True(result.IsSuccess); + Assert.True(log.IsEmpty); + } + + [Fact] + public void Validate_Should_Succeed_When_StandInLookupHasDistinctName() + { + // arrange + // The stand-in's lookup is named distinctly from the covering lookup and marked @internal; + // the projected field "views" is fetched through it while identity is recovered through A. + var (result, log) = ValidateSatisfiability( + [ + """ + # name: a + type Query { mediaById(id: ID!): Media @lookup } + interface Media { id: ID! title: String! } + type Book implements Media @key(fields: "id") { id: ID! title: String! isbn: String! } + type Movie implements Media @key(fields: "id") { id: ID! title: String! runtime: Int! } + """, + """ + # name: b + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + type Media @interfaceObject @key(fields: "id") { id: ID! views: Int! } + """ + ]); + + // assert + Assert.True(result.IsSuccess); + Assert.True(log.IsEmpty); + } + + [Fact] + public void Validate_Should_Fail_When_StandInContributesFieldWithoutLookup() + { + // arrange + // The stand-in contributes the non-key field "reviews" but declares no lookup, so no plan + // can ever fetch the projected field. + var (result, log) = ValidateSatisfiability( + [ + """ + # name: a + interface Media { id: ID! title: String! } + type Book implements Media { id: ID! title: String! } + type Movie implements Media { id: ID! title: String! } + type Query { mediaById(id: ID!): Media @lookup } + """, + """ + # name: b + type Media @interfaceObject @key(fields: "id") { id: ID! reviews: [Review!]! } + type Review { body: String! } + """ + ]); + + // assert + Assert.True(result.IsFailure); + Assert.All(log, e => Assert.Equal(LogEntryCodes.UnsatisfiableQueryPath, e.Code)); + Assert.Contains( + log, + e => e.Message == "Source schema 'B' contributes fields to 'Media' but provides no lookup to resolve them."); + } + + [Fact] + public void Validate_Should_Fail_When_NoSingleSchemaCoversPossibleTypes() + { + // arrange + // Source schema C adds "Photo", an implementing type source schema A does not define, so + // no single schema's interface lookup covers the composite possible-type set. + var (result, log) = ValidateSatisfiability( + [ + """ + # name: a + interface Media { id: ID! title: String! } + type Book implements Media { id: ID! title: String! author: String! } + type Movie implements Media { id: ID! title: String! director: String! } + type Query { mediaById(id: ID!): Media @lookup } + """, + """ + # name: b + type Media @interfaceObject @key(fields: "id") { id: ID! reviews: [Review!]! } + type Review { body: String! } + type Query { + mediaById(id: ID!): Media @lookup @internal + topReviewed(limit: Int = 10): [Media!]! + } + """, + """ + # name: c + interface Media { id: ID! title: String! } + type Photo implements Media { id: ID! title: String! width: Int! } + type Query { photoById(id: ID!): Photo @lookup } + """ + ]); + + // assert + Assert.True(result.IsFailure); + Assert.All(log, e => Assert.Equal(LogEntryCodes.UnsatisfiableQueryPath, e.Code)); + string.Join("\n\n", log.Select(e => e.Message)).MatchInlineSnapshot( + """ + The query path 'Query.topReviewed' cannot be satisfied: values of 'Media' produced by source schema 'B' are opaque, and no source schema provides a lookup for 'Media' that covers the possible type(s) 'Photo' introduced by source schema(s) 'C'. + """); + } + + private static (CompositionResult Result, CompositionLog Log) ValidateSatisfiability(string[] sdl) + { + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions(sdl), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var options = new SatisfiabilityOptions { IncludeSatisfiabilityPaths = true }; + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log, options); + + return (satisfiabilityValidator.Validate(), log); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs index ab60f2edf74..385dc84c65d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs @@ -2,6 +2,7 @@ using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Options; using HotChocolate.Fusion.Results; +using HotChocolate.Types.Mutable; using static HotChocolate.Fusion.CompositionTestHelper; namespace HotChocolate.Fusion; @@ -2873,6 +2874,148 @@ type Oven implements Node @key(fields: "id") { Assert.True(result.IsSuccess); } + [Fact] + public void SourceSchemaNodeResolution_Should_WarnForUncoveredCompositePossibleType() + { + var schema = CreateDistributedNodeSchema(); + var log = new CompositionLog(); + var validator = new SatisfiabilityValidator( + schema, + log, + nodeResolution: NodeResolution.SourceSchema); + + var result = validator.Validate(); + + Assert.True(result.IsSuccess); + var warning = Assert.Single(log); + Assert.Equal(LogSeverity.Warning, warning.Severity); + Assert.Equal(LogEntryCodes.UnsatisfiableQueryPath, warning.Code); + Assert.Equal( + "Type 'Oven' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node' lookup field for this type.", + warning.Message); + } + + [Fact] + public void GatewayNodeResolution_Should_ErrorForUncoveredCompositePossibleType() + { + var schema = CreateDistributedNodeSchema(); + var log = new CompositionLog(); + var validator = new SatisfiabilityValidator(schema, log); + + var result = validator.Validate(); + + Assert.True(result.IsFailure); + var error = Assert.Single(log); + Assert.Equal(LogSeverity.Error, error.Severity); + Assert.Equal( + "Type 'Oven' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node' lookup field for this type.", + error.Message); + } + + [Theory] + [InlineData("@internal")] + [InlineData("")] + public void SourceSchemaNodeResolution_Should_ErrorWithoutPublicRootDispatcher( + string internalDirective) + { + var sourceSchema = string.IsNullOrEmpty(internalDirective) + ? """ + type Query { + lookups: Lookups + } + + type Lookups { + node(id: ID!): Node @lookup + } + + interface Node { + id: ID! + } + + type Cat implements Node { + id: ID! + } + """ + : $$""" + type Query { + node(id: ID!): Node @lookup {{internalDirective}} + } + + interface Node { + id: ID! + } + + type Cat implements Node { + id: ID! + } + """; + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions([sourceSchema]), + new SourceSchemaMergerOptions + { + AddFusionDefinitions = false, + EnableGlobalObjectIdentification = true + }); + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var validator = new SatisfiabilityValidator( + schema, + log, + nodeResolution: NodeResolution.SourceSchema); + + var result = validator.Validate(); + + Assert.True(result.IsFailure); + Assert.All(log, entry => Assert.Equal(LogSeverity.Error, entry.Severity)); + Assert.Contains(log, entry => entry.Message.Contains("Type 'Cat' implements the 'Node' interface")); + } + + private static MutableSchemaDefinition CreateDistributedNodeSchema() + { + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + node(id: ID!): Node @lookup + toasters: [Toaster] + } + + interface Node { + id: ID! + } + + type Toaster implements Node { + id: ID! + warranty: Int + } + """, + """ + # Schema B + type Query { + ovens: [Oven] + } + + interface Node { + id: ID! + } + + type Oven implements Node { + id: ID! + warranty: Int + } + """ + ]), + new SourceSchemaMergerOptions + { + AddFusionDefinitions = false, + EnableGlobalObjectIdentification = true + }); + + return merger.Merge().Value; + } + [Fact] // https://github.com/graphql-hive/federation-gateway-audit/tree/main/src/test-suites/union-intersection public void UnionIntersection() diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs index c8944ddf456..7989fcf1d0c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs @@ -1,10 +1,197 @@ using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Options; +using HotChocolate.Language; namespace HotChocolate.Fusion; public sealed class SchemaComposerTests { + [Theory] + [InlineData(NodeResolution.Gateway)] + [InlineData(NodeResolution.SourceSchema)] + public void Compose_Should_EmitExecutionMetadata(NodeResolution nodeResolution) + { + var options = new SchemaComposerOptions + { + Merger = + { + EnableGlobalObjectIdentification = nodeResolution is NodeResolution.SourceSchema, + NodeResolution = nodeResolution + } + }; + var composer = new SchemaComposer( + [new SourceSchemaText("A", "type Query { ping: String }")], + options, + new CompositionLog()); + + var result = composer.Compose(); + + Assert.True(result.IsSuccess); + var document = result.Value.ToSyntaxNode(); + var executionMetadataDefinitions = new DocumentNode( + [ + document.Definitions.Single( + definition => definition is EnumTypeDefinitionNode + { + Name.Value: "fusion__NodeResolution" + }), + document.Definitions.Single( + definition => definition is EnumTypeDefinitionNode + { + Name.Value: "fusion__ShareableFieldRuntimeTypeRouting" + }), + document.Definitions.Single( + definition => definition is DirectiveDefinitionNode + { + Name.Value: "fusion__execution" + }) + ]); + + executionMetadataDefinitions.ToString().MatchInlineSnapshot( + """ + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + """); + + var schemaDefinition = document.Definitions + .OfType() + .Single(); + var executionApplication = schemaDefinition.Directives.Single( + directive => directive.Name.Value == "fusion__execution"); + + executionApplication.ToString().MatchInlineSnapshot( + nodeResolution is NodeResolution.Gateway + ? """ + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) + """ + : """ + @fusion__execution( + nodeResolution: SOURCE_SCHEMA + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) + """); + } + + [Theory] + [InlineData(ShareableFieldRuntimeTypeRouting.SourceLocal, "SOURCE_LOCAL")] + [InlineData(ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, "COMMON_RUNTIME_TYPES")] + public void Compose_Should_EmitShareableFieldRuntimeTypeRouting( + ShareableFieldRuntimeTypeRouting routing, + string expectedValue) + { + var options = new SchemaComposerOptions + { + ApolloFederationCompatibility = { ShareableFieldRuntimeTypeRouting = routing } + }; + var composer = new SchemaComposer( + [new SourceSchemaText("A", "type Query { ping: String }")], + options, + new CompositionLog()); + + var result = composer.Compose(); + + Assert.True(result.IsSuccess); + var document = result.Value.ToSyntaxNode(); + var enumType = Assert.Single( + document.Definitions.OfType(), + definition => definition.Name.Value == "fusion__ShareableFieldRuntimeTypeRouting"); + Assert.Equal( + ["SOURCE_LOCAL", "COMMON_RUNTIME_TYPES"], + enumType.Values.Select(value => value.Name.Value)); + var schemaDefinition = Assert.Single(document.Definitions.OfType()); + var directive = Assert.Single( + schemaDefinition.Directives, + application => application.Name.Value == "fusion__execution"); + Assert.Equal(expectedValue, Assert.IsType( + directive.Arguments[1].Value).Value); + } + + [Fact] + public void Compose_Should_Fail_When_SourceSchemaNodeResolutionIsDisabled() + { + var log = new CompositionLog(); + var options = new SchemaComposerOptions + { + Merger = { NodeResolution = NodeResolution.SourceSchema } + }; + var composer = new SchemaComposer( + [new SourceSchemaText("A", "type Query { ping: String }")], + options, + log); + + var result = composer.Compose(); + + Assert.True(result.IsFailure); + var entry = Assert.Single(log); + Assert.Equal(LogEntryCodes.InvalidNodeResolution, entry.Code); + Assert.Equal(LogSeverity.Error, entry.Severity); + Assert.Equal( + "Source-schema node resolution requires global object identification to be enabled.", + entry.Message); + } + + [Fact] + public void Compose_Should_Fail_When_NodeResolutionIsInvalid() + { + var log = new CompositionLog(); + var options = new SchemaComposerOptions + { + Merger = { NodeResolution = (NodeResolution)42 } + }; + var composer = new SchemaComposer( + [new SourceSchemaText("A", "type Query { ping: String }")], + options, + log); + + var result = composer.Compose(); + + Assert.True(result.IsFailure); + var entry = Assert.Single(log); + Assert.Equal(LogEntryCodes.InvalidNodeResolution, entry.Code); + Assert.Equal("The node resolution mode '42' is invalid.", entry.Message); + } + + [Fact] + public void Compose_Should_Fail_When_ShareableFieldRuntimeTypeRoutingIsInvalid() + { + var log = new CompositionLog(); + var options = new SchemaComposerOptions + { + ApolloFederationCompatibility = + { + ShareableFieldRuntimeTypeRouting = (ShareableFieldRuntimeTypeRouting)42 + } + }; + var composer = new SchemaComposer( + [new SourceSchemaText("A", "type Query { ping: String }")], + options, + log); + + var result = composer.Compose(); + + Assert.True(result.IsFailure); + var entry = Assert.Single(log); + Assert.Equal(LogEntryCodes.InvalidShareableFieldRuntimeTypeRouting, entry.Code); + Assert.Equal( + "The shareable field runtime type routing mode '42' is invalid.", + entry.Message); + } + [Fact] public void Compose_Should_Succeed_When_CursorFieldAndArgumentAreValid() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceImplementations.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceImplementations.Tests.cs new file mode 100644 index 00000000000..ddfff9ee344 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceImplementations.Tests.cs @@ -0,0 +1,55 @@ +namespace HotChocolate.Fusion; + +public sealed class SourceSchemaMergerInterfaceImplementationsTests : SourceSchemaMergerTestBase +{ + // Two source schemas each declare one unrelated interface on the shared "Chair" type. Neither + // interface implements the other, so composition unions the two declarations. + [Fact] + public void Merge_UnrelatedInterfacesUnioned_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Product { + id: ID! + } + + type Chair implements Product @key(fields: "id") { + id: ID! + legs: Int + } + """, + """ + # Schema B + interface Searchable { + score: Float + } + + type Chair implements Searchable @key(fields: "id") { + id: ID! + score: Float + } + """ + ], + """ + type Chair implements Product & Searchable + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__implements(schema: A, interface: "Product") + @fusion__implements(schema: B, interface: "Searchable") { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + legs: Int @fusion__field(schema: A) + score: Float @fusion__field(schema: B) + } + + interface Product @fusion__type(schema: A) { + id: ID! @fusion__field(schema: A) + } + + interface Searchable @fusion__type(schema: B) { + score: Float @fusion__field(schema: B) + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceObject.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceObject.Tests.cs new file mode 100644 index 00000000000..ee4861dc4f1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceObject.Tests.cs @@ -0,0 +1,304 @@ +namespace HotChocolate.Fusion; + +public sealed class SourceSchemaMergerInterfaceObjectTests : SourceSchemaMergerTestBase +{ + // Source schema A defines the "Media" interface. Source schema B defines an @interfaceObject + // stand-in that contributes a non-key "reviews" field. The stand-in is merged into the interface + // (it never appears as its own object type), and "reviews" is attributed to source schema B. + [Fact] + public void Merge_StandInContributesFieldToInterface_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! + } + """ + ], + """ + type Review @fusion__type(schema: B) { + rating: Int! @fusion__field(schema: B) + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + """); + } + + // A field whose value is produced by the stand-in schema returns an opaque type: the stand-in + // schema holds no authoritative concrete type. Opacity is recorded once on the interface via + // @fusion__interfaceObject(schema:), not per field, and the field is attributed to the stand-in + // schema. A field returning the real interface (source schema A) is not opaque. + [Fact] + public void Merge_FieldReturningStandIn_MarkedOpaque_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Query { + mediaById(id: ID!): Media + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! + } + + type Query { + featured: Media + } + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + featured: Media @fusion__field(schema: B, sourceType: "Media") + mediaById(id: ID! @fusion__inputField(schema: A)): Media + @fusion__field(schema: A) + } + + type Review @fusion__type(schema: B) { + rating: Int! @fusion__field(schema: B) + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + """); + } + + // "Book" implements "Media" in source schema A and declares no "reviews" of its own, so it + // inherits the default contributed by source schema B's stand-in. The projected "reviews" field + // on "Book" is routed to source schema B. + [Fact] + public void Merge_StandInDefaultProjectedOntoImplementingType_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + rating: Int! + } + """ + ], + """ + type Book implements Media + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Media") { + id: ID! @fusion__field(schema: A) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + + type Review @fusion__type(schema: B) { + rating: Int! @fusion__field(schema: B) + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + """); + } + + // Source schema A declares that "PhysicalProduct" implements "Product", while source schema B + // declares that "Chair" implements "PhysicalProduct" and never mentions "Product". The composite + // "Chair" must implement both through the transitively closed implements relation. + [Fact] + public void Merge_TransitiveClosureAddsDerivedEdge_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Product { + id: ID! + } + + interface PhysicalProduct implements Product { + id: ID! + weight: Int + } + """, + """ + # Schema B + interface PhysicalProduct { + id: ID! + weight: Int + } + + type Chair implements PhysicalProduct { + id: ID! + weight: Int + legs: Int + } + """ + ], + """ + type Chair implements PhysicalProduct & Product + @fusion__type(schema: B) + @fusion__implements(schema: B, interface: "PhysicalProduct") { + id: ID! @fusion__field(schema: B) + legs: Int @fusion__field(schema: B) + weight: Int @fusion__field(schema: B) + } + + interface PhysicalProduct implements Product + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__implements(schema: A, interface: "Product") { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + weight: Int @fusion__field(schema: A) @fusion__field(schema: B) + } + + interface Product @fusion__type(schema: A) { + id: ID! @fusion__field(schema: A) + } + """); + } + + // The "Catalog" schema contributes "reviews" directly on "Book" and "Movie". The "Reviews" schema + // takes over by declaring "Media" as a stand-in whose "reviews" field overrides "Catalog". The + // direct "Catalog" declarations are dropped and "reviews" is projected as a default owned by + // "Reviews". + [Fact] + public void Merge_StandInOverrideDropsDirectDeclarations_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + author: String! + reviews: [Review!]! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + director: String! + reviews: [Review!]! + } + + type Review { + id: ID! @shareable + rating: Int! @shareable + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! @override(from: "A") + } + + type Review { + id: ID! @shareable + rating: Int! @shareable + } + """ + ], + """ + type Book implements Media + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Media") { + author: String! @fusion__field(schema: A) + id: ID! @fusion__field(schema: A) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + + type Movie implements Media + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Media") { + director: String! @fusion__field(schema: A) + id: ID! @fusion__field(schema: A) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + + type Review @fusion__type(schema: A) @fusion__type(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + rating: Int! @fusion__field(schema: A) @fusion__field(schema: B) + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceObjectProjection.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceObjectProjection.Tests.cs new file mode 100644 index 00000000000..e05f8b66e13 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.InterfaceObjectProjection.Tests.cs @@ -0,0 +1,175 @@ +namespace HotChocolate.Fusion; + +public sealed class SourceSchemaMergerInterfaceObjectProjectionTests : SourceSchemaMergerTestBase +{ + // Schema A defines "Media" with "Book" and "Movie". Schema B contributes "reviews" through a + // stand-in. Schema C replaces that default on "Photo" with its own @implement implementation. + // "Book" and "Movie" adopt the default (owner: B); "Photo.reviews" is owned by C. + [Fact] + public void Merge_DefaultProjectedAndReplacedOnOneType_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + } + + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + } + """, + """ + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + body: String! @shareable + } + """, + """ + # Schema C + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Photo implements Media @key(fields: "id") { + id: ID! + title: String! + reviews: [Review!]! @implement + } + + type Review { + body: String! @shareable + } + """ + ], + """ + type Book implements Media + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Media") { + id: ID! @fusion__field(schema: A) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + + type Movie implements Media + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Media") { + id: ID! @fusion__field(schema: A) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) + } + + type Photo implements Media + @fusion__type(schema: C) + @fusion__implements(schema: C, interface: "Media") { + id: ID! @fusion__field(schema: C) + reviews: [Review!]! @fusion__field(schema: C) + title: String! @fusion__field(schema: C) + } + + type Review @fusion__type(schema: B) @fusion__type(schema: C) { + body: String! @fusion__field(schema: B) @fusion__field(schema: C) + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__type(schema: C) + @fusion__interfaceObject(schema: B) { + id: ID! + @fusion__field(schema: A) + @fusion__field(schema: B) + @fusion__field(schema: C) + reviews: [Review!]! @fusion__field(schema: B) + title: String! @fusion__field(schema: A) @fusion__field(schema: C) + } + """); + } + + // A stand-in that declares only its key field is a valid reference-only entry point. It contributes + // no default field, and "Book" gains nothing from it beyond the shared "id" key. + [Fact] + public void Merge_ReferenceOnlyStandIn_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + type Media @interfaceObject @key(fields: "id") { + id: ID! + } + + type Rating { + id: ID! + subject: Media! + stars: Int! + } + """, + """ + # Schema B + interface Media @key(fields: "id") { + id: ID! + } + + type Book implements Media @key(fields: "id") { + id: ID! + } + + type Query { + mediaById(id: ID!): Media @lookup + } + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: B) { + mediaById(id: ID! @fusion__inputField(schema: B)): Media + @fusion__field(schema: B) + } + + type Book implements Media + @fusion__type(schema: B) + @fusion__implements(schema: B, interface: "Media") { + id: ID! @fusion__field(schema: B) + } + + type Rating @fusion__type(schema: A) { + id: ID! @fusion__field(schema: A) + stars: Int! @fusion__field(schema: A) + subject: Media! @fusion__field(schema: A, sourceType: "Media!") + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: A) + @fusion__lookup( + schema: B + key: "id" + field: "mediaById(id: ID!): Media" + map: ["id"] + path: null + internal: false + ) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/InterfaceObjectKeyMissingRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/InterfaceObjectKeyMissingRuleTests.cs new file mode 100644 index 00000000000..fc1ba4e2044 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/InterfaceObjectKeyMissingRuleTests.cs @@ -0,0 +1,61 @@ +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +public sealed class InterfaceObjectKeyMissingRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new InterfaceObjectKeyMissingRule(); + + // The "Media" stand-in declares a @key, so it can be joined to the "Media" interface by "id". + [Fact] + public void Validate_StandInDeclaresKey_Succeeds() + { + AssertValid( + [ + """ + # Schema A + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ]); + } + + // The "Media" stand-in declares no @key, so the entity it stands in for cannot be resolved. + [Fact] + public void Validate_StandInMissingKey_Fails() + { + AssertInvalid( + [ + """ + # Schema A + type Media @interfaceObject { + id: ID! + reviews: [Review!]! + } + + type Review { + id: ID! + rating: Int! + } + """ + ], + [ + """ + { + "message": "The @interfaceObject stand-in 'Media' in schema 'A' must declare at least one @key directive.", + "code": "INTERFACE_OBJECT_KEY_MISSING", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql index 9b639e393b9..9858a10685f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql @@ -1,3 +1,8 @@ +enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA +} + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { EXAMPLE_FOUR_FIVE @fusion__schema_metadata(name: "ExampleFourFive") @@ -6,6 +11,11 @@ enum fusion__Schema { EXAMPLE_THREE @fusion__schema_metadata(name: "Example__Three") } +enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES +} + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -47,6 +57,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION +directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL +) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -55,6 +70,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -91,6 +108,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +"The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." +directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! +) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -137,6 +160,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs index 72a476972d5..f90d4d0631b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs @@ -17,8 +17,8 @@ internal static class AuditAssertions /// The full gateway response serialized as JSON (i.e. result.ToJson()). /// /// - /// The expected data payload. When , the data - /// payload is not asserted. + /// The expected data payload. A value expects the response + /// data to be absent or explicitly , matching the official audit runner. /// /// /// When not , the presence of an errors array is @@ -34,31 +34,30 @@ public static void Assert( var actual = JsonNode.Parse(actualJson) ?? throw new InvalidOperationException("Gateway response JSON parsed to null."); - if (expectedDataJson is not null) + var actualData = actual["data"]; + var expectedData = expectedDataJson is null + ? null + : JsonNode.Parse(expectedDataJson); + + if (!JsonNode.DeepEquals(actualData, expectedData)) { - var actualData = actual["data"]; - var expectedData = JsonNode.Parse(expectedDataJson); + var actualDataText = actualData?.ToJsonString(s_indented) ?? "null"; + var expectedDataText = expectedData?.ToJsonString(s_indented) ?? "null"; + var errorsText = actual["errors"]?.ToJsonString(s_indented) ?? ""; - if (!JsonNode.DeepEquals(actualData, expectedData)) - { - var actualDataText = actualData?.ToJsonString(s_indented) ?? "null"; - var expectedDataText = expectedData?.ToJsonString(s_indented) ?? "null"; - var errorsText = actual["errors"]?.ToJsonString(s_indented) ?? ""; + Xunit.Assert.Fail( + $""" + Data payload did not match. - Xunit.Assert.Fail( - $""" - Data payload did not match. + Expected: + {expectedDataText} - Expected: - {expectedDataText} + Actual: + {actualDataText} - Actual: - {actualDataText} - - Errors: - {errorsText} - """); - } + Errors: + {errorsText} + """); } if (expectsErrors is not null) diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs index 88006108105..fbb91d67bc3 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs @@ -27,8 +27,8 @@ public abstract class ComplianceTestBase : IAsyncLifetime /// /// The GraphQL operation to execute. /// - /// The expected data payload as a JSON object. When , - /// the data payload is not asserted. + /// The expected data payload as JSON. A value expects the + /// response data to be absent or explicitly . /// /// /// When not , asserts whether an errors array is diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs index c00e072fe9b..d8a033e00c2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs @@ -43,8 +43,25 @@ public static Task ComposeAsync( capture: null, sourceSchemaSettings: null, NodeResolution.Gateway, + allowNonResolvableInterfaceObjects: false, + ShareableFieldRuntimeTypeRouting.SourceLocal, subgraphs); + public static Task ComposeAsync( + ApolloFederationCompatibilityOptions compatibility, + params (string Name, Func> Factory)[] subgraphs) + { + ArgumentNullException.ThrowIfNull(compatibility); + + return ComposeAsync( + capture: null, + sourceSchemaSettings: null, + NodeResolution.Gateway, + compatibility.AllowNonResolvableInterfaceObjects, + compatibility.ShareableFieldRuntimeTypeRouting, + subgraphs); + } + /// /// Composes a Fusion gateway around the supplied Apollo Federation subgraphs. /// @@ -55,7 +72,13 @@ public static Task ComposeAsync( public static Task ComposeAsync( NodeResolution nodeResolution, params (string Name, Func> Factory)[] subgraphs) - => ComposeAsync(capture: null, sourceSchemaSettings: null, nodeResolution, subgraphs); + => ComposeAsync( + capture: null, + sourceSchemaSettings: null, + nodeResolution, + allowNonResolvableInterfaceObjects: false, + ShareableFieldRuntimeTypeRouting.SourceLocal, + subgraphs); /// /// Composes a Fusion gateway around the supplied Apollo Federation subgraphs, @@ -69,7 +92,13 @@ public static Task ComposeAsync( public static Task ComposeAsync( SubgraphRequestCapture? capture, params (string Name, Func> Factory)[] subgraphs) - => ComposeAsync(capture, sourceSchemaSettings: null, NodeResolution.Gateway, subgraphs); + => ComposeAsync( + capture, + sourceSchemaSettings: null, + NodeResolution.Gateway, + allowNonResolvableInterfaceObjects: false, + ShareableFieldRuntimeTypeRouting.SourceLocal, + subgraphs); /// /// Composes a Fusion gateway around the supplied Apollo Federation subgraphs, @@ -90,7 +119,13 @@ public static Task ComposeAsync( SubgraphRequestCapture? capture, IReadOnlyDictionary? sourceSchemaSettings, params (string Name, Func> Factory)[] subgraphs) - => ComposeAsync(capture, sourceSchemaSettings, NodeResolution.Gateway, subgraphs); + => ComposeAsync( + capture, + sourceSchemaSettings, + NodeResolution.Gateway, + allowNonResolvableInterfaceObjects: false, + ShareableFieldRuntimeTypeRouting.SourceLocal, + subgraphs); /// /// Composes a Fusion gateway around the supplied Apollo Federation subgraphs, @@ -114,6 +149,8 @@ public static async Task ComposeAsync( SubgraphRequestCapture? capture, IReadOnlyDictionary? sourceSchemaSettings, NodeResolution nodeResolution, + bool allowNonResolvableInterfaceObjects, + ShareableFieldRuntimeTypeRouting shareableFieldRuntimeTypeRouting, params (string Name, Func> Factory)[] subgraphs) { ArgumentNullException.ThrowIfNull(subgraphs); @@ -149,10 +186,12 @@ public static async Task ComposeAsync( subgraphInfos.Add(info); } - var enableGlobalObjectIdentification = nodeResolution != NodeResolution.Gateway; var schemaDocument = ComposeSchema( sourceSchemaTexts, - enableGlobalObjectIdentification); + nodeResolution != NodeResolution.Gateway, + nodeResolution, + allowNonResolvableInterfaceObjects, + shareableFieldRuntimeTypeRouting); var settings = BuildGatewaySettings(subgraphInfos, sourceSchemaSettings); var gatewayServices = new ServiceCollection(); @@ -162,7 +201,6 @@ public static async Task ComposeAsync( gatewayServices .AddGraphQLGateway() .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) - .ModifyOptions(o => o.NodeResolution = nodeResolution) .AddInMemoryConfiguration(schemaDocument, settings); var services = gatewayServices.BuildServiceProvider(); @@ -233,10 +271,17 @@ private static async Task FetchSubgraphSdlAsync(SubgraphHost host) private static DocumentNode ComposeSchema( IReadOnlyList sourceSchemas, - bool enableGlobalObjectIdentification) + bool enableGlobalObjectIdentification, + NodeResolution nodeResolution, + bool allowNonResolvableInterfaceObjects, + ShareableFieldRuntimeTypeRouting shareableFieldRuntimeTypeRouting) { var compositionLog = new CompositionLog(); var options = new SchemaComposerOptions(); + options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects = + allowNonResolvableInterfaceObjects; + options.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting = + shareableFieldRuntimeTypeRouting; // The Apollo Federation transformer already emits every resolvable // '@key' as an explicit '@lookup' field with '@is' metadata. Turning @@ -260,6 +305,8 @@ private static DocumentNode ComposeSchema( options.Merger.EnableGlobalObjectIdentification = true; } + options.Merger.NodeResolution = nodeResolution; + var composer = new SchemaComposer(sourceSchemas, options, compositionLog); var result = composer.Compose(); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/A/ChatType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/A/ChatType.cs index e4e2b5bb48b..ce29c5a5f75 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/A/ChatType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/A/ChatType.cs @@ -17,7 +17,7 @@ protected override void Configure(IObjectTypeDescriptor descriptor) .Key("id") .ResolveReferenceWith(_ => ResolveById(default!)); - descriptor.Field(c => c.Id).Type>(); + descriptor.Field(c => c.Id).External().Type>(); descriptor .Field("account") diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/B/AccountType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/B/AccountType.cs index d645bb05418..85c24737643 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/B/AccountType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/B/AccountType.cs @@ -17,7 +17,7 @@ protected override void Configure(IObjectTypeDescriptor descriptor) .Key("id") .ResolveReferenceWith(_ => ResolveById(default!)); - descriptor.Field(a => a.Id).Type>(); + descriptor.Field(a => a.Id).External().Type>(); descriptor .Field("chats") diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs index 8a1ed36ca54..258b6177860 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs @@ -28,6 +28,7 @@ public Task Node_Account_Id_Only() => RunAsync( } } """, + expectedData: "null", expectsErrors: true); [Fact] @@ -154,6 +155,7 @@ ... on Account { } } """, + expectedData: "null", expectsErrors: true); [Fact] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/A/MediaInterfaceType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/A/MediaInterfaceType.cs index db0ab9a6447..13bfc5f8f69 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/A/MediaInterfaceType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/A/MediaInterfaceType.cs @@ -5,16 +5,23 @@ namespace HotChocolate.Fusion.Suites.InterfaceObjectIndirectExtension.A; /// /// Descriptor for the Media interface in the a subgraph -/// (interface Media @key(fields: "id")). +/// (interface Media @key(fields: "id")). The reference resolver dispatches +/// to the concrete Video implementer so the gateway can recover the +/// concrete __typename for the Media @interfaceObject declared in +/// subgraph c. /// public sealed class MediaInterfaceType : InterfaceType { protected override void Configure(IInterfaceTypeDescriptor descriptor) { descriptor.Name("Media"); - descriptor.Key("id"); + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); descriptor.Field(m => m.Id).Type>(); descriptor.Field(m => m.Title).Type(); } + + private static IMedia ResolveById(string id) => AData.VideoById(id); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/INodeWithName.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/INodeWithName.cs new file mode 100644 index 00000000000..22ed8f8b22c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/INodeWithName.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.InterfaceObjectWithRequires.A; + +/// +/// The NodeWithName interface as projected by the a subgraph +/// (interface NodeWithName @key(fields: "id") { id: ID!, name: String }). +/// The audit's subgraph b extends this same federated interface via +/// @interfaceObject to contribute the username field. +/// +public interface INodeWithName +{ + string Id { get; } + + string? Name { get; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/NodeWithNameType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/NodeWithNameType.cs index 4e3815acb49..3f20269d577 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/NodeWithNameType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/NodeWithNameType.cs @@ -5,17 +5,24 @@ namespace HotChocolate.Fusion.Suites.InterfaceObjectWithRequires.A; /// /// The federated NodeWithName interface owned by the a subgraph -/// (@key(fields: "id")). Concrete implementations such as User -/// carry the entity reference resolver. +/// (@key(fields: "id")). The reference resolver dispatches to the concrete +/// User implementer so the gateway can recover the concrete +/// __typename for the NodeWithName @interfaceObject declared in +/// subgraph b. /// -public sealed class NodeWithNameType : InterfaceType +public sealed class NodeWithNameType : InterfaceType { - protected override void Configure(IInterfaceTypeDescriptor descriptor) + protected override void Configure(IInterfaceTypeDescriptor descriptor) { descriptor.Name("NodeWithName"); - descriptor.Key("id"); + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); - descriptor.Field("id").Type>(); - descriptor.Field("name").Type(); + descriptor.Field(n => n.Id).Type>(); + descriptor.Field(n => n.Name).Type(); } + + private static INodeWithName? ResolveById(string id) + => AData.ById.TryGetValue(id, out var user) ? user : null; } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/User.cs index 7897f3e2955..51380d38b7c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/User.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/A/User.cs @@ -5,7 +5,7 @@ namespace HotChocolate.Fusion.Suites.InterfaceObjectWithRequires.A; /// federated NodeWithName interface (@key(fields: "id")) and owns /// the name and age fields. /// -public sealed class User +public sealed class User : INodeWithName { public string Id { get; init; } = default!; diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/A/QueryType.cs index 57ac0f54688..7b68ffb2c41 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/A/QueryType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/A/QueryType.cs @@ -11,7 +11,11 @@ protected override void Configure(IObjectTypeDescriptor descriptor) descriptor .Field("a") .Type() - .Resolve(_ => new NodeImpl { Id = AData.NodeId }); + .Resolve(_ => new Dictionary + { + ["__typename"] = "Node", + ["id"] = AData.NodeId + }); descriptor .Field("product") diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/Bread.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/Bread.cs index 0bd46955dcc..3bc0a8ce3bc 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/Bread.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/Bread.cs @@ -1,6 +1,11 @@ namespace HotChocolate.Fusion.Suites.NonResolvableInterfaceObject.B; -public sealed class Bread +public interface IProduct +{ + string Id { get; } +} + +public sealed class Bread : IProduct { public string Id { get; init; } = default!; diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/ProductInterfaceType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/ProductInterfaceType.cs index 30399853dca..718ecb841db 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/ProductInterfaceType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/B/ProductInterfaceType.cs @@ -3,14 +3,19 @@ namespace HotChocolate.Fusion.Suites.NonResolvableInterfaceObject.B; -public sealed class ProductInterfaceType : InterfaceType +public sealed class ProductInterfaceType : InterfaceType { - protected override void Configure(IInterfaceTypeDescriptor descriptor) + protected override void Configure(IInterfaceTypeDescriptor descriptor) { descriptor.Name("Product"); - descriptor.Key("id"); + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); - descriptor.Field("id").Type>(); + descriptor.Field(p => p.Id).Type>(); } + + private static IProduct? ResolveById(string id) + => BData.ProductsById.TryGetValue(id, out var bread) ? bread : null; } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs index c405cabed8c..cd7733ac9bf 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs @@ -1,3 +1,6 @@ +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Options; using HotChocolate.Fusion.Suites.NonResolvableInterfaceObject.A; using HotChocolate.Fusion.Suites.NonResolvableInterfaceObject.B; @@ -16,6 +19,10 @@ public sealed class NonResolvableInterfaceObjectTests : ComplianceTestBase { protected override Task BuildGatewayAsync() => FusionGatewayBuilder.ComposeAsync( + new ApolloFederationCompatibilityOptions + { + AllowNonResolvableInterfaceObjects = true + }, (ASubgraph.Name, ASubgraph.BuildAsync), (BSubgraph.Name, BSubgraph.BuildAsync)); @@ -39,16 +46,37 @@ public Task B_ReturnsIdAndField() => RunAsync( """); [Fact] - public Task A_Field_Errors_WhenInterfaceObjectNotResolvable() => RunAsync( - query: """ + public async Task A_Field_Errors_WhenInterfaceObjectNotResolvable() + { + // arrange + var capture = new SubgraphRequestCapture(); + await using var gateway = await FusionGatewayBuilder.ComposeAsync( + capture, + sourceSchemaSettings: null, + NodeResolution.Gateway, + allowNonResolvableInterfaceObjects: true, + ShareableFieldRuntimeTypeRouting.SourceLocal, + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync)); + const string query = + """ query { a { field } } - """, - expectedData: null, - expectsErrors: true); + """; + + // act + var result = await gateway.Executor.ExecuteAsync( + query, + TestContext.Current.CancellationToken); + var json = result.ToJson(withIndentations: false); + + // assert + AuditAssertions.Assert(json, expectedDataJson: "null", expectsErrors: true); + Assert.Empty(capture.Requests); + } [Fact] public Task B_ReturnsId() => RunAsync( @@ -101,7 +129,7 @@ public Task Product_ReturnsId() => RunAsync( """); [Fact] - public Task Product_IdAndName_Errors_WhenNotResolvable() => RunAsync( + public Task Product_IdAndName_ReturnsNullWithError_WhenNotResolvable() => RunAsync( query: """ query { product { diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/A/QueryType.cs index 8f6ab7be817..1178f2656ee 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/A/QueryType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/A/QueryType.cs @@ -19,5 +19,11 @@ protected override void Configure(IObjectTypeDescriptor descriptor) .Shareable() .Type() .Resolve(_ => new Container { Id = AData.ContainerId, Actions = AData.SharedActions }); + + descriptor + .Field("sharedActions") + .Shareable() + .Type>>>() + .Resolve(_ => AData.AActions); } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/B/QueryType.cs index 7e31660ef5e..8675393e7a6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/B/QueryType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/B/QueryType.cs @@ -19,5 +19,11 @@ protected override void Configure(IObjectTypeDescriptor descriptor) .Shareable() .Type() .Resolve(_ => new Container { Id = BData.ContainerId, Actions = BData.SharedActions }); + + descriptor + .Field("sharedActions") + .Shareable() + .Type>>>() + .Resolve(_ => BData.BActions); } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/PartialUnionComplexTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/PartialUnionComplexTests.cs index 52f689878e4..691e3dbd474 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/PartialUnionComplexTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/PartialUnionComplex/PartialUnionComplexTests.cs @@ -1,3 +1,4 @@ +using HotChocolate.Fusion.Options; using HotChocolate.Fusion.Suites.PartialUnionComplex.A; using HotChocolate.Fusion.Suites.PartialUnionComplex.B; @@ -10,13 +11,18 @@ namespace HotChocolate.Fusion.Suites; /// Wrapper.actions union. The union Action exposes a member /// (OnlyA) only in a and a member (OnlyB) only in b, /// with Common shared. The gateway must keep the union member fragments -/// resolvable for the subgraph that owns the current path rather than the global -/// intersection of all members. +/// that are common to every viable provider of a shareable field. Provider scope +/// may expand across the keyed Container, but narrows again below provider-specific fields. /// public sealed class PartialUnionComplexTests : ComplianceTestBase { protected override Task BuildGatewayAsync() => FusionGatewayBuilder.ComposeAsync( + new ApolloFederationCompatibilityOptions + { + ShareableFieldRuntimeTypeRouting = + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes + }, (ASubgraph.Name, ASubgraph.BuildAsync), (BSubgraph.Name, BSubgraph.BuildAsync)); @@ -172,6 +178,39 @@ ... on OnlyB { } """); + [Fact] + public Task SharedRoot_Actions_UsesCommonRuntimeTypesAcrossRootProviders() => RunAsync( + query: """ + query { + sharedActions { + __typename + ... on Common { + label + } + ... on OnlyA { + a + } + ... on OnlyB { + b + } + } + } + """, + expectedData: """ + { + "sharedActions": [ + { + "__typename": "Common", + "label": "common label" + }, + { + "__typename": "OnlyA", + "a": null + } + ] + } + """); + [Fact] public Task RootA_BWrapper_Actions_EntityHopResolvesCommonAndOnlyB() => RunAsync( query: """ diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/Friends/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/Friends/UserType.cs index c7bf0a3eca2..9942fd47556 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/Friends/UserType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/Friends/UserType.cs @@ -13,11 +13,10 @@ namespace HotChocolate.Fusion.Suites.SimpleInaccessible.Friends; /// type: FriendType /// } /// -/// The type argument default is the @inaccessible enum value, so -/// the supergraph hides the default from clients while the source schema still -/// resolves friends without an explicit type selection. The type -/// field always returns FAMILY; the supergraph nulls that value out -/// because FAMILY is inaccessible. +/// The type argument is marked @inaccessible, so the supergraph hides +/// the entire argument while the source schema still applies its FAMILY +/// default. The type field always returns FAMILY; the supergraph nulls +/// that value out because FAMILY is inaccessible. /// public sealed class UserType : ObjectType { diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs index 4bbc75c5b08..c52aaf0e06b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs @@ -9,10 +9,10 @@ namespace HotChocolate.Fusion.Suites; /// subgraphs share the User entity. Subgraph age owns /// id and age and exposes a shareable usersInAge list; /// subgraph friends owns the friends field, including a -/// type: FriendType = FAMILY @inaccessible argument default and a +/// type: FriendType = FAMILY argument marked @inaccessible and a /// type: FriendType field whose resolver always returns the /// inaccessible FAMILY value. The audit verifies that the supergraph -/// hides the @inaccessible argument default and the inaccessible enum +/// hides the @inaccessible argument and the inaccessible enum /// value while the source schemas continue to round-trip values internally. /// public sealed class SimpleInaccessibleTests : ComplianceTestBase @@ -25,8 +25,8 @@ protected override Task BuildGatewayAsync() /// /// usersInAge originates in subgraph age; the planner /// enriches each user with friends { id } from subgraph - /// friends, which uses the @inaccessible default to omit the - /// type argument from the entity call. + /// friends. The hidden type argument is omitted, so the source + /// schema applies its FAMILY default. /// [Fact] public Task UsersInAge_Friends_Id() => RunAsync( @@ -77,15 +77,11 @@ public Task UsersInFriends_Friends_Id() => RunAsync( """); /// - /// Selecting friends(type: FRIEND) succeeds at validation because - /// FRIEND is the only accessible enum value. The audit expects - /// errors because the underlying resolver does not project the - /// type argument and returns FAMILY from type, which - /// is inaccessible at the supergraph and so must surface as a field - /// error. + /// The type argument is marked @inaccessible and is absent from + /// the public schema. Supplying it must fail validation with null response data. /// [Fact] - public Task UsersInFriends_Friends_Type_Friend_Errors() => RunAsync( + public Task UsersInFriends_Friends_Type_Friend_Is_Rejected() => RunAsync( query: """ query { usersInFriends { diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/A/QueryType.cs index 9b4993fe122..bb5626143d9 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/A/QueryType.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/A/QueryType.cs @@ -25,10 +25,14 @@ protected override void Configure(IObjectTypeDescriptor descriptor) { var id = ctx.ArgumentValue("id"); - // Only Toaster implements Node in subgraph A. + if (SubgraphAData.OvensById.TryGetValue(id, out var oven)) + { + return oven; + } + if (SubgraphAData.ToastersById.TryGetValue(id, out var toaster)) { - return (INode)toaster; + return toaster; } return null; diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs index c58bafa949d..7df8d960655 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs @@ -1,3 +1,4 @@ +using HotChocolate.Fusion.Execution; using HotChocolate.Fusion.Suites.UnionInterfaceDistributed.A; using HotChocolate.Fusion.Suites.UnionInterfaceDistributed.B; @@ -15,6 +16,7 @@ public sealed class UnionInterfaceDistributedTests : ComplianceTestBase { protected override Task BuildGatewayAsync() => FusionGatewayBuilder.ComposeAsync( + NodeResolution.SourceSchema, (SubgraphASubgraph.Name, SubgraphASubgraph.BuildAsync), (SubgraphBSubgraph.Name, SubgraphBSubgraph.BuildAsync)); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.InMemory.Tests/InMemoryConnectorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.InMemory.Tests/InMemoryConnectorTests.cs index 73468772778..56830bba60a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.InMemory.Tests/InMemoryConnectorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.InMemory.Tests/InMemoryConnectorTests.cs @@ -1,11 +1,32 @@ using HotChocolate.Execution; +using HotChocolate.Fusion.Options; using HotChocolate.Types.Composite; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace HotChocolate.Fusion; public sealed class InMemoryConnectorTests { + [Fact] + public void ModifyInMemoryCompositionOptions_Should_ConfigureSchemaComposerOptions() + { + // arrange + var services = new ServiceCollection(); + + services.AddGraphQLGateway() + .ModifyInMemoryCompositionOptions( + options => options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects = true); + + using var serviceProvider = services.BuildServiceProvider(); + + // act + var options = serviceProvider.GetRequiredService>().Value; + + // assert + Assert.True(options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects); + } + [Fact] public async Task Execute_Should_ReturnData_When_SingleInMemorySchema() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/ApolloEntityInterfaceLookupPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/ApolloEntityInterfaceLookupPlanningTests.cs new file mode 100644 index 00000000000..c7c5ba7ca69 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/ApolloEntityInterfaceLookupPlanningTests.cs @@ -0,0 +1,435 @@ +using HotChocolate.Features; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Execution.Nodes.Serialization; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Types; + +namespace HotChocolate.Fusion; + +public sealed class ApolloEntityInterfaceLookupPlanningTests : FusionTestBase +{ + private const string CorruptedNodeSchemaA = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"]) + + type Query { + node(id: ID!): Node @shareable + } + + interface Node { + id: ID! + } + + type Account implements Node @key(fields: "id") { + id: ID! + username: String! + } + + type Chat implements Node @key(fields: "id") { + id: ID! @external + account: Account! + } + """; + + private const string CorruptedNodeSchemaB = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"]) + + type Query { + node(id: ID!): Node @shareable + } + + interface Node { + id: ID! + } + + type Account implements Node @key(fields: "id") { + id: ID! @external + chats: [Chat!]! + } + + type Chat implements Node @key(fields: "id") { + id: ID! + text: String! + } + """; + + private const string RequiredNodeSchemaA = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external", "@requires"]) + + type Query { + node(id: ID!): Node @shareable + } + + interface Node { + id: ID! + } + + type Account implements Node @key(fields: "id") { + id: ID! @external + displayName: String! @requires(fields: "id") + } + """; + + private const string RequiredNodeSchemaB = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"]) + + type Query { + node(id: ID!): Node @shareable + } + + interface Node { + id: ID! + } + + type Account implements Node @key(fields: "id") { + id: ID! + } + """; + + private const string LocallyRequiredNodeSchemaA = + """ + type Query { + node(id: ID!): Node @lookup @shareable + accountById(id: ID! @is(field: "id")): Account @lookup @internal + } + + interface Node { + id: ID! + } + + type Account implements Node { + id: ID! @shareable + displayName(id: ID! @require(field: "id")): String! + } + """; + + private const string LocallyRequiredNodeSchemaB = + """ + type Query { + node(id: ID!): Node @lookup @shareable + } + + interface Node { + id: ID! + } + + type Account implements Node { + id: ID! @shareable + } + """; + + // An Apollo Federation subgraph that owns the root 'product' field and the entity interface + // 'Media'. The entity interface exercises the @key-on-interface path through composition + // (its key fields must not be stamped @shareable). Product exposes only 'id' here. + private const string Catalog = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) { + query: Query + } + + type Query { + product: Product + media: [Media!]! + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + interface Media @key(fields: "id") { + id: ID! + title: String! + } + + type Article implements Media @key(fields: "id") { + id: ID! + title: String! + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + + type _Service { sdl: String! } + + union _Entity = Product | Article + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // A second Apollo subgraph that resolves 'Product.reviews'. Because 'reviews' lives only here, + // reaching it from a Product produced by 'catalog' forces an entity lookup into this subgraph, + // which must be issued as an Apollo '_entities(representations:)' query rather than a synthetic + // 'productById' root field (which the subgraph does not expose). + private const string Reviews = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) { + query: Query + } + + type Query { + _service: _Service! + _entities(representations: [_Any!]!): [_Entity]! + } + + type Product @key(fields: "id") { + id: ID! + reviews: [Review!]! + } + + type Review { body: String! } + + type _Service { sdl: String! } + + union _Entity = Product + + scalar FieldSet + scalar _Any + + directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + [Fact] + public void Plan_Should_Route_Apollo_Entity_Lookup_Through_Entities_Query() + { + // arrange + // Composition succeeds only because the @key on the 'Media' interface no longer stamps its + // key fields @shareable. + var schema = ComposeSchema(Catalog, Reviews); + + // act + var plan = PlanOperation( + schema, + """ + { + product { + reviews { body } + } + } + """); + + var yaml = new YamlOperationPlanFormatter().Format(plan); + + // assert + // The lookup into the reviews subgraph is issued as an Apollo '_entities' query. + Assert.Contains("_entities", yaml); + } + + [Fact] + public void Plan_Should_Fail_When_AbstractFieldIsExternalForSourceLocalRuntimeType() + { + // arrange + var schema = CreateCorruptedNodeSchema(); + var chat = schema.Types.GetType("Chat"); + var account = schema.Types.GetType("Account"); + + Assert.False(chat.Fields["id"].Sources["a"].IsExternal); + Assert.True(chat.Fields["id"].Sources["a"].IsSourceExternal); + Assert.False(account.Fields["id"].Sources["b"].IsExternal); + Assert.True(account.Fields["id"].Sources["b"].IsSourceExternal); + + // act + var exception = Assert.Throws( + () => PlanOperation( + schema, + """ + { + node(id: "a1") { + id + } + } + """)); + + // assert + Assert.Equal("No possible plan was found.", exception.Message); + } + + [Fact] + public void Plan_Should_UseViableSource_When_ConcreteSelectionHasLocalOwner() + { + // arrange + var schema = CreateCorruptedNodeSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + node(id: "a1") { + ... on Account { + id + username + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Complete_When_MismatchedConcreteSelectionsHaveExternalKeys() + { + // arrange + var schema = CreateCorruptedNodeSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + account: node(id: "a1") { + __typename + ... on Chat { + id + } + } + chat: node(id: "c1") { + __typename + ... on Account { + id + } + } + } + """); + + // assert + Assert.Equal( + ["a", "b"], + plan.AllNodes + .OfType() + .Where(node => node.SchemaName is not null) + .Select(node => node.SchemaName!)); + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_UseSafeSource_When_LocalFieldRequiresSourceExternalField() + { + // arrange + var schema = CreateNodeSchema( + new SourceSchemaText("a", RequiredNodeSchemaA), + new SourceSchemaText("b", RequiredNodeSchemaB)); + var account = schema.Types.GetType("Account"); + + Assert.True(account.Fields["id"].Sources["a"].IsSourceExternal); + + // act + var plan = PlanOperation( + schema, + """ + { + node(id: "a1") { + ... on Account { + displayName + } + } + } + """); + + // assert + Assert.Equal( + ["b", "a"], + plan.AllNodes + .Select(node => node switch + { + OperationExecutionNode operation => operation.SchemaName, + ApolloOperationExecutionNode operation => operation.SchemaName, + _ => null + }) + .OfType()); + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_UseSource_When_FieldRequirementIsLocallyResolvable() + { + // arrange + var schema = CreateNodeSchema( + new SourceSchemaText("a", LocallyRequiredNodeSchemaA), + new SourceSchemaText("b", LocallyRequiredNodeSchemaB)); + var account = schema.Types.GetType("Account"); + + Assert.False(account.Fields["id"].Sources["a"].IsSourceExternal); + + // act + var plan = PlanOperation( + schema, + """ + { + node(id: "a1") { + ... on Account { + displayName + } + } + } + """); + + // assert + Assert.Equal( + ["b", "a"], + plan.AllNodes + .OfType() + .Where(node => node.SchemaName is not null) + .Select(node => node.SchemaName!)); + } + + private static FusionSchemaDefinition CreateCorruptedNodeSchema() + => CreateNodeSchema( + new SourceSchemaText("a", CorruptedNodeSchemaA), + new SourceSchemaText("b", CorruptedNodeSchemaB)); + + private static FusionSchemaDefinition CreateNodeSchema(params SourceSchemaText[] sources) + { + var options = new SchemaComposerOptions(); + options.Merger.EnableGlobalObjectIdentification = true; + options.Merger.NodeResolution = NodeResolution.SourceSchema; + + foreach (var source in sources) + { + options.SourceSchemas[source.Name] = new SourceSchemaOptions + { + Preprocessor = new SourceSchemaPreprocessorOptions + { + InferKeysFromLookups = false + } + }; + } + + var result = new SchemaComposer(sources, options, new CompositionLog()).Compose(); + if (!result.IsSuccess) + { + throw new InvalidOperationException(result.Errors[0].Message); + } + + var features = new FeatureCollection(); + var fusionOptions = new FusionOptions(); + features.Set(fusionOptions); + features.Set(fusionOptions); + return FusionSchemaDefinition.Create( + result.Value.ToSyntaxNode(), + features: features); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreRepresentationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreRepresentationTests.cs index 2c12531de9c..69e0f33f83d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreRepresentationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreRepresentationTests.cs @@ -2435,19 +2435,373 @@ public void AddRepresentationResult_Should_RouteErrorTrie_When_SubgraphErrorPath """); } + [Fact] + public void AddRepresentationResult_Should_PreserveRootErrorWithoutMerging_When_EntitiesDataIsMissing() + { + // arrange + var schema = ComposeSchema(MergeSchemaA, MergeSchemaB); + var plan = PlanOperation(schema, "{ foos { id name } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse( + sourceArena, + """{"errors":[{"message":"boom"}]}"""); + var before = RenderData(store); + var added = false; + + // act + var exception = Record.Exception( + () => added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: true)); + + // assert + store.FinalizePocketedErrors(); + var error = Assert.Single(store.Errors!); + Assert.Equal("boom", error.Message); + Assert.Null(error.Path); + Assert.Equal(before, RenderData(store)); + Assert.Null(exception); + Assert.True(added); + } + + [Fact] + public void AddRepresentationResult_Should_RebaseEntityErrorWithoutMerging_When_EntitiesDataIsMissing() + { + // arrange + var schema = ComposeSchema(MergeSchemaA, MergeSchemaB); + var plan = PlanOperation(schema, "{ foos { id name } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse( + sourceArena, + """{"errors":[{"message":"boom","path":["_entities",0,"name"]}]}"""); + var before = RenderData(store); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: true); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal("boom", error.Message); + Assert.Equal("foos[0].name", error.Path!.Print()); + Assert.Equal(before, RenderData(store)); + } + + [Fact] + public void AddRepresentationResult_Should_PropagateNonNullField_When_EntitiesDataIsMissing() + { + // arrange + var schema = ComposeSchema( + MergeSchemaA, + MergeSchemaB.Replace("name: String", "name: String!", StringComparison.Ordinal)); + var plan = PlanOperation(schema, "{ foos { id name } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse( + sourceArena, + """{"errors":[{"message":"boom","path":["_entities",0,"name"]}]}"""); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: true); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal("boom", error.Message); + Assert.Equal("foos[0].name", error.Path!.Print()); + RenderData(store).MatchInlineSnapshot( + """ + {"foos":[null],"bars":[]} + """); + } + + [Fact] + public void AddRepresentationResult_Should_NullNullableAncestor_When_NestedErrorLeafIsUnreachable() + { + // arrange + var schemaB = MergeSchemaB.Replace( + "name: String", + "details: Details\n }\n\n type Details {\n name: String!", + StringComparison.Ordinal); + var schema = ComposeSchema(MergeSchemaA, schemaB); + var plan = PlanOperation(schema, "{ foos { id details { name } } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse( + sourceArena, + """{"errors":[{"message":"boom","path":["_entities",0,"details","name"]}]}"""); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: true); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal("boom", error.Message); + Assert.Equal("foos[0].details.name", error.Path!.Print()); + RenderData(store).MatchInlineSnapshot( + """ + {"foos":[{"id":"1","details":null}],"bars":[]} + """); + } + + [Fact] + public void AddRepresentationResult_Should_PropagateNonNullAncestor_When_NestedErrorLeafIsUnreachable() + { + // arrange + var schemaB = MergeSchemaB.Replace( + "name: String", + "details: Details!\n }\n\n type Details {\n name: String!", + StringComparison.Ordinal); + var schema = ComposeSchema(MergeSchemaA, schemaB); + var plan = PlanOperation(schema, "{ foos { id details { name } } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse( + sourceArena, + """{"errors":[{"message":"boom","path":["_entities",0,"details","name"]}]}"""); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: true); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal("boom", error.Message); + Assert.Equal("foos[0].details.name", error.Path!.Print()); + RenderData(store).MatchInlineSnapshot( + """ + {"foos":[null],"bars":[]} + """); + } + + [Fact] + public void AddRepresentationResult_Should_ApplyErrorHandler_When_EntitiesDataIsMissing() + { + // arrange + var schema = ComposeSchema(MergeSchemaA, MergeSchemaB); + var plan = PlanOperation(schema, "{ foos { id name } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena, + new MaskingErrorHandler()); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse( + sourceArena, + """{"errors":[{"message":"boom","path":["_entities",0,"name"]}]}"""); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: true); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal("masked", error.Message); + Assert.Equal("foos[0].name", error.Path!.Print()); + } + + [Fact] + public void AddRepresentationResult_Should_AddTargetedError_When_EntityCountDoesNotMatchRepresentations() + { + // arrange + var schema = ComposeSchema(MergeSchemaA, MergeSchemaB); + var plan = PlanOperation(schema, "{ foos { id name } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse(sourceArena, """{"data":{"_entities":[]}}"""); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: false); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal( + "The representation result at '$._entities' contains 0 entries, but 1 were expected.", + error.Message); + Assert.Equal("foos[0].name", error.Path!.Print()); + } + + [Theory] + [InlineData("{}", "Undefined")] + [InlineData("{\"data\":{\"_entities\":null}}", "Null")] + [InlineData("{\"data\":{\"_entities\":{}}}", "Object")] + public void AddRepresentationResult_Should_AddTargetedError_When_EntitiesDataIsNotArray( + string responseJson, + string valueKind) + { + // arrange + var schema = ComposeSchema(MergeSchemaA, MergeSchemaB); + var plan = PlanOperation(schema, "{ foos { id name } bars { id title } }"); + var fooDefinition = GetLookupDefinition(plan, "fooById"); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = CreateMergeStore( + schema, + plan, + """{"data":{"foos":[{"id":"1"}],"bars":[]}}""", + resultArena, + sourceArena); + + var representation = CreateLookupRepresentation(store, schema, "Foo", fooDefinition); + var response = CreateResponse(sourceArena, responseJson); + + // act + var added = store.AddRepresentationResult( + SelectionPath.Root.AppendField("_entities"), + response, + representation, + fooDefinition.ResultSelectionSet, + containsErrors: false); + + // assert + store.FinalizePocketedErrors(); + Assert.True(added); + var error = Assert.Single(store.Errors!); + Assert.Equal( + $"The representation result at '$._entities' must be an array but was '{valueKind}'.", + error.Message); + Assert.Equal("foos[0].name", error.Path!.Print()); + } + private static FetchResultStore CreateMergeStore( FusionSchemaDefinition schema, OperationPlan plan, string payloadJson, MemoryArena resultArena, MemoryArena sourceArena) + => CreateMergeStore( + schema, + plan, + payloadJson, + resultArena, + sourceArena, + DefaultErrorHandler.Default); + + private static FetchResultStore CreateMergeStore( + FusionSchemaDefinition schema, + OperationPlan plan, + string payloadJson, + MemoryArena resultArena, + MemoryArena sourceArena, + IErrorHandler errorHandler) { var root = Assert.IsType(Assert.Single(plan.RootNodes)); var store = new FetchResultStore(); store.Initialize( resultArena, schema, - DefaultErrorHandler.Default, + errorHandler, plan.Operation, ErrorHandlingMode.Propagate, includeFlags: 0, @@ -2466,6 +2820,14 @@ [new SourceSchemaResult(CompactPath.Root, document)], return store; } + private sealed class MaskingErrorHandler : IErrorHandler + { + public IError Handle(IError error) + => ErrorBuilder.FromError(error) + .SetMessage("masked") + .Build(); + } + private static SingleOperationDefinition GetLookupDefinition( OperationPlan plan, string lookupFieldName) diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs index 407b51cdbd6..61f022c6365 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs @@ -25,6 +25,54 @@ public sealed class FetchResultStoreTests : FusionTestBase private static readonly byte[] s_fieldPayload = """{"data":{"field":"value"}}"""u8.ToArray(); private static readonly FusionSchemaDefinition s_schema = CreateCompositeSchema(); + [Fact] + public void GetResultPaths_Should_ThrowInvalidOperationException_When_TargetTraversesScalar() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + field: String + } + """); + var plan = PlanOperation(schema, "{ field }"); + var node = Assert.IsType(Assert.Single(plan.RootNodes)); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = new FetchResultStore(); + store.Initialize( + resultArena, + schema, + DefaultErrorHandler.Default, + plan.Operation, + ErrorHandlingMode.Propagate, + includeFlags: 0, + deferFlags: 0, + pathSegmentLocalPoolCapacity: 16); + + var document = SourceResultDocument.Parse( + sourceArena, + s_fieldPayload, + s_fieldPayload.Length); + store.AddPartialResult( + SelectionPath.Root, + new SourceSchemaResult(CompactPath.Root, document), + node.ResultSelectionSet, + containsErrors: false); + + // act + var exception = Assert.Throws( + () => store.GetResultPaths(SelectionPath.Root.AppendField("field"))); + + // assert + Assert.Equal( + "Expected the value at result path 'field' for selection path '$.field' to be an object or list, " + + "but found 'String'.", + exception.Message); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -77,7 +125,7 @@ type Query { } [Fact] - public void AddErrors_Should_NullParentAndSkipSiblings_When_ErrorOnNonNullFieldNullsParent() + public void AddErrors_Should_UseAliasesAndPreserveSiblings_When_NonNullFieldNullsParent() { // arrange var schema = ComposeSchema( @@ -93,10 +141,12 @@ type Foo { sku: String! } """); - var plan = PlanOperation(schema, "{ foos { id name sku } }"); + var plan = PlanOperation( + schema, + "{ aliasedFoos: foos { aliasedId: id name aliasedSku: sku } }"); var node = Assert.IsType(Assert.Single(plan.RootNodes)); var elementSelectionSet = ResultSelectionSet.Create( - Utf8GraphQLParser.Parse("{ id name sku }") + Utf8GraphQLParser.Parse("{ aliasedId: id name aliasedSku: sku }") .Definitions .OfType() .Single() @@ -116,7 +166,9 @@ type Foo { deferFlags: 0, pathSegmentLocalPoolCapacity: 16); - var payload = """{"data":{"foos":[{"name":"one"},{"id":"2","name":"two","sku":"sku-2"}]}}"""u8.ToArray(); + var payload = + """{"data":{"aliasedFoos":[{"name":"one","aliasedSku":"sku-1"},{"aliasedId":"2","name":"two","aliasedSku":"sku-2"}]}}"""u8 + .ToArray(); var document = SourceResultDocument.Parse(sourceArena, payload, payload.Length); var added = store.AddPartialResults( SelectionPath.Root, @@ -132,20 +184,169 @@ [new SourceSchemaResult(CompactPath.Root, document)], var completed = store.AddErrors( error, elementSelectionSet, - global::HotChocolate.Path.Root.Append("foos").Append(0)); + global::HotChocolate.Path.Root.Append("aliasedFoos").Append(0)); // assert Assert.True(added); Assert.True(completed); Assert.NotNull(store.Errors); - Assert.Contains( + Assert.Collection( store.Errors, - error => error.Message == "boom" - && error.Path?.Print() is "foos[0].id" or "foos[0].sku"); + error => + { + Assert.Equal("boom", error.Message); + Assert.Equal("aliasedFoos[0].aliasedId", error.Path?.Print()); + }); + RenderData(store).MatchInlineSnapshot( + """ + {"aliasedFoos":[null,{"aliasedId":"2","name":"two","aliasedSku":"sku-2"}]} + """); + } + + [Fact] + public void AddErrors_Should_NullFieldAndPreserveSiblings_When_FieldIsNullable() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foo: Foo + sibling: String + } + + type Foo { + id: ID! + value: String + } + """); + var plan = PlanOperation( + schema, + "{ foo { id value } sibling }"); + var node = Assert.IsType(Assert.Single(plan.RootNodes)); + var fieldSelectionSet = ResultSelectionSet.Create( + Utf8GraphQLParser.Parse("{ value }") + .Definitions + .OfType() + .Single() + .SelectionSet, + schema); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = new FetchResultStore(); + store.Initialize( + resultArena, + schema, + DefaultErrorHandler.Default, + plan.Operation, + ErrorHandlingMode.Propagate, + includeFlags: 0, + deferFlags: 0, + pathSegmentLocalPoolCapacity: 16); + + var payload = + """{"data":{"foo":{"id":"1"},"sibling":"sibling"}}"""u8 + .ToArray(); + var document = SourceResultDocument.Parse(sourceArena, payload, payload.Length); + var added = store.AddPartialResults( + SelectionPath.Root, + [new SourceSchemaResult(CompactPath.Root, document)], + node.ResultSelectionSet, + containsErrors: false); + + var error = ErrorBuilder.New() + .SetMessage("boom") + .Build(); + + // act + var completed = store.AddErrors( + error, + fieldSelectionSet, + global::HotChocolate.Path.Root.Append("foo")); + + // assert + Assert.True(added); + Assert.True(completed); + Assert.Collection( + store.Errors!, + error => + { + Assert.Equal("boom", error.Message); + Assert.Equal("foo.value", error.Path?.Print()); + }); RenderData(store).MatchInlineSnapshot( """ - {"foos":[null,{"id":"2","name":"two","sku":"sku-2"}]} + {"foo":{"id":"1","value":null},"sibling":"sibling"} + """); + } + + [Fact] + public void AddErrors_Should_UseAliasesInErrorPath() + { + // arrange + var schema = ComposeSchema( + """ + # name: test + type Query { + foo: Foo + } + + type Foo { + id: ID! + value: String + } """); + var plan = PlanOperation( + schema, + "{ aliasedFoo: foo { id aliasedValue: value } }"); + var node = Assert.IsType(Assert.Single(plan.RootNodes)); + var fieldSelectionSet = ResultSelectionSet.Create( + Utf8GraphQLParser.Parse("{ aliasedValue: value }") + .Definitions + .OfType() + .Single() + .SelectionSet, + schema); + + using var resultArena = new MemoryArena(); + using var sourceArena = new MemoryArena(); + using var store = new FetchResultStore(); + store.Initialize( + resultArena, + schema, + DefaultErrorHandler.Default, + plan.Operation, + ErrorHandlingMode.Propagate, + includeFlags: 0, + deferFlags: 0, + pathSegmentLocalPoolCapacity: 16); + + var payload = + """{"data":{"aliasedFoo":{"id":"1","aliasedValue":"value"}}}"""u8.ToArray(); + var document = SourceResultDocument.Parse(sourceArena, payload, payload.Length); + var added = store.AddPartialResults( + SelectionPath.Root, + [new SourceSchemaResult(CompactPath.Root, document)], + node.ResultSelectionSet, + containsErrors: false); + + var error = ErrorBuilder.New() + .SetMessage("boom") + .Build(); + + // act + var completed = store.AddErrors( + error, + fieldSelectionSet, + global::HotChocolate.Path.Root.Append("aliasedFoo")); + + // assert + Assert.True(added); + Assert.True(completed); + Assert.Collection( + store.Errors!, + error => Assert.Equal("aliasedFoo.aliasedValue", error.Path?.Print())); } [Fact] diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs index d634cfd1300..56513f52734 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs @@ -56,6 +56,131 @@ fragment Product on Product { formatter.Format(parsedPlan).MatchInlineSnapshot(Encoding.UTF8.GetString(buffer.WrittenSpan)); } + // Source schema A defines the Media interface and its implementing types; source schema B is + // an @interfaceObject stand-in contributing "views". A value produced by B is opaque, and that + // opacity is derived when the result selection set is (re)built, so it must survive a plan + // serialize/parse round-trip for a cached plan to execute correctly. + private const string InterfaceObjectSchemaA = + """ + # name: a + type Query { + mediaById(id: ID!): Media @lookup + } + interface Media { + id: ID! + title: String! + } + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + """; + + private const string InterfaceObjectSchemaB = + """ + # name: b + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + } + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + """; + + [Fact] + public void Parse_Plan_Preserves_InterfaceObject_Opacity_On_StandIn_Fetch() + { + // arrange + var compositeSchema = ComposeSchema(InterfaceObjectSchemaA, InterfaceObjectSchemaB); + var originalPlan = PlanOperation( + compositeSchema, + """ + query { + trendingMedia { + __typename + id + views + } + } + """); + + var formatter = new JsonOperationPlanFormatter( + new JsonWriterOptions + { + Indented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + var planSource = Encoding.UTF8.GetBytes(formatter.Format(originalPlan)); + var compiler = new OperationCompiler( + compositeSchema, + new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>())); + var parser = new JsonOperationPlanParser(compiler); + + // act + var parsedPlan = parser.Parse(planSource); + + // assert + // The stand-in fetch (schema b) resolves { id views }; its trendingMedia child must carry + // the opacity marker after deserialization so the executor completes it interface-typed. + var standInNode = parsedPlan.AllNodes + .OfType() + .Single(t => t.SchemaName == "b"); + var opaqueChild = standInNode.ResultSelectionSet.TryGetChild("trendingMedia"); + Assert.NotNull(opaqueChild); + Assert.True(opaqueChild.ProducesOpaqueElements); + } + + [Fact] + public void Parse_Plan_Preserves_LazySkipped_InterfaceObject_Plan() + { + // arrange + var compositeSchema = ComposeSchema(InterfaceObjectSchemaA, InterfaceObjectSchemaB); + var originalPlan = PlanOperation( + compositeSchema, + """ + query { + trendingMedia { + id + } + } + """); + + var formatter = new JsonOperationPlanFormatter( + new JsonWriterOptions + { + Indented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + var planSource = Encoding.UTF8.GetBytes(formatter.Format(originalPlan)); + var compiler = new OperationCompiler( + compositeSchema, + new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>())); + var parser = new JsonOperationPlanParser(compiler); + + // act + var parsedPlan = parser.Parse(planSource); + + // assert + // Neither __typename nor any type-conditioned field is selected, so the plan is a single + // stand-in fetch with no covering lookup, and that shape survives the round-trip while the + // value still completes interface-typed (opacity preserved). + var standInNode = Assert.Single(parsedPlan.AllNodes.OfType()); + Assert.Equal("b", standInNode.SchemaName); + var opaqueChild = standInNode.ResultSelectionSet.TryGetChild("trendingMedia"); + Assert.NotNull(opaqueChild); + Assert.True(opaqueChild.ProducesOpaqueElements); + } + [Fact] public void Parse_Plan_Uses_SelectionSet_Syntax_When_Present() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionConnectorKindTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionConnectorKindTests.cs index e918f1eea96..8853fe3e13c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionConnectorKindTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionConnectorKindTests.cs @@ -1,6 +1,9 @@ +using HotChocolate.Fusion.ApolloFederation; using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Options; using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Metadata; +using HotChocolate.Language; namespace HotChocolate.Fusion.Execution.Types; @@ -34,6 +37,22 @@ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJEC directive @link(url: String! import: [String!]) repeatable on SCHEMA """; + [Fact] + public void SourceSchemaInfo_Should_PreserveThreeValueConstructorAndDeconstruction() + { + // arrange + var sourceSchema = new SourceSchemaInfo("key", "name", "connector"); + + // act + var (key, name, connectorKind) = sourceSchema; + + // assert + Assert.Equal("key", key); + Assert.Equal("name", name); + Assert.Equal("connector", connectorKind); + Assert.False(sourceSchema.AllowNonResolvableInterfaceObjects); + } + [Fact] public void GetSourceSchemaConnectorKind_Should_ReturnApolloFederation_When_SourceIsFederation() { @@ -79,7 +98,121 @@ public void GetSourceSchemaConnectorKind_Should_ReturnNull_When_NameIsNotASource Assert.Null(kind); } + [Fact] + public void SourceExternal_Should_DefaultFalse_When_FieldWasNotExternal() + { + // arrange + var schema = ComposeSchema(("Products", FederationSubgraphSdl)); + + // act + var source = schema.Types + .GetType("Product") + .Fields["id"] + .Sources["Products"]; + + // assert + Assert.False(source.IsSourceExternal); + } + + [Fact] + public void SourceExternal_Should_RoundTrip_When_ExternalKeyWasPromoted() + { + // arrange + var document = ComposeSchemaDocument( + ("Products", + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + + type Query { + product: Product + } + + type Product @key(fields: "id") { + id: ID! + } + """), + ("Reviews", + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@external"]) + + type Query { + version: String + } + + type Product @key(fields: "id") { + id: ID! @external + reviews: [String!] + } + """)); + + // act + var roundTripped = FusionSchemaDefinition.Create( + Utf8GraphQLParser.Parse(document.ToString(indented: true))); + var source = roundTripped.Types + .GetType("Product") + .Fields["id"] + .Sources["Reviews"]; + + // assert + Assert.False(source.IsExternal); + Assert.True(source.IsSourceExternal); + } + + [Fact] + public void SourceExternal_Should_RoundTrip_When_TransformedSdlIsComposed() + { + // arrange + const string federationSdl = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@external"]) + + type Query { + product: Product + } + + type Product @key(fields: "id") { + id: ID! @external + } + """; + + var transformed = FederationSchemaTransformer.Transform(federationSdl); + Assert.True(transformed.IsSuccess); + + // act + var document = ComposeSchemaDocument(("Products", transformed.Value)); + var schema = FusionSchemaDefinition.Create(document); + var source = schema.Types + .GetType("Product") + .Fields["id"] + .Sources["Products"]; + + // assert + Assert.True(source.IsSourceExternal); + Assert.Equal( + 0, + document.Definitions + .OfType() + .Count(definition => definition.Name.Value == "fusion__sourceExternal")); + Assert.Equal( + 0, + document.Definitions + .OfType() + .SelectMany(type => type.Fields) + .Concat( + document.Definitions + .OfType() + .SelectMany(type => type.Fields)) + .Count(field => field.Directives.Any( + directive => directive.Name.Value == "fusion__sourceExternal"))); + } + private static FusionSchemaDefinition ComposeSchema(params (string Name, string Sdl)[] sources) + => FusionSchemaDefinition.Create(ComposeSchemaDocument(sources)); + + private static DocumentNode ComposeSchemaDocument(params (string Name, string Sdl)[] sources) { var sourceTexts = sources.Select(s => new SourceSchemaText(s.Name, s.Sdl)).ToArray(); var compositionLog = new CompositionLog(); @@ -104,6 +237,6 @@ private static FusionSchemaDefinition ComposeSchema(params (string Name, string throw new InvalidOperationException(result.Errors[0].Message); } - return FusionSchemaDefinition.Create(result.Value.ToSyntaxNode()); + return result.Value.ToSyntaxNode(); } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionNodeResolutionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionNodeResolutionTests.cs new file mode 100644 index 00000000000..cd8f4fbd6c9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionNodeResolutionTests.cs @@ -0,0 +1,122 @@ +using HotChocolate.Fusion.Types; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Execution.Types; + +public sealed class FusionSchemaDefinitionNodeResolutionTests +{ + [Fact] + public void Create_Should_DefaultToGateway_When_ExecutionDirectiveIsAbsent() + { + var schema = CreateSchema(); + + Assert.Equal(NodeResolution.Gateway, schema.NodeResolution); + } + + [Fact] + public void Create_Should_DefaultToGateway_When_ArgumentIsOmitted() + { + var schema = CreateSchema("@fusion__execution"); + + Assert.Equal(NodeResolution.Gateway, schema.NodeResolution); + } + + [Theory] + [InlineData("GATEWAY", NodeResolution.Gateway)] + [InlineData("SOURCE_SCHEMA", NodeResolution.SourceSchema)] + public void Create_Should_ParseNodeResolution_When_ArgumentIsExplicit( + string value, + NodeResolution expected) + { + var schema = CreateSchema($"@fusion__execution(nodeResolution: {value})"); + + Assert.Equal(expected, schema.NodeResolution); + } + + [Fact] + public void Create_Should_ParseExecutionDirective_FromSchemaExtension() + { + var document = Utf8GraphQLParser.Parse( + """ + schema { + query: Query + } + + extend schema @fusion__execution(nodeResolution: SOURCE_SCHEMA) + + type Query { + ping: String + } + + enum fusion__Schema { + A + } + """); + + var schema = FusionSchemaDefinition.Create(document); + + Assert.Equal(NodeResolution.SourceSchema, schema.NodeResolution); + } + + [Fact] + public void Create_Should_RejectDuplicateExecutionDirectives() + { + var document = Utf8GraphQLParser.Parse( + """ + schema @fusion__execution(nodeResolution: GATEWAY) { + query: Query + } + + extend schema @fusion__execution(nodeResolution: SOURCE_SCHEMA) + + type Query { + ping: String + } + + enum fusion__Schema { + A + } + """); + + var exception = Assert.Throws( + () => FusionSchemaDefinition.Create(document)); + + Assert.Equal( + "The fusion__execution directive may only be applied once per schema.", + exception.Message); + } + + [Theory] + [InlineData("INVALID")] + [InlineData("null")] + [InlineData("\"SOURCE_SCHEMA\"")] + public void Create_Should_RejectInvalidNodeResolution(string value) + { + var exception = Assert.Throws( + () => CreateSchema($"@fusion__execution(nodeResolution: {value})")); + + Assert.Equal( + "The fusion__execution nodeResolution argument must be GATEWAY or SOURCE_SCHEMA.", + exception.Message); + } + + private static FusionSchemaDefinition CreateSchema(string? executionDirective = null) + { + var document = Utf8GraphQLParser.Parse( + $$""" + schema {{executionDirective}} { + query: Query + } + + type Query { + ping: String + } + + enum fusion__Schema { + A + } + """); + + return FusionSchemaDefinition.Create(document); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionShareableFieldRuntimeTypeRoutingTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionShareableFieldRuntimeTypeRoutingTests.cs new file mode 100644 index 00000000000..a2949a879d9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionSchemaDefinitionShareableFieldRuntimeTypeRoutingTests.cs @@ -0,0 +1,104 @@ +using HotChocolate.Fusion.Types; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Execution.Types; + +public sealed class FusionSchemaDefinitionShareableFieldRuntimeTypeRoutingTests +{ + [Fact] + public void Create_Should_DefaultToSourceLocal_When_ExecutionDirectiveIsAbsent() + { + var schema = CreateSchema(); + + Assert.Equal( + ShareableFieldRuntimeTypeRouting.SourceLocal, + schema.ShareableFieldRuntimeTypeRouting); + } + + [Fact] + public void Create_Should_DefaultToSourceLocal_When_ArgumentIsOmitted() + { + var schema = CreateSchema("@fusion__execution"); + + Assert.Equal( + ShareableFieldRuntimeTypeRouting.SourceLocal, + schema.ShareableFieldRuntimeTypeRouting); + } + + [Theory] + [InlineData("SOURCE_LOCAL", ShareableFieldRuntimeTypeRouting.SourceLocal)] + [InlineData("COMMON_RUNTIME_TYPES", ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes)] + public void Create_Should_ParseRouting_When_ArgumentIsExplicit( + string value, + ShareableFieldRuntimeTypeRouting expected) + { + var schema = CreateSchema( + $"@fusion__execution(shareableFieldRuntimeTypeRouting: {value})"); + + Assert.Equal(expected, schema.ShareableFieldRuntimeTypeRouting); + } + + [Fact] + public void Create_Should_ParseRouting_FromSchemaExtension() + { + var document = Utf8GraphQLParser.Parse( + """ + schema { + query: Query + } + + extend schema + @fusion__execution(shareableFieldRuntimeTypeRouting: COMMON_RUNTIME_TYPES) + + type Query { + ping: String + } + + enum fusion__Schema { + A + } + """); + + var schema = FusionSchemaDefinition.Create(document); + + Assert.Equal( + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + schema.ShareableFieldRuntimeTypeRouting); + } + + [Theory] + [InlineData("INVALID")] + [InlineData("null")] + [InlineData("\"COMMON_RUNTIME_TYPES\"")] + public void Create_Should_RejectInvalidRouting(string value) + { + var exception = Assert.Throws( + () => CreateSchema( + $"@fusion__execution(shareableFieldRuntimeTypeRouting: {value})")); + + Assert.Equal( + "The fusion__execution shareableFieldRuntimeTypeRouting argument must be " + + "SOURCE_LOCAL or COMMON_RUNTIME_TYPES.", + exception.Message); + } + + private static FusionSchemaDefinition CreateSchema(string? executionDirective = null) + { + var document = Utf8GraphQLParser.Parse( + $$""" + schema {{executionDirective}} { + query: Query + } + + type Query { + ping: String + } + + enum fusion__Schema { + A + } + """); + + return FusionSchemaDefinition.Create(document); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/InterfaceObjectPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/InterfaceObjectPlanningTests.cs new file mode 100644 index 00000000000..6bd57ba89eb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/InterfaceObjectPlanningTests.cs @@ -0,0 +1,404 @@ +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion; + +public sealed class InterfaceObjectPlanningTests : FusionTestBase +{ + // Source schema A defines the Media interface and its implementing types; source schema B is an + // @interfaceObject stand-in contributing "views". A value produced by B is opaque, so the + // concrete __typename and the implementing-type fields are recovered through the covering + // interface lookup on A. + private const string SchemaA = + """ + # name: a + type Query { + mediaById(id: ID!): Media @lookup + allMedia: [Media!]! + } + interface Media { + id: ID! + title: String! + } + type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + } + type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! + } + """; + + private const string SchemaB = + """ + # name: b + type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal + container: Container + } + type Container @key(fields: "id") { + id: ID! + media: Media! + } + type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! + } + """; + + private const string NonResolvableSchemaA = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) + + type Query { + a: Node + } + + interface Node @key(fields: "id") { + id: ID! + } + + type NodeImpl implements Node @key(fields: "id") { + id: ID! + } + """; + + private const string NonResolvableSchemaB = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: ["@key", "@interfaceObject"]) + + type Query { + b: Node + } + + type Node @key(fields: "id", resolvable: false) @interfaceObject { + id: ID! + field: String + } + """; + + private const string OpaqueProductSchema = + """ + # name: opaque + type Query { + product: Product + } + type Product @interfaceObject @key(fields: "id") { + id: ID! + } + """; + + private const string ProductLookupSchema = + """ + # name: products + type Query { + productById(id: ID!): Product @lookup + breadById(id: ID!): Bread @lookup @internal + } + interface Product { + id: ID! + } + type Bread implements Product { + id: ID! + } + """; + + [Fact] + public void Plan_Should_Route_Fragment_Narrowing_To_Covering_Lookup() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + trendingMedia { + __typename + id + views + ... on Book { isbn } + ... on Movie { runtime } + } + } + """); + + // assert + // The stand-in schema B resolves only the interface-level fields it owns; the concrete + // __typename and the implementing-type fields are recovered through A's covering interface + // lookup, keyed by the id available from B. + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Route_TypeName_To_Covering_Lookup() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + trendingMedia { + __typename + id + views + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Compile_Should_ExposeOpaqueInterfaceSelectionSetThroughPublicContracts() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + const string sourceText = + """ + query { + trendingMedia { + __typename + id + views + ... on Book { isbn } + } + } + """; + var plan = PlanOperation(schema, sourceText); + var standInNode = Assert.Single( + plan.AllNodes.OfType(), + node => node.ResultSelectionSet + .TryGetChild("trendingMedia")?.ProducesOpaqueElements is true); + Assert.Equal("b", standInNode.SchemaName); + + var operation = plan.Operation; + Assert.Equal(1, operation.RootSelectionSet.Selections.Length); + var trendingMedia = operation.RootSelectionSet.Selections[0]; + var internalSelectionSet = operation.GetSelectionSet( + trendingMedia, + trendingMedia.Type.NamedType()); + Assert.IsType(internalSelectionSet.Type); + + // act and assert + ISelectionSet selectionSet = internalSelectionSet; + Assert.Equal("Media", selectionSet.Type.Name); + + var selection = Assert.Single( + selectionSet.GetSelections(), + selection => selection.Field.Name == "id"); + Assert.Equal("Media", selection.DeclaringSelectionSet.Type.Name); + } + + [Fact] + public void Plan_Should_Skip_Covering_Lookup_When_Only_InterfaceFields_Selected() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + trendingMedia { + id + } + } + """); + + // assert + // The client selects neither __typename nor any type-conditioned field, so identity is + // never observed. The stand-in schema B resolves the interface-level fields on its own and + // no covering interface lookup is planned (single fetch, Apollo parity). + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Route_ConcreteFragment_TypeNameOnly_To_Covering_Lookup() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + trendingMedia { + ... on Book { __typename } + } + } + """); + + // assert + // A concrete fragment whose only selection is __typename still observes identity, so the + // covering interface lookup on A recovers the concrete type even though nothing else is + // selected inside the fragment. + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Route_ConcreteFragment_StandInField_Through_StandIn() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + trendingMedia { + ... on Book { views } + } + } + """); + + // assert + // "views" is contributed by the stand-in schema B. Under a concrete fragment the covering + // lookup on A recovers identity, and the projected field is still routed back to B through + // its covering interface lookup. + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Route_ConcreteFragment_StandInField_From_NonOpaque_Root() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + allMedia { + id + ... on Book { isbn views } + } + } + """); + + // assert + // "allMedia" is served by the concrete-aware schema A, so identity is local; the projected + // field "views" under the concrete fragment is routed to the stand-in schema B. + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Prefer_CoveringAbstractLookup_Over_ConcreteFanOut() + { + // arrange + var schema = ComposeSchema(OpaqueProductSchema, ProductLookupSchema); + + // act + var plan = PlanOperation( + schema, + """ + query { + product { + ... on Bread { + id + } + } + } + """); + + // assert + // The opaque stand-in cannot provide a concrete typename. The covering Product lookup + // must therefore run at $.product; the Bread lookup at $.product would be skipped + // before identity has been recovered. + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_PreserveAliasedNestedInterfaceObjectStandIn() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var plan = PlanOperation( + schema, + """ + query { + container { + aliasedMedia: media { + id + views + } + } + } + """); + + // assert + var operationNode = Assert.Single(plan.AllNodes.OfType()); + var container = operationNode.ResultSelectionSet.TryGetChild("container"); + Assert.NotNull(container); + var media = container.TryGetChild("aliasedMedia"); + Assert.NotNull(media); + Assert.True(media.ProducesOpaqueElements); + } + + [Fact] + public void Plan_Should_Fail_When_InterfaceObjectHasNoResolvableLookup() + { + // arrange + var schema = ComposeNonResolvableInterfaceObjectSchema(); + + // act + var exception = Assert.Throws( + () => PlanOperation( + schema, + """ + query { + a { + field + } + } + """)); + + // assert + Assert.Equal("No possible plan was found.", exception.Message); + } + + private static FusionSchemaDefinition ComposeNonResolvableInterfaceObjectSchema() + { + var options = new SchemaComposerOptions(); + options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects = true; + + var log = new CompositionLog(); + var result = new SchemaComposer( + [ + new SourceSchemaText("a", NonResolvableSchemaA), + new SourceSchemaText("b", NonResolvableSchemaB) + ], + options, + log).Compose(); + + if (!result.IsSuccess) + { + throw new InvalidOperationException(result.Errors[0].Message); + } + + return FusionSchemaDefinition.Create(result.Value.ToSyntaxNode()); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AbstractLookupFanoutPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AbstractLookupFanoutPlanningTests.cs index e7293ae22e7..faeabbffded 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AbstractLookupFanoutPlanningTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AbstractLookupFanoutPlanningTests.cs @@ -31,6 +31,36 @@ ... on Magazine { sku } MatchSnapshot(plan); } + [Fact] + public void Plan_Should_Prune_Impossible_Concrete_Fragment_When_Parent_Is_Object() + { + // arrange + // The nested Product selection is partitioned into concrete Book and Magazine branches. + // A Magazine-only title lookup must not add its id requirement to the sibling Book lookup. + var schema = CreateNestedProductReviewSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + products { + id + reviews { + product { + sku + ... on Magazine { title } + ... on Book { reviewsCount } + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + // sku is co-located with the products root in "a", reviews in "r", Book-only title in "books". private static FusionSchemaDefinition CreateProductTitleSchema() => ComposeSchema( @@ -72,4 +102,53 @@ type Query { type Book @key(fields: "id") { id: ID! title: String } """); + + private static FusionSchemaDefinition CreateNestedProductReviewSchema() + => ComposeSchema( + """ + # name: products + schema { query: Query } + + type Query { + products: [Product] + bookById(id: ID! @is(field: "id")): Book @lookup @internal + magazineById(id: ID! @is(field: "id")): Magazine @lookup @internal + } + + interface Product { id: ID! sku: String } + type Book implements Product @key(fields: "id") { id: ID! sku: String } + type Magazine implements Product @key(fields: "id") { id: ID! sku: String } + """, + """ + # name: reviews + schema { query: Query } + + type Query { + bookById(id: ID! @is(field: "id")): Book @lookup @internal + magazineById(id: ID! @is(field: "id")): Magazine @lookup @internal + } + + interface Product { id: ID! reviews: [Review] reviewsCount: Int } + type Book implements Product @key(fields: "id") { + id: ID! + reviews: [Review] + reviewsCount: Int + } + type Magazine implements Product @key(fields: "id") { + id: ID! + reviews: [Review] + reviewsCount: Int + } + type Review { product: Product } + """, + """ + # name: magazines + schema { query: Query } + + type Query { + magazineById(id: ID! @is(field: "id")): Magazine @lookup @internal + } + + type Magazine @key(fields: "id") { id: ID! title: String } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/ShareableFieldRuntimeTypeRoutingPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/ShareableFieldRuntimeTypeRoutingPlanningTests.cs new file mode 100644 index 00000000000..463a06ba6ff --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/ShareableFieldRuntimeTypeRoutingPlanningTests.cs @@ -0,0 +1,444 @@ +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +public sealed class ShareableFieldRuntimeTypeRoutingPlanningTests : FusionTestBase +{ + [Fact] + public void Plan_Should_IntersectRuntimeTypesAcrossShareableRootProviders() + { + var schema = CreateUnionSchema("ApolloFederation", bFieldArguments: null); + + var plan = PlanOperation(schema, UnionQuery); + + Assert.Equal(["Common"], GetRootFragmentTypeNames(plan, "actions")); + } + + [Fact] + public void Plan_Should_ExcludeSourceExternalProvider() + { + var schema = CreateUnionSchema( + "ApolloFederation", + bFieldArguments: ", sourceExternal: true"); + + var plan = PlanOperation(schema, UnionQuery); + + Assert.Equal(["Common", "OnlyA"], GetRootFragmentTypeNames(plan, "actions")); + } + + [Fact] + public void Plan_Should_UseSourceLocalRuntimeTypesForMixedConnectors() + { + var schema = CreateUnionSchema("GraphQL", bFieldArguments: null); + + var plan = PlanOperation(schema, UnionQuery); + + Assert.Equal(["Common", "OnlyA"], GetRootFragmentTypeNames(plan, "actions")); + } + + [Fact] + public void Plan_Should_ResetProviderScopeForProvidedField() + { + var schema = CreateSchema( + """ + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + wrapper: Wrapper + @fusion__field(schema: A, provides: "actions") + @fusion__field(schema: B) + } + + type Wrapper @fusion__type(schema: A) @fusion__type(schema: B) { + actions: [Action!]! + @fusion__field(schema: A) + @fusion__field(schema: B) + } + + union Action + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__unionMember(schema: A, member: "Common") + @fusion__unionMember(schema: A, member: "OnlyA") + @fusion__unionMember(schema: B, member: "Common") + @fusion__unionMember(schema: B, member: "OnlyB") + = Common | OnlyA | OnlyB + + type Common @fusion__type(schema: A) @fusion__type(schema: B) { + label: String @fusion__field(schema: A) @fusion__field(schema: B) + } + + type OnlyA @fusion__type(schema: A) { + a: String @fusion__field(schema: A) + } + + type OnlyB @fusion__type(schema: B) { + b: String @fusion__field(schema: B) + } + """); + + var plan = PlanOperation( + schema, + """ + { + wrapper { + actions { + ... on Common { label } + ... on OnlyA { a } + ... on OnlyB { b } + } + } + } + """); + + Assert.Equal( + ["Common", "OnlyA"], + GetRootFragmentTypeNames(plan, "wrapper", "actions")); + } + + [Fact] + public void Plan_Should_HonorConcreteSourceTypeNarrowing() + { + var schema = CreateSchema( + """ + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + action: Action + @fusion__field(schema: A, sourceType: "OnlyA") + @fusion__field(schema: B) + } + + interface Action @fusion__type(schema: A) @fusion__type(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + } + + type OnlyA implements Action + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__implements(schema: A, interface: "Action") + @fusion__implements(schema: B, interface: "Action") { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + a: String @fusion__field(schema: A) @fusion__field(schema: B) + } + + type OnlyB implements Action + @fusion__type(schema: B) + @fusion__implements(schema: B, interface: "Action") { + id: ID! @fusion__field(schema: B) + b: String @fusion__field(schema: B) + } + """); + + var plan = PlanOperation( + schema, + "{ action { ... on OnlyA { a } ... on OnlyB { b } } }"); + + Assert.Equal(["OnlyA"], GetRootFragmentTypeNames(plan, "action")); + } + + [Fact] + public void Plan_Should_TreatInterfaceObjectProviderAsWildcard() + { + var schema = CreateSchema( + """ + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + item: Node @fusion__field(schema: A) @fusion__field(schema: B) + } + + interface Node + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + } + + type Common implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__field(schema: A) + label: String @fusion__field(schema: A) + } + + type OnlyA implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__field(schema: A) + a: String @fusion__field(schema: A) + } + """); + + var plan = PlanOperation( + schema, + "{ item { ... on Common { label } ... on OnlyA { a } } }"); + + Assert.Equal(["Common", "OnlyA"], GetRootFragmentTypeNames(plan, "item")); + } + + [Fact] + public void Plan_Should_ApplyPolicyDifferentialOnKeyedShareablePath() + { + var sourceLocalPlan = PlanOperation( + CreateKeyedSchema(ShareableFieldRuntimeTypeRouting.SourceLocal), + KeyedWrapperQuery); + var commonRuntimeTypesPlan = PlanOperation( + CreateKeyedSchema(ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes), + KeyedWrapperQuery); + + Assert.Equal( + ["Common", "OnlyA"], + GetFragmentTypeNamesForField(sourceLocalPlan, "actions")); + Assert.Equal( + ["Common"], + GetFragmentTypeNamesForField(commonRuntimeTypesPlan, "actions")); + } + + [Fact] + public void Plan_Should_NarrowProviderScopeAcrossProviderSpecificField() + { + var plan = PlanOperation( + CreateKeyedSchema(ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes), + """ + { + rootA { + bWrapper { + actions { + ... on Common { label } + ... on OnlyA { a } + ... on OnlyB { b } + } + } + } + } + """); + + Assert.Equal( + ["Common", "OnlyB"], + GetFragmentTypeNamesForField(plan, "actions")); + } + + private static FusionSchemaDefinition CreateUnionSchema( + string bConnectorKind, + string? bFieldArguments) + => CreateSchema( + $$""" + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + actions: [Action!]! + @fusion__field(schema: A) + @fusion__field(schema: B{{bFieldArguments}}) + } + + union Action + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__unionMember(schema: A, member: "Common") + @fusion__unionMember(schema: A, member: "OnlyA") + @fusion__unionMember(schema: B, member: "Common") + @fusion__unionMember(schema: B, member: "OnlyB") + = Common | OnlyA | OnlyB + + type Common @fusion__type(schema: A) @fusion__type(schema: B) { + label: String @fusion__field(schema: A) @fusion__field(schema: B) + } + + type OnlyA @fusion__type(schema: A) { + a: String @fusion__field(schema: A) + } + + type OnlyB @fusion__type(schema: B) { + b: String @fusion__field(schema: B) + } + """, + bConnectorKind); + + private static FusionSchemaDefinition CreateKeyedSchema( + ShareableFieldRuntimeTypeRouting routing) + => CreateSchema( + """ + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + rootA: Container @fusion__field(schema: A) + } + + type Container + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__lookup( + schema: B + key: "id" + field: "containerById(id: ID!): Container" + map: ["id"] + internal: true) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + wrapper: Wrapper @fusion__field(schema: A) @fusion__field(schema: B) + bWrapper: Wrapper @fusion__field(schema: B) + } + + type Wrapper @fusion__type(schema: A) @fusion__type(schema: B) { + actions: [Action!]! + @fusion__field(schema: A) + @fusion__field(schema: B) + } + + union Action + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__unionMember(schema: A, member: "Common") + @fusion__unionMember(schema: A, member: "OnlyA") + @fusion__unionMember(schema: B, member: "Common") + @fusion__unionMember(schema: B, member: "OnlyB") + = Common | OnlyA | OnlyB + + type Common @fusion__type(schema: A) @fusion__type(schema: B) { + label: String @fusion__field(schema: A) @fusion__field(schema: B) + } + + type OnlyA @fusion__type(schema: A) { + a: String @fusion__field(schema: A) + } + + type OnlyB @fusion__type(schema: B) { + b: String @fusion__field(schema: B) + } + """, + routing: routing); + + private static FusionSchemaDefinition CreateSchema( + string types, + string bConnectorKind = "ApolloFederation", + ShareableFieldRuntimeTypeRouting routing = + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes) + { + var document = Utf8GraphQLParser.Parse( + $$""" + schema + @fusion__execution( + shareableFieldRuntimeTypeRouting: {{routing switch + { + ShareableFieldRuntimeTypeRouting.SourceLocal => "SOURCE_LOCAL", + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes => "COMMON_RUNTIME_TYPES", + _ => throw new ArgumentOutOfRangeException(nameof(routing)) + }}}) { + query: Query + } + + {{types}} + + enum fusion__Schema { + A @fusion__schema_metadata(name: "a", kind: "ApolloFederation") + B @fusion__schema_metadata(name: "b", kind: "{{bConnectorKind}}") + } + """); + + return FusionSchemaDefinition.Create(document); + } + + private static string[] GetRootFragmentTypeNames( + OperationPlan plan, + params string[] fieldPath) + { + var root = Assert.IsType(Assert.Single(plan.RootNodes)); + var document = Utf8GraphQLParser.Parse(root.Operation.SourceText); + var operation = Assert.Single(document.Definitions.OfType()); + var selectionSet = operation.SelectionSet; + + foreach (var fieldName in fieldPath) + { + var field = Assert.Single( + selectionSet.Selections.OfType(), + selection => selection.Name.Value == fieldName); + selectionSet = Assert.IsType(field.SelectionSet); + } + + return selectionSet.Selections + .OfType() + .SelectMany(GetTypeNames) + .ToArray(); + + static IEnumerable GetTypeNames(InlineFragmentNode fragment) + { + if (fragment.TypeCondition is { } typeCondition) + { + yield return typeCondition.Name.Value; + } + + foreach (var child in fragment.SelectionSet.Selections.OfType()) + { + foreach (var typeName in GetTypeNames(child)) + { + yield return typeName; + } + } + } + } + + private static string[] GetFragmentTypeNamesForField(OperationPlan plan, string fieldName) + { + var typeNames = new List(); + + foreach (var node in plan.AllNodes) + { + var sourceText = node switch + { + OperationExecutionNode operationNode => operationNode.Operation.SourceText, + ApolloOperationExecutionNode operationNode => operationNode.Operation.SourceText, + _ => null + }; + + if (sourceText is null) + { + continue; + } + + var document = Utf8GraphQLParser.Parse(sourceText); + var operation = Assert.Single(document.Definitions.OfType()); + Collect(operation.SelectionSet); + } + + return typeNames.ToArray(); + + void Collect(SelectionSetNode selectionSet) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode { SelectionSet: { } child } field: + if (field.Name.Value == fieldName) + { + foreach (var fragment in child.Selections.OfType()) + { + typeNames.AddRange(GetTypeNames(fragment)); + } + } + + Collect(child); + break; + + case InlineFragmentNode fragment: + Collect(fragment.SelectionSet); + break; + } + } + } + + static IEnumerable GetTypeNames(InlineFragmentNode fragment) + { + if (fragment.TypeCondition is { } typeCondition) + { + yield return typeCondition.Name.Value; + } + + foreach (var child in fragment.SelectionSet.Selections.OfType()) + { + foreach (var typeName in GetTypeNames(child)) + { + yield return typeName; + } + } + } + } + + private const string UnionQuery = + "{ actions { ... on Common { label } ... on OnlyA { a } ... on OnlyB { b } } }"; + + private const string KeyedWrapperQuery = + "{ rootA { wrapper { actions { ... on Common { label } ... on OnlyA { a } ... on OnlyB { b } } } } }"; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/SupertypeNarrowingPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/SupertypeNarrowingPlanningTests.cs index ad0d4ee65d4..10b9bb0663f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/SupertypeNarrowingPlanningTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/SupertypeNarrowingPlanningTests.cs @@ -8,6 +8,53 @@ namespace HotChocolate.Fusion.Planning; public sealed class SupertypeNarrowingPlanningTests : FusionTestBase { + [Fact] + public void Plan_Should_Preserve_Interface_And_Union_Supertype_Directives_When_Parent_Is_Concrete_Object() + { + // arrange + var schema = ComposeSchema( + """ + # name: A + type Query { + book: Book + publications: [Publication] + } + + interface Media { id: ID! } + union Publication = Book | Magazine + + type Book implements Media { + id: ID! + title: String + } + + type Magazine implements Media { + id: ID! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query($includeMedia: Boolean!, $skipPublication: Boolean!) { + book { + ... on Media @include(if: $includeMedia) { + id + } + ... on Publication @skip(if: $skipPublication) { + ... on Book { + title + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + [Fact] public void Plan_Should_SpillWholeFieldToCoveringSchema_When_UnionNarrowingCannotCoverRequestedMember() { @@ -493,6 +540,303 @@ ... on Product { fieldWithRequirements.FieldSelection.Path); } + [Fact] + public void Partition_Should_Expand_InterfaceFragment_When_SourceMembershipIsNarrower() + { + // arrange + var schema = CreateDistributedInterfaceMembershipSchema(); + + // act + var (resolvable, unresolvable, _, _) = PartitionSchemaA( + schema, + """ + query { + products { + ... on Node { + id + } + } + } + """); + + // assert + resolvable.MatchInlineSnapshot( + """ + { + products { + __typename + ... { + ... on Oven { + id + } + ... on Toaster { + id + } + } + } + } + """); + Assert.True(unresolvable.IsEmpty); + } + + [Fact] + public void Partition_Should_RouteNestedInterfaceFragment_When_ParentWasExpandedToConcreteTypes() + { + // arrange + var schema = CreateDistributedInterfaceMembershipSchema(); + + // act + var (resolvable, unresolvable, _, _) = PartitionSchemaA( + schema, + """ + query { + products { + ... on Node { + ... on WithWarranty { + warranty + } + } + } + } + """); + + // assert + resolvable.MatchInlineSnapshot( + """ + { + products { + __typename + ... { + ... on Oven { + ... on Oven { + __typename + } + } + ... on Toaster { + ... on Toaster { + warranty + } + } + } + } + } + """); + var unresolved = Assert.Single(unresolvable); + Assert.Equal("Oven", unresolved.SelectionSet.Type.Name); + var ovenWarranty = schema.Types.GetType("Oven") + .Fields.GetField("warranty", allowInaccessibleFields: true); + Assert.False(ovenWarranty.Sources.TryGetMember("A", out _)); + Assert.True(ovenWarranty.Sources.TryGetMember("B", out _)); + unresolved.SelectionSet.Node.MatchInlineSnapshot( + """ + { + warranty + } + """); + } + + [Fact] + public void Partition_Should_CloneNestedSelectionSets_When_ParentExpandsToConcreteTypes() + { + // arrange + var schema = CreateDistributedInterfaceMembershipSchema(); + + // act + var (_, unresolvable, _, index) = PartitionSchemaA( + schema, + """ + query { + products { + ... on Node { + details { + __typename + warranty + } + } + } + } + """); + + // assert + var unresolved = unresolvable.ToArray(); + Assert.Equal(2, unresolved.Length); + Assert.All(unresolved, entry => Assert.Equal("Details", entry.SelectionSet.Type.Name)); + + var firstId = unresolved[0].SelectionSet.Id; + var secondId = unresolved[1].SelectionSet.Id; + Assert.NotEqual(firstId, secondId); + Assert.True(index.TryGetOriginalIdFromCloned(firstId, out var firstOriginalId)); + Assert.True(index.TryGetOriginalIdFromCloned(secondId, out var secondOriginalId)); + Assert.Equal(firstOriginalId, secondOriginalId); + + Assert.All( + unresolved, + entry => entry.SelectionSet.Node.MatchInlineSnapshot( + """ + { + warranty + } + """)); + } + + [Fact] + public void Plan_Should_FetchNestedInterfaceField_When_SyntheticBranchIsOwnedByOtherSource() + { + // arrange + var schema = CreateDistributedInterfaceMembershipSchema(); + + // act + var plan = PlanOperation( + schema, + """ + query { + products { + ... on Node { + ... on WithWarranty { + warranty + } + } + } + } + """); + + // assert + MatchInline( + plan, + """ + operation: + - document: | + { + products { + __typename @fusion__requirement + ... on Node { + ... on WithWarranty { + warranty + id @fusion__requirement + } + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_123456789101112_1 { + products { + __typename + ... { + ... on Oven { + ... on Oven { + __typename + id + } + } + ... on Toaster { + ... on Toaster { + warranty + } + } + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + ovenById(id: $__fusion_1_id) { + warranty + } + } + source: $.ovenById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + """); + } + + [Fact] + public void Partition_Should_NotExpandInterfaceFragment_When_SourceFieldNarrowsRuntimeType() + { + // arrange + var schema = CreateNarrowedDistributedInterfaceMembershipSchema(); + AssertSourceTypeName(schema, "item", "S", "B"); + + // act + var (resolvable, unresolvable, _, _) = PartitionSchema( + schema, + """ + query { + item { + ... on Node { + id + } + } + } + """, + "S"); + + // assert + resolvable.MatchInlineSnapshot( + """ + { + item { + __typename + ... on Node { + __typename + id + } + } + } + """); + Assert.True(unresolvable.IsEmpty); + } + + [Fact] + public void Partition_Should_PruneConcreteFragment_When_SourceParentCannotProduceType() + { + // arrange + var schema = CreateDistributedInterfaceMembershipSchema(); + + // act + var (resolvable, unresolvable, _, _) = PartitionSchemaA( + schema, + """ + query { + nodes { + ... on Toaster { + warranty + } + ... on Oven { + id + } + } + } + """); + + // assert + resolvable.MatchInlineSnapshot( + """ + { + nodes { + __typename + ... on Toaster { + warranty + } + } + } + """); + Assert.True(unresolvable.IsEmpty); + } + private static FusionSchemaDefinition CreateFeaturedItemSchema() => CreateExecutionSchema( """ @@ -885,12 +1229,144 @@ union ProductItem @fusion__unionMember(schema: B, member: "Product") = Product """); + private static FusionSchemaDefinition CreateDistributedInterfaceMembershipSchema() + => CreateExecutionSchema( + """ + type Query + @fusion__type(schema: A) { + products: [Product] + @fusion__field(schema: A) + nodes: [Node] + @fusion__field(schema: A) + } + + union Product + @fusion__type(schema: A) + @fusion__unionMember(schema: A, member: "Oven") + @fusion__unionMember(schema: A, member: "Toaster") = Oven | Toaster + + interface Node + @fusion__type(schema: A) + @fusion__type(schema: B) { + id: ID! + @fusion__field(schema: A) + @fusion__field(schema: B) + details: Details + @fusion__field(schema: A) + @fusion__field(schema: B) + } + + interface WithWarranty + @fusion__type(schema: B) { + warranty: Int + @fusion__field(schema: B) + } + + type Details + @fusion__type(schema: A) + @fusion__type(schema: B) { + warranty: Int + @fusion__field(schema: B) + } + + type Oven implements Node & WithWarranty + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__implements(schema: B, interface: "Node") + @fusion__implements(schema: B, interface: "WithWarranty") + @fusion__lookup( + schema: B + key: "{ id }" + field: "ovenById(id: ID!): Oven" + map: ["id"] + ) { + id: ID! + @fusion__field(schema: A) + @fusion__field(schema: B) + details: Details + @fusion__field(schema: A) + @fusion__field(schema: B) + warranty: Int + @fusion__field(schema: B) + } + + type Toaster implements Node & WithWarranty + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__implements(schema: A, interface: "Node") + @fusion__implements(schema: B, interface: "WithWarranty") { + id: ID! + @fusion__field(schema: A) + @fusion__field(schema: B) + details: Details + @fusion__field(schema: A) + @fusion__field(schema: B) + warranty: Int + @fusion__field(schema: A) + @fusion__field(schema: B) + } + """); + + private static FusionSchemaDefinition CreateNarrowedDistributedInterfaceMembershipSchema() + => CreateExecutionSchema( + """ + type Query + @fusion__type(schema: S) { + item: AB + @fusion__field(schema: S, sourceType: "B!") + } + + union AB + @fusion__type(schema: S) + @fusion__unionMember(schema: S, member: "B") + @fusion__unionMember(schema: S, member: "C") = B | C + + interface Node + @fusion__type(schema: S) + @fusion__type(schema: A) + { + id: ID! + @fusion__field(schema: S) + @fusion__field(schema: A) + } + + type B implements Node + @fusion__type(schema: S) + @fusion__type(schema: A) + @fusion__implements(schema: S, interface: "Node") + @fusion__implements(schema: A, interface: "Node") { + id: ID! + @fusion__field(schema: S) + @fusion__field(schema: A) + } + + type C implements Node + @fusion__type(schema: S) + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! + @fusion__field(schema: S) + @fusion__field(schema: A) + } + """); + private static FusionSchemaDefinition CreateExecutionSchema(string schema) => CreateCompositeSchema(schema + FusionDefinitions); + private static SelectionSetPartitionerResult PartitionSchemaA( + FusionSchemaDefinition schema, + string operationText) + => PartitionSchema(schema, operationText, "A"); + private static SelectionSetPartitionerResult PartitionSchemaB( FusionSchemaDefinition schema, string operationText) + => PartitionSchema(schema, operationText, "B"); + + private static SelectionSetPartitionerResult PartitionSchema( + FusionSchemaDefinition schema, + string operationText, + string schemaName) { var document = Utf8GraphQLParser.Parse(operationText); var rewriter = new DocumentRewriter(schema); @@ -903,7 +1379,7 @@ private static SelectionSetPartitionerResult PartitionSchemaB( return partitioner.Partition( new SelectionSetPartitionerInput { - SchemaName = "B", + SchemaName = schemaName, SelectionSet = new SelectionSet( index.GetId(operation.SelectionSet), operation.SelectionSet, @@ -965,6 +1441,14 @@ directive @fusion__requires( map: [fusion__FieldSelectionMap]! ) repeatable on FIELD_DEFINITION + directive @fusion__lookup( + schema: fusion__Schema! + key: fusion__FieldSelectionSet! + field: fusion__FieldDefinition! + map: [fusion__FieldSelectionMap!]! + internal: Boolean! = false + ) repeatable on OBJECT | INTERFACE + directive @fusion__implements( schema: fusion__Schema! interface: String! diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Keep_SiblingBranch_Clean_When_Duplicate_TypeFragments_Present.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Keep_SiblingBranch_Clean_When_Duplicate_TypeFragments_Present.yaml index e9ed83295f3..111a2b6bb5b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Keep_SiblingBranch_Clean_When_Duplicate_TypeFragments_Present.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Keep_SiblingBranch_Clean_When_Duplicate_TypeFragments_Present.yaml @@ -20,7 +20,7 @@ operation: } hash: 123456789101112 searchSpace: 1 - expandedNodes: 3 + expandedNodes: 2 nodes: - id: 1 type: Operation @@ -53,12 +53,12 @@ nodes: id dependencies: - id: 1 - - id: 4 + - id: 3 type: Operation schema: r operation: | - query Op_123456789101112_4($__fusion_3_id: ID!) { - bookById(id: $__fusion_3_id) { + query Op_123456789101112_3($__fusion_2_id: ID!) { + bookById(id: $__fusion_2_id) { reviews { id } @@ -71,32 +71,28 @@ nodes: target: $.products batchingGroupId: 2 requirements: - - name: __fusion_3_id + - name: __fusion_2_id selectionMap: >- id dependencies: - id: 1 - - id: 3 - type: OperationBatch + - id: 4 + type: Operation schema: books operation: | - query Op_123456789101112_3($__fusion_2_id: ID!) { - bookById(id: $__fusion_2_id) { + query Op_123456789101112_4($__fusion_3_id: ID!) { + bookById(id: $__fusion_3_id) { title } } source: $.bookById - targets: - - $.products - - $.products - batchingGroupId: 3 + target: $.products requirements: - - name: __fusion_2_id + - name: __fusion_3_id selectionMap: >- id conditions: - variable: $title passingValue: false dependencies: - - id: 1 - - id: 4 + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Prune_Impossible_Concrete_Fragment_When_Parent_Is_Object.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Prune_Impossible_Concrete_Fragment_When_Parent_Is_Object.yaml new file mode 100644 index 00000000000..1d3dc1f4479 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/AbstractLookupFanoutPlanningTests.Plan_Should_Prune_Impossible_Concrete_Fragment_When_Parent_Is_Object.yaml @@ -0,0 +1,150 @@ +operation: + - document: | + { + products { + __typename @fusion__requirement + id + id @fusion__requirement + reviews { + product { + __typename @fusion__requirement + sku + id @fusion__requirement + ... on Magazine { + title + id @fusion__requirement + } + ... on Book { + reviewsCount + } + } + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 3 +nodes: + - id: 1 + type: Operation + schema: products + operation: | + query Op_123456789101112_1 { + products { + __typename + id + } + } + - id: 2 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + magazineById(id: $__fusion_1_id) { + reviews { + product { + __typename + reviewsCount + id + } + } + } + } + source: $.magazineById + target: $.products + batchingGroupId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 6 + type: Operation + schema: reviews + operation: | + query Op_123456789101112_6($__fusion_5_id: ID!) { + bookById(id: $__fusion_5_id) { + reviews { + product { + __typename + reviewsCount + id + } + } + } + } + source: $.bookById + target: $.products + batchingGroupId: 2 + requirements: + - name: __fusion_5_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: OperationBatch + schema: products + operation: | + query Op_123456789101112_3($__fusion_2_id: ID!) { + magazineById(id: $__fusion_2_id) { + sku + ... on Magazine { + id + } + } + } + source: $.magazineById + targets: + - $.products.reviews.product + - $.products.reviews.product + batchingGroupId: 3 + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 + - id: 6 + - id: 5 + type: OperationBatch + schema: products + operation: | + query Op_123456789101112_5($__fusion_4_id: ID!) { + bookById(id: $__fusion_4_id) { + sku + } + } + source: $.bookById + targets: + - $.products.reviews.product + - $.products.reviews.product + batchingGroupId: 3 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 2 + - id: 6 + - id: 4 + type: OperationBatch + schema: magazines + operation: | + query Op_123456789101112_4($__fusion_3_id: ID!) { + magazineById(id: $__fusion_3_id) { + title + } + } + source: $.magazineById + targets: + - $.products.reviews.product + - $.products.reviews.product + batchingGroupId: 4 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 3 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/SupertypeNarrowingPlanningTests.Plan_Should_Preserve_Interface_And_Union_Supertype_Directives_When_Parent_Is_Concrete_Object.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/SupertypeNarrowingPlanningTests.Plan_Should_Preserve_Interface_And_Union_Supertype_Directives_When_Parent_Is_Concrete_Object.yaml new file mode 100644 index 00000000000..c59a6824a95 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/SupertypeNarrowingPlanningTests.Plan_Should_Preserve_Interface_And_Union_Supertype_Directives_When_Parent_Is_Concrete_Object.yaml @@ -0,0 +1,25 @@ +operation: + - document: | + query($includeMedia: Boolean!, $skipPublication: Boolean!) { + book { + id @include(if: $includeMedia) + title @skip(if: $skipPublication) + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_123456789101112_1($includeMedia: Boolean!, $skipPublication: Boolean!) { + book { + id @include(if: $includeMedia) + title @skip(if: $skipPublication) + } + } + forwardedVariables: + - includeMedia + - skipPublication diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_Complete_When_MismatchedConcreteSelectionsHaveExternalKeys.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_Complete_When_MismatchedConcreteSelectionsHaveExternalKeys.yaml new file mode 100644 index 00000000000..e57a06a3392 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_Complete_When_MismatchedConcreteSelectionsHaveExternalKeys.yaml @@ -0,0 +1,86 @@ +operation: + - document: | + { + account: node(id: "a1") { + __typename + ... on Chat { + id + } + } + chat: node(id: "c1") { + __typename + ... on Account { + id + } + } + } + hash: 123456789101112 + searchSpace: 4 + expandedNodes: 9 +nodes: + - id: 1 + type: Node + idValue: "c1" + responseName: chat + branches: + - Account: 3 + fallback: 2 + - id: 2 + type: Operation + operation: | + query Op_123456789101112_2 { + chat: node(id: "c1") { + __typename + } + } + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: a + operation: | + query Op_123456789101112_3 { + chat: node(id: "c1") { + __typename + ... on Account { + __typename + id + } + } + } + dependencies: + - id: 1 + - id: 2 + - id: 4 + type: Node + idValue: "a1" + responseName: account + branches: + - Chat: 6 + fallback: 5 + - id: 5 + type: Operation + operation: | + query Op_123456789101112_5 { + account: node(id: "a1") { + __typename + } + } + dependencies: + - id: 4 + - id: 6 + type: Operation + schema: b + operation: | + query Op_123456789101112_6 { + account: node(id: "a1") { + __typename + ... on Chat { + __typename + id + } + } + } + dependencies: + - id: 4 + - id: 5 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_UseSafeSource_When_LocalFieldRequiresSourceExternalField.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_UseSafeSource_When_LocalFieldRequiresSourceExternalField.yaml new file mode 100644 index 00000000000..cbeb08a0ee5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_UseSafeSource_When_LocalFieldRequiresSourceExternalField.yaml @@ -0,0 +1,69 @@ +operation: + - document: | + { + node(id: "a1") { + __typename @fusion__requirement + id @fusion__requirement + ... on Account { + displayName + } + } + } + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 7 +nodes: + - id: 1 + type: Node + idValue: "a1" + responseName: node + branches: + - Account: 3 + fallback: 2 + - id: 2 + type: Operation + operation: | + query Op_123456789101112_2 { + node(id: "a1") { + __typename + } + } + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: b + operation: | + query Op_123456789101112_3 { + node(id: "a1") { + __typename + ... on Account { + __typename + id + } + } + } + dependencies: + - id: 1 + - id: 2 + - id: 4 + type: ApolloOperation + schema: a + operation: | + query Op_123456789101112_4($__fusion_1_id: ID!, $__fusion_2_id: ID) { + fusion__lookup_accountById(id: $__fusion_1_id) { + __typename + displayName(id: $__fusion_2_id) + } + } + source: $._entities + target: $.node + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_UseViableSource_When_ConcreteSelectionHasLocalOwner.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_UseViableSource_When_ConcreteSelectionHasLocalOwner.yaml new file mode 100644 index 00000000000..df5bca28dd4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/ApolloEntityInterfaceLookupPlanningTests.Plan_Should_UseViableSource_When_ConcreteSelectionHasLocalOwner.yaml @@ -0,0 +1,49 @@ +operation: + - document: | + { + node(id: "a1") { + __typename @fusion__requirement + ... on Account { + id + username + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Node + idValue: "a1" + responseName: node + branches: + - Account: 3 + fallback: 2 + - id: 2 + type: Operation + operation: | + query Op_123456789101112_2 { + node(id: "a1") { + __typename + } + } + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: a + operation: | + query Op_123456789101112_3 { + node(id: "a1") { + __typename + ... on Account { + __typename + id + username + } + } + } + dependencies: + - id: 1 + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Prefer_CoveringAbstractLookup_Over_ConcreteFanOut.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Prefer_CoveringAbstractLookup_Over_ConcreteFanOut.yaml new file mode 100644 index 00000000000..fee3ab3fa21 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Prefer_CoveringAbstractLookup_Over_ConcreteFanOut.yaml @@ -0,0 +1,44 @@ +operation: + - document: | + { + product { + __typename @fusion__requirement + id @fusion__requirement + ... on Bread { + id + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: opaque + operation: | + query Op_123456789101112_1 { + product { + id + } + } + - id: 2 + type: Operation + schema: products + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + __typename + ... on Bread { + id + } + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_StandInField_From_NonOpaque_Root.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_StandInField_From_NonOpaque_Root.yaml new file mode 100644 index 00000000000..5d4fbfa9ef4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_StandInField_From_NonOpaque_Root.yaml @@ -0,0 +1,48 @@ +operation: + - document: | + { + allMedia { + __typename @fusion__requirement + id + ... on Book { + isbn + views + id @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + allMedia { + __typename + id + ... on Book { + isbn + id + } + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + mediaByKey(id: $__fusion_1_id) { + views + } + } + source: $.mediaByKey + target: $.allMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_StandInField_Through_StandIn.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_StandInField_Through_StandIn.yaml new file mode 100644 index 00000000000..e62c30a42bb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_StandInField_Through_StandIn.yaml @@ -0,0 +1,62 @@ +operation: + - document: | + { + trendingMedia { + __typename @fusion__requirement + id @fusion__requirement + ... on Book { + views + id @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 3 +nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_123456789101112_1 { + trendingMedia { + id + } + } + - id: 2 + type: Operation + schema: a + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + id + } + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 3 + type: Operation + schema: b + operation: | + query Op_123456789101112_3($__fusion_2_id: ID!) { + mediaByKey(id: $__fusion_2_id) { + views + } + } + source: $.mediaByKey + target: $.trendingMedia + requirements: + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_TypeNameOnly_To_Covering_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_TypeNameOnly_To_Covering_Lookup.yaml new file mode 100644 index 00000000000..aa106c9842b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_ConcreteFragment_TypeNameOnly_To_Covering_Lookup.yaml @@ -0,0 +1,44 @@ +operation: + - document: | + { + trendingMedia { + __typename @fusion__requirement + id @fusion__requirement + ... on Book { + __typename + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_123456789101112_1 { + trendingMedia { + id + } + } + - id: 2 + type: Operation + schema: a + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + __typename + } + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_Fragment_Narrowing_To_Covering_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_Fragment_Narrowing_To_Covering_Lookup.yaml new file mode 100644 index 00000000000..a76ee148bcb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_Fragment_Narrowing_To_Covering_Lookup.yaml @@ -0,0 +1,53 @@ +operation: + - document: | + { + trendingMedia { + __typename + id + id @fusion__requirement + views + ... on Book { + isbn + } + ... on Movie { + runtime + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_123456789101112_1 { + trendingMedia { + id + views + } + } + - id: 2 + type: Operation + schema: a + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + ... on Book { + isbn + } + ... on Movie { + runtime + } + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_TypeName_To_Covering_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_TypeName_To_Covering_Lookup.yaml new file mode 100644 index 00000000000..67adf3819cf --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Route_TypeName_To_Covering_Lookup.yaml @@ -0,0 +1,41 @@ +operation: + - document: | + { + trendingMedia { + __typename + id + id @fusion__requirement + views + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_123456789101112_1 { + trendingMedia { + id + views + } + } + - id: 2 + type: Operation + schema: a + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + mediaById(id: $__fusion_1_id) { + __typename + } + } + source: $.mediaById + target: $.trendingMedia + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Skip_Covering_Lookup_When_Only_InterfaceFields_Selected.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Skip_Covering_Lookup_When_Only_InterfaceFields_Selected.yaml new file mode 100644 index 00000000000..3f7d5e9a571 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/__snapshots__/InterfaceObjectPlanningTests.Plan_Should_Skip_Covering_Lookup_When_Only_InterfaceFields_Selected.yaml @@ -0,0 +1,21 @@ +operation: + - document: | + { + trendingMedia { + __typename @fusion__requirement + id + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_123456789101112_1 { + trendingMedia { + id + } + } diff --git a/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs b/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs index 4fdb5940878..6668abcb2ec 100644 --- a/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs +++ b/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs @@ -339,6 +339,30 @@ public static class Arguments } } + /// + /// The name constants of the @interfaceObject directive. + /// + /// + public static class InterfaceObject + { + /// + /// The name of the @interfaceObject directive. + /// + public const string Name = "interfaceObject"; + } + + /// + /// The name constants of the @implement directive. + /// + /// + public static class Implement + { + /// + /// The name of the @implement directive. + /// + public const string Name = "implement"; + } + /// /// The name constants of the @require directive. /// diff --git a/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs index 9e202985afc..5d1e18549b1 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs @@ -6,6 +6,8 @@ internal sealed class FusionSettingsNameArgument : Argument public const string CacheControlMergeBehavior = "cache-control-merge-behavior"; public const string ExcludeByTag = "exclude-by-tag"; public const string GlobalObjectIdentification = "global-object-identification"; + public const string NodeResolution = "node-resolution"; + public const string ShareableFieldRuntimeTypeRouting = "shareable-field-runtime-type-routing"; public const string TagMergeBehavior = "tag-merge-behavior"; public static readonly string[] All = @@ -13,6 +15,8 @@ internal sealed class FusionSettingsNameArgument : Argument CacheControlMergeBehavior, ExcludeByTag, GlobalObjectIdentification, + NodeResolution, + ShareableFieldRuntimeTypeRouting, TagMergeBehavior ]; diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs index bc8e6529fd1..8ecbb1d6941 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs @@ -17,6 +17,8 @@ public FusionComposeCommand() : base("compose") Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); @@ -66,6 +68,17 @@ private static async Task ExecuteAsync( var environment = parseResult.GetValue(Opt.Instance); var enableGlobalObjectIdentification = parseResult.GetValue( Opt.Instance); + var nodeResolutionOption = Opt.Instance; + var nodeResolution = parseResult.Tokens.Any( + static token => token.Value == NodeResolutionOption.OptionName) + ? parseResult.GetValue(nodeResolutionOption) + : null; + var shareableFieldRuntimeTypeRoutingOption = + Opt.Instance; + var shareableFieldRuntimeTypeRouting = parseResult.Tokens.Any( + static token => token.Value == ShareableFieldRuntimeTypeRoutingOption.OptionName) + ? parseResult.GetValue(shareableFieldRuntimeTypeRoutingOption) + : null; var includeSatisfiabilityPaths = parseResult.GetValue( Opt.Instance); var watchMode = parseResult.GetValue(Opt.Instance); @@ -111,6 +124,8 @@ private static async Task ExecuteAsync( archiveFile, environment, enableGlobalObjectIdentification, + nodeResolution, + shareableFieldRuntimeTypeRouting, includeSatisfiabilityPaths, tagsToExclude, cancellationToken); @@ -128,7 +143,8 @@ private static async Task ExecuteAsync( { Merger = new CompositionSettings.MergerSettings { - EnableGlobalObjectIdentification = enableGlobalObjectIdentification + EnableGlobalObjectIdentification = enableGlobalObjectIdentification, + NodeResolution = nodeResolution }, Satisfiability = new CompositionSettings.SatisfiabilitySettings { @@ -137,7 +153,12 @@ private static async Task ExecuteAsync( Preprocessor = new CompositionSettings.PreprocessorSettings { ExcludeByTag = tagsToExclude?.ToHashSet() - } + }, + ApolloFederationCompatibility = + new CompositionSettings.ApolloFederationCompatibilitySettings + { + ShareableFieldRuntimeTypeRouting = shareableFieldRuntimeTypeRouting + } }, removeSourceSchemas, cancellationToken); @@ -152,6 +173,8 @@ private static async Task WatchComposeAsync( string archiveFile, string? environment, bool? enableGlobalObjectIdentification, + NodeResolution? nodeResolution, + ShareableFieldRuntimeTypeRouting? shareableFieldRuntimeTypeRouting, bool? includeSatisfiabilityPaths, List? tagsToExclude, CancellationToken cancellationToken) @@ -171,7 +194,8 @@ await ComposeAsync( { Merger = new CompositionSettings.MergerSettings { - EnableGlobalObjectIdentification = enableGlobalObjectIdentification + EnableGlobalObjectIdentification = enableGlobalObjectIdentification, + NodeResolution = nodeResolution }, Satisfiability = new CompositionSettings.SatisfiabilitySettings { @@ -180,7 +204,12 @@ await ComposeAsync( Preprocessor = new CompositionSettings.PreprocessorSettings { ExcludeByTag = tagsToExclude?.ToHashSet() - } + }, + ApolloFederationCompatibility = + new CompositionSettings.ApolloFederationCompatibilitySettings + { + ShareableFieldRuntimeTypeRouting = shareableFieldRuntimeTypeRouting + } }, [], cancellationToken); @@ -207,6 +236,8 @@ await ComposeAsync( archiveFile, environment, enableGlobalObjectIdentification, + nodeResolution, + shareableFieldRuntimeTypeRouting, includeSatisfiabilityPaths, tagsToExclude, cancellationToken); @@ -338,6 +369,8 @@ private static async Task ProcessCompositionRequestsAsync( string archiveFile, string? environment, bool? enableGlobalObjectIdentification, + NodeResolution? nodeResolution, + ShareableFieldRuntimeTypeRouting? shareableFieldRuntimeTypeRouting, bool? includeSatisfiabilityPaths, List? tagsToExclude, CancellationToken cancellationToken) @@ -377,7 +410,8 @@ await ComposeAsync( { Merger = new CompositionSettings.MergerSettings { - EnableGlobalObjectIdentification = enableGlobalObjectIdentification + EnableGlobalObjectIdentification = enableGlobalObjectIdentification, + NodeResolution = nodeResolution }, Satisfiability = new CompositionSettings.SatisfiabilitySettings { @@ -386,7 +420,13 @@ await ComposeAsync( Preprocessor = new CompositionSettings.PreprocessorSettings { ExcludeByTag = tagsToExclude?.ToHashSet() - } + }, + ApolloFederationCompatibility = + new CompositionSettings.ApolloFederationCompatibilitySettings + { + ShareableFieldRuntimeTypeRouting = + shareableFieldRuntimeTypeRouting + } }, [], cancellationToken); diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs index dfe72769b9e..0e4ad9f6e1f 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs @@ -82,6 +82,30 @@ private static async Task ExecuteAsync( compositionSettings.Merger.EnableGlobalObjectIdentification = enableGlobalObjectIdentification; break; + case FusionSettingsNameArgument.NodeResolution: + compositionSettings.Merger.NodeResolution = settingValue switch + { + "gateway" => NodeResolution.Gateway, + "source-schema" => NodeResolution.SourceSchema, + _ => throw new ExitException( + $"Expected one of the following values for setting '{settingName}': " + + "gateway, source-schema") + }; + break; + + case FusionSettingsNameArgument.ShareableFieldRuntimeTypeRouting: + compositionSettings.ApolloFederationCompatibility + .ShareableFieldRuntimeTypeRouting = settingValue switch + { + "source-local" => ShareableFieldRuntimeTypeRouting.SourceLocal, + "common-runtime-types" => + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + _ => throw new ExitException( + $"Expected one of the following values for setting '{settingName}': " + + "source-local, common-runtime-types") + }; + break; + case FusionSettingsNameArgument.TagMergeBehavior: if (!TryParseDirectiveMergeBehavior(settingValue, out var tagMergeBehavior)) { diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/NodeResolutionOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/NodeResolutionOption.cs new file mode 100644 index 00000000000..09718f8b516 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/NodeResolutionOption.cs @@ -0,0 +1,21 @@ +using HotChocolate.Fusion; + +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class NodeResolutionOption : Option +{ + public const string OptionName = "--node-resolution"; + + public NodeResolutionOption() + : base(OptionName) + { + Description = "Choose whether Query.node identifiers are resolved by the gateway or a source schema"; + AcceptOnlyFromAmong("gateway", "source-schema"); + CustomParser = result => result.Tokens.Single().Value switch + { + "gateway" => NodeResolution.Gateway, + "source-schema" => NodeResolution.SourceSchema, + _ => null + }; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/ShareableFieldRuntimeTypeRoutingOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/ShareableFieldRuntimeTypeRoutingOption.cs new file mode 100644 index 00000000000..6918193fa4d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/ShareableFieldRuntimeTypeRoutingOption.cs @@ -0,0 +1,23 @@ +using HotChocolate.Fusion; + +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class ShareableFieldRuntimeTypeRoutingOption + : Option +{ + public const string OptionName = "--shareable-field-runtime-type-routing"; + + public ShareableFieldRuntimeTypeRoutingOption() + : base(OptionName) + { + Description = + "Choose how runtime types are routed for Apollo Federation shareable abstract fields"; + AcceptOnlyFromAmong("source-local", "common-runtime-types"); + CustomParser = result => result.Tokens.Single().Value switch + { + "source-local" => ShareableFieldRuntimeTypeRouting.SourceLocal, + "common-runtime-types" => ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + _ => null + }; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs index b7807321488..80a393dea3d 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs @@ -39,19 +39,21 @@ Compose multiple source schemas into a single composite schema. nitro fusion compose [options] Options: - -f, --source-schema-file One or more paths to a source schema file (.graphqls) or directory containing a source schema file - -a, --archive, --configuration The path to a Fusion archive file (the '--configuration' alias is deprecated) [env: NITRO_FUSION_CONFIG_FILE] - -e, --env, --environment The name of the environment used for value substitution in the schema-settings.json files - --enable-global-object-identification Add the 'Query.node' field for global object identification - --include-satisfiability-paths Include paths in satisfiability error messages - --watch Watch for file changes and recompose automatically - -w, --working-directory Set the working directory for the command - --exclude-by-tag One or more tags to exclude from the composition - --remove-source-schema One or more source schemas to remove from the archive before composing. - --cloud-url The URL of the Nitro backend (only needed for self-hosted or dedicated deployments) [env: NITRO_CLOUD_URL] - --api-key The API key or PAT used for authentication [env: NITRO_API_KEY] - --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] - -?, -h, --help Show help and usage information + -f, --source-schema-file One or more paths to a source schema file (.graphqls) or directory containing a source schema file + -a, --archive, --configuration The path to a Fusion archive file (the '--configuration' alias is deprecated) [env: NITRO_FUSION_CONFIG_FILE] + -e, --env, --environment The name of the environment used for value substitution in the schema-settings.json files + --enable-global-object-identification Add the 'Query.node' field for global object identification + --node-resolution Choose whether Query.node identifiers are resolved by the gateway or a source schema + --shareable-field-runtime-type-routing Choose how runtime types are routed for Apollo Federation shareable abstract fields + --include-satisfiability-paths Include paths in satisfiability error messages + --watch Watch for file changes and recompose automatically + -w, --working-directory Set the working directory for the command + --exclude-by-tag One or more tags to exclude from the composition + --remove-source-schema One or more source schemas to remove from the archive before composing. + --cloud-url The URL of the Nitro backend (only needed for self-hosted or dedicated deployments) [env: NITRO_CLOUD_URL] + --api-key The API key or PAT used for authentication [env: NITRO_API_KEY] + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information Example: nitro fusion compose \ @@ -829,6 +831,203 @@ public async Task Compose_EnableGlobalObjectIdentification_ReturnsSuccess() Assert.True(File.Exists(archiveFileName)); } + [Fact] + public async Task Compose_Should_EmitExecutionMetadata_When_SourceSchemaNodeResolution() + { + var archiveFileName = CreateTempFile(); + SetupSourceSchemaFromResources("valid-example-1/source-schema-1.graphqls"); + SetupSourceSchemaFromResources("valid-example-1/source-schema-2.graphqls"); + + var result = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-1.graphqls"), + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-2.graphqls"), + "--archive", + archiveFileName, + "--enable-global-object-identification", + "--node-resolution", + "source-schema"); + + Assert.Equal(0, result.ExitCode); + using var archive = FusionArchive.Open(archiveFileName); + var schema = await GetFusionSchemaAsync(archive); + Assert.Contains("nodeResolution: SOURCE_SCHEMA", schema); + } + + [Fact] + public async Task Compose_Should_PersistShareableFieldRuntimeTypeRouting() + { + var archiveFileName = CreateTempFile(); + SetupSourceSchemaFromResources("valid-example-1/source-schema-1.graphqls"); + SetupSourceSchemaFromResources("valid-example-1/source-schema-2.graphqls"); + + var result = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-1.graphqls"), + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-2.graphqls"), + "--archive", + archiveFileName, + "--shareable-field-runtime-type-routing", + "common-runtime-types"); + + Assert.Equal(0, result.ExitCode); + using var archive = FusionArchive.Open(archiveFileName); + var settings = await archive.GetCompositionSettingsAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(settings); + Assert.Contains( + "\"shareableFieldRuntimeTypeRouting\": \"CommonRuntimeTypes\"", + settings.RootElement.ToString()); + var schema = await GetFusionSchemaAsync(archive); + Assert.Contains( + "shareableFieldRuntimeTypeRouting: COMMON_RUNTIME_TYPES", + schema); + } + + [Fact] + public async Task Compose_Should_ReturnError_When_SourceSchemaResolutionWithoutGlobalIdentification() + { + var archiveFileName = CreateTempFile(); + SetupSourceSchemaFromResources("valid-example-1/source-schema-1.graphqls"); + SetupSourceSchemaFromResources("valid-example-1/source-schema-2.graphqls"); + + var result = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-1.graphqls"), + "--source-schema-file", + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-2.graphqls"), + "--archive", + archiveFileName, + "--node-resolution", + "source-schema"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains( + "Source-schema node resolution requires global object identification to be enabled.", + result.StdErr); + } + + [Fact] + public async Task Compose_Should_PreserveArchiveSetting_When_NodeResolutionIsOmitted() + { + var archiveFileName = CreateTempFile(); + SetupSourceSchemaFromResources("valid-example-1/source-schema-1.graphqls"); + SetupSourceSchemaFromResources("valid-example-1/source-schema-2.graphqls"); + var schemaFiles = new[] + { + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-1.graphqls"), + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-2.graphqls") + }; + + var first = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + schemaFiles[0], + "--source-schema-file", + schemaFiles[1], + "--archive", + archiveFileName, + "--enable-global-object-identification", + "--node-resolution", + "source-schema"); + Assert.Equal(0, first.ExitCode); + using (var firstArchive = FusionArchive.Open(archiveFileName)) + { + var settings = await firstArchive.GetCompositionSettingsAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(settings); + Assert.Contains("\"nodeResolution\": \"SourceSchema\"", settings.RootElement.ToString()); + } + SetupFile(archiveFileName, new MemoryStream(File.ReadAllBytes(archiveFileName))); + + var second = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + schemaFiles[0], + "--source-schema-file", + schemaFiles[1], + "--archive", + archiveFileName); + + Assert.Equal(0, second.ExitCode); + using var archive = FusionArchive.Open(archiveFileName); + var finalSettings = await archive.GetCompositionSettingsAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(finalSettings); + Assert.Contains("\"nodeResolution\": \"SourceSchema\"", finalSettings.RootElement.ToString()); + var schema = await GetFusionSchemaAsync(archive); + Assert.Contains("nodeResolution: SOURCE_SCHEMA", schema); + } + + [Fact] + public async Task Compose_Should_PreserveArchiveSetting_When_ShareableFieldRuntimeTypeRoutingIsOmitted() + { + var archiveFileName = CreateTempFile(); + SetupSourceSchemaFromResources("valid-example-1/source-schema-1.graphqls"); + SetupSourceSchemaFromResources("valid-example-1/source-schema-2.graphqls"); + var schemaFiles = new[] + { + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-1.graphqls"), + Path.Combine(s_resourcesDir, "valid-example-1/source-schema-2.graphqls") + }; + + var first = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + schemaFiles[0], + "--source-schema-file", + schemaFiles[1], + "--archive", + archiveFileName, + "--shareable-field-runtime-type-routing", + "common-runtime-types"); + Assert.Equal(0, first.ExitCode); + using (var firstArchive = FusionArchive.Open(archiveFileName)) + { + var settings = await firstArchive.GetCompositionSettingsAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(settings); + Assert.Contains( + "\"shareableFieldRuntimeTypeRouting\": \"CommonRuntimeTypes\"", + settings.RootElement.ToString()); + } + SetupFile(archiveFileName, new MemoryStream(File.ReadAllBytes(archiveFileName))); + + var second = await ExecuteCommandAsync( + "fusion", + "compose", + "--source-schema-file", + schemaFiles[0], + "--source-schema-file", + schemaFiles[1], + "--archive", + archiveFileName); + + Assert.Equal(0, second.ExitCode); + using var archive = FusionArchive.Open(archiveFileName); + var finalSettings = await archive.GetCompositionSettingsAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(finalSettings); + Assert.Contains( + "\"shareableFieldRuntimeTypeRouting\": \"CommonRuntimeTypes\"", + finalSettings.RootElement.ToString()); + var schema = await GetFusionSchemaAsync(archive); + Assert.Contains( + "shareableFieldRuntimeTypeRouting: COMMON_RUNTIME_TYPES", + schema); + } + [Fact] public async Task Compose_WithEnvironmentOption_ReturnsSuccess() { diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs index 25c6b9958e3..33576c65e7b 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs @@ -4477,7 +4477,11 @@ private void AssertMigratedFusionSchema(string schema) { schema.MatchInlineSnapshot( """ - schema { + schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -4519,12 +4523,22 @@ PUBLIC @fusion__enumValue(schema: REVIEWS) PRIVATE @fusion__enumValue(schema: REVIEWS) } + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { PRODUCTS @fusion__schema_metadata(name: "products") REVIEWS @fusion__schema_metadata(name: "reviews") } + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -4574,6 +4588,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -4582,6 +4601,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -4618,6 +4639,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." + directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! + ) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -4664,6 +4691,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! @@ -4690,7 +4718,11 @@ private void AssertOverriddenFusionSchema(string schema) { schema.MatchInlineSnapshot( """ - schema { + schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -4698,11 +4730,21 @@ type Query @fusion__type(schema: REVIEWS) { field: String! @fusion__field(schema: REVIEWS) } + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { REVIEWS @fusion__schema_metadata(name: "reviews") } + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -4744,6 +4786,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -4752,6 +4799,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -4788,6 +4837,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." + directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! + ) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -4834,6 +4889,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! @@ -4930,7 +4986,11 @@ private void AssertComposedFusionSchema(string schema) { schema.MatchInlineSnapshot( """ - schema { + schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -4963,12 +5023,22 @@ PUBLIC @fusion__enumValue(schema: REVIEWS) PRIVATE @fusion__enumValue(schema: REVIEWS) } + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { PRODUCTS @fusion__schema_metadata(name: "products") REVIEWS @fusion__schema_metadata(name: "reviews") } + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -5018,6 +5088,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -5026,6 +5101,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -5062,6 +5139,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." + directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! + ) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -5108,6 +5191,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs index 844598d9e7a..99b5864ea4b 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs @@ -1,3 +1,5 @@ +using HotChocolate.Fusion.Packaging; + namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Fusion; public sealed class FusionSettingsSetCommandTests(NitroCommandFixture fixture) : FusionCommandTestBase(fixture) @@ -22,8 +24,8 @@ Set a Fusion composition setting in a Fusion archive. nitro fusion settings set [options] Arguments: - The name of the setting to change - The value to set + The name of the setting to change + The value to set Options: -a, --archive, --configuration (REQUIRED) The path to a Fusion archive file (the '--configuration' alias is deprecated) [env: NITRO_FUSION_CONFIG_FILE] @@ -90,6 +92,8 @@ public async Task InvalidSettingName_ReturnsError(InteractionMode mode) 'cache-control-merge-behavior' 'exclude-by-tag' 'global-object-identification' + 'node-resolution' + 'shareable-field-runtime-type-routing' 'tag-merge-behavior' """); Assert.Equal(1, result.ExitCode); @@ -198,4 +202,124 @@ public async Task InvalidGlobalObjectIdentification_ReturnsError(InteractionMode Expected a boolean value for setting 'global-object-identification'. """); } + + [Theory] + [InlineData(InteractionMode.Interactive)] + [InlineData(InteractionMode.NonInteractive)] + [InlineData(InteractionMode.JsonOutput)] + public async Task Execute_Should_ReturnError_When_NodeResolutionIsInvalid(InteractionMode mode) + { + SetupInteractionMode(mode); + + var result = await ExecuteCommandAsync( + "fusion", + "settings", + "set", + "node-resolution", + "invalid-value", + "--archive", + ArchiveFile); + + result.AssertError( + """ + Expected one of the following values for setting 'node-resolution': gateway, source-schema + """); + } + + [Fact] + public async Task Execute_Should_ReturnSuccess_When_NodeResolutionIsGateway() + { + var archiveFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + File.Copy( + Path.Combine(AppContext.BaseDirectory, "__resources__", "fusion-archives", "gateway.far"), + archiveFile); + SetupFile(archiveFile, new MemoryStream(File.ReadAllBytes(archiveFile))); + + try + { + var result = await ExecuteCommandAsync( + "fusion", + "settings", + "set", + "node-resolution", + "gateway", + "--archive", + archiveFile); + + Assert.True( + result.ExitCode == 0, + $"Standard output:{Environment.NewLine}{result.StdOut}{Environment.NewLine}" + + $"Standard error:{Environment.NewLine}{result.StdErr}"); + } + finally + { + File.Delete(archiveFile); + } + } + + [Theory] + [InlineData(InteractionMode.Interactive)] + [InlineData(InteractionMode.NonInteractive)] + [InlineData(InteractionMode.JsonOutput)] + public async Task Execute_Should_ReturnError_When_ShareableFieldRuntimeTypeRoutingIsInvalid( + InteractionMode mode) + { + SetupInteractionMode(mode); + + var result = await ExecuteCommandAsync( + "fusion", + "settings", + "set", + "shareable-field-runtime-type-routing", + "invalid-value", + "--archive", + ArchiveFile); + + result.AssertError( + """ + Expected one of the following values for setting 'shareable-field-runtime-type-routing': source-local, common-runtime-types + """); + } + + [Theory] + [InlineData("source-local", "SourceLocal")] + [InlineData("common-runtime-types", "CommonRuntimeTypes")] + public async Task Execute_Should_PersistShareableFieldRuntimeTypeRouting( + string value, + string expectedValue) + { + var archiveFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + File.Copy( + Path.Combine(AppContext.BaseDirectory, "__resources__", "fusion-archives", "gateway.far"), + archiveFile); + SetupFile(archiveFile, new MemoryStream(File.ReadAllBytes(archiveFile))); + + try + { + var result = await ExecuteCommandAsync( + "fusion", + "settings", + "set", + "shareable-field-runtime-type-routing", + value, + "--archive", + archiveFile); + + Assert.Equal(0, result.ExitCode); + using var archive = FusionArchive.Open(archiveFile); + var settings = await archive.GetCompositionSettingsAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(settings); + Assert.Equal( + expectedValue, + settings.RootElement + .GetProperty("apolloFederationCompatibility") + .GetProperty("shareableFieldRuntimeTypeRouting") + .GetString()); + } + finally + { + File.Delete(archiveFile); + } + } } diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs index 37012eef31b..d1aecf23135 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs @@ -1050,7 +1050,11 @@ private static void AssertOverriddenSchemaUpload(MemoryStream stream) var str = Encoding.UTF8.GetString(stream.ToArray()); str.MatchInlineSnapshot( """ - schema { + schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -1058,11 +1062,21 @@ type Query @fusion__type(schema: REVIEWS) { field: String! @fusion__field(schema: REVIEWS) } + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { REVIEWS @fusion__schema_metadata(name: "reviews") } + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -1104,6 +1118,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -1112,6 +1131,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -1148,6 +1169,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." + directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! + ) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -1194,6 +1221,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! @@ -1221,7 +1249,11 @@ private static void AssertSchemaUploadAfterCompose(MemoryStream stream) var str = Encoding.UTF8.GetString(stream.ToArray()); str.MatchInlineSnapshot( """ - schema { + schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -1254,12 +1286,22 @@ PUBLIC @fusion__enumValue(schema: REVIEWS) PRIVATE @fusion__enumValue(schema: REVIEWS) } + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { PRODUCTS @fusion__schema_metadata(name: "products") REVIEWS @fusion__schema_metadata(name: "reviews") } + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -1309,6 +1351,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -1317,6 +1364,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -1353,6 +1402,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." + directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! + ) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -1399,6 +1454,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! @@ -1426,7 +1482,11 @@ private static void AssertMigratedSchemaUploadAfterCompose(MemoryStream stream) var str = Encoding.UTF8.GetString(stream.ToArray()); str.MatchInlineSnapshot( """ - schema { + schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -1468,12 +1528,22 @@ PUBLIC @fusion__enumValue(schema: REVIEWS) PRIVATE @fusion__enumValue(schema: REVIEWS) } + enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA + } + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { PRODUCTS @fusion__schema_metadata(name: "products") REVIEWS @fusion__schema_metadata(name: "reviews") } + enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES + } + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -1523,6 +1593,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION + directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL + ) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -1531,6 +1606,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -1567,6 +1644,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." + directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! + ) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -1613,6 +1696,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls index 37ca15191fa..7214cb5303a 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls @@ -1,4 +1,8 @@ -schema { +schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -7,12 +11,22 @@ type Query @fusion__type(schema: SCHEMA1) @fusion__type(schema: SCHEMA2) { schema2Field: Int! @fusion__field(schema: SCHEMA2) } +enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA +} + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { SCHEMA1 @fusion__schema_metadata(name: "Schema1") SCHEMA2 @fusion__schema_metadata(name: "Schema2") } +enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES +} + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -54,6 +68,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION +directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL +) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -62,6 +81,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -98,6 +119,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +"The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." +directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! +) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -144,6 +171,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls index 37ca15191fa..7214cb5303a 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls @@ -1,4 +1,8 @@ -schema { +schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -7,12 +11,22 @@ type Query @fusion__type(schema: SCHEMA1) @fusion__type(schema: SCHEMA2) { schema2Field: Int! @fusion__field(schema: SCHEMA2) } +enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA +} + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { SCHEMA1 @fusion__schema_metadata(name: "Schema1") SCHEMA2 @fusion__schema_metadata(name: "Schema2") } +enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES +} + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -54,6 +68,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION +directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL +) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -62,6 +81,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -98,6 +119,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +"The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." +directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! +) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -144,6 +171,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls index d581c5b2c19..6f653ea10cf 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls @@ -1,4 +1,8 @@ -schema { +schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { query: Query } @@ -20,11 +24,21 @@ type Product id: ID! @fusion__field(schema: SCHEMA1) } +enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA +} + "The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." enum fusion__Schema { SCHEMA1 @fusion__schema_metadata(name: "Schema1") } +enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES +} + "The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." scalar fusion__FieldDefinition @@ -66,6 +80,11 @@ directive @fusion__eventStream( topics: [String!] ) on FIELD_DEFINITION +directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL +) on SCHEMA + "The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." directive @fusion__field( "Indicates that this field is only partially provided and must be combined with `provides`." @@ -74,6 +93,8 @@ directive @fusion__field( provides: fusion__FieldSelectionSet "The name of the source schema that originally provided this field." schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false "The field type in the source schema if it differs in nullability or structure." sourceType: String ) repeatable on FIELD_DEFINITION @@ -110,6 +131,12 @@ directive @fusion__inputField( sourceType: String ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +"The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." +directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! +) repeatable on INTERFACE + "The @fusion__listSize directive specifies list size metadata for each source schema." directive @fusion__listSize( "The assumed size of the list as defined in the source schema." @@ -156,6 +183,7 @@ directive @fusion__requires( "The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean kind: String "The name of the source schema." name: String! diff --git a/website/content/docs/fusion/aspire-integration.md b/website/content/docs/fusion/aspire-integration.md index 5fb93674842..074f0e49c8f 100644 --- a/website/content/docs/fusion/aspire-integration.md +++ b/website/content/docs/fusion/aspire-integration.md @@ -143,13 +143,17 @@ builder settings: new GraphQLCompositionSettings { EnableGlobalObjectIdentification = true, + NodeResolution = NodeResolution.SourceSchema, EnvironmentName = "aspire" }) .WithReference(productsApi) .WithReference(reviewsApi); ``` -- **`EnableGlobalObjectIdentification`** enables the `Node` interface and Relay-style global object IDs in the composite schema. Set this to `true` if your subgraphs use the `[NodeResolver]` pattern. +- **`EnableGlobalObjectIdentification`** enables the `Node` interface and Relay-style global object IDs in the composite schema. Set this to `true` if your subgraphs use the `[NodeResolver]` pattern or source-schema node resolution. +- **`NodeResolution`** controls whether the gateway decodes `Query.node` identifiers or forwards them to a source schema. If you do not set it, composition uses the archive's stored value, or `NodeResolution.Gateway` when the archive has no value. `NodeResolution.SourceSchema` requires `EnableGlobalObjectIdentification = true`. +- **`AllowNonResolvableInterfaceObjects`** allows Apollo Federation interface objects with `resolvable: false` keys to compose when Fusion cannot build a route to their projected fields. The default is `false`. Enabling it can move an unresolved selection from composition time to a field error at runtime. See [Allow Non-Resolvable Interface Objects](./connectors/apollofederation.md#allow-non-resolvable-interface-objects). +- **`ShareableFieldRuntimeTypeRouting`** controls how Fusion routes type-conditioned selections for Apollo Federation shareable fields that return an interface or union. `SourceLocal` is the default and follows the source that resolves the field. `CommonRuntimeTypes` routes type-conditioned selections only for runtime types common to Apollo providers already in the current provider scope or directly reachable from it through one entity lookup. At an operation root, Fusion considers all non-external providers. See [Shareable Abstract Field Routing](./connectors/apollofederation.md#shareable-abstract-field-routing). - **`EnvironmentName`** selects the environment for variable substitution in `schema-settings.json`. For example, if your settings file defines an `"aspire"` environment with local URLs, the orchestrator uses those URLs instead of the defaults. The output file name defaults to `gateway.far`. You can change it if needed: diff --git a/website/content/docs/fusion/cli.md b/website/content/docs/fusion/cli.md index 812079b892d..d01b68a45f2 100644 --- a/website/content/docs/fusion/cli.md +++ b/website/content/docs/fusion/cli.md @@ -77,19 +77,23 @@ nitro fusion compose [options] ## Options -| Option | Description | Default | -| --------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | -| `--source-schema-file ` (alias: `-f`) | Path to a source schema file. Can be repeated. | Auto-discovers `*.graphql`/`*.graphqls` in working directory | -| `--archive ` (alias: `-a`) | Output path for the Fusion archive | `./gateway.far` | -| `--environment ` (alias: `--env`, `-e`) | Environment name for variable substitution | `ASPNETCORE_ENVIRONMENT` or `Development` | -| `--enable-global-object-identification` | Enable Relay-style global object identification | `false` | -| `--include-satisfiability-paths` | Include satisfiability diagnostic paths | `false` | -| `--watch` | Recompose on file changes | `false` | -| `--exclude-by-tag ` | Exclude fields/types by tag. Can be repeated. | -- | -| `--working-directory ` (alias: `-w`) | Working directory for resolving paths | Current directory | +| Option | Description | Default | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `--source-schema-file ` (alias: `-f`) | Path to a source schema file. Can be repeated. | Auto-discovers `*.graphql`/`*.graphqls` in working directory | +| `--archive ` (alias: `-a`) | Output path for the Fusion archive | `./gateway.far` | +| `--environment ` (alias: `--env`, `-e`) | Environment name for variable substitution | `ASPNETCORE_ENVIRONMENT` or `Development` | +| `--enable-global-object-identification` | Enable Relay-style global object identification | `false` | +| `--node-resolution ` | Choose who resolves `Query.node` identifiers | `gateway` | +| `--shareable-field-runtime-type-routing ` | Choose how type-conditioned selections are routed for Apollo shareable abstractions | `source-local` | +| `--include-satisfiability-paths` | Include satisfiability diagnostic paths | `false` | +| `--watch` | Recompose on file changes | `false` | +| `--exclude-by-tag ` | Exclude fields/types by tag. Can be repeated. | -- | +| `--working-directory ` (alias: `-w`) | Working directory for resolving paths | Current directory | Each `.graphqls` file must have a companion `-settings.json` file (for example, `schema.graphqls` requires `schema-settings.json`). If no `--source-schema-file` is specified, the CLI scans the working directory for all `.graphql` and `.graphqls` files. +When an archive has no stored setting, composition uses `gateway` for node resolution and `source-local` for shareable-field runtime type routing. If you recompose an existing archive without the corresponding flag, Nitro preserves the stored value. See [Shareable Abstract Field Routing](./connectors/apollofederation.md#shareable-abstract-field-routing) for the routing behavior. + ## Examples Compose from specific files: @@ -384,21 +388,33 @@ nitro fusion settings set [options] ## Available Settings -| Setting | Values | Description | -| ------------------------------ | -------------------------------------- | --------------------------------------- | -| `global-object-identification` | `true`, `false` | Enable Relay-style node queries | -| `cache-control-merge-behavior` | `ignore`, `include`, `include-private` | How to merge `@cacheControl` directives | -| `tag-merge-behavior` | `ignore`, `include`, `include-private` | How to merge `@tag` directives | -| `exclude-by-tag` | Comma-separated tags | Exclude fields/types by tag | +| Setting | Values | Description | +| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------ | +| `global-object-identification` | `true`, `false` | Enable Relay-style node queries | +| `node-resolution` | `gateway`, `source-schema` | Choose who resolves `Query.node` IDs | +| `shareable-field-runtime-type-routing` | `source-local`, `common-runtime-types` | Choose routing for type-conditioned selections on shareable fields | +| `cache-control-merge-behavior` | `ignore`, `include`, `include-private` | How to merge `@cacheControl` directives | +| `tag-merge-behavior` | `ignore`, `include`, `include-private` | How to merge `@tag` directives | +| `exclude-by-tag` | Comma-separated tags | Exclude fields/types by tag | ## Examples -Enable global object identification: +Enable source-schema node resolution on an existing archive: ```shell nitro fusion settings set global-object-identification true --archive gateway.far +nitro fusion settings set node-resolution source-schema --archive gateway.far ``` +Use common-runtime-types routing on an existing archive: + +```shell +nitro fusion settings set shareable-field-runtime-type-routing common-runtime-types \ + --archive gateway.far +``` + +For the behavior of each routing value, see [Shareable Abstract Field Routing](./connectors/apollofederation.md#shareable-abstract-field-routing). + Configure cache control merging: ```shell diff --git a/website/content/docs/fusion/composition.md b/website/content/docs/fusion/composition.md index 79f14d34bfc..c10717df6ed 100644 --- a/website/content/docs/fusion/composition.md +++ b/website/content/docs/fusion/composition.md @@ -72,7 +72,7 @@ Validates the merged schema as a whole. The rules here treat the composed schema ## 8. Validate Satisfiability -Performs reachability analysis. Starting from the root types, the pipeline walks every reachable field in the merged schema and confirms it can be resolved by at least one subgraph given the available `@lookup` and `@key` paths. If a field is reachable from a query but no subgraph can produce it, satisfiability fails with `UNSATISFIABLE_QUERY_PATH`. This is the last line of defense against shapes that look valid statically but cannot actually be served at runtime. +Performs reachability analysis. Starting from the root types, the pipeline walks every reachable field in the merged schema and confirms it can be resolved by at least one subgraph given the available `@lookup` and `@key` paths. If a field is reachable from a query but no subgraph can produce it, satisfiability reports `UNSATISFIABLE_QUERY_PATH`. This is normally an error. When source-schema node resolution has a usable dispatcher but does not cover a composite `Node` type, Fusion reports a warning because the source resolver can return `null` or an error at runtime. # Common Scenarios @@ -116,75 +116,83 @@ The placeholders `{0}`, `{1}`, etc. in the message column are replaced with the ## Errors -| Code | Message | How to resolve | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CONFLICTING_SOURCE_SCHEMA_NAME` | '\{0\}' conflicts with the existing source schema name '\{1\}'. Either rename '\{0\}' to '\{1\}' if they're the same, or rename '\{0\}' to something else if they're different. | Two source schemas were registered under names that the composition treats as the same identity. Rename one of them in your composition configuration so each source schema has a unique name. | -| [DISALLOWED_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Disallowed-Inaccessible-Elements) | (Message varies by element kind: built-in scalar, introspection type, introspection field, introspection argument, or built-in directive argument.) | Built-in scalars, introspection types and their members, and built-in directive arguments cannot be marked `@inaccessible`. Remove the directive from the offending element in the named source schema. | -| [EMPTY_MERGED_ENUM_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Enum-Type) | The merged enum type '\{0\}' is empty. | Every value of the enum was excluded by `@inaccessible` or tag filters, leaving nothing to merge. Either expose at least one enum value or remove the type entirely. | -| [EMPTY_MERGED_INPUT_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Input-Object-Type) | The merged input object type '\{0\}' is empty. | All fields of the input type were excluded after merging. Expose at least one input field or remove the input type from your source schemas. | -| [EMPTY_MERGED_INTERFACE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Interface-Type) | The merged interface type '\{0\}' is empty. | All fields of the interface were filtered out by `@inaccessible` or tag exclusions. Expose at least one field on the interface, remove the filters that hide them, or drop the interface from the schema. | -| [EMPTY_MERGED_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Object-Type) | The merged object type '\{0\}' is empty. | Every field on the object type was excluded. Expose at least one field, or remove the type if it is no longer needed. | -| [EMPTY_MERGED_UNION_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Union-Type) | The merged union type '\{0\}' is empty. | All members of the union were excluded by `@inaccessible` or tag exclusions. Expose at least one member type, remove the filters that hide them, or remove the union. | -| [ENUM_TYPE_DEFAULT_VALUE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Type-Default-Value-Inaccessible) | The default value of '\{0\}' references the inaccessible enum value '\{1\}'. | A default points to an enum value that is hidden from the composed schema. Either change the default to a value that is accessible or remove `@inaccessible` from the referenced enum value. | -| [ENUM_VALUES_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Values-Mismatch) | The enum type '\{0\}' in schema '\{1\}' must define the value '\{2\}'. | An enum is defined in more than one source schema and one of them is missing a value the others declare. Add the missing value to the named source schema so all definitions agree. | -| [EXTERNAL_ARGUMENT_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Default-Mismatch) | The default value '\{0\}' of external argument '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same argument has different defaults in the external definition and the owning source schema. Align the default values across both source schemas. | -| [EXTERNAL_ARGUMENT_MISSING](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Missing) | The external field '\{0\}' in schema '\{1\}' must define the argument '\{2\}'. | An `@external` field declaration is missing an argument that exists on the canonical definition. Add the argument to the external declaration with a matching type. | -| [EXTERNAL_ARGUMENT_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Type-Mismatch) | The argument '\{0\}' on external field '\{1\}' in schema '\{2\}' has a different type (\{3\}) than it does in schema '\{4\}' (\{5\}). | The argument types of an `@external` declaration and the owning source schema disagree. Change the argument type in one of the source schemas so the signatures match exactly, including nullability. | -| [EXTERNAL_MISSING_ON_BASE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Missing-on-Base) | The external field '\{0\}' in schema '\{1\}' is not defined (non-external) in any other schema. | A field marked `@external` has no non-external definition anywhere. Add the canonical definition in another source schema, or remove `@external` if this source schema is meant to own the field. See [Field Ownership](./field-ownership-and-sharing.md). | -| [EXTERNAL_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be marked as external. | `@external` is not valid on interface fields. Remove `@external` from the interface field; if you need to mark concrete implementations as external, place the directive on the implementing object types instead. | -| [EXTERNAL_OVERRIDE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Override-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | A field cannot be both `@external` and `@override`. Decide which source schema owns the field and apply only the appropriate directive. | -| [EXTERNAL_PROVIDES_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Provides-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @provides directive. | `@external` declares a field as not owned here, while `@provides` declares it as supplied here. Remove one of the directives so the ownership story is unambiguous. | -| [EXTERNAL_REQUIRE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Require-Collision) | The external field '\{0\}' in schema '\{1\}' must not have arguments that are annotated with the @require directive. | An `@external` field cannot consume `@require` arguments because it is not actually executed in this subgraph. Remove `@require` from the arguments, or move the field to a source schema where it is owned. | -| [EXTERNAL_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Type-Mismatch) | The external field '\{0\}' in schema '\{1\}' has a different type (\{2\}) than it does in schema '\{3\}' (\{4\}). | The return type of the `@external` declaration differs from the canonical definition. Align the field type and nullability in both source schemas. | -| [EXTERNAL_UNUSED](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Unused) | The external field '\{0\}' in schema '\{1\}' is not referenced by a @provides directive in the schema. | An `@external` field is declared but no `@provides` in this schema references it. Remove the unused declaration, or add a `@provides` that references the field. See [Field Ownership](./field-ownership-and-sharing.md). | -| `FEDERATION_DIRECTIVE_NOT_SUPPORTED` | The @\{0\} directive is not supported. | The schema uses an Apollo Federation directive that Fusion does not support. Remove the directive or replace it with the equivalent Fusion construct. | -| `FEDERATION_V1_NOT_SUPPORTED` | Federation v1 is not supported. | Upgrade the source schema to Federation v2 (or to native Fusion directives) before running composition. | -| [FIELD_ARGUMENT_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-Argument-Types-Mergeable) | The argument '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The argument has a fundamentally different type (different named type or different list/non-null structure) across source schemas. Pick one canonical type and update the other source schema to match. | -| [FIELD_WITH_MISSING_REQUIRED_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-With-Missing-Required-Arguments) | The argument '\{0\}' must be defined as required in schema '\{1\}'. Arguments marked with @require are treated as non-required. | The argument is required in some source schemas and optional or `@require`-driven in another. Mark the argument as required in the named source schema, or align all source schemas on the same nullability. | -| [IMPLEMENTED_BY_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Implemented-by-Inaccessible) | The field '\{0\}' implementing interface field '\{1\}' is inaccessible in the composed schema. | An object type implements an interface field, but its implementation is hidden. Either expose the implementing field or hide the interface field as well. | -| [INPUT_FIELD_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Default-Mismatch) | The default value '\{0\}' of input field '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same input field has different defaults in two source schemas. Align the default value across all definitions of the input type. | -| [INPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Types-mergeable) | The input field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | An input field has a different type structure (named type, list, or nullability) across source schemas. Decide on the canonical type and update the other source schema. | -| [INPUT_WITH_MISSING_REQUIRED_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-With-Missing-Required-Fields) | The input type '\{0\}' in schema '\{1\}' must define the required field '\{2\}'. | One source schema requires a field on an input type that another source schema omits. Add the field (with the same type) to the named source schema so the input shape is consistent. | -| `INPUT_WITH_MISSING_ONEOF` | The input type '\{0\}' in schema '\{1\}' must be annotated with the '@oneOf' directive, since the same type has been annotated with it in schema '\{2\}'. | The same input type is `@oneOf` in one source schema but not in another. Add `@oneOf` to the input type in the named source schema (or remove it from the other) so all definitions agree. | -| [INTERFACE_FIELD_NO_IMPLEMENTATION](https://graphql.github.io/composite-schemas-spec/draft/#sec-Interface-Field-No-Implementation) | The merged object type '\{0\}' must implement the field '\{1\}' on interface '\{2\}'. | After merging, an object type declares it implements the interface but does not provide every field the interface requires. Add the missing field to the object type, or stop implementing the interface. | -| [INVALID_FIELD_SHARING](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Field-Sharing) | The field '\{0\}' in schema '\{1\}' must be shareable. | The same non-key field is owned by more than one source schema without an explicit sharing contract. Add `@shareable` to the field (or to the enclosing type, which applies to all of its fields) in every source schema that defines it, or move ownership to a single source schema. See [Field Ownership](./field-ownership-and-sharing.md). | -| [INVALID_GRAPHQL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-GraphQL) | Invalid GraphQL in source schema. Exception message: \{0\}. | The source schema does not parse as valid GraphQL. Read the included parser exception, fix the SDL in the offending source schema, and re-export. See [Getting Started](./getting-started.md) for how to export a source schema. | -| [INVALID_SHAREABLE_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Shareable-Usage) | The field '\{0\}' in schema '\{1\}' must not be marked as shareable. | `@shareable` was applied where it is not allowed (for example, on a field that is already governed by another ownership directive). Remove `@shareable` from the field. See [Field Ownership](./field-ownership-and-sharing.md). | -| [IS_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Fields) | The @is directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@is(field: ...)` does not resolve against the entity's fields. Update it to reference fields that actually exist on the parent type. See [Entities and Lookups](./entities-and-lookups.md). | -| [IS_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Field-Type) | The @is directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument was given as a non-string literal. Pass it as a quoted string, for example `@is(field: "id")`. | -| [IS_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Syntax) | The @is directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The string value for `field` is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | -| [IS_INVALID_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Usage) | The @is directive on argument '\{0\}' in schema '\{1\}' is invalid because the declaring field is not a lookup field. | `@is` is only valid on arguments of lookup fields. Move `@is` to a lookup, or annotate the declaring field with `@lookup`. See [Entities and Lookups](./entities-and-lookups.md). | -| [KEY_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Directive-in-Fields-Argument) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The `fields` selection of `@key` cannot apply directives to the selected fields. Remove the directive applications from the selection set. See [Entities and Lookups](./entities-and-lookups.md). | -| [KEY_FIELDS_SELECT_INVALID_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Fields-Select-Invalid-Type) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not be a list, interface, or union type. | Key fields must resolve to scalar, enum, or object types, not lists, interfaces, or unions. Pick a different field for the key, or model the key as a scalar identifier. | -| [KEY_INVALID_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Arguments) | A @key directive on type '\{0\}' in schema '\{1\}' specifies invalid arguments. \{2\} | Key fields support constant arguments. Remove or correct any argument that is unknown, has an incompatible value type, or leaves a required argument unsupplied. | -| [KEY_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields) | A @key directive on type '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The `fields` selection refers to fields that do not exist on the type. Add the missing fields to the type or correct the selection set. See [Entities and Lookups](./entities-and-lookups.md). | -| [KEY_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields-Type) | A @key directive on type '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string, for example `@key(fields: "id")`. | -| [KEY_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Syntax) | A @key directive on type '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The string value for `fields` is not a valid GraphQL selection set. Rewrite it as a syntactically valid selection. | -| [LOOKUP_RETURNS_LIST](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-List) | The lookup field '\{0\}' in schema '\{1\}' must not return a list. | A `@lookup` field returned a list, which prevents the gateway from mapping a key to a single entity. Change the return type to a single nullable entity. See [Entities and Lookups](./entities-and-lookups.md). | -| [NON_NULL_INPUT_FIELD_IS_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Non-Null-Input-Fields-cannot-be-inaccessible) | The non-null input field '\{0\}' in schema '\{1\}' must be accessible in the composed schema. | A required input field was hidden by `@inaccessible`, which would make the input impossible to construct from the gateway. Either make the field nullable or expose it. | -| [NO_QUERIES](https://graphql.github.io/composite-schemas-spec/draft/#sec-No-Queries) | The merged query type has no accessible fields. | After merging and applying accessibility filters, no query fields remain. Expose at least one query field, or remove `@inaccessible` from the queries you intend to publish. | -| `OPT_IN_FEATURE_STABILITY_MISMATCH` | The opt-in feature '\{0\}' has a different stability in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | Two or more source schemas declare the same opt-in feature with different `stability` values. Align the stability for that feature across all subgraphs that declare it using `OptInFeatureStability("featureName", "stability")` so every declaration uses the same string. | -| [OUTPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Output-Field-Types-Mergeable) | The output field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The field's return type cannot be unified across source schemas: leaf types (scalar or enum) differ in name or kind, the list shape differs, or composite types (object, interface, or union) share no common supertype. Nullability differences and composite types with a common supertype merge automatically; otherwise align the return types. | -| [OVERRIDE_FROM_SELF](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-from-Self) | The @override directive on field '\{0\}' in schema '\{1\}' must not reference the same schema. | `@override(from: "...")` cannot point at the source schema that owns the directive. Set `from` to the name of the source schema the field is being taken from. See [Field Ownership](./field-ownership-and-sharing.md). | -| [OVERRIDE_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | `@override` cannot be applied to interface fields. Move the directive to the implementing object types if you need to take ownership of a specific implementation. See [Field Ownership](./field-ownership-and-sharing.md). | -| [PROVIDES_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Directive-in-Fields-Argument) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The selection in `@provides(fields: ...)` cannot apply directives. Remove the directive applications from the selection. See [Field Ownership](./field-ownership-and-sharing.md). | -| [PROVIDES_FIELDS_HAS_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Has-Arguments) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not have arguments. | A field selected by `@provides` is being called with arguments, which is not allowed. Pick an argument-less field or restructure the schema so the provided field needs no arguments. | -| [PROVIDES_FIELDS_MISSING_EXTERNAL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Missing-External) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must be marked as external. | `@provides` only makes sense when the referenced field is `@external` here. Add `@external` to the referenced field in the same source schema. See [Field Ownership](./field-ownership-and-sharing.md). | -| [PROVIDES_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields) | The @provides directive on field '\{0\}' in schema '\{1\}' specifies an invalid field selection. | The selection points to fields that do not exist on the returned type. Update the selection so it matches fields that the returned type actually defines. | -| [PROVIDES_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields-Type) | The @provides directive on field '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string. | -| [PROVIDES_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Syntax) | The @provides directive on field '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The selection set syntax in `fields` is invalid. Rewrite it as a valid GraphQL selection set. | -| [PROVIDES_ON_NON_COMPOSITE_FIELD](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-on-Non-Composite-Field) | The field '\{0\}' in schema '\{1\}' includes a @provides directive, but does not return a composite type. | `@provides` only applies when a field returns an object, interface, or union, because the directive describes which sub-fields are also delivered. Remove `@provides`, or change the field to return a composite type. | -| [QUERY_ROOT_TYPE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Query-Root-Type-Inaccessible) | The root query type in schema '\{0\}' must be accessible. | The query root cannot be marked `@inaccessible`. Remove the directive from the root query type. | -| [REFERENCE_TO_INACCESSIBLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Inaccessible-Type) | (Message varies by reference site: field argument, input field, or output field.) | A publicly accessible position (a field argument, input field, or output field) references a type that has been hidden from the composed schema. Either expose the referenced type or change the position to use a publicly accessible type. | -| [REFERENCE_TO_INTERNAL_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Internal-Type) | The merged field '\{0\}' in type '\{1\}' cannot reference the internal type '\{2\}'. | A public field returns an internal type, which would leak the internal type into the composed schema. Either mark the field as internal as well, or change the return type to a non-internal one. | -| [REQUIRE_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Fields) | The @require directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@require(field: ...)` references fields that the parent type does not expose. Update it to reference fields that exist on the parent. See [Entities and Lookups](./entities-and-lookups.md). | -| [REQUIRE_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Field-Type) | The @require directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument must be a string literal. Pass it as a quoted string. | -| [REQUIRE_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Syntax) | The @require directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The `field` value is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | -| [ROOT_MUTATION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Mutation-Used) | The root mutation type in schema '\{0\}' must be named 'Mutation'. | Fusion requires the root mutation type to use the canonical name `Mutation`. Rename the type in the named source schema. A type named `Mutation` must not exist unless it is the root mutation type. | -| [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. | -| [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. See [Diagnosing UNSATISFIABLE_QUERY_PATH Errors](#diagnosing-unsatisfiable_query_path-errors) for how to read the message and fix the underlying gap. | +| Code | Message | How to resolve | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CONFLICTING_SOURCE_SCHEMA_NAME` | '\{0\}' conflicts with the existing source schema name '\{1\}'. Either rename '\{0\}' to '\{1\}' if they're the same, or rename '\{0\}' to something else if they're different. | Two source schemas were registered under names that the composition treats as the same identity. Rename one of them in your composition configuration so each source schema has a unique name. | +| [DISALLOWED_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Disallowed-Inaccessible-Elements) | (Message varies by element kind: built-in scalar, introspection type, introspection field, introspection argument, or built-in directive argument.) | Built-in scalars, introspection types and their members, and built-in directive arguments cannot be marked `@inaccessible`. Remove the directive from the offending element in the named source schema. | +| [EMPTY_MERGED_ENUM_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Enum-Type) | The merged enum type '\{0\}' is empty. | Every value of the enum was excluded by `@inaccessible` or tag filters, leaving nothing to merge. Either expose at least one enum value or remove the type entirely. | +| [EMPTY_MERGED_INPUT_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Input-Object-Type) | The merged input object type '\{0\}' is empty. | All fields of the input type were excluded after merging. Expose at least one input field or remove the input type from your source schemas. | +| [EMPTY_MERGED_INTERFACE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Interface-Type) | The merged interface type '\{0\}' is empty. | All fields of the interface were filtered out by `@inaccessible` or tag exclusions. Expose at least one field on the interface, remove the filters that hide them, or drop the interface from the schema. | +| [EMPTY_MERGED_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Object-Type) | The merged object type '\{0\}' is empty. | Every field on the object type was excluded. Expose at least one field, or remove the type if it is no longer needed. | +| [EMPTY_MERGED_UNION_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Union-Type) | The merged union type '\{0\}' is empty. | All members of the union were excluded by `@inaccessible` or tag exclusions. Expose at least one member type, remove the filters that hide them, or remove the union. | +| [ENUM_TYPE_DEFAULT_VALUE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Type-Default-Value-Inaccessible) | The default value of '\{0\}' references the inaccessible enum value '\{1\}'. | A default points to an enum value that is hidden from the composed schema. Either change the default to a value that is accessible or remove `@inaccessible` from the referenced enum value. | +| [ENUM_VALUES_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Values-Mismatch) | The enum type '\{0\}' in schema '\{1\}' must define the value '\{2\}'. | An enum is defined in more than one source schema and one of them is missing a value the others declare. Add the missing value to the named source schema so all definitions agree. | +| [EXTERNAL_ARGUMENT_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Default-Mismatch) | The default value '\{0\}' of external argument '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same argument has different defaults in the external definition and the owning source schema. Align the default values across both source schemas. | +| [EXTERNAL_ARGUMENT_MISSING](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Missing) | The external field '\{0\}' in schema '\{1\}' must define the argument '\{2\}'. | An `@external` field declaration is missing an argument that exists on the canonical definition. Add the argument to the external declaration with a matching type. | +| [EXTERNAL_ARGUMENT_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Type-Mismatch) | The argument '\{0\}' on external field '\{1\}' in schema '\{2\}' has a different type (\{3\}) than it does in schema '\{4\}' (\{5\}). | The argument types of an `@external` declaration and the owning source schema disagree. Change the argument type in one of the source schemas so the signatures match exactly, including nullability. | +| [EXTERNAL_MISSING_ON_BASE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Missing-on-Base) | The external field '\{0\}' in schema '\{1\}' is not defined (non-external) in any other schema. | A field marked `@external` has no non-external definition anywhere. Add the canonical definition in another source schema, or remove `@external` if this source schema is meant to own the field. See [Field Ownership](./field-ownership-and-sharing.md). | +| [EXTERNAL_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be marked as external. | `@external` is not valid on interface fields. Remove `@external` from the interface field; if you need to mark concrete implementations as external, place the directive on the implementing object types instead. | +| [EXTERNAL_OVERRIDE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Override-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | A field cannot be both `@external` and `@override`. Decide which source schema owns the field and apply only the appropriate directive. | +| [EXTERNAL_PROVIDES_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Provides-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @provides directive. | `@external` declares a field as not owned here, while `@provides` declares it as supplied here. Remove one of the directives so the ownership story is unambiguous. | +| [EXTERNAL_REQUIRE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Require-Collision) | The external field '\{0\}' in schema '\{1\}' must not have arguments that are annotated with the @require directive. | An `@external` field cannot consume `@require` arguments because it is not actually executed in this subgraph. Remove `@require` from the arguments, or move the field to a source schema where it is owned. | +| [EXTERNAL_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Type-Mismatch) | The external field '\{0\}' in schema '\{1\}' has a different type (\{2\}) than it does in schema '\{3\}' (\{4\}). | The return type of the `@external` declaration differs from the canonical definition. Align the field type and nullability in both source schemas. | +| [EXTERNAL_UNUSED](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Unused) | The external field '\{0\}' in schema '\{1\}' is not referenced by a @provides directive in the schema. | An `@external` field is declared but no `@provides` in this schema references it. Remove the unused declaration, or add a `@provides` that references the field. See [Field Ownership](./field-ownership-and-sharing.md). | +| `FEDERATION_DIRECTIVE_NOT_SUPPORTED` | The @\{0\} directive is not supported. | The schema uses an Apollo Federation directive that Fusion does not support. Remove the directive or replace it with the equivalent Fusion construct. | +| `FEDERATION_V1_NOT_SUPPORTED` | Federation v1 is not supported. | Upgrade the source schema to Federation v2 (or to native Fusion directives) before running composition. | +| [FIELD_ARGUMENT_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-Argument-Types-Mergeable) | The argument '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The argument has a fundamentally different type (different named type or different list/non-null structure) across source schemas. Pick one canonical type and update the other source schema to match. | +| [FIELD_WITH_MISSING_REQUIRED_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-With-Missing-Required-Arguments) | The argument '\{0\}' must be defined as required in schema '\{1\}'. Arguments marked with @require are treated as non-required. | The argument is required in some source schemas and optional or `@require`-driven in another. Mark the argument as required in the named source schema, or align all source schemas on the same nullability. | +| [IMPLEMENTED_BY_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Implemented-by-Inaccessible) | The field '\{0\}' implementing interface field '\{1\}' is inaccessible in the composed schema. | An object type implements an interface field, but its implementation is hidden. Either expose the implementing field or hide the interface field as well. | +| `IMPLEMENT_WITHOUT_DEFAULT` | The field '\{0\}' is marked with @implement in schema '\{1\}', but no applicable default implementation exists for it. | Remove `@implement`, or add an applicable default through an interface-object stand-in. Use `@implement` only when the declaration intentionally replaces that projected default. See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default). | +| [INPUT_FIELD_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Default-Mismatch) | The default value '\{0\}' of input field '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same input field has different defaults in two source schemas. Align the default value across all definitions of the input type. | +| [INPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Types-mergeable) | The input field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | An input field has a different type structure (named type, list, or nullability) across source schemas. Decide on the canonical type and update the other source schema. | +| [INPUT_WITH_MISSING_REQUIRED_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-With-Missing-Required-Fields) | The input type '\{0\}' in schema '\{1\}' must define the required field '\{2\}'. | One source schema requires a field on an input type that another source schema omits. Add the field (with the same type) to the named source schema so the input shape is consistent. | +| `INPUT_WITH_MISSING_ONEOF` | The input type '\{0\}' in schema '\{1\}' must be annotated with the '@oneOf' directive, since the same type has been annotated with it in schema '\{2\}'. | The same input type is `@oneOf` in one source schema but not in another. Add `@oneOf` to the input type in the named source schema (or remove it from the other) so all definitions agree. | +| [INTERFACE_FIELD_NO_IMPLEMENTATION](https://graphql.github.io/composite-schemas-spec/draft/#sec-Interface-Field-No-Implementation) | The merged object type '\{0\}' must implement the field '\{1\}' on interface '\{2\}'. | After merging, an object type declares it implements the interface but does not provide every field the interface requires. Add the missing field to the object type, or stop implementing the interface. | +| `INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT` | The field '\{0\}' provided by schema '\{1\}' replaces the default implementation contributed to interface '\{2\}'. Mark it with @implement to keep this implementation, or remove it to adopt the default. | Add `@implement` when this source intentionally replaces the projected default. Otherwise, remove the field declaration so the type inherits the default. See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default). | +| `INTERFACE_OBJECT_KEY_MISMATCH` | The @interfaceObject stand-in '\{0\}' in schema '\{1\}' declares the key '\{2\}', which does not match any key declared on the interface it stands in for. | Change the stand-in key to one declared or inferred on the same-named interface. The key fields and selection shape must match. See [Interface Objects](./interface-objects.md). | +| `INTERFACE_OBJECT_KEY_MISSING` | The @interfaceObject stand-in '\{0\}' in schema '\{1\}' must declare at least one @key directive. | Add at least one `@key` that matches a key on the same-named interface and can populate the stand-in lookup. See [Interface Objects](./interface-objects.md). | +| `INTERFACE_OBJECT_NO_INTERFACE` | The type '\{0\}' is declared as an @interfaceObject stand-in, but no source schema defines '\{0\}' as an interface. | Define the same-named interface in a concrete-aware source schema, or remove `@interfaceObject` if the type is not a stand-in. See [Interface Objects](./interface-objects.md). | +| [INVALID_FIELD_SHARING](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Field-Sharing) | The field '\{0\}' in schema '\{1\}' must be shareable. | The same non-key field is owned by more than one source schema without an explicit sharing contract. Add `@shareable` to the field (or to the enclosing type, which applies to all of its fields) in every source schema that defines it, or move ownership to a single source schema. See [Field Ownership](./field-ownership-and-sharing.md). | +| [INVALID_GRAPHQL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-GraphQL) | Invalid GraphQL in source schema. Exception message: \{0\}. | The source schema does not parse as valid GraphQL. Read the included parser exception, fix the SDL in the offending source schema, and re-export. See [Getting Started](./getting-started.md) for how to export a source schema. | +| `INVALID_NODE_RESOLUTION` | Source-schema node resolution requires global object identification to be enabled. Or: The node resolution mode '\{0\}' is invalid. | Enable Global Object Identification when you select source-schema resolution. For programmatic composition, use `NodeResolution.Gateway` or `NodeResolution.SourceSchema`. See [Configure node resolution](./connectors/apollofederation.md#configure-node-resolution). | +| `INVALID_PROJECTED_FIELD_SHARING` | The default field '\{0\}' on type '\{1\}' is contributed by unrelated interfaces \{2\}; every contributing @interfaceObject declaration must be marked @shareable. | Mark every contributing stand-in field `@shareable` only when its type and semantics match, or remove the conflicting default from all but one interface. See [Share Defaults from Unrelated Interfaces](./interface-objects.md#share-defaults-from-unrelated-interfaces). | +| `INVALID_SHAREABLE_FIELD_RUNTIME_TYPE_ROUTING` | The shareable field runtime type routing mode '\{0\}' is invalid. | Set `ShareableFieldRuntimeTypeRouting` to `SourceLocal` or `CommonRuntimeTypes`. See [Composition Settings](./aspire-integration.md#composition-settings) for the Aspire setting. | +| [INVALID_SHAREABLE_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Shareable-Usage) | The field '\{0\}' in schema '\{1\}' must not be marked as shareable. | `@shareable` was applied where it is not allowed (for example, on a field that is already governed by another ownership directive). Remove `@shareable` from the field. See [Field Ownership](./field-ownership-and-sharing.md). | +| [IS_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Fields) | The @is directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@is(field: ...)` does not resolve against the entity's fields. Update it to reference fields that actually exist on the parent type. See [Entities and Lookups](./entities-and-lookups.md). | +| [IS_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Field-Type) | The @is directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument was given as a non-string literal. Pass it as a quoted string, for example `@is(field: "id")`. | +| [IS_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Syntax) | The @is directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The string value for `field` is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | +| [IS_INVALID_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Usage) | The @is directive on argument '\{0\}' in schema '\{1\}' is invalid because the declaring field is not a lookup field. | `@is` is only valid on arguments of lookup fields. Move `@is` to a lookup, or annotate the declaring field with `@lookup`. See [Entities and Lookups](./entities-and-lookups.md). | +| [KEY_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Directive-in-Fields-Argument) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The `fields` selection of `@key` cannot apply directives to the selected fields. Remove the directive applications from the selection set. See [Entities and Lookups](./entities-and-lookups.md). | +| [KEY_FIELDS_SELECT_INVALID_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Fields-Select-Invalid-Type) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not be a list, interface, or union type. | Key fields must resolve to scalar, enum, or object types, not lists, interfaces, or unions. Pick a different field for the key, or model the key as a scalar identifier. | +| [KEY_INVALID_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Arguments) | A @key directive on type '\{0\}' in schema '\{1\}' specifies invalid arguments. \{2\} | Key fields support constant arguments. Remove or correct any argument that is unknown, has an incompatible value type, or leaves a required argument unsupplied. | +| [KEY_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields) | A @key directive on type '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The `fields` selection refers to fields that do not exist on the type. Add the missing fields to the type or correct the selection set. See [Entities and Lookups](./entities-and-lookups.md). | +| [KEY_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields-Type) | A @key directive on type '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string, for example `@key(fields: "id")`. | +| [KEY_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Syntax) | A @key directive on type '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The string value for `fields` is not a valid GraphQL selection set. Rewrite it as a syntactically valid selection. | +| [LOOKUP_RETURNS_LIST](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-List) | The lookup field '\{0\}' in schema '\{1\}' must not return a list. | A `@lookup` field returned a list, which prevents the gateway from mapping a key to a single entity. Change the return type to a single nullable entity. See [Entities and Lookups](./entities-and-lookups.md). | +| [NON_NULL_INPUT_FIELD_IS_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Non-Null-Input-Fields-cannot-be-inaccessible) | The non-null input field '\{0\}' in schema '\{1\}' must be accessible in the composed schema. | A required input field was hidden by `@inaccessible`, which would make the input impossible to construct from the gateway. Either make the field nullable or expose it. | +| [NO_QUERIES](https://graphql.github.io/composite-schemas-spec/draft/#sec-No-Queries) | The merged query type has no accessible fields. | After merging and applying accessibility filters, no query fields remain. Expose at least one query field, or remove `@inaccessible` from the queries you intend to publish. | +| `OPT_IN_FEATURE_STABILITY_MISMATCH` | The opt-in feature '\{0\}' has a different stability in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | Two or more source schemas declare the same opt-in feature with different `stability` values. Align the stability for that feature across all subgraphs that declare it using `OptInFeatureStability("featureName", "stability")` so every declaration uses the same string. | +| [OUTPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Output-Field-Types-Mergeable) | The output field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The field's return type cannot be unified across source schemas: leaf types (scalar or enum) differ in name or kind, the list shape differs, or composite types (object, interface, or union) share no common supertype. Nullability differences and composite types with a common supertype merge automatically; otherwise align the return types. | +| [OVERRIDE_FROM_SELF](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-from-Self) | The @override directive on field '\{0\}' in schema '\{1\}' must not reference the same schema. | `@override(from: "...")` cannot point at the source schema that owns the directive. Set `from` to the name of the source schema the field is being taken from. See [Field Ownership](./field-ownership-and-sharing.md). | +| [OVERRIDE_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | `@override` cannot be applied to interface fields. Move the directive to the implementing object types if you need to take ownership of a specific implementation. See [Field Ownership](./field-ownership-and-sharing.md). | +| [PROVIDES_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Directive-in-Fields-Argument) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The selection in `@provides(fields: ...)` cannot apply directives. Remove the directive applications from the selection. See [Field Ownership](./field-ownership-and-sharing.md). | +| [PROVIDES_FIELDS_HAS_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Has-Arguments) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not have arguments. | A field selected by `@provides` is being called with arguments, which is not allowed. Pick an argument-less field or restructure the schema so the provided field needs no arguments. | +| [PROVIDES_FIELDS_MISSING_EXTERNAL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Missing-External) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must be marked as external. | `@provides` only makes sense when the referenced field is `@external` here. Add `@external` to the referenced field in the same source schema. See [Field Ownership](./field-ownership-and-sharing.md). | +| [PROVIDES_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields) | The @provides directive on field '\{0\}' in schema '\{1\}' specifies an invalid field selection. | The selection points to fields that do not exist on the returned type. Update the selection so it matches fields that the returned type actually defines. | +| [PROVIDES_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields-Type) | The @provides directive on field '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string. | +| [PROVIDES_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Syntax) | The @provides directive on field '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The selection set syntax in `fields` is invalid. Rewrite it as a valid GraphQL selection set. | +| [PROVIDES_ON_NON_COMPOSITE_FIELD](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-on-Non-Composite-Field) | The field '\{0\}' in schema '\{1\}' includes a @provides directive, but does not return a composite type. | `@provides` only applies when a field returns an object, interface, or union, because the directive describes which sub-fields are also delivered. Remove `@provides`, or change the field to return a composite type. | +| [QUERY_ROOT_TYPE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Query-Root-Type-Inaccessible) | The root query type in schema '\{0\}' must be accessible. | The query root cannot be marked `@inaccessible`. Remove the directive from the root query type. | +| [REFERENCE_TO_INACCESSIBLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Inaccessible-Type) | (Message varies by reference site: field argument, input field, or output field.) | A publicly accessible position (a field argument, input field, or output field) references a type that has been hidden from the composed schema. Either expose the referenced type or change the position to use a publicly accessible type. | +| [REFERENCE_TO_INTERNAL_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Internal-Type) | The merged field '\{0\}' in type '\{1\}' cannot reference the internal type '\{2\}'. | A public field returns an internal type, which would leak the internal type into the composed schema. Either mark the field as internal as well, or change the return type to a non-internal one. | +| [REQUIRE_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Fields) | The @require directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@require(field: ...)` references fields that the parent type does not expose. Update it to reference fields that exist on the parent. See [Entities and Lookups](./entities-and-lookups.md). | +| [REQUIRE_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Field-Type) | The @require directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument must be a string literal. Pass it as a quoted string. | +| [REQUIRE_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Syntax) | The @require directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The `field` value is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | +| [ROOT_MUTATION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Mutation-Used) | The root mutation type in schema '\{0\}' must be named 'Mutation'. | Fusion requires the root mutation type to use the canonical name `Mutation`. Rename the type in the named source schema. A type named `Mutation` must not exist unless it is the root mutation type. | +| [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. | +| [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 @@ -192,10 +200,11 @@ The placeholders `{0}`, `{1}`, etc. in the message column are replaced with the | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [LOOKUP_RETURNS_NON_NULLABLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-Non-Nullable-Type) | The lookup field '\{0\}' in schema '\{1\}' should return a nullable type. | Lookups should return nullable entities so the gateway can represent missing keys without throwing. Change the return type of the lookup field to nullable. See [Entities and Lookups](./entities-and-lookups.md). | | [SPECIFIED_BY_URL_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-SpecifiedBy-URL-Mismatch) | The scalar type '\{0\}' has a different specified-by URL in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | The same scalar declares conflicting `@specifiedBy(url: ...)` URLs in different source schemas. Align the URL across all source schemas that define the scalar so the composed schema points to a single specification. | +| [UNSATISFIABLE_QUERY_PATH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Unsatisfiable-Query-Path) | A source-schema `Node` dispatcher does not cover a composite `Node` type. | In source-schema node-resolution mode, Fusion allows the source resolver to report the uncovered type as `null` or an error at runtime. Confirm that this compatibility behavior is intended. | -## Diagnosing UNSATISFIABLE_QUERY_PATH Errors +## Diagnosing UNSATISFIABLE_QUERY_PATH Diagnostics -Most composition errors point at a single source schema or definition. `UNSATISFIABLE_QUERY_PATH` is different. It is raised by the satisfiability validator (phase 8) and tells you that the merged schema as a whole has at least one reachable field that no source schema can serve through the available `@lookup` and `@key` paths. +Most composition diagnostics point at a single source schema or definition. `UNSATISFIABLE_QUERY_PATH` is different. It is raised by the satisfiability validator (phase 8) and tells you that the merged schema as a whole has at least one reachable field that no source schema can serve through the available `@lookup` and `@key` paths. It is an error by default. Source-schema node resolution can downgrade uncovered composite `Node` types to warnings when a usable source dispatcher exists. Because the validator weighs every option for reaching a field across every source schema, the diagnostic carries a tree of nested errors that explain why each candidate option failed. Reading that tree from the bottom up is the fastest way to find the gap. @@ -244,7 +253,7 @@ Paths add noise to short outputs and pay off on any non-trivial graph. Turn them - **`@require` reaches for a field that is not on the path.** A `@require(field: "...")` references parent fields that the validator cannot resolve given the path it took. Either expose the required field on the parent type along that path, or rewrite the requirement. - **`Node` interface without a node lookup.** A type implements `Node` but no source schema exposes a `node(id: ID!): Node` lookup that returns it. Add one in any source schema that owns the entity. -When you fix the root cause, both the top-level `UNSATISFIABLE_QUERY_PATH` error and its nested children disappear together. +When you fix the root cause, both the top-level `UNSATISFIABLE_QUERY_PATH` diagnostic and its nested children disappear together. ### Temporarily ignoring a non-accessible field diff --git a/website/content/docs/fusion/connectors/apollofederation.md b/website/content/docs/fusion/connectors/apollofederation.md index 6bbb5f1a15f..564cfdfdc61 100644 --- a/website/content/docs/fusion/connectors/apollofederation.md +++ b/website/content/docs/fusion/connectors/apollofederation.md @@ -80,28 +80,174 @@ Composition resolves Apollo Federation constructs before clients see the schema. | `@override(from: "...")` | Preserved as override semantics. | | `@inaccessible` | Preserved. | | `@tag` | Preserved. | +| `@interfaceObject` | Projects fields onto the same-named interface and compatible implementations. | | Union members contributed across subgraphs | Merged into a single union in the composed schema. | -# Global Object Identification +# Shareable Abstract Field Routing + +Apollo Federation subgraphs can define the same shareable field while declaring different members for its interface or union result type. Fusion offers two policies for routing selections under inline fragments and other type conditions: + +| Composition API value | CLI value | Execution-schema value | Behavior | +| --------------------- | ---------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SourceLocal` | `source-local` | `SOURCE_LOCAL` | Routes type-conditioned selections according to the source schema that resolves the shareable field. This is the default, including for archives that predate the policy. | +| `CommonRuntimeTypes` | `common-runtime-types` | `COMMON_RUNTIME_TYPES` | Routes only runtime types shared by viable Apollo providers: those in current scope, directly reachable through one entity lookup, or non-external at an operation root. | + +`CommonRuntimeTypes` does not filter objects returned by a source or change `__typename`. A source-specific object can still appear in the response, while fields requested only through a type condition that was not routed can be `null`. + +Use `CommonRuntimeTypes` to reproduce the conservative routing exercised by the legacy `PartialUnion` and `PartialUnionComplex` federation gateway-audit cases, or whenever viable providers expose different members of the same interface or union. Select it while composing: -If your Apollo Federation subgraphs implement the Relay `node` field (a `Query.node(id: ID!): Node` field over a `Node` interface), the connector turns it into a lookup during composition. The gateway can then resolve any `Node` type through that lookup. +```bash +nitro fusion compose \ + --source-schema-file ./products/schema.graphqls \ + --source-schema-file ./reviews/schema.graphqls \ + --shareable-field-runtime-type-routing common-runtime-types \ + --archive gateway.far +``` + +For programmatic composition, set the compatibility option: + +```csharp +var options = new SchemaComposerOptions(); +options.ApolloFederationCompatibility.ShareableFieldRuntimeTypeRouting = + ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes; + +var log = new CompositionLog(); +var result = new SchemaComposer(sourceSchemas, options, log).Compose(); +``` + +With Aspire, set `GraphQLCompositionSettings.ShareableFieldRuntimeTypeRouting` to `ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes`. See [Composition Settings](../aspire-integration.md#composition-settings). To update an existing archive, use [`nitro fusion settings set`](../cli.md#nitro-fusion-settings-set). + +Composition records the policy in the execution schema. For example, `common-runtime-types` produces: + +```graphql +schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: COMMON_RUNTIME_TYPES + ) { + query: Query +} +``` + +The default is recorded as `shareableFieldRuntimeTypeRouting: SOURCE_LOCAL`. + +# Interface Objects + +Apollo Federation v2 interface objects compose with native GraphQL Federation interfaces. Keep the Apollo declaration and its Federation v2 import in the subgraph SDL: + +```graphql +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: ["@key", "@interfaceObject"] + ) + +type Media @key(fields: "id") @interfaceObject { + id: ID! + views: Int! +} +``` + +Fusion projects this stand-in's fields onto the interface and compatible implementations. Values produced by the stand-in remain opaque until Fusion recovers their concrete type through a source that knows the implementations. + +Apollo Federation does not define Fusion's native `@implement` directive. When an Apollo implementing type redeclares a projected field, mark the compatible declarations with Apollo `@shareable`. Fusion uses that declaration as the explicit replacement contract. A redeclaration that is not shareable fails composition. + +At runtime, Fusion enters the Apollo stand-in through `_entities`. Native GraphQL Federation interface objects use `@lookup` fields instead. Both source kinds can participate in the same interface. + +See [Interface Objects](../interface-objects.md) for field projection, opaque identity, covering lookups, and native source-schema examples. + +## Allow Non-Resolvable Interface Objects + +By default, composition rejects an Apollo interface object whose key uses `resolvable: false` when Fusion cannot build the required route. This strict default catches projected fields that the gateway could not fetch. + +For compatibility with an existing Apollo graph, you can allow these stand-ins during programmatic composition: + +```csharp +var options = new SchemaComposerOptions(); +options.ApolloFederationCompatibility.AllowNonResolvableInterfaceObjects = true; -Configure how the gateway resolves `node(id:)` at runtime with the `NodeResolution` option on `FusionOptions`: +var log = new CompositionLog(); +var result = new SchemaComposer(sourceSchemas, options, log).Compose(); +``` -| Value | Behavior | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NodeResolution.Gateway` | The gateway decodes the `id`, determines the object's type from it, and routes the lookup to the source schema that owns that type. This is the default. | -| `NodeResolution.SourceSchema` | The gateway does not interpret the `id`. It forwards `node(id:)` to a source schema that owns a node lookup, and that subgraph resolves the type. Use this when identifiers are opaque to the gateway, which is often the case for Apollo Federation subgraphs that mint their own IDs. | +Here, `sourceSchemas` is the `IEnumerable` used by your existing programmatic composition flow. Inspect `result` and `log` as you do for other composition errors. -Set the option on the gateway builder with `ModifyOptions`: +With Aspire composition, set the equivalent property: ```csharp builder - .AddGraphQLGateway() - .ModifyOptions(options => options.NodeResolution = NodeResolution.SourceSchema) - .AddFileSystemConfiguration("./gateway.far"); + .AddProject("gateway-api") + .WithGraphQLSchemaComposition( + settings: new GraphQLCompositionSettings + { + AllowNonResolvableInterfaceObjects = true + }); ``` +> This option applies only to source schemas detected as Apollo Federation schemas. It does not relax native interface-object satisfiability. Enabling it does not create a lookup route, so an unresolved selection produces a field error at runtime. + +# Global Object Identification + +If your Apollo Federation subgraphs implement the Relay `node` field (a `Query.node(id: ID!): Node` field over a `Node` interface), the connector turns it into a lookup during composition. Keep the Apollo Federation SDL as exported. You do not need to add Fusion's `@lookup` directive to the field. + +## Configure node resolution + +Choose how the gateway resolves `node(id:)` when you compose the gateway archive. Fusion records the mode in the execution schema, so every compatible gateway that loads the archive uses the same behavior. + +| CLI value | Execution-schema value | Behavior | +| --------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `gateway` | `GATEWAY` | The gateway decodes the ID, determines the object type, and routes the lookup to the source schema that owns that type. This is the default. | +| `source-schema` | `SOURCE_SCHEMA` | The gateway forwards the opaque ID to a source schema with a public root `Query.node(id: ID!): Node` lookup. That source schema determines the object type and can resolve the concrete `Node` implementations that it declares. Use this mode when the gateway cannot decode your identifiers. | + +To use source-schema resolution, enable Global Object Identification and select the mode in the compose command: + +```bash +nitro fusion compose \ + --source-schema-file ./accounts/schema.graphqls \ + --source-schema-file ./reviews/schema.graphqls \ + --archive gateway.far \ + --enable-global-object-identification \ + --node-resolution source-schema +``` + +To update an existing archive, enable Global Object Identification before changing the node-resolution setting: + +```bash +nitro fusion settings set global-object-identification true \ + --archive gateway.far + +nitro fusion settings set node-resolution source-schema \ + --archive gateway.far +``` + +For the settings command reference, see [nitro fusion settings set](../cli.md#nitro-fusion-settings-set). If you compose through Aspire, set `EnableGlobalObjectIdentification` to `true` and `NodeResolution` to `NodeResolution.SourceSchema` in `GraphQLCompositionSettings`. See [Composition settings](../aspire-integration.md#composition-settings). + +## Verify node resolution + +After a successful composition, Nitro prints the archive path: + +```text +✅ Composite schema written to '/absolute/path/to/gateway.far'. +``` + +The generated execution schema should also contain: + +```graphql +schema @fusion__execution(nodeResolution: SOURCE_SCHEMA) { + query: Query +} +``` + +Deploy a `SOURCE_SCHEMA` archive only to compatible gateways that support the `@fusion__execution` metadata. An older gateway can fall back to gateway-side ID decoding. + +## Troubleshoot source-schema resolution + +If composition reports `Source-schema node resolution requires global object identification to be enabled.`, you selected `source-schema` without enabling Global Object Identification. Add both `--enable-global-object-identification` and `--node-resolution source-schema` to the compose command. + +Source-schema resolution covers only concrete `Node` implementations declared by a source schema with a public root `Query.node(id: ID!): Node` lookup. If a valid dispatcher covers some implementations but another composite `Node` type is uncovered, composition emits an `UNSATISFIABLE_QUERY_PATH` warning. A request for an uncovered type can return `node: null`, with an error if the source resolver reports one. Add the type as a `Node` implementation in a source schema with a public root node lookup. If no public root dispatcher covers any `Node` implementation, composition fails. + +See [GraphQL Global Object Identification](../entities-and-lookups.md#graphql-global-object-identification) for the required schema shape and the [composition log-code reference](../composition.md#log-codes-reference) for diagnostic details. + # Runtime Behavior The gateway uses Apollo's entity protocol when it routes to Apollo Federation subgraphs. @@ -119,7 +265,7 @@ When a query plan needs several different lookups from the same subgraph, the ga The connector is under active development and ships as a preview. Composition rejects unsupported Apollo Federation features with a specific error code, so the gateway does not produce a schema that would misbehave at runtime. - **Apollo Federation v1 is not supported.** A subgraph must be an Apollo Federation v2 schema, which means it carries a `@link` to `https://specs.apollo.dev/federation`. A v1 subgraph (which has no such `@link`) is rejected with `FEDERATION_V1_NOT_SUPPORTED`. Upgrade the subgraph to Apollo Federation v2. -- **Several Apollo Federation v2 directives are not supported.** Composition rejects `@interfaceObject`, `@composeDirective`, `@authenticated`, `@requiresScopes`, and `@policy` with `FEDERATION_DIRECTIVE_NOT_SUPPORTED`. Remove the directive, or express the equivalent with a GraphQL Federation construct. +- **Several Apollo Federation v2 directives are not supported.** Composition rejects `@composeDirective`, `@authenticated`, `@requiresScopes`, and `@policy` with `FEDERATION_DIRECTIVE_NOT_SUPPORTED`. Remove the directive, or express the equivalent with a GraphQL Federation construct. Both error codes are listed in the [Composition log-code reference](../composition.md#log-codes-reference). @@ -135,4 +281,5 @@ You do not have to choose one protocol for the whole graph. Apollo Federation an - Compose and run a gateway: [nitro fusion compose](../cli.md#nitro-fusion-compose) and [Getting Started](../getting-started.md). - Understand entity resolution in the GraphQL Federation model: [Entities and Lookups](../entities-and-lookups.md). +- Extend interfaces across source schemas: [Interface Objects](../interface-objects.md). - Move subgraphs to the GraphQL Federation protocol: [Coming from Apollo Federation](../migration/coming-from-apollo-federation.md). diff --git a/website/content/docs/fusion/directives-reference.md b/website/content/docs/fusion/directives-reference.md index b42138a0782..059b7fcb34b 100644 --- a/website/content/docs/fusion/directives-reference.md +++ b/website/content/docs/fusion/directives-reference.md @@ -5,24 +5,26 @@ description: "Reference for Fusion composition directives like @key, @lookup, @r Fusion implements the [GraphQL Composite Schemas Specification](https://graphql.github.io/composite-schemas-spec/draft/). The directives defined in this specification are applied to source schemas (subgraph schemas) to control how they compose into a unified composite schema. Each directive entry below shows its SDL definition, what it does, and a practical example with the resulting composed output. -In HotChocolate, these directives are expressed using C# attributes. See the individual guide pages for attribute usage and tutorials. +In Hot Chocolate, many of these directives are expressed using C# attributes. See the individual guide pages for supported C# usage and tutorials. # Quick Reference -| Directive | Locations | Repeatable | Purpose | -| -------------------------------- | ----------------------------------------- | :--------: | ------------------------------------------------- | -| [`@key`](#key) | `OBJECT`, `INTERFACE` | Yes | Define entity identity | -| [`@lookup`](#lookup) | `FIELD_DEFINITION` | No | Mark entity lookup resolvers | -| [`@is`](#is) | `ARGUMENT_DEFINITION` | No | Map lookup arguments to entity fields | -| [`@require`](#require) | `ARGUMENT_DEFINITION` | No | Declare cross-subgraph data dependencies | -| [`@shareable`](#shareable) | `OBJECT`, `FIELD_DEFINITION` | Yes | Allow multiple subgraphs to define the same field | -| [`@provides`](#provides) | `FIELD_DEFINITION` | No | Declare locally-resolvable subfields | -| [`@external`](#external) | `FIELD_DEFINITION` | No | Mark field as owned by another subgraph | -| [`@override`](#override) | `FIELD_DEFINITION` | No | Migrate field ownership between subgraphs | -| [`@internal`](#internal) | `OBJECT`, `FIELD_DEFINITION` | No | Hide from composite schema and merge process | -| [`@inaccessible`](#inaccessible) | 10 locations | No | Hide from client-facing composite schema | -| [`@eventStream`](#eventstream) | `FIELD_DEFINITION` | No | Back a subscription field with a message broker | -| [`@eventCursor`](#eventcursor) | `ARGUMENT_DEFINITION`, `FIELD_DEFINITION` | No | Mark the resume cursor for resumable streams | +| Directive | Locations | Repeatable | Purpose | +| -------------------------------------- | ----------------------------------------- | :--------: | ------------------------------------------------- | +| [`@key`](#key) | `OBJECT`, `INTERFACE` | Yes | Define entity identity | +| [`@lookup`](#lookup) | `FIELD_DEFINITION` | No | Mark entity lookup resolvers | +| [`@is`](#is) | `ARGUMENT_DEFINITION` | No | Map lookup arguments to entity fields | +| [`@interfaceObject`](#interfaceobject) | `OBJECT` | No | Contribute fields to an interface | +| [`@implement`](#implement) | `FIELD_DEFINITION` | No | Replace a projected interface-object default | +| [`@require`](#require) | `ARGUMENT_DEFINITION` | No | Declare cross-subgraph data dependencies | +| [`@shareable`](#shareable) | `OBJECT`, `FIELD_DEFINITION` | Yes | Allow multiple subgraphs to define the same field | +| [`@provides`](#provides) | `FIELD_DEFINITION` | No | Declare locally-resolvable subfields | +| [`@external`](#external) | `FIELD_DEFINITION` | No | Mark field as owned by another subgraph | +| [`@override`](#override) | `FIELD_DEFINITION` | No | Migrate field ownership between subgraphs | +| [`@internal`](#internal) | `OBJECT`, `FIELD_DEFINITION` | No | Hide from composite schema and merge process | +| [`@inaccessible`](#inaccessible) | 10 locations | No | Hide from client-facing composite schema | +| [`@eventStream`](#eventstream) | `FIELD_DEFINITION` | No | Back a subscription field with a message broker | +| [`@eventCursor`](#eventcursor) | `ARGUMENT_DEFINITION`, `FIELD_DEFINITION` | No | Mark the resume cursor for resumable streams | --- @@ -185,6 +187,99 @@ type Query { --- +# Interface Objects + +## `@interfaceObject` + +Declares an object type as a stand-in for an interface with the same name in another source schema. The stand-in can contribute fields to the interface without declaring its concrete implementations. + +```graphql +directive @interfaceObject on OBJECT +``` + +The stand-in must declare at least one `@key`, and each key must match a key on the interface entity. A source that contributes non-key fields through the stand-in must also provide a lookup returning the stand-in. + +**Example:** + +```graphql +# Catalog source schema +interface Media @key(fields: "id") { + id: ID! + title: String! +} + +type Book implements Media @key(fields: "id") { + id: ID! + title: String! +} +``` + +```graphql +# Analytics source schema +type Query { + mediaByKey(id: ID!): Media @lookup @internal +} + +type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! +} +``` + +```graphql +# Composed schema +interface Media { + id: ID! + title: String! + views: Int! +} + +type Book implements Media { + id: ID! + title: String! + views: Int! +} +``` + +See [Interface Objects](./interface-objects.md) for lookup requirements, projected fields, and concrete type recovery. + +--- + +## `@implement` + +Marks a field as an explicit implementation that replaces a default projected from an interface object. + +```graphql +directive @implement on FIELD_DEFINITION +``` + +Use `@implement` only when a concrete object or a more-specific interface stand-in declares a field that would otherwise adopt a projected default. Composition rejects an unmarked replacement and rejects `@implement` when there is no applicable default. + +**Example:** + +```graphql +# Catalog source schema +type Book implements Media @key(fields: "id") { + id: ID! + title: String! + views: Int! @implement +} +``` + +```graphql +# Analytics source schema +type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! +} +``` + +In the composed schema, Catalog owns `Book.views`. Other `Media` implementations continue to use the Analytics default. + +See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default) for the complete pattern. + +--- + # Data Requirements ## `@require` diff --git a/website/content/docs/fusion/entities-and-lookups.md b/website/content/docs/fusion/entities-and-lookups.md index 0e7671be44b..31372dbc399 100644 --- a/website/content/docs/fusion/entities-and-lookups.md +++ b/website/content/docs/fusion/entities-and-lookups.md @@ -410,6 +410,7 @@ builder # Next Steps +- **Need to add fields to an interface from another source?** See [Interface Objects](./interface-objects.md). - **Need field ownership contracts?** See [Field Ownership](./field-ownership-and-sharing.md). - **Need argument mapping and cross-subgraph dependencies?** See [Data Requirements](./data-requirements-and-mapping.md) for `@is`, `@require`, and FieldSelectionMap patterns. - **Need runtime performance guidance?** See Hot Chocolate docs for DataLoader and batching patterns used inside lookup resolvers. diff --git a/website/content/docs/fusion/field-ownership-and-sharing.md b/website/content/docs/fusion/field-ownership-and-sharing.md index 910e54cb0cb..a116f39966a 100644 --- a/website/content/docs/fusion/field-ownership-and-sharing.md +++ b/website/content/docs/fusion/field-ownership-and-sharing.md @@ -125,5 +125,6 @@ Before composition, verify: # Next Steps - **Need identity and lookup routing?** See [Entities and Lookups](./entities-and-lookups.md). +- **Need to add a default field to every implementation of an interface?** See [Interface Objects](./interface-objects.md). - **Need dependency and mapping syntax?** See [Data Requirements and Mapping](./data-requirements-and-mapping.md). - **Need merge and validation rules?** See [Composition](./composition.md). diff --git a/website/content/docs/fusion/interface-objects.md b/website/content/docs/fusion/interface-objects.md new file mode 100644 index 00000000000..2b96dffb9a0 --- /dev/null +++ b/website/content/docs/fusion/interface-objects.md @@ -0,0 +1,352 @@ +--- +title: "Interface Objects" +description: "Extend an interface from another Fusion source schema with @interfaceObject, project fields to its implementations, and recover concrete identity through lookups." +--- + +An interface object lets one source schema add fields to an interface without declaring every type that implements it. This is useful when a source owns behavior that applies to all implementations, but another source owns the interface and its concrete types. + +This page explains how Fusion composes a same-named object stand-in, projects its fields, and uses lookups to recover concrete types. The examples focus on the source-schema contracts and the resulting query flow. + +Before you begin, make sure you understand [entity keys and lookups](./entities-and-lookups.md). Interface objects use the same identity and lookup contracts to move values between source schemas. + +# Extend an Interface from Another Source + +The Catalog source owns a `Media` interface and knows its concrete `Book` and `Movie` types. It also exposes a lookup that returns the interface. + +**Catalog source schema** + +```graphql +type Query { + mediaById(id: ID!): Media @lookup +} + +interface Media { + id: ID! + title: String! +} + +type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! +} + +type Movie implements Media @key(fields: "id") { + id: ID! + title: String! + runtime: Int! +} +``` + +The Analytics source does not need to declare `Book` or `Movie`. Instead, it declares an object named `Media` and marks it with `@interfaceObject`. + +**Analytics source schema** + +```graphql +type Query { + trendingMedia: [Media!]! + mediaByKey(id: ID!): Media @lookup @internal +} + +type Media @interfaceObject @key(fields: "id") { + id: ID! + views: Int! +} +``` + +The object name must match the interface name. Fusion infers `id` as a `Media` key from the Catalog lookup. The stand-in must declare the matching key explicitly. Fusion uses this key to correlate an opaque `Media` value with a concrete value from another source. + +The `views` field becomes part of `Media` and is projected as a default field onto every compatible implementation. The client-facing schema contains this shape: + +```graphql +interface Media { + id: ID! + title: String! + views: Int! +} + +type Book implements Media { + id: ID! + title: String! + isbn: String! + views: Int! +} + +type Movie implements Media { + id: ID! + title: String! + runtime: Int! + views: Int! +} +``` + +The internal `mediaByKey` lookup gives Fusion a route back into Analytics when it needs a projected field. `@internal` hides this routing field from clients. See [Internal Lookups](./entities-and-lookups.md#internal-lookups) for the complete pattern. + +> A stand-in that contributes a non-key field must have a lookup in its own source schema. Declaring a matching type and key without a lookup is not enough to make the projected field reachable. + +# Query Fields from the Stand-In + +When a query selects only fields that Analytics can provide, Fusion can resolve the operation from that source. + +```graphql +query TrendingMedia { + trendingMedia { + id + views + } +} +``` + +For one media item, the result can look like this: + +```json +{ + "data": { + "trendingMedia": [ + { + "id": "TWVkaWE6MQ==", + "views": 123 + } + ] + } +} +``` + +Fusion performs one Analytics fetch. It does not ask Catalog for the concrete type because the operation does not observe concrete identity. + +# Recover Concrete Types + +Analytics knows that a value has the `Media` shape, but it does not know whether that value is a `Book`, `Movie`, or another implementation. Values returned through the stand-in are therefore opaque. + +Selecting `__typename` or a concrete inline fragment makes the concrete identity observable. Fusion then uses the key from Analytics to call the covering interface lookup in Catalog. + +```mermaid +flowchart LR + C[Client] --> G[Fusion gateway] + G -->|trendingMedia| A[Analytics stand-in] + A -->|opaque Media and id| G + G -->|mediaById using id| K[Catalog] + K -->|Book or Movie| G + G --> C +``` + +The Catalog lookup is a covering lookup because it returns `Media` and its source can resolve every possible concrete type in the composite schema. + +```graphql +query TrendingMedia { + trendingMedia { + __typename + id + views + ... on Book { + isbn + } + ... on Movie { + runtime + } + } +} +``` + +For a `Book`, the result can look like this: + +```json +{ + "data": { + "trendingMedia": [ + { + "__typename": "Book", + "id": "TWVkaWE6MQ==", + "views": 123, + "isbn": "Book: TWVkaWE6MQ==" + } + ] + } +} +``` + +The data flow has four steps: + +1. Analytics returns the `id` and `views` fields for each opaque `Media` value. +2. Fusion batches Catalog `mediaById` lookups for the returned IDs. +3. Catalog returns the authoritative `__typename` and concrete fields. +4. Fusion merges the concrete data into the original result. + +A covering lookup must meet all of these conditions: + +- It returns the interface, not one concrete implementation. +- Its arguments can be populated from the stand-in key. +- One source schema containing the lookup covers every possible implementation in the composite schema. + +Several concrete lookups spread across different source schemas do not form one covering lookup. + +## Resolve a Stand-In Field Inside a Concrete Fragment + +When you select a projected field inside a concrete fragment, Analytics cannot return that field until Fusion knows whether the fragment applies. This produces an Analytics to Catalog to Analytics route (B to A to B): + +```graphql +query TrendingBooks { + trendingMedia { + ... on Book { + views + } + } +} +``` + +For the earlier `Book` value, the result can look like this: + +```json +{ + "data": { + "trendingMedia": [ + { + "views": 123 + } + ] + } +} +``` + +Fusion resolves this query in three source operations: + +1. Analytics returns each opaque value's `id`. +2. Catalog uses `mediaById` to recover `Book` as the concrete type. +3. Analytics uses `mediaByKey` to fetch `views` for values that match the `Book` fragment. + +Fusion carries the `id` from the first operation through both lookups. + +# Replace a Projected Default + +An implementation normally adopts a field projected from the interface object. To provide a different implementation for one concrete type, declare the field on that type and mark it with `@implement`. + +```graphql +type Book implements Media @key(fields: "id") { + id: ID! + title: String! + isbn: String! + views: Int! @implement +} +``` + +This declaration tells composition that Catalog intentionally replaces the Analytics default for `Book.views`. Other implementations, such as `Movie`, continue to use the projected default. + +If you declare `Book.views` without `@implement`, composition reports `INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT`. If there is no projected default to replace, `@implement` reports `IMPLEMENT_WITHOUT_DEFAULT`. + +# Share Defaults from Unrelated Interfaces + +An object can implement unrelated interfaces whose stand-ins project a field with the same name. When the field has the same contract and result in every source, mark every contributing declaration with `@shareable`. + +The following three source schemas define the interfaces, a concrete type that implements both, and the lookups needed to resolve the projected field. + +**Catalog source schema** + +```graphql +type Query { + physicalProductById(id: ID!): PhysicalProduct @lookup + digitalProductById(id: ID!): DigitalProduct @lookup +} + +interface PhysicalProduct @key(fields: "id") { + id: ID! +} + +interface DigitalProduct @key(fields: "id") { + id: ID! +} + +type Chair implements PhysicalProduct & DigitalProduct @key(fields: "id") { + id: ID! +} +``` + +**Physical reviews source schema** + +```graphql +type Query { + physicalProductByKey(id: ID!): PhysicalProduct @lookup @internal +} + +type PhysicalProduct @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! @shareable +} + +type Review { + rating: Int! @shareable +} +``` + +**Digital reviews source schema** + +```graphql +type Query { + digitalProductByKey(id: ID!): DigitalProduct @lookup @internal +} + +type DigitalProduct @interfaceObject @key(fields: "id") { + id: ID! + reviews: [Review!]! @shareable +} + +type Review { + rating: Int! @shareable +} +``` + +Both defaults project `reviews` onto `Chair`. If any contributing declaration is not shareable, composition reports `INVALID_PROJECTED_FIELD_SHARING`. Use `@shareable` only when the field semantics match. See [Field Ownership](./field-ownership-and-sharing.md) for the full sharing contract. + +# Use Interface Objects with Apollo Federation + +Apollo Federation v2 subgraphs use the same `type Media @interfaceObject @key(...)` stand-in shape, imported through `@link`. Fusion composes that construct with native interface objects. + +Resolution still follows each source protocol. Fusion enters native GraphQL Federation sources through `@lookup` fields and Apollo Federation sources through `_entities` representations. Apollo Federation has no `@implement` directive. When an Apollo implementation redeclares a projected default, mark the compatible declarations with `@shareable`; Fusion uses that declaration as the explicit replacement contract. + +For Apollo-specific translation, runtime behavior, and non-resolvable key compatibility, see the [Apollo Federation Connector](./connectors/apollofederation.md#interface-objects). + +# Troubleshooting + +## `INTERFACE_OBJECT_KEY_MISSING` + +The stand-in has no key. Add at least one key that identifies the interface value and can be passed to its lookups. + +```graphql +type Media @interfaceObject @key(fields: "id") { + id: ID! +} +``` + +## `INTERFACE_OBJECT_NO_INTERFACE` + +No source schema defines an interface with the stand-in's name. Define the real interface and its implementing types in at least one concrete-aware source. + +## `INTERFACE_OBJECT_KEY_MISMATCH` + +The stand-in key does not match a key on the real interface. Use one of the interface entity's keys on the stand-in. + +## Source schema 'B' contributes fields to 'Media' but provides no lookup to resolve them. + +This `UNSATISFIABLE_QUERY_PATH` message means a stand-in adds a non-key field, but its source has no lookup returning that stand-in. Add a nullable lookup. You can mark it `@internal` when clients should not call it. + +```graphql +type Query { + mediaByKey(id: ID!): Media @lookup @internal +} +``` + +## `UNSATISFIABLE_QUERY_PATH`: no lookup covers the possible types + +The diagnostic identifies the uncovered types and the source schemas that introduced them. For example: + +> The query path 'Query.topReviewed' cannot be satisfied: values of 'Media' produced by source schema 'B' are opaque, and no source schema provides a lookup for 'Media' that covers the possible type(s) 'Photo' introduced by source schema(s) 'C'. + +An opaque value can reach a client, but no concrete-aware source can recover every possible runtime type. Add an interface lookup to one source schema and make every composite implementation resolvable there. + +See [Diagnosing `UNSATISFIABLE_QUERY_PATH` Diagnostics](./composition.md#diagnosing-unsatisfiable_query_path-diagnostics) for help reading the complete lookup failure tree. + +# Next Steps + +- Define stable identity and routing paths: [Entities and Lookups](./entities-and-lookups.md). +- Review sharing and ownership contracts: [Field Ownership](./field-ownership-and-sharing.md). +- Look up exact directive signatures: [Directive Reference](./directives-reference.md#interface-objects). +- Connect Apollo Federation subgraphs: [Apollo Federation Connector](./connectors/apollofederation.md#interface-objects). diff --git a/website/content/docs/fusion/structure.yaml b/website/content/docs/fusion/structure.yaml index 2de6988c7ee..7db035037bb 100644 --- a/website/content/docs/fusion/structure.yaml +++ b/website/content/docs/fusion/structure.yaml @@ -12,6 +12,8 @@ items: title: Guides - path: entities-and-lookups title: Entities and Lookups + - path: interface-objects + title: Interface Objects - path: data-requirements-and-mapping title: Data Requirements - path: field-ownership-and-sharing