diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs index 64683e4a642..f418f3d1a80 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/MutableSchemaDefinitionExtensions.cs @@ -415,13 +415,12 @@ public static HashSet GetPreservedTypeNames( { foreach (var argument in field.Arguments) { - var argumentInnerType = argument.Type.InnerType(); + var argumentNamedType = argument.Type.NamedType(); if ((argument.HasRequireDirective || field.IsLookup) - && argumentInnerType is INameProvider { Name: var name } - && !SpecScalarNames.IsSpecScalar(name)) + && !SpecScalarNames.IsSpecScalar(argumentNamedType.Name)) { - preservedTypeNames.Add(name); + preservedTypeNames.Add(argumentNamedType.Name); } } } 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 39971b32ade..3d40866346b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -1726,6 +1726,15 @@ internal static string RequirementsValidator_FieldDoesNotExistOnType { } } + /// + /// Looks up a localized string similar to The field '{0}.{1}' in schema '{2}' requires data from another schema, but schema '{2}' has no lookup for type '{0}' that could resolve the field once the required data has been fetched.. + /// + internal static string RequirementsValidator_NoLookupForRequiringField { + get { + return ResourceManager.GetString("RequirementsValidator_NoLookupForRequiringField", resourceCulture); + } + } + /// /// Looks up a localized string similar to No lookups found for type '{0}' in schema '{1}'.. /// @@ -1852,6 +1861,15 @@ internal static string SatisfiabilityValidator_NodeTypeHasNoNodeLookup { } } + /// + /// Looks up a localized string similar to The field '{0}.{1}' in schema '{2}' requires data from another schema, but schema '{2}' has no lookup for type '{0}' that could resolve the field once the required data has been fetched.. + /// + internal static string SatisfiabilityValidator_NoLookupForRequiringField { + get { + return ResourceManager.GetString("SatisfiabilityValidator_NoLookupForRequiringField", resourceCulture); + } + } + /// /// Looks up a localized string similar to No lookups found for type '{0}' in schema '{1}'.. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx index f8d50cd465c..0fb9352bcc3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -588,6 +588,9 @@ The field '{0}' does not exist on the type '{1}'. + + The field '{0}.{1}' in schema '{2}' requires data from another schema, but schema '{2}' has no lookup for type '{0}' that could resolve the field once the required data has been fetched. + No lookups found for type '{0}' in schema '{1}'. @@ -630,6 +633,9 @@ Type '{0}' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node<Node>' lookup field for this type. + + The field '{0}.{1}' in schema '{2}' requires data from another schema, but schema '{2}' has no lookup for type '{0}' that could resolve the field once the required data has been fetched. + No lookups found for type '{0}' in schema '{1}'. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs index cedef908597..3372e2ae2bc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs @@ -5,6 +5,7 @@ using HotChocolate.Types; using HotChocolate.Types.Mutable; using static HotChocolate.Fusion.Properties.CompositionResources; +using static HotChocolate.Language.Utf8GraphQLParser.Syntax; namespace HotChocolate.Fusion.Satisfiability; @@ -17,20 +18,41 @@ internal sealed class RequirementsValidator( private readonly FusionLookupDirectiveCache _lookupCache = lookupCache; private readonly SatisfiabilityFacts _facts = facts; + /// + /// Validates that can be resolved for + /// at the position described by . + /// + /// The requirement selection set. + /// The object type the requirements are selected on. + /// The path item holding the type, or null at a root type. + /// + /// The schema the requirement leaves must not be resolved from, or null to allow every schema. + /// + /// + /// Whether intermediate fields of the requirement may be resolved from the excluded schema. + /// + /// The shared cycle detection path, or null to start a new one. + /// + /// Schemas whose data is unavailable at the position for any purpose, including lookup keys + /// and nested requirements. + /// + /// The satisfiability errors, empty when the requirements are satisfiable. public ImmutableArray Validate( SelectionSetNode requirements, MutableObjectTypeDefinition contextType, SatisfiabilityPathItem? parentPathItem, - string excludeSchemaName, + string? excludeSchemaName, bool allowIntermediatesFromExcludedSchema = false, - SatisfiabilityPath? cycleDetectionPath = null) + SatisfiabilityPath? cycleDetectionPath = null, + ImmutableHashSet? unavailableSchemaNames = null) { var context = new RequirementsValidatorContext( contextType, parentPathItem, excludeSchemaName, allowIntermediatesFromExcludedSchema, - cycleDetectionPath); + cycleDetectionPath, + unavailableSchemaNames ?? []); var errors = new List(); @@ -154,9 +176,14 @@ private ImmutableArray Visit( // not been entered yet, so intermediates must also come from outside // it (default behavior). var schemaNames = field.GetSchemaNames(); - if (fieldNode.SelectionSet is null || !context.AllowIntermediatesFromExcludedSchema) + if (context.ExcludeSchemaName is { } excludeSchemaName + && (fieldNode.SelectionSet is null || !context.AllowIntermediatesFromExcludedSchema)) { - schemaNames = schemaNames.Remove(context.ExcludeSchemaName); + schemaNames = schemaNames.Remove(excludeSchemaName); + } + if (!context.UnavailableSchemaNames.IsEmpty) + { + schemaNames = schemaNames.RemoveAll(context.UnavailableSchemaNames.Contains); } var fieldType = field.Type.AsTypeDefinition(); var optionCount = 0; @@ -242,17 +269,29 @@ private ImmutableArray Visit( || !_facts.IsFieldResolvableOn(type, field, schemaName))) { var requirementErrors = - new RequirementsValidator( - schema, - _lookupCache, - _facts, - includeSatisfiabilityPaths).Validate( + Validate( requirements, type, - context.Path.Peek(), + previousPathItem, excludeSchemaName: schemaName, allowIntermediatesFromExcludedSchema: true, - cycleDetectionPath: context.CycleDetectionPath); + cycleDetectionPath: context.CycleDetectionPath, + unavailableSchemaNames: context.UnavailableSchemaNames); + + if (requirementErrors.IsEmpty) + { + requirementErrors = + ValidateReentry( + requirements, + type, + field, + schemaName, + previousPathItem, + RequirementsValidator_NoLookupForRequiringField, + RequirementsValidator_UnableToSatisfyRequirementForLookup, + cycleDetectionPath: context.CycleDetectionPath, + unavailableSchemaNames: context.UnavailableSchemaNames); + } if (requirementErrors.Length != 0) { @@ -351,10 +390,113 @@ private ImmutableArray ValidateSourceSchemaTransition( contextType, parentPathItem, excludeSchemaName: transitionToSchemaName, - cycleDetectionPath: context.CycleDetectionPath), + cycleDetectionPath: context.CycleDetectionPath, + unavailableSchemaNames: context.UnavailableSchemaNames), RequirementsValidator_NoLookupsFoundForType, RequirementsValidator_UnableToSatisfyRequirementForLookup); } + + /// + /// Validates that can resolve once its + /// requirements have been fetched from other schemas. This holds when the requirements can be + /// resolved without data from the schema, or when the schema has a lookup for + /// whose key is resolvable while the type is held on the schema. + /// + /// The requirement selection set of the field. + /// The object type declaring the field. + /// The field with requirements. + /// The schema resolving the field. + /// The path item holding the type, or null at a root type. + /// The message format used when the schema cannot be re-entered. + /// + /// The message format used when a lookup key cannot be satisfied. + /// + /// The shared cycle detection path, or null to start a new one. + /// + /// Schemas whose data is unavailable at the position for any purpose. + /// + /// The satisfiability errors, empty when the field can be resolved. + public ImmutableArray ValidateReentry( + SelectionSetNode requirements, + MutableObjectTypeDefinition type, + MutableOutputFieldDefinition field, + string schemaName, + SatisfiabilityPathItem? parentPathItem, + string noLookupMessageFormat, + string unableToSatisfyRequirementForLookupMessageFormat, + SatisfiabilityPath? cycleDetectionPath = null, + ImmutableHashSet? unavailableSchemaNames = null) + { + if (schema.IsRootOperationType(type)) + { + return []; + } + + unavailableSchemaNames ??= []; + + var lookupErrors = new List(); + + foreach (var lookup in _lookupCache.GetPossibleFusionLookupDirectives(type, schemaName)) + { + var lookupKeyArg = (string)lookup.Arguments[WellKnownArgumentNames.Key].Value!; + var lookupFieldArg = (string)lookup.Arguments[WellKnownArgumentNames.Field].Value!; + var lookupPathArg = (string?)lookup.Arguments[WellKnownArgumentNames.Path].Value; + var lookupRequirements = ParseSelectionSet($"{{ {lookupKeyArg} }}"); + + // The key is fetched by the first call to the schema, so the schema itself is a valid + // source for the key fields. + var keyErrors = + Validate( + lookupRequirements, + type, + parentPathItem, + excludeSchemaName: null, + cycleDetectionPath: cycleDetectionPath, + unavailableSchemaNames: unavailableSchemaNames); + + if (keyErrors.IsEmpty) + { + return []; + } + + var lookupFieldName = ParseFieldDefinition(lookupFieldArg).Name.Value; + var lookupName = lookupPathArg is null + ? lookupFieldName + : $"{lookupPathArg}.{lookupFieldName}"; + + lookupErrors.Add( + new SatisfiabilityError( + string.Format( + unableToSatisfyRequirementForLookupMessageFormat, + lookupRequirements.ToString(indented: false), + lookupName, + schemaName), + keyErrors)); + } + + // Without a lookup the field can only be resolved by the call that already holds the type, + // which works when the requirements do not depend on data produced by that call. + var independentErrors = + Validate( + requirements, + type, + parentPathItem, + excludeSchemaName: schemaName, + cycleDetectionPath: cycleDetectionPath, + unavailableSchemaNames: unavailableSchemaNames.Add(schemaName)); + + if (independentErrors.IsEmpty) + { + return []; + } + + return + [ + new SatisfiabilityError( + string.Format(noLookupMessageFormat, type.Name, field.Name, schemaName), + [.. lookupErrors]) + ]; + } } internal sealed class RequirementsValidatorContext @@ -362,9 +504,10 @@ internal sealed class RequirementsValidatorContext public RequirementsValidatorContext( MutableObjectTypeDefinition contextType, SatisfiabilityPathItem? parentPathItem, - string excludeSchemaName, - bool allowIntermediatesFromExcludedSchema = false, - SatisfiabilityPath? cycleDetectionPath = null) + string? excludeSchemaName, + bool allowIntermediatesFromExcludedSchema, + SatisfiabilityPath? cycleDetectionPath, + ImmutableHashSet unavailableSchemaNames) { TypeContext.Push(contextType); @@ -376,17 +519,28 @@ public RequirementsValidatorContext( ExcludeSchemaName = excludeSchemaName; AllowIntermediatesFromExcludedSchema = allowIntermediatesFromExcludedSchema; CycleDetectionPath = cycleDetectionPath ?? []; + UnavailableSchemaNames = unavailableSchemaNames; } public Stack TypeContext { get; } = []; public SatisfiabilityPath Path { get; } = []; - public string ExcludeSchemaName { get; } + /// + /// Gets the schema the requirement leaves must not be resolved from, or null when every + /// schema is allowed. + /// + public string? ExcludeSchemaName { get; } public bool AllowIntermediatesFromExcludedSchema { get; } public SatisfiabilityPath CycleDetectionPath { get; } + /// + /// Gets the schemas whose data is unavailable at the position for any purpose, including + /// lookup keys and nested requirements. + /// + public ImmutableHashSet UnavailableSchemaNames { get; } + public HashSet FieldAccessCache { get; } = []; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs index 7f8af448ae4..e55b70939cd 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs @@ -48,8 +48,9 @@ public bool IsFieldAccessible( /// /// Determines whether is resolvable as the entity's own field on - /// , that is the field exists there, is not partial, and its - /// @require is satisfied. This does not account for reaching + /// , that is the field exists there, is not partial, its + /// @require is satisfied, and the schema can be re-entered at the type through a + /// lookup once the required data has been fetched. This does not account for reaching /// ; see for that. /// /// The object type declaring the field. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs index da7ac7c500d..e83e6bd572a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs @@ -90,12 +90,13 @@ private bool DeriveResolvableFields(MutableObjectTypeDefinition type) var requirements = field.GetFusionRequiresRequirements(schemaName); if (requirements is not null - && !CanResolveSelectionSet( - requirements, - type, - fromSchema: schemaName, - excludeSchema: schemaName, - SelectionSetResolutionMode.FieldRequire)) + && (!CanResolveSelectionSet( + requirements, + type, + fromSchema: schemaName, + excludeSchema: schemaName, + SelectionSetResolutionMode.FieldRequire) + || !CanReenterSchema(type, schemaName))) { continue; } @@ -108,6 +109,34 @@ private bool DeriveResolvableFields(MutableObjectTypeDefinition type) return changed; } + /// + /// Determines whether can be re-entered at + /// once requirement data has been fetched from other schemas, that is the schema declares a + /// lookup for the type whose key is resolvable while the type is held on the schema. + /// + private bool CanReenterSchema(MutableObjectTypeDefinition type, string schemaName) + { + if (_schema.IsRootOperationType(type)) + { + return true; + } + + foreach (var lookup in _lookupCache.GetPossibleFusionLookupDirectives(type, schemaName)) + { + if (CanResolveSelectionSet( + GetLookupKeySelectionSet(lookup), + type, + fromSchema: schemaName, + excludeSchema: null, + SelectionSetResolutionMode.LookupKey)) + { + return true; + } + } + + return false; + } + private bool DeriveTransitions(MutableObjectTypeDefinition type, string fromSchema) { var changed = false; @@ -242,7 +271,7 @@ private bool CanResolveSelectionSet( SelectionSetNode selectionSet, MutableObjectTypeDefinition contextType, string fromSchema, - string excludeSchema, + string? excludeSchema, SelectionSetResolutionMode mode) { foreach (var selection in selectionSet.Selections) @@ -260,7 +289,7 @@ private bool CanResolveSelection( ISelectionNode selection, MutableObjectTypeDefinition contextType, string fromSchema, - string excludeSchema, + string? excludeSchema, SelectionSetResolutionMode mode) { switch (selection) @@ -303,7 +332,7 @@ private bool CanResolveFieldSelection( FieldNode fieldNode, MutableObjectTypeDefinition contextType, string fromSchema, - string excludeSchema, + string? excludeSchema, SelectionSetResolutionMode mode) { if (!contextType.Fields.TryGetField(fieldNode.Name.Value, out var field)) @@ -341,7 +370,7 @@ private bool CanResolveChildSelectionSet( MutableOutputFieldDefinition field, string schemaName, SelectionSetNode selectionSet, - string excludeSchema, + string? excludeSchema, SelectionSetResolutionMode mode) { var fieldType = field.Type.AsTypeDefinition(); @@ -364,7 +393,7 @@ private bool CanResolveChildSelectionSet( private static IEnumerable GetCandidateSchemaNames( MutableOutputFieldDefinition field, - string excludeSchema, + string? excludeSchema, FieldNode fieldNode, SelectionSetResolutionMode mode) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs index b1b38e76886..c37aeb5fd54 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs @@ -228,6 +228,22 @@ private void VisitOutputField( excludeSchemaName: schemaName, allowIntermediatesFromExcludedSchema: true); + // The requirement data is fetched from other schemas, so the schema must be able + // to resolve the field afterwards, either without its own data or by re-entering + // the type through a lookup. + if (requirementErrors.IsEmpty) + { + requirementErrors = + _requirementsValidator.ValidateReentry( + requirements, + type, + field, + schemaName, + path?.Item, + SatisfiabilityValidator_NoLookupForRequiringField, + SatisfiabilityValidator_UnableToSatisfyRequirementForLookup); + } + if (requirementErrors.Length != 0) { errors.Add( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs index 7bd3cfe4970..b42bc07ea0e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs @@ -44,7 +44,7 @@ private static bool Visit( return VisitObject(objectValue, result, schema, internalAlias, writer); case PathObjectValueSelectionNode pathObject: - return VisitPathObject(pathObject, result, schema, internalAlias, writer); + return VisitPathObject(pathObject, result, type, schema, internalAlias, writer); case PathListValueSelectionNode pathList: return VisitPathList(pathList, result, type, schema, internalAlias, writer); @@ -210,6 +210,7 @@ private static bool VisitObject( private static bool VisitPathObject( PathObjectValueSelectionNode node, CompositeResultElement result, + ITypeNode? type, ISchemaDefinition schema, string? internalAlias, JsonWriter writer) @@ -217,11 +218,22 @@ private static bool VisitPathObject( var resolved = ResolvePath(schema, result, node.Path, internalAlias); var valueKind = resolved.ValueKind; - if (valueKind is JsonValueKind.Null or JsonValueKind.Undefined) + if (valueKind is JsonValueKind.Undefined) { return false; } + if (valueKind is JsonValueKind.Null) + { + if (IsNonNullPosition(type)) + { + return false; + } + + writer.WriteNullValue(); + return true; + } + if (valueKind is not JsonValueKind.Object) { throw new InvalidOperationException("Only object results are supported."); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs index 4e918f5e7d3..5c431c7d4c8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -2298,7 +2298,7 @@ private static bool SubtreeContainsOpaqueStandIn( private static bool DoVariablesContainUploadScalar( IReadOnlyList variables, - ISchemaDefinition schema) + FusionSchemaDefinition schema) { var inputObjectTypes = new Queue(); var visited = new HashSet(ReferenceEqualityComparer.Instance); @@ -2306,7 +2306,11 @@ private static bool DoVariablesContainUploadScalar( foreach (var variable in variables) { var variableTypeName = variable.Type.NamedType().Name.Value; - var variableType = schema.Types[variableTypeName]; + + if (!schema.Types.TryGetType(variableTypeName, allowInaccessibleFields: true, out var variableType)) + { + continue; + } if (variableType is IScalarTypeDefinition { Name: UploadScalarName }) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 7cbe76f9997..a89c876df67 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -1152,10 +1152,13 @@ private void PlanSelections( return; } + // Steps that depend on this work item's data also depend on the parts that + // this step could not resolve, so the child lookups inherit its dependents. backlog = backlog.PushUnresolvable( unresolvable, current.SchemaName, stepDepth, + dependents: workItem.Dependents, allowSourceSchemaReentry: isEventStreamRoot, sourceSchemaNodePolicy: workItem.SourceSchemaNodePolicy is null ? null @@ -1698,7 +1701,7 @@ private void PlanInlineFieldWithRequirements( InlineSelections( currentStep.Definition, index, - currentStep.Type, + workItem.Selection.Field.DeclaringType, workItem.Selection.SelectionSetId, new SelectionSetNode( [workItem.Selection.Node.WithArguments(arguments).WithSelectionSet(childSelections)])); @@ -1827,9 +1830,9 @@ private void PlanFieldWithRequirement( var leftoverRequirements = TryInlineFieldRequirements( workItem, - stepConsumer.StepId, + stepId, ref current, - currentStep, + mergeWithExistingStep ? existingStep : currentStep, indexBuilder, ref backlog, ref steps, @@ -2823,7 +2826,7 @@ private Backlog PushRequirementLeftover( // inlining performs must happen up front here. RegisterRequirementSelectionSets(requirements, index); - foreach (var (step, stepIndex, _) in current.GetCandidateSteps(workItem.Selection.SelectionSetId)) + foreach (var (step, stepIndex, schemaName) in current.GetCandidateSteps(workItem.Selection.SelectionSetId)) { if (currentStep.Id == step.Id) { @@ -2839,6 +2842,14 @@ private Backlog PushRequirementLeftover( continue; } + if (schemaName.Equals(current.SchemaName, StringComparison.Ordinal)) + { + // the required data is not natively resolvable in the requiring field's schema + // (composition forbids declaring the required leaves there), so a step of that + // schema could only carry the path, adding a dead selection and a spurious dependency. + continue; + } + if (!TryInlineSelectionSetIntoStep( step, workItem.Selection.SelectionSetId, @@ -2881,6 +2892,9 @@ private Backlog PushRequirementLeftover( entry.SelectionSet, FromSchema: current.SchemaName) { + // the requiring step also depends on the lookups that + // resolve the parts this step could not inline. + Dependents = ImmutableHashSet.Empty.Add(dependentStepId), ParentDepth = GetOperationStepDepth(current, step.Id), Conditions = entry.Conditions, SourceSchemaNodePolicy = workItem.SourceSchemaNodePolicy is null @@ -2912,7 +2926,8 @@ private Backlog PushRequirementLeftover( // Fallback: if no candidate step was found via exact selection set ID match, // walk the internal operation AST to find the nearest ancestor step and the - // complete connector chain to the target selection set. + // complete connector chain to the target selection set. An ancestor step of the + // requiring field's schema is skipped for the same reason as in the candidate loop. if (requirements is not null && TryFindAncestorStepForRequirement( current.InternalOperationDefinition, @@ -2921,7 +2936,8 @@ private Backlog PushRequirementLeftover( workItem.Selection.SelectionSetId, workItem.Selection.Path) is { } ancestorMatch && currentStep.Id != ancestorMatch.Step.Id - && !ancestorMatch.Step.DependsOn(currentStep, steps)) + && !ancestorMatch.Step.DependsOn(currentStep, steps) + && !string.Equals(ancestorMatch.Step.SchemaName, current.SchemaName, StringComparison.Ordinal)) { if (TryInlineIntoAncestorStep( ancestorMatch, requirements, workItem.Selection.Path, @@ -2947,6 +2963,7 @@ private Backlog PushRequirementLeftover( entry.SelectionSet, FromSchema: current.SchemaName) { + Dependents = ImmutableHashSet.Empty.Add(dependentStepId), ParentDepth = GetOperationStepDepth(current, ancestorMatch.Step.Id), Conditions = entry.Conditions, SourceSchemaNodePolicy = workItem.SourceSchemaNodePolicy is null diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/RequireTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/RequireTests.cs index bde5f706263..92571218d70 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/RequireTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/RequireTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Transport; using HotChocolate.Transport.Http; using HotChocolate.Types; @@ -399,6 +400,50 @@ input ProductKeyInput { await MatchSnapshotAsync(gateway, request, result); } + [Theory] + [InlineData("1", "WithPromoCode")] + [InlineData("2", "WithoutPromoCode")] + public async Task Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable( + string cartId, + string postFix) + { + // arrange + // The promotions schema extends Cart with a nullable promoCode that is null for cart 2. + using var server1 = CreateSourceSchema( + "cart", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "promotions", + b => b.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("cart", server1), + ("promotions", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + $$""" + { + cartById(id: "{{cartId}}") { + discountPercent + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await MatchSnapshotAsync(gateway, request, result, postFix); + } + [Fact] public async Task Require_Object_In_A_List() { @@ -637,6 +682,182 @@ type Query { await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task Require_Should_Fulfill_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require() + { + // arrange + // cart owns items and subtotal; subtotal requires items(first: 50).nodes[product.discountedPrice]. + // discountedPrice is owned by promotions and itself requires price, which is owned by products. + using var server1 = CreateSourceSchema( + "cart", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "products", + b => b.AddQueryType()); + + using var server3 = CreateSourceSchema( + "promotions", + b => b.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("cart", server1), + ("products", server2), + ("promotions", server3) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + cart { + subtotal + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal( + """ + {"cart":{"subtotal":15}} + """, + response.Data.GetRawText()); + }); + } + + [Fact] + public async Task Require_Should_Fulfill_Input_Object_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require() + { + // arrange + // same topology as above, the requirement uses the input object list form + // items(first: 50).nodes[{ unitPrice: product.discountedPrice }]. + using var server1 = CreateSourceSchema( + "cart", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "products", + b => b.AddQueryType()); + + using var server3 = CreateSourceSchema( + "promotions", + b => b.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("cart", server1), + ("products", server2), + ("promotions", server3) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + cart { + lineTotal + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal( + """ + {"cart":{"lineTotal":15}} + """, + response.Data.GetRawText()); + }); + } + + [Fact] + public async Task Require_Should_Fulfill_Nested_Requirement_When_Two_Fields_Require_It_Next_To_Required_Parent() + { + // arrange + // unitPrice and lineTotal both require product.discountedPrice, which is owned by + // promotions, while the client also selects product next to them. + using var server1 = CreateSourceSchema( + "cart", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "promotions", + b => b.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("cart", server1), + ("promotions", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + item { + unitPrice + lineTotal + product { + id + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + await AssertAndMatchSnapshotAsync( + gateway, + request, + result, + results => + { + var response = Assert.Single(results); + Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); + Assert.Equal( + """ + {"item":{"unitPrice":5,"lineTotal":15,"product":{"id":1}}} + """, + response.Data.GetRawText()); + }); + } + private static class NullableLeafFieldRequirement { public class Query @@ -671,6 +892,47 @@ public string GetFieldWithNullableRequirement([Require("nullableObject.field")] } } + private static class CartWithPromoCodeRequirement + { + public class Query + { + [Lookup] + public Cart? GetCartById([ID] int id) => new Cart(id); + } + + public record Cart([property: ID] int Id) + { + public int GetDiscountPercent( + [Require("promoCode.{ discountPercent isExpired }")] PromoInput? promo) + => promo is null || promo.IsExpired ? 0 : promo.DiscountPercent; + } + + public class PromoInput + { + public required int DiscountPercent { get; set; } + + public required bool IsExpired { get; set; } + } + } + + private static class Promotions + { + public class Query + { + [Lookup] + [Internal] + public Cart? GetCartById([ID] int id) => new Cart(id); + } + + public record Cart([property: ID] int Id) + { + public PromoCode? PromoCode + => Id == 1 ? new PromoCode("SAVE10", 10, false) : null; + } + + public record PromoCode(string Code, int DiscountPercent, bool IsExpired); + } + public static class BookCatalog { private static readonly Dictionary s_books = new() @@ -816,4 +1078,138 @@ public class BookDimensionInput public required int Height { get; set; } } + + public static class CartService + { + public class Query + { + public Cart GetCart() => new() { Id = 1 }; + + [Lookup] + [Internal] + public Cart? GetCartById(int id) => new() { Id = id }; + + [Lookup] + [Internal] + public Product? GetProductById(int id) => new() { Id = id }; + } + + public class Cart + { + public int Id { get; set; } + + [UsePaging] + public CartItem[] GetItems() => + [ + new CartItem { Product = new Product { Id = 1 } }, + new CartItem { Product = new Product { Id = 2 } } + ]; + + public double GetSubtotal( + [Require("items(first: 50).nodes[product.discountedPrice]")] List? lines) + => lines?.Sum() ?? -1; + + public double GetLineTotal( + [Require("items(first: 50).nodes[{ unitPrice: product.discountedPrice }]")] List? lines) + => lines?.Sum(line => line.UnitPrice) ?? -1; + } + + public class CartItem + { + public required Product Product { get; set; } + } + + public class Product + { + public int Id { get; set; } + } + + public class CartLineInput + { + public double UnitPrice { get; set; } + } + } + + public static class ProductPricing + { + public class Query + { + [Lookup] + [Internal] + public Product? GetProductById(int id) => new() { Id = id }; + } + + public class Product + { + public int Id { get; set; } + + public double GetPrice() => Id * 10; + } + } + + public static class ProductPromotions + { + public class Query + { + [Lookup] + [Internal] + public Product? GetProductById(int id) => new() { Id = id }; + } + + public class Product + { + public int Id { get; set; } + + public double GetDiscountedPrice([Require("price")] double price) => price / 2; + } + } + + public static class CartItemPricing + { + public class Query + { + public CartItem GetItem() => new() { Id = 1, Quantity = 3, Product = new Product { Id = 1 } }; + + [Lookup] + [Internal] + public CartItem? GetCartItemById(int id) + => new() { Id = id, Quantity = 3, Product = new Product { Id = id } }; + } + + public class CartItem + { + public int Id { get; set; } + + public int Quantity { get; set; } + + public required Product Product { get; set; } + + public double GetUnitPrice([Require("product.discountedPrice")] double price) => price; + + public double GetLineTotal([Require("product.discountedPrice")] double price) => price * Quantity; + } + + public class Product + { + [Shareable] + public int Id { get; set; } + } + } + + public static class CartItemPromotions + { + public class Query + { + [Lookup] + [Internal] + public Product? GetProductById(int id) => new() { Id = id }; + } + + public class Product + { + public int Id { get; set; } + + public double GetDiscountedPrice() => Id * 5; + } + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_Input_Object_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_Input_Object_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require.yaml new file mode 100644 index 00000000000..9eebb4cab77 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_Input_Object_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require.yaml @@ -0,0 +1,405 @@ +title: Require_Should_Fulfill_Input_Object_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require +request: + document: | + { + cart { + lineTotal + } + } +response: + body: | + { + "data": { + "cart": { + "lineTotal": 15 + } + } + } +sourceSchemas: + - name: cart + schema: | + schema { + query: Query + } + + type Query { + cart: Cart! + cartById(id: Int!): Cart @lookup @internal + productById(id: Int!): Product @lookup @internal + } + + type Cart @key(fields: "id") { + items( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): ItemsConnection + subtotal( + lines: [Float!] @require(field: "items(first: 50).nodes[product.discountedPrice]") + ): Float! + lineTotal( + lines: [CartLineInput!] @require(field: "items(first: 50).nodes[{\n unitPrice: product.discountedPrice\n}]") + ): Float! + id: Int! + } + + type CartItem { + product: Product! + } + + "A connection to a list of items." + type ItemsConnection @shareable { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [ItemsEdge!] + "A flattened list of the nodes." + nodes: [CartItem!] + } + + "An edge in a connection." + type ItemsEdge @shareable { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: CartItem! + } + + "Information about pagination in a connection." + type PageInfo @shareable { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String + } + + type Product @key(fields: "id") { + id: Int! + } + + input CartLineInput { + unitPrice: Float! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_2797b581_2($__fusion_1_lines: [CartLineInput!], $__fusion_2_id: Int!) { + cartById(id: $__fusion_2_id) { + lineTotal(lines: $__fusion_1_lines) + } + } + variables: | + { + "__fusion_1_lines": [ + { + "unitPrice": 5 + }, + { + "unitPrice": 10 + } + ], + "__fusion_2_id": 1 + } + response: + results: + - | + { + "data": { + "cartById": { + "lineTotal": 15 + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_2797b581_3($__fusion_3_id: Int!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + variables: | + { + "__fusion_3_id": 1 + } + response: + results: + - | + { + "data": { + "cartById": { + "items": { + "nodes": [ + { + "product": { + "id": 1 + } + }, + { + "product": { + "id": 2 + } + } + ] + } + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_2797b581_7 { + cart { + id + } + } + response: + results: + - | + { + "data": { + "cart": { + "id": 1 + } + } + } + - name: products + schema: | + schema { + query: Query + } + + type Query { + productById(id: Int!): Product @lookup @internal + } + + type Product @key(fields: "id") { + price: Float! + id: Int! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_2797b581_6($__fusion_7_id: Int!) { + productById(id: $__fusion_7_id) { + price + } + } + variables: | + [ + { + "__fusion_7_id": 1 + }, + { + "__fusion_7_id": 2 + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "productById": { + "price": 10 + } + } + } + - | + { + "data": { + "productById": { + "price": 20 + } + } + } + - name: promotions + schema: | + schema { + query: Query + } + + type Query { + productById(id: Int!): Product @lookup @internal + } + + type Product @key(fields: "id") { + discountedPrice(price: Float! @require(field: "price")): Float! + id: Int! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_2797b581_5($__fusion_5_price: Float!, $__fusion_6_id: Int!) { + productById(id: $__fusion_6_id) { + discountedPrice(price: $__fusion_5_price) + } + } + variables: | + [ + { + "__fusion_5_price": 10, + "__fusion_6_id": 1 + }, + { + "__fusion_5_price": 20, + "__fusion_6_id": 2 + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "productById": { + "discountedPrice": 5 + } + } + } + - | + { + "data": { + "productById": { + "discountedPrice": 10 + } + } + } +operationPlan: + operation: + - document: | + { + cart { + lineTotal + id @fusion__requirement + items(first: 50) @fusion__requirement { + nodes @fusion__requirement { + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + price @fusion__requirement + } + } + } + } + } + hash: 2797b581ad6bc7233178a022ae906edb + searchSpace: 2 + expandedNodes: 13 + nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_2797b581_2($__fusion_1_lines: [CartLineInput!], $__fusion_2_id: Int!) { + cartById(id: $__fusion_2_id) { + lineTotal(lines: $__fusion_1_lines) + } + } + source: $.cartById + target: $.cart + requirements: + - name: __fusion_1_lines + selectionMap: >- + items(first: 50).nodes[{ + unitPrice: product.discountedPrice + }] + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 5 + - id: 9 + - id: 3 + type: Operation + schema: cart + operation: | + query Op_2797b581_3($__fusion_3_id: Int!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + source: $.cartById + target: $.cart + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 9 + - id: 5 + type: Operation + schema: promotions + operation: | + query Op_2797b581_5($__fusion_5_price: Float!, $__fusion_6_id: Int!) { + productById(id: $__fusion_6_id) { + discountedPrice(price: $__fusion_5_price) + } + } + source: $.productById + target: $.cart.items.nodes.product + requirements: + - name: __fusion_5_price + selectionMap: >- + price + - name: __fusion_6_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 6 + - id: 6 + type: Operation + schema: products + operation: | + query Op_2797b581_6($__fusion_7_id: Int!) { + productById(id: $__fusion_7_id) { + price + } + } + source: $.productById + target: $.cart.items.nodes.product + requirements: + - name: __fusion_7_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 7 + type: OperationBatch + schema: cart + operation: | + query Op_2797b581_7 { + cart { + id + } + } + targets: + - $ + - $ + batchingGroupId: 9 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require.yaml new file mode 100644 index 00000000000..96b56042dc7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require.yaml @@ -0,0 +1,399 @@ +title: Require_Should_Fulfill_List_Requirement_When_Path_Traverses_Connection_With_Nested_Require +request: + document: | + { + cart { + subtotal + } + } +response: + body: | + { + "data": { + "cart": { + "subtotal": 15 + } + } + } +sourceSchemas: + - name: cart + schema: | + schema { + query: Query + } + + type Query { + cart: Cart! + cartById(id: Int!): Cart @lookup @internal + productById(id: Int!): Product @lookup @internal + } + + type Cart @key(fields: "id") { + items( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): ItemsConnection + subtotal( + lines: [Float!] @require(field: "items(first: 50).nodes[product.discountedPrice]") + ): Float! + lineTotal( + lines: [CartLineInput!] @require(field: "items(first: 50).nodes[{\n unitPrice: product.discountedPrice\n}]") + ): Float! + id: Int! + } + + type CartItem { + product: Product! + } + + "A connection to a list of items." + type ItemsConnection @shareable { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [ItemsEdge!] + "A flattened list of the nodes." + nodes: [CartItem!] + } + + "An edge in a connection." + type ItemsEdge @shareable { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: CartItem! + } + + "Information about pagination in a connection." + type PageInfo @shareable { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String + } + + type Product @key(fields: "id") { + id: Int! + } + + input CartLineInput { + unitPrice: Float! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_fc20cd27_2($__fusion_1_lines: [Float!], $__fusion_2_id: Int!) { + cartById(id: $__fusion_2_id) { + subtotal(lines: $__fusion_1_lines) + } + } + variables: | + { + "__fusion_1_lines": [ + 5, + 10 + ], + "__fusion_2_id": 1 + } + response: + results: + - | + { + "data": { + "cartById": { + "subtotal": 15 + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_fc20cd27_3($__fusion_3_id: Int!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + variables: | + { + "__fusion_3_id": 1 + } + response: + results: + - | + { + "data": { + "cartById": { + "items": { + "nodes": [ + { + "product": { + "id": 1 + } + }, + { + "product": { + "id": 2 + } + } + ] + } + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_fc20cd27_7 { + cart { + id + } + } + response: + results: + - | + { + "data": { + "cart": { + "id": 1 + } + } + } + - name: products + schema: | + schema { + query: Query + } + + type Query { + productById(id: Int!): Product @lookup @internal + } + + type Product @key(fields: "id") { + price: Float! + id: Int! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_fc20cd27_6($__fusion_7_id: Int!) { + productById(id: $__fusion_7_id) { + price + } + } + variables: | + [ + { + "__fusion_7_id": 1 + }, + { + "__fusion_7_id": 2 + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "productById": { + "price": 10 + } + } + } + - | + { + "data": { + "productById": { + "price": 20 + } + } + } + - name: promotions + schema: | + schema { + query: Query + } + + type Query { + productById(id: Int!): Product @lookup @internal + } + + type Product @key(fields: "id") { + discountedPrice(price: Float! @require(field: "price")): Float! + id: Int! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query Op_fc20cd27_5($__fusion_5_price: Float!, $__fusion_6_id: Int!) { + productById(id: $__fusion_6_id) { + discountedPrice(price: $__fusion_5_price) + } + } + variables: | + [ + { + "__fusion_5_price": 10, + "__fusion_6_id": 1 + }, + { + "__fusion_5_price": 20, + "__fusion_6_id": 2 + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "productById": { + "discountedPrice": 5 + } + } + } + - | + { + "data": { + "productById": { + "discountedPrice": 10 + } + } + } +operationPlan: + operation: + - document: | + { + cart { + subtotal + id @fusion__requirement + items(first: 50) @fusion__requirement { + nodes @fusion__requirement { + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + price @fusion__requirement + } + } + } + } + } + hash: fc20cd275b755b8ef7eedc1b7833b882 + searchSpace: 2 + expandedNodes: 13 + nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_fc20cd27_2($__fusion_1_lines: [Float!], $__fusion_2_id: Int!) { + cartById(id: $__fusion_2_id) { + subtotal(lines: $__fusion_1_lines) + } + } + source: $.cartById + target: $.cart + requirements: + - name: __fusion_1_lines + selectionMap: >- + items(first: 50).nodes[product.discountedPrice] + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 5 + - id: 9 + - id: 3 + type: Operation + schema: cart + operation: | + query Op_fc20cd27_3($__fusion_3_id: Int!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + source: $.cartById + target: $.cart + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 9 + - id: 5 + type: Operation + schema: promotions + operation: | + query Op_fc20cd27_5($__fusion_5_price: Float!, $__fusion_6_id: Int!) { + productById(id: $__fusion_6_id) { + discountedPrice(price: $__fusion_5_price) + } + } + source: $.productById + target: $.cart.items.nodes.product + requirements: + - name: __fusion_5_price + selectionMap: >- + price + - name: __fusion_6_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 6 + - id: 6 + type: Operation + schema: products + operation: | + query Op_fc20cd27_6($__fusion_7_id: Int!) { + productById(id: $__fusion_7_id) { + price + } + } + source: $.productById + target: $.cart.items.nodes.product + requirements: + - name: __fusion_7_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 7 + type: OperationBatch + schema: cart + operation: | + query Op_fc20cd27_7 { + cart { + id + } + } + targets: + - $ + - $ + batchingGroupId: 9 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_Nested_Requirement_When_Two_Fields_Require_It_Next_To_Required_Parent.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_Nested_Requirement_When_Two_Fields_Require_It_Next_To_Required_Parent.yaml new file mode 100644 index 00000000000..32d069979c3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_Fulfill_Nested_Requirement_When_Two_Fields_Require_It_Next_To_Required_Parent.yaml @@ -0,0 +1,251 @@ +title: Require_Should_Fulfill_Nested_Requirement_When_Two_Fields_Require_It_Next_To_Required_Parent +request: + document: | + { + item { + unitPrice + lineTotal + product { + id + } + } + } +response: + body: | + { + "data": { + "item": { + "unitPrice": 5, + "lineTotal": 15, + "product": { + "id": 1 + } + } + } + } +sourceSchemas: + - name: cart + schema: | + schema { + query: Query + } + + type Query { + item: CartItem! + cartItemById(id: Int!): CartItem @lookup @internal + } + + type CartItem @key(fields: "id") { + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + lineTotal(price: Float! @require(field: "product.discountedPrice")): Float! + id: Int! + quantity: Int! + product: Product! + } + + type Product { + id: Int! @shareable + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_d59199f1_2( + $__fusion_1_price: Float! + $__fusion_2_id: Int! + $__fusion_4_price: Float! + ) { + cartItemById(id: $__fusion_2_id) { + lineTotal(price: $__fusion_1_price) + unitPrice(price: $__fusion_4_price) + } + } + variables: | + { + "__fusion_1_price": 5, + "__fusion_2_id": 1, + "__fusion_4_price": 5 + } + response: + results: + - | + { + "data": { + "cartItemById": { + "lineTotal": 15, + "unitPrice": 5 + } + } + } + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + kind: OperationBatch + items: + - document: | + query Op_d59199f1_1 { + item { + product { + id + } + } + } + - document: | + query Op_d59199f1_5 { + item { + id + } + } + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "item": { + "product": { + "id": 1 + } + } + } + } + - | + { + "data": { + "item": { + "id": 1 + } + } + } + - name: promotions + schema: | + schema { + query: Query + } + + type Query { + productById(id: Int!): Product @lookup @internal + } + + type Product @key(fields: "id") { + discountedPrice: Float! + id: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_d59199f1_3($__fusion_3_id: Int!) { + productById(id: $__fusion_3_id) { + discountedPrice + } + } + variables: | + { + "__fusion_3_id": 1 + } + response: + results: + - | + { + "data": { + "productById": { + "discountedPrice": 5 + } + } + } +operationPlan: + operation: + - document: | + { + item { + unitPrice + lineTotal + product { + id + } + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + } + id @fusion__requirement + } + } + hash: d59199f16408e91778f6578aefb97905 + searchSpace: 3 + expandedNodes: 14 + nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_d59199f1_2( + $__fusion_1_price: Float! + $__fusion_2_id: Int! + $__fusion_4_price: Float! + ) { + cartItemById(id: $__fusion_2_id) { + lineTotal(price: $__fusion_1_price) + unitPrice(price: $__fusion_4_price) + } + } + source: $.cartItemById + target: $.item + requirements: + - name: __fusion_1_price + selectionMap: >- + product.discountedPrice + - name: __fusion_2_id + selectionMap: >- + id + - name: __fusion_4_price + selectionMap: >- + product.discountedPrice + dependencies: + - id: 8 + - id: 9 + - id: 1 + type: OperationBatch + schema: cart + operation: | + query Op_d59199f1_1 { + item { + product { + id + } + } + } + targets: + - $ + - $ + - $ + batchingGroupId: 8 + - id: 5 + type: Operation + schema: cart + operation: | + query Op_d59199f1_5 { + item { + id + } + } + batchingGroupId: 8 + - id: 3 + type: OperationBatch + schema: promotions + operation: | + query Op_d59199f1_3($__fusion_3_id: Int!) { + productById(id: $__fusion_3_id) { + discountedPrice + } + } + source: $.productById + targets: + - $.item.product + - $.item.product + batchingGroupId: 9 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml new file mode 100644 index 00000000000..0d0e343e829 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithPromoCode.yaml @@ -0,0 +1,242 @@ +title: Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable +request: + document: | + { + cartById(id: "1") { + discountPercent + } + } +response: + body: | + { + "data": { + "cartById": { + "discountPercent": 10 + } + } + } +sourceSchemas: + - name: cart + schema: | + schema { + query: Query + } + + type Query { + cartById(id: ID!): Cart @lookup + } + + type Cart @key(fields: "id") { + discountPercent( + promo: PromoInput @require(field: "promoCode.{\n discountPercent\n isExpired\n}") + ): Int! + id: ID! + } + + input PromoInput { + discountPercent: Int! + isExpired: Boolean! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_640ade6c_1 { + cartById(id: "1") { + id + } + } + response: + results: + - | + { + "data": { + "cartById": { + "id": "1" + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_640ade6c_2($__fusion_1_promo: PromoInput, $__fusion_2_id: ID!) { + cartById(id: $__fusion_2_id) { + discountPercent(promo: $__fusion_1_promo) + } + } + variables: | + { + "__fusion_1_promo": { + "discountPercent": 10, + "isExpired": false + }, + "__fusion_2_id": "1" + } + response: + results: + - | + { + "data": { + "cartById": { + "discountPercent": 10 + } + } + } + - name: promotions + schema: | + schema { + query: Query + } + + type Query { + cartById(id: ID!): Cart @lookup @internal + } + + type Cart @key(fields: "id") { + id: ID! + promoCode: PromoCode + } + + type PromoCode { + code: String! + discountPercent: Int! + isExpired: Boolean! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + kind: OperationBatch + items: + - document: | + query Op_640ade6c_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + promoCode { + discountPercent + isExpired + } + } + } + variables: | + { + "__fusion_3_id": "1" + } + - document: | + query Op_640ade6c_4($__fusion_4_id: ID!) { + cartById(id: $__fusion_4_id) { + id + } + } + variables: | + { + "__fusion_4_id": "1" + } + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "cartById": { + "promoCode": { + "discountPercent": 10, + "isExpired": false + } + } + } + } + - | + { + "data": { + "cartById": { + "id": "1" + } + } + } +operationPlan: + operation: + - document: | + { + cartById(id: "1") { + discountPercent + id @fusion__requirement + promoCode @fusion__requirement { + discountPercent @fusion__requirement + isExpired @fusion__requirement + } + } + } + hash: 640ade6c01c355dbd76d8d5ea9fdade4 + searchSpace: 2 + expandedNodes: 6 + nodes: + - id: 1 + type: Operation + schema: cart + operation: | + query Op_640ade6c_1 { + cartById(id: "1") { + id + } + } + - id: 2 + type: Operation + schema: cart + operation: | + query Op_640ade6c_2($__fusion_1_promo: PromoInput, $__fusion_2_id: ID!) { + cartById(id: $__fusion_2_id) { + discountPercent(promo: $__fusion_1_promo) + } + } + source: $.cartById + target: $.cartById + requirements: + - name: __fusion_1_promo + selectionMap: >- + promoCode.{ + discountPercent + isExpired + } + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 5 + - id: 3 + type: Operation + schema: promotions + operation: | + query Op_640ade6c_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + promoCode { + discountPercent + isExpired + } + } + } + source: $.cartById + target: $.cartById + batchingGroupId: 5 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 4 + type: Operation + schema: promotions + operation: | + query Op_640ade6c_4($__fusion_4_id: ID!) { + cartById(id: $__fusion_4_id) { + id + } + } + source: $.cartById + target: $.cartById + batchingGroupId: 5 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml new file mode 100644 index 00000000000..d33740e941e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable_WithoutPromoCode.yaml @@ -0,0 +1,236 @@ +title: Require_Should_InvokeResolverWithProjectedValue_When_ProjectionTargetIsNullable +request: + document: | + { + cartById(id: "2") { + discountPercent + } + } +response: + body: | + { + "data": { + "cartById": { + "discountPercent": 0 + } + } + } +sourceSchemas: + - name: cart + schema: | + schema { + query: Query + } + + type Query { + cartById(id: ID!): Cart @lookup + } + + type Cart @key(fields: "id") { + discountPercent( + promo: PromoInput @require(field: "promoCode.{\n discountPercent\n isExpired\n}") + ): Int! + id: ID! + } + + input PromoInput { + discountPercent: Int! + isExpired: Boolean! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_5ebdd92d_1 { + cartById(id: "2") { + id + } + } + response: + results: + - | + { + "data": { + "cartById": { + "id": "2" + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_5ebdd92d_2($__fusion_1_promo: PromoInput, $__fusion_2_id: ID!) { + cartById(id: $__fusion_2_id) { + discountPercent(promo: $__fusion_1_promo) + } + } + variables: | + { + "__fusion_1_promo": null, + "__fusion_2_id": "2" + } + response: + results: + - | + { + "data": { + "cartById": { + "discountPercent": 0 + } + } + } + - name: promotions + schema: | + schema { + query: Query + } + + type Query { + cartById(id: ID!): Cart @lookup @internal + } + + type Cart @key(fields: "id") { + id: ID! + promoCode: PromoCode + } + + type PromoCode { + code: String! + discountPercent: Int! + isExpired: Boolean! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + kind: OperationBatch + items: + - document: | + query Op_5ebdd92d_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + promoCode { + discountPercent + isExpired + } + } + } + variables: | + { + "__fusion_3_id": "2" + } + - document: | + query Op_5ebdd92d_4($__fusion_4_id: ID!) { + cartById(id: $__fusion_4_id) { + id + } + } + variables: | + { + "__fusion_4_id": "2" + } + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "cartById": { + "promoCode": null + } + } + } + - | + { + "data": { + "cartById": { + "id": "2" + } + } + } +operationPlan: + operation: + - document: | + { + cartById(id: "2") { + discountPercent + id @fusion__requirement + promoCode @fusion__requirement { + discountPercent @fusion__requirement + isExpired @fusion__requirement + } + } + } + hash: 5ebdd92d18786ad6e2d34b14ed75b875 + searchSpace: 2 + expandedNodes: 6 + nodes: + - id: 1 + type: Operation + schema: cart + operation: | + query Op_5ebdd92d_1 { + cartById(id: "2") { + id + } + } + - id: 2 + type: Operation + schema: cart + operation: | + query Op_5ebdd92d_2($__fusion_1_promo: PromoInput, $__fusion_2_id: ID!) { + cartById(id: $__fusion_2_id) { + discountPercent(promo: $__fusion_1_promo) + } + } + source: $.cartById + target: $.cartById + requirements: + - name: __fusion_1_promo + selectionMap: >- + promoCode.{ + discountPercent + isExpired + } + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 5 + - id: 3 + type: Operation + schema: promotions + operation: | + query Op_5ebdd92d_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + promoCode { + discountPercent + isExpired + } + } + } + source: $.cartById + target: $.cartById + batchingGroupId: 5 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 + - id: 4 + type: Operation + schema: promotions + operation: | + query Op_5ebdd92d_4($__fusion_4_id: ID!) { + cartById(id: $__fusion_4_id) { + id + } + } + source: $.cartById + target: $.cartById + batchingGroupId: 5 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs index 0992baa6bf2..32bdfde0e24 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs @@ -209,6 +209,105 @@ type Product { Assert.True(facts.IsFieldAccessible(product, sku, "A")); } + [Fact] + public void FieldResolvable_Should_BeFalse_When_RequiringSchemaHasNoLookupForType() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + cart: Cart + productById(id: ID!): Product @lookup @internal + } + + type Cart { + items: [CartItem!] + } + + type CartItem { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product { + id: ID! @shareable + } + """, + """ + # Schema B + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product { + id: ID! @shareable + discountedPrice: Float! + } + """ + ]); + var cartItem = GetObjectType(schema, "CartItem"); + var unitPrice = cartItem.Fields["unitPrice"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.False(facts.IsFieldResolvableOn(cartItem, unitPrice, "A")); + } + + [Fact] + public void FieldResolvable_Should_BeTrue_When_RequiringSchemaHasLookupForType() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + cart: Cart + cartItemById(id: ID!): CartItem @lookup @internal + productById(id: ID!): Product @lookup @internal + } + + type Cart { + items: [CartItem!] + } + + type CartItem { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product { + id: ID! @shareable + } + """, + """ + # Schema B + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product { + id: ID! @shareable + discountedPrice: Float! + } + """ + ]); + var cartItem = GetObjectType(schema, "CartItem"); + var unitPrice = cartItem.Fields["unitPrice"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.True(facts.IsFieldResolvableOn(cartItem, unitPrice, "A")); + } + private static SatisfiabilityFacts BuildFacts(MutableSchemaDefinition schema) => new SatisfiabilityFactsBuilder(schema, new FusionLookupDirectiveCache(schema)).Build(); diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs index 3deec28dedb..96ab3f1d6f2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs @@ -4148,6 +4148,248 @@ type Viewer { Assert.False(result.IsFailure); } + [Fact] + public void Validate_Should_Fail_When_RequiringSchemaHasNoLookupToResolveFieldAfterRequirement() + { + // arrange + // the Product key comes from A itself, so A must be re-entered at CartItem once B has + // delivered discountedPrice, but A declares no lookup for CartItem. + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + cart: Cart + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Cart { + id: ID! + items: [CartItem!] + } + + type CartItem { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice: Float! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var options = new SatisfiabilityOptions { IncludeSatisfiabilityPaths = true }; + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log, options); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsFailure); + Assert.All(log, e => Assert.Equal(LogEntryCodes.UnsatisfiableQueryPath, e.Code)); + string.Join("\n\n", log.Select(e => e.Message)).MatchInlineSnapshot( + """ + Unable to access the field 'CartItem.unitPrice' on path 'A:Query.cart -> A:Cart.items'. + Unable to satisfy the requirement '{ product { discountedPrice } }' on field 'A:CartItem.unitPrice'. + The field 'CartItem.unitPrice' in schema 'A' requires data from another schema, but schema 'A' has no lookup for type 'CartItem' that could resolve the field once the required data has been fetched. + """); + } + + [Fact] + public void Validate_Should_Fail_When_RequiringSchemaLookupKeyIsUnsatisfiable() + { + // arrange + // A declares a CartItem lookup, but its key 'sku' is only known to C, which A cannot reach. + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + cart: Cart + cartItemBySku(sku: String!): CartItem @lookup @internal + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Cart { + id: ID! + items: [CartItem!] + } + + type CartItem { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice: Float! + } + """, + """ + # Schema C + type CartItem { + sku: String! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var options = new SatisfiabilityOptions { IncludeSatisfiabilityPaths = true }; + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log, options); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsFailure); + Assert.All(log, e => Assert.Equal(LogEntryCodes.UnsatisfiableQueryPath, e.Code)); + string.Join("\n\n", log.Select(e => e.Message)).MatchInlineSnapshot( + """ + Unable to access the field 'CartItem.unitPrice' on path 'A:Query.cart -> A:Cart.items'. + Unable to satisfy the requirement '{ product { discountedPrice } }' on field 'A:CartItem.unitPrice'. + The field 'CartItem.unitPrice' in schema 'A' requires data from another schema, but schema 'A' has no lookup for type 'CartItem' that could resolve the field once the required data has been fetched. + Unable to satisfy the requirement '{ sku }' for lookup 'cartItemBySku' in schema 'A'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'CartItem.sku' on path 'A:Cart.items'. + Unable to transition between schemas 'A' and 'C' for access to required field 'C:CartItem.sku'. + No lookups found for type 'CartItem' in schema 'C'. + + Unable to access the field 'CartItem.sku' on path 'A:Query.cart -> A:Cart.items'. + Unable to transition between schemas 'A' and 'C' for access to field 'C:CartItem.sku'. + No lookups found for type 'CartItem' in schema 'C'. + """); + } + + [Fact] + public void Validate_Should_Succeed_When_RequiringSchemaHasLookupToResolveFieldAfterRequirement() + { + // arrange + // A declares a CartItem lookup, so the gateway can re-enter A with the fetched discountedPrice. + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + cart: Cart + cartItemById(id: ID! @is(field: "id")): CartItem @lookup @internal + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Cart { + id: ID! + items: [CartItem!] + } + + type CartItem { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # Schema B + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice: Float! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + + [Fact] + public void Validate_Should_Succeed_When_RequirementIsResolvableWithoutRequiringSchema() + { + // arrange + // A delivers firstName through the shared root path, so B resolves fullName in its first + // call and needs no lookup for Viewer. + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions( + [ + """ + # Schema A + type Query { + viewers: [Viewer] @shareable + } + + type Viewer { + firstName: String! + } + """, + """ + # Schema B + type Query { + viewers: [Viewer] @shareable + } + + type Viewer { + fullName(firstName: String! @require(field: "firstName")): String! + } + """ + ]), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + } + [Fact] public void Type_Without_Lookup_Not_All_Schemas_Share_Path_Up_To_Root() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs index c212c5b9d7e..12b9c12073d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMergerTests.cs @@ -268,6 +268,125 @@ scalar Position Assert.True(result.Value.Types.ContainsName("Position")); } + [Fact] + public void Merge_Should_RetainListWrappedInputTypes_When_UsedByRequireAndLookupArguments() + { + // arrange + // The @require argument and the internal lookup are removed from the client-facing + // schema, so their list-wrapped input types are only referenced inside directive strings. + var schemas = CreateSchemaDefinitions( + [ + """ + type Query { + products: [Product] + productById(id: ID! @is(field: "id")): Product @lookup @internal + productByVariantPrices( + prices: [VariantPriceInput!]! @is(field: "variants[{ price }]") + ): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + variants: [Variant] + } + + type Variant { + price: Float! + } + + input VariantPriceInput { + price: Float! + } + """, + """ + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice(prices: [PriceInput!]! @require(field: "variants[{ price: price }]")): Float! + } + + input PriceInput { + price: Float! + } + """ + ]); + var options = new SourceSchemaMergerOptions + { + AddFusionDefinitions = false + }; + var merger = new SourceSchemaMerger(schemas, options); + + // act + var result = merger.Merge(); + + // assert + Assert.True(result.IsSuccess); + SchemaFormatter.FormatAsString(result.Value).MatchInlineSnapshot( + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + products: [Product] @fusion__field(schema: A) + } + + type Product + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__lookup( + schema: A + key: "id" + field: "productById(id: ID!): Product" + map: ["id"] + path: null + internal: true + ) + @fusion__lookup( + schema: A + key: "variants { price }" + field: "productByVariantPrices(prices: [VariantPriceInput!]!): Product" + map: ["variants[{ price }]"] + path: null + internal: true + ) + @fusion__lookup( + schema: B + key: "id" + field: "productById(id: ID!): Product" + map: ["id"] + path: null + internal: true + ) { + discountedPrice: Float! + @fusion__field(schema: B) + @fusion__requires( + schema: B + requirements: "variants { price }" + field: "discountedPrice(prices: [PriceInput!]!): Float!" + map: ["variants[{ price: price }]"] + ) + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + variants: [Variant] @fusion__field(schema: A) + } + + type Variant @fusion__type(schema: A) { + price: Float! @fusion__field(schema: A) + } + + input PriceInput @fusion__type(schema: B) { + price: Float! @fusion__inputField(schema: B) + } + + input VariantPriceInput @fusion__type(schema: A) { + price: Float! @fusion__inputField(schema: A) + } + """); + } + [Fact] public void Merge_DirectiveDefinitionWithDifferentArgumentOrder_MergesSuccessfully() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementArgumentTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementArgumentTests.cs index 7eddaf967e0..6736d7984a6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementArgumentTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementArgumentTests.cs @@ -259,6 +259,27 @@ public void Simple_Requires_Arguments() MatchSnapshot(plan); } + [Fact] + public void Plan_Should_Succeed_When_Require_Only_Input_Type_And_Upload_Scalar_Exist() + { + // arrange + var schema = CreateRequireOnlyInputTypeWithUploadSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + products { + discountedPrice + } + } + """); + + // assert + MatchSnapshot(plan); + } + private static FusionSchemaDefinition CreateSimpleRequiresArgsSchema() { return ComposeSchema( @@ -518,4 +539,57 @@ type Category @key(fields: "id") { } """); } + + private static FusionSchemaDefinition CreateRequireOnlyInputTypeWithUploadSchema() + { + return ComposeSchema( + """ + # name: a + schema { + query: Query + mutation: Mutation + } + + type Query { + products: [Product] + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Mutation { + uploadProductPicture(file: Upload!): Boolean + } + + type Product @key(fields: "id") { + id: ID! + variants: [Variant] + } + + type Variant { + price: Float! + } + + scalar Upload + """, + """ + # name: b + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice( + prices: [PriceInput!]! + @require(field: "variants[{ price: price }]")): Float! + } + + input PriceInput { + price: Float! + } + """); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementCrossEntityTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementCrossEntityTests.cs index 1a38ecdc09d..519d9ffd5ac 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementCrossEntityTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/RequirementCrossEntityTests.cs @@ -157,6 +157,144 @@ public void Plan_Should_Keep_Requirement_And_Client_List_Field_Separate_When_Arg MatchSnapshot(plan); } + [Fact] + public void Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Nested_Require() + { + // arrange + // cart owns items and subtotal; subtotal requires items(first: 50).nodes[product.discountedPrice]. + // discountedPrice is owned by promotions and itself requires price, which is owned by products. + // The list element is not an entity, so the leaf is reached through the product lookup + // spawned from the cart lookup that fetches the list; subtotal must depend on that lookup. + var schema = CreateCartSchema( + """ + items(first: Int, after: String): CartItemsConnection + subtotal(lines: [Float!] @require(field: "items(first: 50).nodes[product.discountedPrice]")): Float! + """); + + // act + var plan = PlanOperation( + schema, + """ + { + viewer { + cart { + subtotal + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Input_Object_List_Form() + { + // arrange + // same topology, the requirement uses the input object list form. + var schema = CreateCartSchema( + """ + items(first: Int, after: String): CartItemsConnection + subtotal(lines: [CartLineInput] @require(field: "items(first: 50).nodes[{ unitPrice: product.discountedPrice }]")): Float! + """, + cartTypes: + """ + input CartLineInput { + unitPrice: Float! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + { + viewer { + cart { + subtotal + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Resolve_Subtotal_When_Connection_Leaf_Has_No_Nested_Require() + { + // arrange + // control: the leaf (price) is owned by products and has no requirement of its own. + var schema = CreateCartSchema( + """ + items(first: Int, after: String): CartItemsConnection + subtotal(lines: [Float!] @require(field: "items(first: 50).nodes[product.price]")): Float! + """); + + // act + var plan = PlanOperation( + schema, + """ + { + viewer { + cart { + subtotal + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection() + { + // arrange + // subtotal is owned by checkout while the items connection stays on cart, so the + // requirement is inlined into the cart step and only the nested product leaf is + // resolved by lookups spawned from that inlining. + var schema = CreateCartSchema( + """ + items(first: Int, after: String): CartItemsConnection + """, + additionalSchemas: + """ + # name: checkout + schema { + query: Query + } + + type Query { + cartById(id: ID! @is(field: "id")): Cart @lookup @internal + } + + type Cart @key(fields: "id") { + id: ID! + subtotal(lines: [Float!] @require(field: "items(first: 50).nodes[product.discountedPrice]")): Float! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + { + viewer { + cart { + subtotal + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + [Fact] public void Plan_Should_Resolve_ByNovice_When_Require_Is_Single_Provider() { @@ -203,6 +341,112 @@ public void Plan_Should_Resolve_NeedsFlag_When_Require_Is_Same_Entity_Direct_Fie MatchSnapshot(plan); } + [Fact] + public void Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent() + { + // arrange + // unitPrice requires product.discountedPrice, which is owned by promotions, so the + // requirement crosses the entity boundary of product. Selecting unitPrice under two + // response names next to a client selection of product must plan like a single one. + var schema = CreateNestedRequireSelectedTwiceSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + item { + unitPrice + u2: unitPrice + product { + id + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent_Under_Interface() + { + // arrange + // same topology, but item is exposed as the Item interface so the requiring field + // sits under an inline fragment and requirement inlining takes the ancestor path. + var schema = CreateNestedRequireSelectedTwiceUnderInterfaceSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + item { + ... on CartItem { + unitPrice + u2: unitPrice + product { + id + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice() + { + // arrange + // same topology without the client selection of product. Both response names of + // unitPrice must get their price from the promotions step. + var schema = CreateNestedRequireSelectedTwiceSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + item { + unitPrice + u2: unitPrice + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Plan_Should_Reenter_Requiring_Schema_When_Requiring_Type_Has_Lookup() + { + // arrange + // the Product key comes from cart itself, so once promotions has delivered discountedPrice + // the planner re-enters cart at CartItem through cartItemById to pass the value. + var schema = CreateCartPricingSchema(); + + // act + var plan = PlanOperation( + schema, + """ + { + cart { + items { + unitPrice + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + private static FusionSchemaDefinition CreateCircularCrossProviderSchema() { return ComposeSchema( @@ -496,4 +740,218 @@ type Post @key(fields: "id") { } """); } + + private static FusionSchemaDefinition CreateCartSchema( + string cartFields, + string cartTypes = "", + params string[] additionalSchemas) + { + var cartSchema = + $$""" + # name: cart + schema { + query: Query + } + + type Query { + viewer: Viewer + cartById(id: ID! @is(field: "id")): Cart @lookup @internal + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Viewer { + cart: Cart + } + + type Cart @key(fields: "id") { + id: ID! + {{cartFields}} + } + + type CartItemsConnection { + nodes: [CartItem!] + } + + type CartItem { + id: ID! + quantity: Int! + product: Product! + } + + type Product @key(fields: "id") { + id: ID! + } + + {{cartTypes}} + """; + + const string productsSchema = + """ + # name: products + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + price: Float! + } + """; + + const string promotionsSchema = + """ + # name: promotions + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice(price: Float! @require(field: "price")): Float! + } + """; + + return ComposeSchema([cartSchema, productsSchema, promotionsSchema, .. additionalSchemas]); + } + + private static FusionSchemaDefinition CreateNestedRequireSelectedTwiceSchema() + { + return ComposeSchema( + """ + # name: cart + schema { + query: Query + } + + type Query { + item: CartItem! + cartItemById(id: ID! @is(field: "id")): CartItem @lookup @internal + } + + type CartItem @key(fields: "id") { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # name: promotions + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice: Float! + } + """); + } + + private static FusionSchemaDefinition CreateCartPricingSchema() + { + return ComposeSchema( + """ + # name: cart + schema { + query: Query + } + + type Query { + cart: Cart + cartItemById(id: ID! @is(field: "id")): CartItem @lookup @internal + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Cart { + id: ID! + items: [CartItem!] + } + + type CartItem { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # name: promotions + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice: Float! + } + """); + } + + private static FusionSchemaDefinition CreateNestedRequireSelectedTwiceUnderInterfaceSchema() + { + return ComposeSchema( + """ + # name: cart + schema { + query: Query + } + + type Query { + item: Item! + cartItemById(id: ID! @is(field: "id")): CartItem @lookup @internal + } + + interface Item { + id: ID! + } + + type CartItem implements Item @key(fields: "id") { + id: ID! + product: Product! + unitPrice(price: Float! @require(field: "product.discountedPrice")): Float! + } + + type Product @key(fields: "id") { + id: ID! + } + """, + """ + # name: promotions + schema { + query: Query + } + + type Query { + productById(id: ID! @is(field: "id")): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + discountedPrice: Float! + } + """); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml index 18aaa9950e4..e4d0532f83a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Plain_Field_And_Requires_With_Args_That_Conflicts.yaml @@ -12,7 +12,7 @@ operation: } hash: 123456789101112 searchSpace: 2 - expandedNodes: 9 + expandedNodes: 8 nodes: - id: 1 type: Operation @@ -52,9 +52,9 @@ nodes: selectionMap: >- otherField dependencies: - - id: 7 + - id: 6 - id: 3 - type: OperationBatch + type: Operation schema: b operation: | query Op_123456789101112_3($__fusion_3_id: ID!) { @@ -63,10 +63,8 @@ nodes: } } source: $.testById - targets: - - $.test - - $.test - batchingGroupId: 7 + target: $.test + batchingGroupId: 6 requirements: - name: __fusion_3_id selectionMap: >- @@ -84,27 +82,27 @@ nodes: } source: $.testById target: $.test - batchingGroupId: 7 + batchingGroupId: 6 requirements: - name: __fusion_4_id selectionMap: >- id dependencies: - id: 1 - - id: 6 + - id: 5 type: Operation schema: b operation: | - query Op_123456789101112_6($__fusion_7_id: ID!) { - testById(id: $__fusion_7_id) { + query Op_123456789101112_5($__fusion_6_id: ID!) { + testById(id: $__fusion_6_id) { otherField(arg: 1) } } source: $.testById target: $.test - batchingGroupId: 7 + batchingGroupId: 6 requirements: - - name: __fusion_7_id + - name: __fusion_6_id selectionMap: >- id dependencies: diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml index 5995aff6c7c..1d73393be00 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Multiple_Requires_With_Args_That_Conflicts.yaml @@ -11,7 +11,7 @@ operation: } hash: 123456789101112 searchSpace: 2 - expandedNodes: 8 + expandedNodes: 7 nodes: - id: 1 type: Operation @@ -49,9 +49,9 @@ nodes: selectionMap: >- otherField dependencies: - - id: 6 + - id: 5 - id: 3 - type: OperationBatch + type: Operation schema: b operation: | query Op_123456789101112_3($__fusion_3_id: ID!) { @@ -60,10 +60,8 @@ nodes: } } source: $.testById - targets: - - $.test - - $.test - batchingGroupId: 6 + target: $.test + batchingGroupId: 5 requirements: - name: __fusion_3_id selectionMap: >- @@ -81,7 +79,7 @@ nodes: } source: $.testById target: $.test - batchingGroupId: 6 + batchingGroupId: 5 requirements: - name: __fusion_4_id selectionMap: >- diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Plan_Should_Succeed_When_Require_Only_Input_Type_And_Upload_Scalar_Exist.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Plan_Should_Succeed_When_Require_Only_Input_Type_And_Upload_Scalar_Exist.yaml new file mode 100644 index 00000000000..831204f2922 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Plan_Should_Succeed_When_Require_Only_Input_Type_And_Upload_Scalar_Exist.yaml @@ -0,0 +1,52 @@ +operation: + - document: | + { + products { + discountedPrice + id @fusion__requirement + variants @fusion__requirement { + price @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 2 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + products { + id + variants { + price + } + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2( + $__fusion_1_id: ID! + $__fusion_2_prices: [PriceInput!]! + ) { + productById(id: $__fusion_1_id) { + discountedPrice(prices: $__fusion_2_prices) + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_prices + selectionMap: >- + variants[{ + price: price + }] + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Requires_With_Arguments.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Requires_With_Arguments.yaml index 4d2510925ad..3f333493ff2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Requires_With_Arguments.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementArgumentTests.Requires_With_Arguments.yaml @@ -40,6 +40,7 @@ nodes: selectionMap: >- comments[somethingElse] dependencies: + - id: 3 - id: 5 - id: 3 type: Operation diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Reenter_Requiring_Schema_When_Requiring_Type_Has_Lookup.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Reenter_Requiring_Schema_When_Requiring_Type_Has_Lookup.yaml new file mode 100644 index 00000000000..5c0ea4bbd6b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Reenter_Requiring_Schema_When_Requiring_Type_Has_Lookup.yaml @@ -0,0 +1,82 @@ +operation: + - document: | + { + cart { + items { + unitPrice + id @fusion__requirement + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + } + } + } + } + hash: 123456789101112 + searchSpace: 4 + expandedNodes: 10 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2($__fusion_1_price: Float!, $__fusion_2_id: ID!) { + cartItemById(id: $__fusion_2_id) { + unitPrice(price: $__fusion_1_price) + } + } + source: $.cartItemById + target: $.cart.items + requirements: + - name: __fusion_1_price + selectionMap: >- + product.discountedPrice + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 6 + - id: 3 + type: Operation + schema: promotions + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + discountedPrice + } + } + source: $.productById + target: $.cart.items.product + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 6 + - id: 4 + type: Operation + schema: cart + operation: | + query Op_123456789101112_4 { + cart { + items { + product { + id + } + } + } + } + batchingGroupId: 6 + - id: 5 + type: Operation + schema: cart + operation: | + query Op_123456789101112_5 { + cart { + items { + id + } + } + } + batchingGroupId: 6 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice.yaml new file mode 100644 index 00000000000..379ab45d6fb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice.yaml @@ -0,0 +1,91 @@ +operation: + - document: | + { + item { + unitPrice + u2: unitPrice + id @fusion__requirement + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 3 + expandedNodes: 14 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2( + $__fusion_1_price: Float! + $__fusion_2_id: ID! + $__fusion_4_price: Float! + ) { + cartItemById(id: $__fusion_2_id) { + u2: unitPrice(price: $__fusion_1_price) + unitPrice(price: $__fusion_4_price) + } + } + source: $.cartItemById + target: $.item + requirements: + - name: __fusion_1_price + selectionMap: >- + product.discountedPrice + - name: __fusion_2_id + selectionMap: >- + id + - name: __fusion_4_price + selectionMap: >- + product.discountedPrice + dependencies: + - id: 8 + - id: 9 + - id: 4 + type: OperationBatch + schema: cart + operation: | + query Op_123456789101112_4 { + item { + product { + id + } + } + } + targets: + - $ + - $ + batchingGroupId: 8 + - id: 5 + type: Operation + schema: cart + operation: | + query Op_123456789101112_5 { + item { + id + } + } + batchingGroupId: 8 + - id: 3 + type: OperationBatch + schema: promotions + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + discountedPrice + } + } + source: $.productById + targets: + - $.item.product + - $.item.product + batchingGroupId: 9 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 4 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent.yaml new file mode 100644 index 00000000000..e676e674d68 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent.yaml @@ -0,0 +1,95 @@ +operation: + - document: | + { + item { + unitPrice + u2: unitPrice + product { + id + } + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + } + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 3 + expandedNodes: 14 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2( + $__fusion_1_price: Float! + $__fusion_2_id: ID! + $__fusion_4_price: Float! + ) { + cartItemById(id: $__fusion_2_id) { + u2: unitPrice(price: $__fusion_1_price) + unitPrice(price: $__fusion_4_price) + } + } + source: $.cartItemById + target: $.item + requirements: + - name: __fusion_1_price + selectionMap: >- + product.discountedPrice + - name: __fusion_2_id + selectionMap: >- + id + - name: __fusion_4_price + selectionMap: >- + product.discountedPrice + dependencies: + - id: 8 + - id: 9 + - id: 1 + type: OperationBatch + schema: cart + operation: | + query Op_123456789101112_1 { + item { + product { + id + } + } + } + targets: + - $ + - $ + - $ + batchingGroupId: 8 + - id: 5 + type: Operation + schema: cart + operation: | + query Op_123456789101112_5 { + item { + id + } + } + batchingGroupId: 8 + - id: 3 + type: OperationBatch + schema: promotions + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + discountedPrice + } + } + source: $.productById + targets: + - $.item.product + - $.item.product + batchingGroupId: 9 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent_Under_Interface.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent_Under_Interface.yaml new file mode 100644 index 00000000000..5c784241af8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Nested_Require_When_Requiring_Field_Is_Selected_Twice_Next_To_Required_Parent_Under_Interface.yaml @@ -0,0 +1,110 @@ +operation: + - document: | + { + item { + __typename @fusion__requirement + ... on CartItem { + unitPrice + u2: unitPrice + product { + id + } + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + } + id @fusion__requirement + } + } + } + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 10 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2( + $__fusion_1_price: Float! + $__fusion_2_id: ID! + $__fusion_4_price: Float! + ) { + cartItemById(id: $__fusion_2_id) { + u2: unitPrice(price: $__fusion_1_price) + unitPrice(price: $__fusion_4_price) + } + } + source: $.cartItemById + target: $.item + requirements: + - name: __fusion_1_price + selectionMap: >- + product.discountedPrice + - name: __fusion_2_id + selectionMap: >- + id + - name: __fusion_4_price + selectionMap: >- + product.discountedPrice + dependencies: + - id: 6 + - id: 7 + - id: 1 + type: Operation + schema: cart + operation: | + query Op_123456789101112_1 { + item { + __typename + ... on CartItem { + product { + id + } + } + ... on CartItem { + product { + id + } + } + ... on CartItem { + product { + id + } + } + } + } + batchingGroupId: 6 + - id: 4 + type: Operation + schema: cart + operation: | + query Op_123456789101112_4 { + item { + __typename + ... on CartItem { + id + } + } + } + batchingGroupId: 6 + - id: 3 + type: OperationBatch + schema: promotions + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + discountedPrice + } + } + source: $.productById + targets: + - $.item.product + - $.item.product + batchingGroupId: 7 + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Connection_Leaf_Has_No_Nested_Require.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Connection_Leaf_Has_No_Nested_Require.yaml new file mode 100644 index 00000000000..8d0e4fa165d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Connection_Leaf_Has_No_Nested_Require.yaml @@ -0,0 +1,99 @@ +operation: + - document: | + { + viewer { + cart { + subtotal + id @fusion__requirement + items(first: 50) @fusion__requirement { + nodes @fusion__requirement { + product @fusion__requirement { + price @fusion__requirement + id @fusion__requirement + } + } + } + } + } + } + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 10 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2($__fusion_1_lines: [Float!], $__fusion_2_id: ID!) { + cartById(id: $__fusion_2_id) { + subtotal(lines: $__fusion_1_lines) + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_1_lines + selectionMap: >- + items(first: 50).nodes[product.price] + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 4 + - id: 7 + - id: 3 + type: Operation + schema: cart + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 7 + - id: 4 + type: Operation + schema: products + operation: | + query Op_123456789101112_4($__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + price + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_4_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 5 + type: OperationBatch + schema: cart + operation: | + query Op_123456789101112_5 { + viewer { + cart { + id + } + } + } + targets: + - $ + - $ + batchingGroupId: 7 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Input_Object_List_Form.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Input_Object_List_Form.yaml new file mode 100644 index 00000000000..25a48db5cb2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Input_Object_List_Form.yaml @@ -0,0 +1,126 @@ +operation: + - document: | + { + viewer { + cart { + subtotal + id @fusion__requirement + items(first: 50) @fusion__requirement { + nodes @fusion__requirement { + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + price @fusion__requirement + } + } + } + } + } + } + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 13 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2( + $__fusion_1_lines: [CartLineInput] + $__fusion_2_id: ID! + ) { + cartById(id: $__fusion_2_id) { + subtotal(lines: $__fusion_1_lines) + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_1_lines + selectionMap: >- + items(first: 50).nodes[{ + unitPrice: product.discountedPrice + }] + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 5 + - id: 9 + - id: 3 + type: Operation + schema: cart + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 9 + - id: 5 + type: Operation + schema: promotions + operation: | + query Op_123456789101112_5($__fusion_5_price: Float!, $__fusion_6_id: ID!) { + productById(id: $__fusion_6_id) { + discountedPrice(price: $__fusion_5_price) + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_5_price + selectionMap: >- + price + - name: __fusion_6_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 6 + - id: 6 + type: Operation + schema: products + operation: | + query Op_123456789101112_6($__fusion_7_id: ID!) { + productById(id: $__fusion_7_id) { + price + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_7_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 7 + type: OperationBatch + schema: cart + operation: | + query Op_123456789101112_7 { + viewer { + cart { + id + } + } + } + targets: + - $ + - $ + batchingGroupId: 9 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Nested_Require.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Nested_Require.yaml new file mode 100644 index 00000000000..3c2d42266cf --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Require_Traverses_Connection_With_Nested_Require.yaml @@ -0,0 +1,121 @@ +operation: + - document: | + { + viewer { + cart { + subtotal + id @fusion__requirement + items(first: 50) @fusion__requirement { + nodes @fusion__requirement { + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + price @fusion__requirement + } + } + } + } + } + } + hash: 123456789101112 + searchSpace: 2 + expandedNodes: 13 +nodes: + - id: 2 + type: Operation + schema: cart + operation: | + query Op_123456789101112_2($__fusion_1_lines: [Float!], $__fusion_2_id: ID!) { + cartById(id: $__fusion_2_id) { + subtotal(lines: $__fusion_1_lines) + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_1_lines + selectionMap: >- + items(first: 50).nodes[product.discountedPrice] + - name: __fusion_2_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 5 + - id: 9 + - id: 3 + type: Operation + schema: cart + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + cartById(id: $__fusion_3_id) { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 9 + - id: 5 + type: Operation + schema: promotions + operation: | + query Op_123456789101112_5($__fusion_5_price: Float!, $__fusion_6_id: ID!) { + productById(id: $__fusion_6_id) { + discountedPrice(price: $__fusion_5_price) + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_5_price + selectionMap: >- + price + - name: __fusion_6_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 6 + - id: 6 + type: Operation + schema: products + operation: | + query Op_123456789101112_6($__fusion_7_id: ID!) { + productById(id: $__fusion_7_id) { + price + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_7_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 7 + type: OperationBatch + schema: cart + operation: | + query Op_123456789101112_7 { + viewer { + cart { + id + } + } + } + targets: + - $ + - $ + batchingGroupId: 9 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml new file mode 100644 index 00000000000..95310cb00d3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementCrossEntityTests.Plan_Should_Resolve_Subtotal_When_Requiring_Field_Is_On_Other_Schema_Than_Connection.yaml @@ -0,0 +1,154 @@ +operation: + - document: | + { + viewer { + cart { + subtotal + id @fusion__requirement + items(first: 50) @fusion__requirement { + nodes @fusion__requirement { + product @fusion__requirement { + discountedPrice @fusion__requirement + id @fusion__requirement + price @fusion__requirement + } + } + } + } + } + } + hash: 123456789101112 + searchSpace: 8 + expandedNodes: 24 +nodes: + - id: 2 + type: Operation + schema: checkout + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!, $__fusion_2_lines: [Float!]) { + cartById(id: $__fusion_1_id) { + subtotal(lines: $__fusion_2_lines) + } + } + source: $.cartById + target: $.viewer.cart + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_lines + selectionMap: >- + items(first: 50).nodes[product.discountedPrice] + dependencies: + - id: 3 + - id: 4 + - id: 8 + - id: 3 + type: Operation + schema: promotions + operation: | + query Op_123456789101112_3($__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + id + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - id: 8 + - id: 4 + type: Operation + schema: promotions + operation: | + query Op_123456789101112_4($__fusion_4_price: Float!, $__fusion_5_id: ID!) { + productById(id: $__fusion_5_id) { + discountedPrice(price: $__fusion_4_price) + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_4_price + selectionMap: >- + price + - name: __fusion_5_id + selectionMap: >- + id + dependencies: + - id: 5 + - id: 6 + - id: 5 + type: Operation + schema: products + operation: | + query Op_123456789101112_5($__fusion_6_id: ID!) { + productById(id: $__fusion_6_id) { + price + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_6_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 6 + type: Operation + schema: cart + operation: | + query Op_123456789101112_6($__fusion_7_id: ID!) { + productById(id: $__fusion_7_id) { + id + } + } + source: $.productById + target: $.viewer.cart.items.nodes.product + requirements: + - name: __fusion_7_id + selectionMap: >- + id + dependencies: + - id: 3 + - id: 1 + type: Operation + schema: cart + operation: | + query Op_123456789101112_1 { + viewer { + cart { + id + items(first: 50) { + nodes { + product { + __typename + } + } + } + } + } + } + batchingGroupId: 8 + - id: 7 + type: Operation + schema: cart + operation: | + query Op_123456789101112_7 { + viewer { + cart { + items(first: 50) { + nodes { + product { + id + } + } + } + } + } + } + batchingGroupId: 8 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Deep_Requires.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Deep_Requires.yaml index 5bec5310da7..1787e49e744 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Deep_Requires.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Deep_Requires.yaml @@ -31,7 +31,7 @@ nodes: operation: | query Op_123456789101112_3( $__fusion_2_commentAuthorIds: [ID] - $__fusion_3_id: ID! + $__fusion_3_id: ID! ) { postById(id: $__fusion_3_id) { author(commentAuthorIds: $__fusion_2_commentAuthorIds) { @@ -51,6 +51,7 @@ nodes: dependencies: - id: 1 - id: 4 + - id: 5 - id: 4 type: Operation schema: b diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml index f2e9cf1b9ea..c2acf982950 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Args_Conflicts.yaml @@ -11,7 +11,7 @@ operation: } hash: 123456789101112 searchSpace: 2 - expandedNodes: 9 + expandedNodes: 8 nodes: - id: 1 type: Operation @@ -51,9 +51,9 @@ nodes: selectionMap: >- price dependencies: - - id: 7 + - id: 6 - id: 3 - type: OperationBatch + type: Operation schema: products operation: | query Op_123456789101112_3($__fusion_3_upc: String!) { @@ -62,10 +62,8 @@ nodes: } } source: $.productByUpc - targets: - - $.products - - $.products - batchingGroupId: 7 + target: $.products + batchingGroupId: 6 requirements: - name: __fusion_3_upc selectionMap: >- @@ -83,27 +81,27 @@ nodes: } source: $.productByUpc target: $.products - batchingGroupId: 7 + batchingGroupId: 6 requirements: - name: __fusion_4_upc selectionMap: >- upc dependencies: - id: 1 - - id: 6 + - id: 5 type: Operation schema: products operation: | - query Op_123456789101112_6($__fusion_7_upc: String!) { - productByUpc(upc: $__fusion_7_upc) { + query Op_123456789101112_5($__fusion_6_upc: String!) { + productByUpc(upc: $__fusion_6_upc) { price(withDiscount: true) } } source: $.productByUpc target: $.products - batchingGroupId: 7 + batchingGroupId: 6 requirements: - - name: __fusion_7_upc + - name: __fusion_6_upc selectionMap: >- upc dependencies: diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml index 0627803f763..9840a2502dd 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/RequirementParityTests.Two_Same_Service_Calls_With_Different_Requirement_Args_Do_Not_Merge.yaml @@ -12,7 +12,7 @@ operation: } hash: 123456789101112 searchSpace: 2 - expandedNodes: 9 + expandedNodes: 8 nodes: - id: 1 type: Operation @@ -52,7 +52,7 @@ nodes: selectionMap: >- price(withDiscount: true) dependencies: - - id: 7 + - id: 6 - id: 3 type: Operation schema: products @@ -60,11 +60,12 @@ nodes: query Op_123456789101112_3($__fusion_3_upc: String!) { productByUpc(upc: $__fusion_3_upc) { fusion__requirement_price_0: price(withDiscount: false) + fusion__requirement_price_1: price(withDiscount: true) } } source: $.productByUpc target: $.products - batchingGroupId: 7 + batchingGroupId: 6 requirements: - name: __fusion_3_upc selectionMap: >- @@ -82,7 +83,7 @@ nodes: } source: $.productByUpc target: $.products - batchingGroupId: 7 + batchingGroupId: 6 requirements: - name: __fusion_4_upc selectionMap: >- @@ -95,32 +96,14 @@ nodes: operation: | query Op_123456789101112_5($__fusion_6_upc: String!) { productByUpc(upc: $__fusion_6_upc) { - fusion__requirement_price_1: price(withDiscount: true) - } - } - source: $.productByUpc - target: $.products - batchingGroupId: 7 - requirements: - - name: __fusion_6_upc - selectionMap: >- - upc - dependencies: - - id: 1 - - id: 6 - type: Operation - schema: products - operation: | - query Op_123456789101112_6($__fusion_7_upc: String!) { - productByUpc(upc: $__fusion_7_upc) { price } } source: $.productByUpc target: $.products - batchingGroupId: 7 + batchingGroupId: 6 requirements: - - name: __fusion_7_upc + - name: __fusion_6_upc selectionMap: >- upc dependencies: