diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Composite.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Composite.cs index 23d923d66ba..c0c17479f6d 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Composite.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Composite.cs @@ -33,6 +33,7 @@ public static IRequestExecutorBuilder AddSourceSchemaDefaults( o.ApplyShareableToPageInfo = true; o.ApplyShareableToNodeFields = true; o.ApplySerializeAsToScalars = true; + o.InferKeysFromLookups = true; }); } } diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs index e0532f05e47..919009582b6 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using HotChocolate.Fetching; using HotChocolate.Language; using HotChocolate.Resolvers; +using HotChocolate.Types.Composite; using HotChocolate.Validation; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.ObjectPool; @@ -144,6 +145,7 @@ private static DefaultRequestExecutorBuilder CreateBuilder( builder.TryAddTypeInterceptor(); builder.TryAddTypeInterceptor(); + builder.TryAddTypeInterceptor(); builder.AddDocumentCache(); diff --git a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index 27c0685c11d..ea4f5e7f812 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -244,4 +244,10 @@ public interface IReadOnlySchemaOptions /// Applies the @serializeAs directive to scalar types that specify a serialization format. /// bool ApplySerializeAsToScalars { get; } + + /// + /// Infers @key directives from the arguments of @lookup fields so that the published + /// source schema describes the entity keys that the lookups resolve. + /// + bool InferKeysFromLookups { get; } } diff --git a/src/HotChocolate/Core/src/Types/SchemaOptions.cs b/src/HotChocolate/Core/src/Types/SchemaOptions.cs index f00708c0ad5..219f3dfe07c 100644 --- a/src/HotChocolate/Core/src/Types/SchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/SchemaOptions.cs @@ -195,6 +195,9 @@ public int OperationDocumentCacheSize /// public bool ApplySerializeAsToScalars { get; set; } + /// + public bool InferKeysFromLookups { get; set; } + /// /// Creates a mutable options object from a read-only options object. /// @@ -238,6 +241,7 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options) ApplyShareableToPageInfo = options.ApplyShareableToPageInfo, ApplyShareableToConnections = options.ApplyShareableToConnections, ApplyShareableToNodeFields = options.ApplyShareableToNodeFields, - ApplySerializeAsToScalars = options.ApplySerializeAsToScalars + ApplySerializeAsToScalars = options.ApplySerializeAsToScalars, + InferKeysFromLookups = options.InferKeysFromLookups }; } diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs index 085e491f481..ab32ddd81b7 100644 --- a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs +++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/EntityKey.cs @@ -9,7 +9,7 @@ namespace HotChocolate.Types.Composite; /// an entity across different source schemas. /// /// -/// directive @key(fields: FieldSelectionSet!) on OBJECT | INTERFACE +/// directive @key(fields: FieldSelectionSet!) repeatable on OBJECT | INTERFACE /// /// /// @@ -19,7 +19,7 @@ namespace HotChocolate.Types.Composite; DirectiveNames.Key.Name, DirectiveLocation.Object | DirectiveLocation.Interface, - IsRepeatable = false)] + IsRepeatable = true)] [GraphQLDescription( """ The @key directive is used to designate an entity's unique key, diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/SourceSchemaKeyInferenceTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Types/Composite/SourceSchemaKeyInferenceTypeInterceptor.cs new file mode 100644 index 00000000000..b6617e259e8 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Composite/SourceSchemaKeyInferenceTypeInterceptor.cs @@ -0,0 +1,496 @@ +using HotChocolate.Configuration; +using HotChocolate.Fusion.Language; +using HotChocolate.Fusion.Rewriters; +using HotChocolate.Internal; +using HotChocolate.Language; +using HotChocolate.Types.Descriptors; +using HotChocolate.Types.Descriptors.Configurations; + +namespace HotChocolate.Types.Composite; + +/// +/// Infers @key directives from the arguments of @lookup fields and applies them to the +/// types that the lookups resolve so that the published source schema describes the +/// entity keys. +/// +internal sealed class SourceSchemaKeyInferenceTypeInterceptor : TypeInterceptor +{ + private const string IsFieldArgumentName = "field"; + + private readonly List _lookups = []; + private readonly Dictionary _typesByName = new(StringComparer.Ordinal); + private readonly Dictionary> _implementersByInterface = + new(StringComparer.Ordinal); + private readonly Dictionary> _membersByUnion = + new(StringComparer.Ordinal); + private ITypeCompletionContext? _completionContext; + private TypeReference _entityKeyRef = null!; + + public override bool IsEnabled(IDescriptorContext context) + => context.Options.InferKeysFromLookups; + + public override void OnBeforeRegisterDependencies( + ITypeDiscoveryContext discoveryContext, + TypeSystemConfiguration configuration) + { + // If a lookup is present we make sure that the @key directive type is registered, + // so that the inferred directive can be resolved even when no @key was declared manually. + if (configuration is not (ObjectTypeConfiguration or InterfaceTypeConfiguration)) + { + return; + } + + if (!HasInferableLookupField((TypeConfiguration)configuration)) + { + return; + } + + _entityKeyRef ??= discoveryContext.TypeInspector.GetTypeRef(typeof(EntityKey)); + discoveryContext.Dependencies.Add(new TypeDependency(_entityKeyRef)); + } + + public override void OnAfterCompleteName( + ITypeCompletionContext completionContext, + TypeSystemConfiguration configuration) + { + // We capture a completion context so that we can resolve type references once all + // type names are completed. + _completionContext ??= completionContext; + + switch (configuration) + { + case ObjectTypeConfiguration objectType: + // We only index the base type, not type extensions. Extensions are merged into the + // base before keys are applied, so the indexed base carries the merged directives + // that the deduplication relies on. Lookups declared on an extension are still + // collected below. + if (!objectType.IsExtension) + { + _typesByName[objectType.Name] = objectType; + } + CollectLookups(objectType.Fields); + break; + + case InterfaceTypeConfiguration interfaceType: + _typesByName[interfaceType.Name] = interfaceType; + CollectLookups(interfaceType.Fields); + break; + + case UnionTypeConfiguration unionType: + _typesByName[unionType.Name] = unionType; + break; + } + } + + public override void OnBeforeCompleteTypes() + { + if (_lookups.Count == 0 || _completionContext is null) + { + return; + } + + var context = _completionContext; + + BuildImplementerIndex(context); + + foreach (var lookup in _lookups) + { + if (!context.TryGetType(lookup.ReturnTypeRef, out var returnType)) + { + continue; + } + + var namedType = returnType.NamedType(); + + // We only infer keys for nullable, non-list lookups, mirroring the composer. + if (returnType.IsNonNullType() || returnType.IsListType()) + { + continue; + } + + var targets = ResolveTargets(namedType); + + if (targets.Count == 0) + { + continue; + } + + foreach (var keyFields in BuildKeySelectionSets(lookup)) + { + foreach (var target in targets) + { + ApplyKey(context, target, keyFields); + } + } + } + } + + private void CollectLookups(IEnumerable fields) + where TField : OutputFieldConfiguration + { + foreach (var field in fields) + { + if (field.Type is null || !HasDirective(field.GetDirectives(), DirectiveNames.Lookup.Name)) + { + continue; + } + + // We exclude lookups without arguments, mirroring the composer which cannot derive any + // key fields from an argument-less lookup. + if (!field.HasArguments) + { + continue; + } + + _lookups.Add(new LookupInfo(field, field.Type)); + } + } + + private void BuildImplementerIndex(ITypeCompletionContext context) + { + foreach (var (_, configuration) in _typesByName) + { + switch (configuration) + { + // We only index object types as interface implementers, mirroring the composer's + // inference pass which applies inferred keys to the possible (object) types of an + // interface and the interface itself. Sub-interfaces are intentionally excluded. + case ObjectTypeConfiguration objectType: + foreach (var interfaceRef in objectType.GetInterfaces()) + { + if (context.TryGetType(interfaceRef, out var interfaceType) + && interfaceType.NamedType() is IInterfaceTypeDefinition i) + { + AddToIndex(_implementersByInterface, i.Name, objectType); + } + } + break; + + case UnionTypeConfiguration unionType: + foreach (var memberRef in unionType.Types) + { + if (context.TryGetType(memberRef, out var memberType) + && memberType.NamedType() is IObjectTypeDefinition o + && _typesByName.TryGetValue(o.Name, out var memberConfig)) + { + AddToIndex(_membersByUnion, unionType.Name, memberConfig); + } + } + break; + } + } + } + + private List ResolveTargets(ITypeDefinition namedType) + { + var targets = new List(); + + switch (namedType) + { + case IObjectTypeDefinition objectType: + if (_typesByName.TryGetValue(objectType.Name, out var objectConfig)) + { + targets.Add(objectConfig); + } + break; + + case IInterfaceTypeDefinition interfaceType: + if (_typesByName.TryGetValue(interfaceType.Name, out var interfaceConfig)) + { + targets.Add(interfaceConfig); + } + + if (_implementersByInterface.TryGetValue(interfaceType.Name, out var implementers)) + { + targets.AddRange(implementers); + } + break; + + case IUnionTypeDefinition unionType: + // The @key directive is only valid on OBJECT and INTERFACE, so we apply it to the + // members of the union and not to the union itself. + if (_membersByUnion.TryGetValue(unionType.Name, out var members)) + { + targets.AddRange(members); + } + break; + } + + return targets; + } + + private static IEnumerable BuildKeySelectionSets(LookupInfo lookup) + { + // We convert the @is value selections into @key selection sets purely syntactically so + // that the conversion does not depend on the completed type graph. This lets nested @is + // paths (for example "address.id") be turned into nested selection sets ("address { id }") + // even though the referenced child type is not yet completed at this phase. Malformed + // value selections are filtered out earlier while parsing, so genuine bugs surface here + // instead of being silently dropped. + foreach (var group in GetValueSelectionGroups(lookup.Field)) + { + yield return ValueSelectionToSelectionSetRewriter.Rewrite(group); + } + } + + private static List> GetValueSelectionGroups(OutputFieldConfiguration field) + { + var arrays = new List(); + + foreach (var argument in field.GetArguments()) + { + var selectionMap = GetIsFieldSelectionMap(argument) ?? argument.Name; + + IValueSelectionNode parsed; + + try + { + parsed = FieldSelectionMapParser.Parse(selectionMap); + } + catch (FieldSelectionMapSyntaxException) + { + return []; + } + + if (parsed is ChoiceValueSelectionNode choice) + { + arrays.Add([.. choice.Branches]); + } + else + { + arrays.Add([parsed]); + } + } + + return GetAllCombinations(arrays); + } + + private static string? GetIsFieldSelectionMap(ArgumentConfiguration argument) + { + foreach (var directive in argument.GetDirectives()) + { + switch (directive.Value) + { + case Is @is: + return @is.Field.ToString(false); + + case DirectiveNode { Name.Value: DirectiveNames.Is.Name } node: + var fieldArgument = node.Arguments + .FirstOrDefault(a => a.Name.Value == IsFieldArgumentName); + + if (fieldArgument?.Value is StringValueNode stringValue) + { + return stringValue.Value; + } + break; + } + } + + return null; + } + + private static void ApplyKey( + ITypeCompletionContext context, + TypeConfiguration target, + SelectionSetNode keyFields) + { + // We deduplicate keys order-insensitively and structurally (so "id sku" equals "sku id" + // and "address { id }" is compared by shape). This also suppresses an inferred key when an + // equivalent key was declared manually, which keeps the interceptor idempotent against + // manually authored keys. + var normalizedKey = NormalizeSelectionSet(keyFields); + + foreach (var directive in target.GetDirectives()) + { + if (TryGetKeySelectionSet(directive.Value, out var existing) + && string.Equals(NormalizeSelectionSet(existing), normalizedKey, StringComparison.Ordinal)) + { + return; + } + } + + target.AddDirective(new EntityKey(keyFields), context.TypeInspector); + } + + private static bool TryGetKeySelectionSet(object value, out SelectionSetNode selectionSet) + { + switch (value) + { + case EntityKey entityKey: + selectionSet = entityKey.Fields; + return true; + + case DirectiveNode { Name.Value: DirectiveNames.Key.Name } node: + var fieldsArgument = node.Arguments + .FirstOrDefault(a => a.Name.Value == DirectiveNames.Key.Arguments.Fields); + + if (fieldsArgument?.Value is StringValueNode stringValue) + { + try + { + selectionSet = FieldSelectionSetType.ParseSelectionSet(stringValue.Value); + return true; + } + catch (SyntaxException) + { + // ignore invalid manual key syntax for deduplication purposes. + } + } + break; + } + + selectionSet = null!; + return false; + } + + private static string NormalizeSelectionSet(SelectionSetNode selectionSet) + { + var entries = new List(); + + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + var nested = field.SelectionSet is null + ? string.Empty + : "{" + NormalizeSelectionSet(field.SelectionSet) + "}"; + entries.Add(field.Name.Value + nested); + break; + + case InlineFragmentNode inlineFragment: + var typeCondition = inlineFragment.TypeCondition?.Name.Value ?? string.Empty; + entries.Add( + "..." + typeCondition + "{" + NormalizeSelectionSet(inlineFragment.SelectionSet) + "}"); + break; + } + } + + entries.Sort(StringComparer.Ordinal); + + return string.Join(" ", entries); + } + + private static bool HasInferableLookupField(TypeConfiguration type) + { + switch (type) + { + case ObjectTypeConfiguration objectType: + foreach (var field in objectType.Fields) + { + if (IsInferableLookup(field)) + { + return true; + } + } + break; + + case InterfaceTypeConfiguration interfaceType: + foreach (var field in interfaceType.Fields) + { + if (IsInferableLookup(field)) + { + return true; + } + } + break; + } + + return false; + } + + // A lookup can only contribute a key when it is a @lookup field that carries arguments and + // resolves a nullable, non-list type, mirroring the eligibility applied while keys are built. + // We restrict the @key type dependency to those fields so that schemas whose lookups never + // produce a key are not forced to reference the inferred key's scalar. + private static bool IsInferableLookup(OutputFieldConfiguration field) + { + if (field.Type is null + || !field.HasArguments + || !HasDirective(field.GetDirectives(), DirectiveNames.Lookup.Name)) + { + return false; + } + + return IsNullableNonListReturnType(field.Type); + } + + private static bool IsNullableNonListReturnType(TypeReference typeReference) + => typeReference switch + { + ExtendedTypeReference extended + => extended.Type is { IsNullable: true, IsArrayOrList: false }, + + SyntaxTypeReference syntax + => syntax.Type is not (NonNullTypeNode or ListTypeNode), + + // For reference kinds whose nullability cannot be inspected at this phase we keep the + // dependency so that a key can still be applied later if the resolved type is eligible. + _ => true + }; + + private static bool HasDirective( + IReadOnlyList directives, + string name) + { + foreach (var directive in directives) + { + switch (directive.Value) + { + case Lookup when name == DirectiveNames.Lookup.Name: + return true; + + case DirectiveNode node when node.Name.Value == name: + return true; + } + } + + return false; + } + + private static void AddToIndex( + Dictionary> index, + string key, + TypeConfiguration value) + { + if (!index.TryGetValue(key, out var list)) + { + list = []; + index[key] = list; + } + + list.Add(value); + } + + private static List> GetAllCombinations(List arrays) + { + if (arrays.Count == 0) + { + return [[]]; + } + + var result = new List> { new() }; + + foreach (var array in arrays) + { + var temp = new List>(); + + foreach (var item in array) + { + foreach (var combination in result) + { + var newCombination = new List(combination) { item }; + temp.Add(newCombination); + } + } + + result = temp; + } + + return result; + } + + private readonly record struct LookupInfo( + OutputFieldConfiguration Field, + TypeReference ReturnTypeRef); +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/LookupTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/LookupTests.cs index c19ab713d43..c509298e1fa 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/LookupTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/LookupTests.cs @@ -120,7 +120,7 @@ directive @is("The field selection map syntax." field: FieldSelectionMap!) on which identifies how to uniquely reference an instance of an entity across different source schemas. """ - directive @key("The field selection set syntax." fields: FieldSelectionSet!) on + directive @key("The field selection set syntax." fields: FieldSelectionSet!) repeatable on | OBJECT | INTERFACE @@ -179,7 +179,7 @@ directive @is("The field selection map syntax." field: FieldSelectionMap!) on which identifies how to uniquely reference an instance of an entity across different source schemas. """ - directive @key("The field selection set syntax." fields: FieldSelectionSet!) on + directive @key("The field selection set syntax." fields: FieldSelectionSet!) repeatable on | OBJECT | INTERFACE diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/SourceSchemaKeyInferenceTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/SourceSchemaKeyInferenceTests.cs new file mode 100644 index 00000000000..2b57b6b01d8 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/SourceSchemaKeyInferenceTests.cs @@ -0,0 +1,689 @@ +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Types.Composite; + +public static class SourceSchemaKeyInferenceTests +{ + [Fact] + public static async Task InferKey_Should_AddKeyToObject_When_LookupReturnsObject() + { + // arrange & act + var schema = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .ModifyOptions(o => o.InferKeysFromLookups = true) + .BuildSchemaAsync(); + + // assert + schema.MatchInlineSnapshot( + """" + schema { + query: ObjectQuery + } + + type ObjectQuery { + productById(id: Int!): Product @lookup + } + + type Product @key(fields: "id") { + id: Int! + name: String! + } + + scalar FieldSelectionSet + + """ + The @key directive is used to designate an entity's unique key, + which identifies how to uniquely reference an instance of + an entity across different source schemas. + """ + directive @key("The field selection set syntax." fields: FieldSelectionSet!) repeatable on + | OBJECT + | INTERFACE + + """ + The @lookup directive is used within a source schema to specify output fields + that can be used by the distributed GraphQL executor to resolve an entity by + a stable key. + """ + directive @lookup on FIELD_DEFINITION + """"); + } + + [Fact] + public static async Task InferKey_Should_AddKeyToInterfaceAndImplementers_When_LookupReturnsInterface() + { + // arrange & act + var schema = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddType
() + .AddType