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