From 9b99de78ad2a64196c3f4241d2d8799c7d552b74 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 29 Jun 2026 11:56:54 +0200 Subject: [PATCH 1/3] Add propagateNull directive --- .../benchmarks/k6/Directory.Build.props | 4 +- .../FusionLookupMutableDirectiveDefinition.cs | 6 + ...PropagateNullMutableDirectiveDefinition.cs | 19 + .../OutputFieldDefinitionExtensions.cs | 3 + .../Features/SourceOutputFieldMetadata.cs | 2 + .../src/Fusion.Composition/FusionBuiltIns.cs | 1 + .../Logging/LogEntryCodes.cs | 3 + .../Logging/LogEntryHelper.cs | 32 ++ .../CompositionResources.Designer.cs | 36 ++ .../Properties/CompositionResources.resx | 12 + .../src/Fusion.Composition/SchemaComposer.cs | 2 + .../SourceSchemaEnricher.cs | 1 + .../Fusion.Composition/SourceSchemaMerger.cs | 22 +- .../PropagateNullNotRepeatableRule.cs | 26 + .../PropagateNullOnNonLookupFieldRule.cs | 24 + .../WellKnownArgumentNames.cs | 1 + .../WellKnownDirectiveNames.cs | 1 + .../Completion/CompletionTools.cs | 1 + .../Directives/LookupDirective.cs | 4 + .../Directives/LookupDirectiveParser.cs | 14 +- .../Fusion.Execution.Types/Metadata/Lookup.cs | 8 + .../Fusion.Execution/Execution/ErrorHelper.cs | 19 +- .../Nodes/BatchOperationDefinition.cs | 6 +- .../Nodes/EventStreamExecutionNode.cs | 3 +- .../Nodes/OperationBatchExecutionNode.cs | 6 +- .../Execution/Nodes/OperationDefinition.cs | 9 +- .../Execution/Nodes/OperationExecutionNode.cs | 23 +- .../JsonOperationPlanFormatter.cs | 18 + .../Serialization/JsonOperationPlanParser.cs | 23 +- .../YamlOperationPlanFormatter.cs | 15 + .../Nodes/SingleOperationDefinition.cs | 6 +- .../Execution/OperationPlanContext.cs | 20 +- .../Execution/PendingMerge.cs | 28 +- .../Execution/Results/FetchResultStore.cs | 170 +++++-- .../Execution/Results/ResultDataMapper.cs | 22 +- .../Execution/Results/ValueCompletion.cs | 70 ++- .../OperationPlanner.BuildExecutionTree.cs | 49 +- .../FusionExecutionResources.Designer.cs | 30 ++ .../Properties/FusionExecutionResources.resx | 15 + .../SourceSchemaMerger.Object.Tests.cs | 44 ++ .../PropagateNullNotRepeatableRuleTests.cs | 55 ++ .../PropagateNullOnNonLookupFieldRuleTests.cs | 55 ++ ...NamedSchemas_AddsFusionDefinitions.graphql | 2 + .../PropagateNullLookupExecutionTests.cs | 472 ++++++++++++++++++ .../Results/FetchResultStoreTests.cs | 3 +- 45 files changed, 1288 insertions(+), 97 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PropagateNullMutableDirectiveDefinition.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullNotRepeatableRule.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRule.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullNotRepeatableRuleTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRuleTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs diff --git a/src/HotChocolate/Fusion/benchmarks/k6/Directory.Build.props b/src/HotChocolate/Fusion/benchmarks/k6/Directory.Build.props index 5a34c7ed431..404a1ca9f4d 100644 --- a/src/HotChocolate/Fusion/benchmarks/k6/Directory.Build.props +++ b/src/HotChocolate/Fusion/benchmarks/k6/Directory.Build.props @@ -2,8 +2,8 @@ - net11.0 - net11.0 + net10.0 + net10.0 enable enable diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionLookupMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionLookupMutableDirectiveDefinition.cs index 3fc9f843643..f9fb6910216 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionLookupMutableDirectiveDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionLookupMutableDirectiveDefinition.cs @@ -69,6 +69,12 @@ public FusionLookupMutableDirectiveDefinition( Description = FusionLookupMutableDirectiveDefinition_Argument_Internal_Description }); + Arguments.Add(new MutableInputFieldDefinition(ArgumentNames.PropagateNull, new NonNullType(booleanType)) + { + DefaultValue = new BooleanValueNode(false), + Description = FusionLookupMutableDirectiveDefinition_Argument_PropagateNull_Description + }); + IsRepeatable = true; Locations = diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PropagateNullMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PropagateNullMutableDirectiveDefinition.cs new file mode 100644 index 00000000000..c8db2f3aff3 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PropagateNullMutableDirectiveDefinition.cs @@ -0,0 +1,19 @@ +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Properties.CompositionResources; + +namespace HotChocolate.Fusion.Definitions; + +/// +/// The @propagateNull directive marks a lookup field whose null result invalidates the +/// entity node resolved by that lookup. +/// +internal sealed class PropagateNullMutableDirectiveDefinition : MutableDirectiveDefinition +{ + public PropagateNullMutableDirectiveDefinition() : base(WellKnownDirectiveNames.PropagateNull) + { + Description = PropagateNullMutableDirectiveDefinition_Description; + + Locations = DirectiveLocation.FieldDefinition; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs index 7dde0aeaa62..433849ea07f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/OutputFieldDefinitionExtensions.cs @@ -42,6 +42,9 @@ public bool HasOverrideDirective public bool HasProvidesDirective => outputField.Features.GetRequired().HasProvidesDirective; + public bool HasPropagateNullDirective + => outputField.Features.GetRequired().HasPropagateNullDirective; + public bool HasShareableDirective => outputField.Features.GetRequired().HasShareableDirective; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs index b80390d9fc2..d4af9593500 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/SourceOutputFieldMetadata.cs @@ -16,6 +16,8 @@ internal sealed class SourceOutputFieldMetadata public bool HasProvidesDirective { get; set; } + public bool HasPropagateNullDirective { get; set; } + public bool HasShareableDirective { get; set; } public bool IsExternal => HasExternalDirective; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs index 4db043d5c1e..0f97a27d772 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs @@ -27,6 +27,7 @@ internal static class FusionBuiltIns new KeyMutableDirectiveDefinition(s_fieldSelectionSetType), new LookupMutableDirectiveDefinition(), new OverrideMutableDirectiveDefinition(s_stringType), + new PropagateNullMutableDirectiveDefinition(), new ProvidesMutableDirectiveDefinition(s_fieldSelectionSetType), new RequireMutableDirectiveDefinition(s_fieldSelectionMapType), new ShareableMutableDirectiveDefinition(), diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs index ad92196c601..7bd7d3e6e23 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -58,6 +58,9 @@ public static class LogEntryCodes public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE"; public const string OverrideFromSelf = "OVERRIDE_FROM_SELF"; public const string OverrideOnInterface = "OVERRIDE_ON_INTERFACE"; + public const string PropagateNullDirectiveNotRepeatable = + "PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE"; + public const string PropagateNullOnNonLookupField = "PROPAGATE_NULL_ON_NON_LOOKUP_FIELD"; public const string ProvidesDirectiveInFieldsArgument = "PROVIDES_DIRECTIVE_IN_FIELDS_ARGUMENT"; public const string ProvidesFieldsHasArguments = "PROVIDES_FIELDS_HAS_ARGUMENTS"; public const string ProvidesFieldsMissingExternal = "PROVIDES_FIELDS_MISSING_EXTERNAL"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs index fa1b1af6ad1..c6b22e0da24 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -1166,6 +1166,38 @@ public static LogEntry ProvidesOnNonCompositeField( .Build(); } + public static LogEntry PropagateNullDirectiveNotRepeatable( + MutableOutputFieldDefinition field, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_PropagateNullDirectiveNotRepeatable, + field.Coordinate.ToString(), + schema.Name) + .SetCode(LogEntryCodes.PropagateNullDirectiveNotRepeatable) + .SetSeverity(LogSeverity.Error) + .SetTypeSystemMember(field) + .SetSchema(schema) + .Build(); + } + + public static LogEntry PropagateNullOnNonLookupField( + MutableOutputFieldDefinition field, + MutableSchemaDefinition schema) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_PropagateNullOnNonLookupField, + field.Coordinate.ToString(), + schema.Name) + .SetCode(LogEntryCodes.PropagateNullOnNonLookupField) + .SetSeverity(LogSeverity.Error) + .SetTypeSystemMember(field) + .SetSchema(schema) + .Build(); + } + public static LogEntry EventStreamMessageInvalidFields( MutableOutputFieldDefinition field, MutableSchemaDefinition schema, 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 2e16af26b8c..ce407bd0c02 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -511,6 +511,15 @@ internal static string FusionLookupMutableDirectiveDefinition_Argument_Path_Desc } } + /// + /// Looks up a localized string similar to Specifies whether a null result from this lookup invalidates the resolved entity.. + /// + internal static string FusionLookupMutableDirectiveDefinition_Argument_PropagateNull_Description { + get { + return ResourceManager.GetString("FusionLookupMutableDirectiveDefinition_Argument_PropagateNull_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The name of the source schema where the annotated entity type can be looked up from.. /// @@ -1285,6 +1294,24 @@ internal static string LogEntryHelper_OverrideOnInterface { } } + /// + /// Looks up a localized string similar to The field '{0}' in schema '{1}' includes multiple @propagateNull directives, but @propagateNull is not repeatable.. + /// + internal static string LogEntryHelper_PropagateNullDirectiveNotRepeatable { + get { + return ResourceManager.GetString("LogEntryHelper_PropagateNullDirectiveNotRepeatable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The field '{0}' in schema '{1}' includes a @propagateNull directive, but is not a lookup field.. + /// + internal static string LogEntryHelper_PropagateNullOnNonLookupField { + get { + return ResourceManager.GetString("LogEntryHelper_PropagateNullOnNonLookupField", resourceCulture); + } + } + /// /// Looks up a localized string similar to The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications.. /// @@ -1483,6 +1510,15 @@ internal static string LookupMutableDirectiveDefinition_Description { } } + /// + /// Looks up a localized string similar to The @propagateNull directive marks a lookup field whose null result invalidates the resolved entity.. + /// + internal static string PropagateNullMutableDirectiveDefinition_Description { + get { + return ResourceManager.GetString("PropagateNullMutableDirectiveDefinition_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The name of the source schema that originally provided this field.. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx index f3d0b58863e..ba15706b4e7 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -168,6 +168,9 @@ The path to the lookup field relative to the Query type. + + Specifies whether a null result from this lookup invalidates the resolved entity. + The name of the source schema where the annotated entity type can be looked up from. @@ -423,6 +426,12 @@ The interface field '{0}' in schema '{1}' must not be annotated with the @override directive. + + The field '{0}' in schema '{1}' includes multiple @propagateNull directives, but @propagateNull is not repeatable. + + + The field '{0}' in schema '{1}' includes a @propagateNull directive, but is not a lookup field. + The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications. @@ -489,6 +498,9 @@ The @lookup directive is used within a source schema to specify output fields that can be used by the distributed GraphQL executor to resolve an entity by a stable key. + + The @propagateNull directive marks a lookup field whose null result invalidates the resolved entity. + The name of the source schema that originally provided this field. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs index 082b2aadbf7..9a20a084d5f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs @@ -163,6 +163,8 @@ public CompositionResult Compose() new LookupReturnsNonNullableTypeRule(), new OverrideFromSelfRule(), new OverrideOnInterfaceRule(), + new PropagateNullNotRepeatableRule(), + new PropagateNullOnNonLookupFieldRule(), new ProvidesDirectiveInFieldsArgumentRule(), new ProvidesFieldsHasArgumentsRule(), new ProvidesFieldsMissingExternalRule(), diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs index 52883a5eb74..f24e8d8e4ca 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaEnricher.cs @@ -88,6 +88,7 @@ private void EnrichOutputField(MutableOutputFieldDefinition outputField) sourceMetadata.HasEventCursorDirective = outputField.Directives.ContainsName(EventCursor); sourceMetadata.HasInternalDirective = outputField.Directives.ContainsName(WellKnownDirectiveNames.Internal); sourceMetadata.HasOverrideDirective = outputField.Directives.ContainsName(Override); + sourceMetadata.HasPropagateNullDirective = outputField.Directives.ContainsName(PropagateNull); sourceMetadata.HasProvidesDirective = outputField.Directives.ContainsName(Provides); sourceMetadata.HasShareableDirective = outputField.Directives.ContainsName(Shareable); diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs index 40880a7593c..4438076f09d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs @@ -243,15 +243,25 @@ private void AddFusionLookupDirectives(MutableSchemaDefinition mergedSchema) valueSelectionGroup.ConvertAll( a => new StringValueNode(a.ToString(indented: false)))); + var argumentAssignments = new List + { + new(ArgumentNames.Schema, schemaArgument), + new(ArgumentNames.Key, keyArgument), + new(ArgumentNames.Field, fieldArgument), + new(ArgumentNames.Map, mapArgument), + new(ArgumentNames.Path, pathArgument), + new(ArgumentNames.Internal, @internal) + }; + + if (sourceField.HasPropagateNullDirective) + { + argumentAssignments.Add(new ArgumentAssignment(ArgumentNames.PropagateNull, true)); + } + mergedType.Directives.Add( new Directive( _fusionDirectiveDefinitions[DirectiveNames.FusionLookup], - new ArgumentAssignment(ArgumentNames.Schema, schemaArgument), - new ArgumentAssignment(ArgumentNames.Key, keyArgument), - new ArgumentAssignment(ArgumentNames.Field, fieldArgument), - new ArgumentAssignment(ArgumentNames.Map, mapArgument), - new ArgumentAssignment(ArgumentNames.Path, pathArgument), - new ArgumentAssignment(ArgumentNames.Internal, @internal))); + argumentAssignments)); } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullNotRepeatableRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullNotRepeatableRule.cs new file mode 100644 index 00000000000..bc5692fb7c0 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullNotRepeatableRule.cs @@ -0,0 +1,26 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Fusion.Extensions; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +/// +/// The @propagateNull directive is not repeatable, so a field must not apply it more than +/// once. Applying it multiple times is invalid and raises a PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE +/// error. +/// +internal sealed class PropagateNullNotRepeatableRule : IEventHandler +{ + public void Handle(OutputFieldEvent @event, CompositionContext context) + { + var (field, _, schema) = @event; + + if (field.HasPropagateNullDirective + && field.Directives.AsEnumerable().Count( + d => d.Name == WellKnownDirectiveNames.PropagateNull) > 1) + { + context.Log.Write(PropagateNullDirectiveNotRepeatable(field, schema)); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRule.cs new file mode 100644 index 00000000000..67983ad8301 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRule.cs @@ -0,0 +1,24 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Fusion.Extensions; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +/// +/// The @propagateNull directive changes how a null lookup result affects its target entity. +/// It is only meaningful on fields that are also marked as lookup fields. Applying it to a +/// non-lookup field is invalid and raises a PROPAGATE_NULL_ON_NON_LOOKUP_FIELD error. +/// +internal sealed class PropagateNullOnNonLookupFieldRule : IEventHandler +{ + public void Handle(OutputFieldEvent @event, CompositionContext context) + { + var (field, _, schema) = @event; + + if (field is { HasPropagateNullDirective: true, IsLookup: false }) + { + context.Log.Write(PropagateNullOnNonLookupField(field, schema)); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs index a91e3702937..a704a5434ca 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs @@ -28,6 +28,7 @@ internal static class WellKnownArgumentNames public const string Path = "path"; public const string Pattern = "pattern"; public const string Policy = "policy"; + public const string PropagateNull = "propagateNull"; public const string Provides = "provides"; public const string Requirements = "requirements"; public const string RequireOneSlicingArgument = "requireOneSlicingArgument"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs index 9c66d527a60..47281411c29 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs @@ -32,6 +32,7 @@ internal static class WellKnownDirectiveNames public const string McpToolAnnotations = "mcpToolAnnotations"; public const string OneOf = DirectiveNames.OneOf.Name; public const string Override = DirectiveNames.Override.Name; + public const string PropagateNull = "propagateNull"; public const string Provides = DirectiveNames.Provides.Name; public const string Require = DirectiveNames.Require.Name; public const string SerializeAs = DirectiveNames.SerializeAs.Name; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs index 2f55b9b1d58..bd51c0dc5ea 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompletionTools.cs @@ -214,6 +214,7 @@ private static ImmutableArray GetLookupBySchema( lookup.Field.Name.Value, lookup.Field.Type.NamedType().Name.Value, lookup.Internal, + lookup.PropagateNull, arguments.ToImmutable(), fields, lookup.Path)); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirective.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirective.cs index dfcea7c4b73..5c58f8c375c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirective.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirective.cs @@ -10,6 +10,7 @@ directive @fusion__lookup( field: fusion__FieldDefinition! map: [fusion__FieldSelectionMap!]! path: fusion__FieldSelectionPath + propagateNull: Boolean! = false internal: Boolean! = false ) repeatable on OBJECT | INTERFACE | UNION */ @@ -19,6 +20,7 @@ internal sealed class LookupDirective( FieldDefinitionNode field, ImmutableArray map, ImmutableArray path, + bool propagateNull, bool @internal = false) { public SchemaKey SchemaKey { get; } = schemaKey; @@ -31,5 +33,7 @@ internal sealed class LookupDirective( public ImmutableArray Path { get; } = path; + public bool PropagateNull { get; } = propagateNull; + public bool Internal { get; } = @internal; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirectiveParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirectiveParser.cs index d4dd138117b..dbd7b7c983a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirectiveParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/LookupDirectiveParser.cs @@ -16,6 +16,7 @@ public static LookupDirective Parse(DirectiveNode directive) FieldDefinitionNode? field = null; ImmutableArray? map = null; ImmutableArray? path = null; + bool? propagateNull = null; bool? @internal = null; foreach (var argument in directive.Arguments) @@ -52,6 +53,10 @@ public static LookupDirective Parse(DirectiveNode directive) } break; + case "propagateNull": + propagateNull = ((BooleanValueNode)argument.Value).Value; + break; + case "internal": @internal = ((BooleanValueNode)argument.Value).Value; break; @@ -86,7 +91,14 @@ public static LookupDirective Parse(DirectiveNode directive) "The `map` argument is required on the @lookup directive."); } - return new LookupDirective(new SchemaKey(schemaKey), key, field, map.Value, path ?? [], @internal ?? false); + return new LookupDirective( + new SchemaKey(schemaKey), + key, + field, + map.Value, + path ?? [], + propagateNull ?? false, + @internal ?? false); } private static ImmutableArray ParseMap(IValueNode value) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/Lookup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/Lookup.cs index e9968e516ee..26d41b01fa3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/Lookup.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/Lookup.cs @@ -24,6 +24,7 @@ public sealed class Lookup : INeedsCompletion /// The name of the lookup field. /// The type the lookup field returns. /// Whether the lookup is internal or not. + /// Whether a null lookup result invalidates the target entity. /// The arguments that represent field requirements. /// The paths to the field that are required. /// @@ -42,6 +43,7 @@ public Lookup( string fieldName, string fieldType, bool isInternal, + bool propagateNull, ImmutableArray arguments, ImmutableArray fields, ImmutableArray path) @@ -66,6 +68,7 @@ public Lookup( SchemaName = schemaName; FieldName = fieldName; IsInternal = isInternal; + PropagateNull = propagateNull; Arguments = arguments; Fields = fields; Path = path; @@ -91,6 +94,11 @@ public Lookup( /// public bool IsInternal { get; } + /// + /// Gets whether a null lookup result invalidates the target entity. + /// + public bool PropagateNull { get; } + /// /// Gets the arguments that represent field requirements. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ErrorHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ErrorHelper.cs index 1926ddd14aa..b2298d1f197 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ErrorHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ErrorHelper.cs @@ -1,5 +1,6 @@ using HotChocolate.Collections.Immutable; using HotChocolate.Execution; +using static HotChocolate.Fusion.Properties.FusionExecutionResources; namespace HotChocolate.Fusion.Execution; @@ -9,20 +10,32 @@ public static OperationResult RequestTimeout(TimeSpan timeout) => OperationResult.FromError( new Error { - Message = string.Format("The request exceeded the configured timeout of `{0}`.", timeout), + Message = string.Format(ErrorHelper_RequestTimeout_Message, timeout), Extensions = ImmutableOrderedDictionary.Empty.Add("code", ErrorCodes.Execution.Timeout) }); public static OperationResult StateInvalidForOperationPlanCache() => OperationResult.FromError( ErrorBuilder.New() - .SetMessage("The operation plan cache requires a operation document hash.") + .SetMessage(ErrorHelper_StateInvalidForOperationPlanCache_Message) .SetCode(ErrorCodes.Execution.OperationDocumentNotFound) .Build()); public static OperationResult StateInvalidForVariableCoercion() => OperationResult.FromError( ErrorBuilder.New() - .SetMessage("The variable coercion requires an operation execution plan.") + .SetMessage(ErrorHelper_StateInvalidForVariableCoercion_Message) .Build()); + + public static IError UnexpectedExecutionError() + => ErrorBuilder.New() + .SetMessage(ErrorHelper_UnexpectedExecutionError_Message) + .Build(); + + public static IError NonNullOutputFieldViolation(Path path) + => ErrorBuilder.New() + .SetMessage(ErrorHelper_NonNullOutputFieldViolation_Message) + .SetCode(ErrorCodes.Execution.NonNullViolation) + .SetPath(path) + .Build(); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/BatchOperationDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/BatchOperationDefinition.cs index e4ce5a3538a..819f7b5bd03 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/BatchOperationDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/BatchOperationDefinition.cs @@ -16,7 +16,8 @@ internal BatchOperationDefinition( string[] forwardedVariables, ResultSelectionSet resultSelectionSet, ExecutionNodeCondition[] conditions, - bool requiresFileUpload) + bool requiresFileUpload, + bool propagateNull) : base( id, operation, @@ -26,7 +27,8 @@ internal BatchOperationDefinition( forwardedVariables, resultSelectionSet, conditions, - requiresFileUpload) + requiresFileUpload, + propagateNull) { _targets = targets; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/EventStreamExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/EventStreamExecutionNode.cs index 54c1b4459b1..5419b57976c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/EventStreamExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/EventStreamExecutionNode.cs @@ -302,7 +302,8 @@ private EventMessageResult ProcessEvent(BrokerSubscription subscription, EventMe _node._source, _resultBuffer, _node._resultSelectionSet, - containsErrors: true); + containsErrors: true, + propagateNull: false); sourceResult = null; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs index 881c2889b54..2a728164e68 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs @@ -109,7 +109,8 @@ private async ValueTask ExecuteSingleAsync( operation.ResultSelectionSet, variables, result, - hasErrors); + hasErrors, + operation.PropagateNull); hasPendingMerge = true; context.EnqueuePendingMerge(pendingMerge); } @@ -206,7 +207,8 @@ private async ValueTask ExecuteBatchAsync( op.ResultSelectionSet, variablesByIndex[requestIndex], result, - hasErrors); + hasErrors, + op.PropagateNull); hasPendingMerge = true; context.EnqueuePendingMerge(pendingMerge); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs index 101a65b0ea0..79e7e152018 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs @@ -24,7 +24,8 @@ protected OperationDefinition( string[] forwardedVariables, ResultSelectionSet resultSelectionSet, ExecutionNodeCondition[] conditions, - bool requiresFileUpload) + bool requiresFileUpload, + bool propagateNull) { Id = id; Operation = operation; @@ -36,6 +37,7 @@ protected OperationDefinition( ResultSelectionSet = resultSelectionSet; _conditions = conditions; RequiresFileUpload = requiresFileUpload; + PropagateNull = propagateNull; } /// @@ -95,6 +97,11 @@ protected OperationDefinition( /// public bool RequiresFileUpload { get; } + /// + /// Gets whether a null source result from this operation invalidates the target entity. + /// + public bool PropagateNull { get; } + /// /// Gets the identifiers of steps in the parent plan scope that must /// complete before this operation definition can run. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs index 6bed7881ed5..9f03ce3f623 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs @@ -21,6 +21,7 @@ public sealed class OperationExecutionNode : ExecutionNode private readonly string? _schemaName; private readonly SelectionPath _target; private readonly SelectionPath _source; + private readonly bool _propagateNull; internal OperationExecutionNode( int id, @@ -32,7 +33,8 @@ internal OperationExecutionNode( string[] forwardedVariables, ResultSelectionSet resultSelectionSet, ExecutionNodeCondition[] conditions, - bool requiresFileUpload) + bool requiresFileUpload, + bool propagateNull) { Id = id; _operation = operation; @@ -45,6 +47,7 @@ internal OperationExecutionNode( _resultSelectionSet = resultSelectionSet; _conditions = conditions; _requiresFileUpload = requiresFileUpload; + _propagateNull = propagateNull; } /// @@ -99,6 +102,11 @@ internal ImmutableArray GetRequirementsArray() /// public bool RequiresFileUpload => _requiresFileUpload; + /// + /// Gets whether a null source result from this operation invalidates the target entity. + /// + public bool PropagateNull => _propagateNull; + protected override async ValueTask OnExecuteAsync( OperationPlanContext context, CancellationToken cancellationToken = default) @@ -217,7 +225,8 @@ protected override async ValueTask OnExecuteAsync( variables, buffer, index, - hasSomeErrors); + hasSomeErrors, + _propagateNull); hasPendingMerge = true; } else if (singleResult is not null) @@ -229,7 +238,8 @@ protected override async ValueTask OnExecuteAsync( _resultSelectionSet, variables, singleResult, - hasSomeErrors); + hasSomeErrors, + _propagateNull); hasPendingMerge = true; } @@ -440,7 +450,12 @@ public async ValueTask MoveNextAsync() // instead of leaving it to the finalizer. _context.SetActiveEventArena(_eventArenaSource.Arena); - _context.AddPartialResults(_node._source, _resultBuffer, _node._resultSelectionSet, containsErrors: true); + _context.AddPartialResults( + _node._source, + _resultBuffer, + _node._resultSelectionSet, + containsErrors: true, + _node._propagateNull); Current = new EventMessageResult( _node.Id, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs index 274b56042ad..370bae66310 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs @@ -399,6 +399,12 @@ private static void WriteOperationNode( jsonWriter.WriteBooleanValue(true); } + if (node.PropagateNull) + { + jsonWriter.WritePropertyName("propagateNull"); + jsonWriter.WriteBooleanValue(true); + } + if (node.Dependencies.Length > 0 || node.ParentDependencies.Length > 0) { jsonWriter.WritePropertyName("dependencies"); @@ -661,6 +667,12 @@ private static void WriteOperationDefinitionAsNode( jsonWriter.WriteBooleanValue(true); } + if (operationDef.PropagateNull) + { + jsonWriter.WritePropertyName("propagateNull"); + jsonWriter.WriteBooleanValue(true); + } + if (operationDef.Dependencies.Length > 0 || operationDef.ParentDependencies.Length > 0) { jsonWriter.WritePropertyName("dependencies"); @@ -781,6 +793,12 @@ private static void WriteBatchOperationDefinitionAsNode( jsonWriter.WriteBooleanValue(true); } + if (operationDef.PropagateNull) + { + jsonWriter.WritePropertyName("propagateNull"); + jsonWriter.WriteBooleanValue(true); + } + if (operationDef.Dependencies.Length > 0 || operationDef.ParentDependencies.Length > 0) { jsonWriter.WritePropertyName("dependencies"); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs index 6a42e0b718b..690d2f6aab8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs @@ -496,7 +496,7 @@ private static ParsedOperationNodeInfo ParseOperationNodeInfo( { var (schemaName, opSource, source, requirements, forwardedVariables, resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions, - requiresFileUpload) = ParseCommonOperationFields(nodeElement, schema); + requiresFileUpload, propagateNull) = ParseCommonOperationFields(nodeElement, schema); SelectionPath? target = null; @@ -520,6 +520,7 @@ private static ParsedOperationNodeInfo ParseOperationNodeInfo( BatchingGroupId = batchingGroupId, Conditions = conditions, RequiresFileUpload = requiresFileUpload, + PropagateNull = propagateNull, Schema = schema }; } @@ -612,7 +613,7 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo( { var (schemaName, opSource, source, requirements, forwardedVariables, resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions, - requiresFileUpload) = ParseCommonOperationFields(nodeElement, schema); + requiresFileUpload, propagateNull) = ParseCommonOperationFields(nodeElement, schema); var targets = nodeElement.TryGetProperty("targets", out var targetsElement) ? targetsElement.EnumerateArray().Select(e => SelectionPath.Parse(e.GetString()!)).ToArray() @@ -633,6 +634,7 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo( BatchingGroupId = batchingGroupId, Conditions = conditions, RequiresFileUpload = requiresFileUpload, + PropagateNull = propagateNull, Schema = schema }; } @@ -640,7 +642,7 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo( private static (string? schemaName, OperationSourceText opSource, SelectionPath? source, List? requirements, string[]? forwardedVariables, SelectionSetNode? resultSelectionSet, int[]? dependencies, int[]? parentDependencies, - int? batchingGroupId, ExecutionNodeCondition[] conditions, bool requiresFileUpload) + int? batchingGroupId, ExecutionNodeCondition[] conditions, bool requiresFileUpload, bool propagateNull) ParseCommonOperationFields(JsonElement nodeElement, ISchemaDefinition _) { string? schemaName = null; @@ -720,8 +722,11 @@ private static (string? schemaName, OperationSourceText opSource, SelectionPath? var requiresFileUpload = nodeElement.TryGetProperty("requiresFileUpload", out var requiresFileUploadElement) && requiresFileUploadElement.ValueKind == JsonValueKind.True; + var propagateNull = nodeElement.TryGetProperty("propagateNull", out var propagateNullElement) + && propagateNullElement.ValueKind == JsonValueKind.True; + return (schemaName, opSource, source, requirements, forwardedVariables, - resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions, requiresFileUpload); + resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions, requiresFileUpload, propagateNull); } private static int[]? TryParseDependencies( @@ -896,6 +901,7 @@ private abstract class ParsedOperationNodeInfo : ParsedNodeInfo public int[]? ParentDependencies { get; init; } public ExecutionNodeCondition[] Conditions { get; init; } = []; public bool RequiresFileUpload { get; init; } + public bool PropagateNull { get; init; } public required ISchemaDefinition Schema { get; init; } public abstract OperationDefinition ToOperationDefinition(); @@ -917,7 +923,8 @@ public override OperationDefinition ToOperationDefinition() ForwardedVariables, ResultSelectionSet, Conditions, - RequiresFileUpload); + RequiresFileUpload, + PropagateNull); if (ParentDependencies is not null) { @@ -942,7 +949,8 @@ public override (ExecutionNode, int[]?, Dictionary?, int?) ToExecut ForwardedVariables, ResultSelectionSet, Conditions, - RequiresFileUpload); + RequiresFileUpload, + PropagateNull); if (ParentDependencies is not null) { @@ -1014,7 +1022,8 @@ public override OperationDefinition ToOperationDefinition() ForwardedVariables, ResultSelectionSet, Conditions, - RequiresFileUpload); + RequiresFileUpload, + PropagateNull); if (ParentDependencies is not null) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs index 579900fe4fe..a3ca8a1917e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs @@ -278,6 +278,11 @@ private static void WriteOperationNode(OperationExecutionNode node, ExecutionNod writer.WriteLine("requiresFileUpload: true"); } + if (node.PropagateNull) + { + writer.WriteLine("propagateNull: true"); + } + if (node.Dependencies.Length > 0 || node.ParentDependencies.Length > 0) { writer.WriteLine("dependencies:"); @@ -431,6 +436,11 @@ private static void WriteOperationDefinitionAsNode( writer.WriteLine("requiresFileUpload: true"); } + if (opDef.PropagateNull) + { + writer.WriteLine("propagateNull: true"); + } + WriteDependencies(opDef.Dependencies, opDef.ParentDependencies, writer); TryWriteNodeTrace(writer, trace); @@ -491,6 +501,11 @@ private static void WriteBatchOperationDefinitionAsNode( writer.WriteLine("requiresFileUpload: true"); } + if (opDef.PropagateNull) + { + writer.WriteLine("propagateNull: true"); + } + WriteDependencies(opDef.Dependencies, opDef.ParentDependencies, writer); TryWriteNodeTrace(writer, trace); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SingleOperationDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SingleOperationDefinition.cs index fc7f8c8aca2..0c4bd6caaa5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SingleOperationDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SingleOperationDefinition.cs @@ -14,7 +14,8 @@ internal SingleOperationDefinition( string[] forwardedVariables, ResultSelectionSet resultSelectionSet, ExecutionNodeCondition[] conditions, - bool requiresFileUpload) + bool requiresFileUpload, + bool propagateNull) : base( id, operation, @@ -24,7 +25,8 @@ internal SingleOperationDefinition( forwardedVariables, resultSelectionSet, conditions, - requiresFileUpload) + requiresFileUpload, + propagateNull) { Target = target; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs index d792520e206..a5bbf00d10d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -547,10 +547,16 @@ internal void AddPartialResult( SelectionPath sourcePath, SourceSchemaResult result, ResultSelectionSet resultSelectionSet, - bool containsErrors) + bool containsErrors, + bool propagateNull) { var canExecutionContinue = - _resultStore.AddPartialResult(sourcePath, result, resultSelectionSet, containsErrors); + _resultStore.AddPartialResult( + sourcePath, + result, + resultSelectionSet, + containsErrors, + propagateNull); if (!canExecutionContinue) { @@ -562,10 +568,16 @@ internal void AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, ResultSelectionSet resultSelectionSet, - bool containsErrors) + bool containsErrors, + bool propagateNull) { var canExecutionContinue = - _resultStore.AddPartialResults(sourcePath, results, resultSelectionSet, containsErrors); + _resultStore.AddPartialResults( + sourcePath, + results, + resultSelectionSet, + containsErrors, + propagateNull); if (!canExecutionContinue) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/PendingMerge.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/PendingMerge.cs index 2cb7495e117..25d03f080af 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/PendingMerge.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/PendingMerge.cs @@ -23,7 +23,8 @@ private PendingMerge( PendingMergeKind kind, SourceSchemaResult? result, SourceSchemaResult[]? buffer, - int count) + int count, + bool propagateNull) { Node = node; SchemaName = schemaName; @@ -31,6 +32,7 @@ private PendingMerge( ResultSelectionSet = resultSelectionSet; VariableValueSets = variableValueSets; ContainsErrors = containsErrors; + PropagateNull = propagateNull; _kind = kind; _result = result; _buffer = buffer; @@ -49,6 +51,8 @@ private PendingMerge( public bool ContainsErrors { get; } + public bool PropagateNull { get; } + public static PendingMerge Single( ExecutionNode node, string schemaName, @@ -56,7 +60,8 @@ public static PendingMerge Single( ResultSelectionSet resultSelectionSet, ImmutableArray variableValueSets, SourceSchemaResult result, - bool containsErrors) + bool containsErrors, + bool propagateNull) => new( node, schemaName, @@ -67,7 +72,8 @@ public static PendingMerge Single( PendingMergeKind.Single, result, buffer: null, - count: 1); + count: 1, + propagateNull); public static PendingMerge Multiple( ExecutionNode node, @@ -77,7 +83,8 @@ public static PendingMerge Multiple( ImmutableArray variableValueSets, SourceSchemaResult[] buffer, int count, - bool containsErrors) + bool containsErrors, + bool propagateNull) => new( node, schemaName, @@ -88,14 +95,20 @@ public static PendingMerge Multiple( PendingMergeKind.Multiple, result: null, buffer, - count); + count, + propagateNull); public void Apply(OperationPlanContext context) { switch (_kind) { case PendingMergeKind.Single: - context.AddPartialResult(SourcePath, _result!, ResultSelectionSet, ContainsErrors); + context.AddPartialResult( + SourcePath, + _result!, + ResultSelectionSet, + ContainsErrors, + PropagateNull); break; case PendingMergeKind.Multiple: @@ -106,7 +119,8 @@ public void Apply(OperationPlanContext context) SourcePath, buffer.AsSpan(0, _count), ResultSelectionSet, - ContainsErrors); + ContainsErrors, + PropagateNull); } finally { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index e48c05c4bf1..6d899e54e40 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -75,21 +75,23 @@ public bool AddPartialResult( SelectionPath sourcePath, SourceSchemaResult result, ResultSelectionSet resultSelectionSet, - bool containsErrors) + bool containsErrors, + bool propagateNull) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(sourcePath); return containsErrors - ? AddSinglePartialResult(sourcePath, result, resultSelectionSet) - : AddSinglePartialResultNoErrors(sourcePath, result, resultSelectionSet); + ? AddSinglePartialResult(sourcePath, result, resultSelectionSet, propagateNull) + : AddSinglePartialResultNoErrors(sourcePath, result, resultSelectionSet, propagateNull); } public bool AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, ResultSelectionSet resultSelectionSet, - bool containsErrors) + bool containsErrors, + bool propagateNull) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(sourcePath); @@ -104,13 +106,13 @@ public bool AddPartialResults( if (!containsErrors) { return results.Length == 1 - ? AddSinglePartialResultNoErrors(sourcePath, results[0], resultSelectionSet) - : AddPartialResultsNoErrors(sourcePath, results, resultSelectionSet); + ? AddSinglePartialResultNoErrors(sourcePath, results[0], resultSelectionSet, propagateNull) + : AddPartialResultsNoErrors(sourcePath, results, resultSelectionSet, propagateNull); } if (results.Length == 1) { - return AddSinglePartialResult(sourcePath, results[0], resultSelectionSet); + return AddSinglePartialResult(sourcePath, results[0], resultSelectionSet, propagateNull); } var dataElements = ArrayPool.Shared.Rent(results.Length); @@ -160,7 +162,8 @@ public bool AddPartialResults( result.AdditionalPaths.AsSpan(), dataElementsSpan[i], errorTriesSpan[i], - resultSelectionSet)) + resultSelectionSet, + propagateNull)) { RegisterRemainingResults(_memory, results, i); return false; @@ -208,7 +211,8 @@ static void RegisterRemainingResults( private bool AddPartialResultsNoErrors( SelectionPath sourcePath, ReadOnlySpan results, - ResultSelectionSet resultSelectionSet) + ResultSelectionSet resultSelectionSet, + bool propagateNull) { var dataElements = ArrayPool.Shared.Rent(results.Length); var dataElementsSpan = dataElements.AsSpan(0, results.Length); @@ -239,7 +243,8 @@ private bool AddPartialResultsNoErrors( result.AdditionalPaths.AsSpan(), dataElementsSpan[i], errorTrie: null, - resultSelectionSet)) + resultSelectionSet, + propagateNull)) { RegisterRemainingResults(_memory, results, i); return false; @@ -285,7 +290,8 @@ static void RegisterRemainingResults( private bool AddSinglePartialResult( SelectionPath sourcePath, SourceSchemaResult result, - ResultSelectionSet resultSelectionSet) + ResultSelectionSet resultSelectionSet, + bool propagateNull) { var errors = result.Errors; var dataElement = GetDataElement(sourcePath, result.Data); @@ -309,7 +315,8 @@ private bool AddSinglePartialResult( result.AdditionalPaths.AsSpan(), dataElement, errorTrie, - resultSelectionSet); + resultSelectionSet, + propagateNull); } finally { @@ -321,7 +328,8 @@ private bool AddSinglePartialResult( private bool AddSinglePartialResultNoErrors( SelectionPath sourcePath, SourceSchemaResult result, - ResultSelectionSet resultSelectionSet) + ResultSelectionSet resultSelectionSet, + bool propagateNull) { var dataElement = GetDataElement(sourcePath, result.Data); @@ -337,7 +345,8 @@ private bool AddSinglePartialResultNoErrors( result.AdditionalPaths.AsSpan(), dataElement, errorTrie: null, - resultSelectionSet); + resultSelectionSet, + propagateNull); } finally { @@ -375,7 +384,8 @@ public bool AddPartialResults(SourceResultDocument document, ResultSelectionSet partial, data, errorTrie: null, - resultSelectionSet: resultSelectionSet); + resultSelectionSet: resultSelectionSet, + propagateNull: false); } } @@ -589,9 +599,10 @@ private bool SaveSafeResult( ReadOnlySpan additionalPaths, SourceResultElement dataElement, ErrorTrie? errorTrie, - ResultSelectionSet resultSelectionSet) + ResultSelectionSet resultSelectionSet, + bool propagateNull) { - if (!SaveSafeResult(resultData, path, dataElement, errorTrie, resultSelectionSet)) + if (!SaveSafeResult(resultData, path, dataElement, errorTrie, resultSelectionSet, propagateNull)) { return false; } @@ -602,27 +613,93 @@ private bool SaveSafeResult( return true; case 1: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet); + return SaveSafeResult( + resultData, + additionalPaths[0], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull); case 2: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[1], dataElement, errorTrie, resultSelectionSet); + return SaveSafeResult( + resultData, + additionalPaths[0], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull) + && SaveSafeResult( + resultData, + additionalPaths[1], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull); case 3: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[1], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[2], dataElement, errorTrie, resultSelectionSet); + return SaveSafeResult( + resultData, + additionalPaths[0], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull) + && SaveSafeResult( + resultData, + additionalPaths[1], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull) + && SaveSafeResult( + resultData, + additionalPaths[2], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull); case 4: - return SaveSafeResult(resultData, additionalPaths[0], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[1], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[2], dataElement, errorTrie, resultSelectionSet) - && SaveSafeResult(resultData, additionalPaths[3], dataElement, errorTrie, resultSelectionSet); + return SaveSafeResult( + resultData, + additionalPaths[0], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull) + && SaveSafeResult( + resultData, + additionalPaths[1], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull) + && SaveSafeResult( + resultData, + additionalPaths[2], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull) + && SaveSafeResult( + resultData, + additionalPaths[3], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull); default: for (var i = 0; i < additionalPaths.Length; i++) { - if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie, resultSelectionSet)) + if (!SaveSafeResult( + resultData, + additionalPaths[i], + dataElement, + errorTrie, + resultSelectionSet, + propagateNull)) { return false; } @@ -637,7 +714,8 @@ private bool SaveSafeResult( CompactPath path, SourceResultElement dataElement, ErrorTrie? errorTrie, - ResultSelectionSet resultSelectionSet) + ResultSelectionSet resultSelectionSet, + bool propagateNull) { if (resultData.IsNullOrInvalidated) { @@ -649,6 +727,18 @@ private bool SaveSafeResult( if (element.IsNullOrInvalidated) { + if (propagateNull + && errorTrie is not null + && dataElement.ValueKind is JsonValueKind.Null) + { + return _valueCompletion.BuildResult( + dataElement, + element, + errorTrie, + resultSelectionSet, + propagateNull); + } + return true; } @@ -657,7 +747,8 @@ private bool SaveSafeResult( dataElement, element, errorTrie, - resultSelectionSet); + resultSelectionSet, + propagateNull); if (canExecutionContinue) { @@ -805,7 +896,8 @@ private ReadOnlySpan CollectTargetElements(SelectionPath for (var j = 0; j < currentCount; j++) { var element = current[j]; - if (element.TryGetProperty(IntrospectionFieldNames.TypeNameSpan, out var value) + if (!element.IsNullOrInvalidated + && element.TryGetProperty(IntrospectionFieldNames.TypeNameSpan, out var value) && value.ValueKind is JsonValueKind.String && value.TextEqualsHelper(segment.Name, isPropertyName: false)) { @@ -818,18 +910,19 @@ private ReadOnlySpan CollectTargetElements(SelectionPath for (var j = 0; j < currentCount; j++) { var element = current[j]; - if (!element.TryGetProperty(segment.Name, out var value)) + if (element.IsNullOrInvalidated + || !element.TryGetProperty(segment.Name, out var value)) { continue; } - var valueKind = value.ValueKind; - - if (valueKind is JsonValueKind.Null or JsonValueKind.Undefined) + if (value.IsNullOrInvalidated || value.ValueKind is JsonValueKind.Undefined) { continue; } + var valueKind = value.ValueKind; + if (valueKind is JsonValueKind.Array) { AppendUnrolledLists(value, ref next, ref nextCount); @@ -1736,9 +1829,14 @@ private static void AppendUnrolledLists( { foreach (var element in list.EnumerateArray()) { + if (element.IsNullOrInvalidated) + { + continue; + } + var elementValueKind = element.ValueKind; - if (elementValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + if (elementValueKind is JsonValueKind.Undefined) { continue; } 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 479b1ae542c..91b741bee3a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ResultDataMapper.cs @@ -18,7 +18,7 @@ public static bool TryMap( IValueSelectionNode valueSelection, ISchemaDefinition schema, JsonWriter writer) - => Visit(valueSelection, result, schema, writer); + => !result.IsNullOrInvalidated && Visit(valueSelection, result, schema, writer); private static bool Visit( IValueSelectionNode node, @@ -224,6 +224,11 @@ private static bool VisitList( foreach (var item in result.EnumerateArray()) { + if (item.IsInvalidated) + { + return false; + } + if (item.ValueKind is JsonValueKind.Null) { writer.WriteNullValue(); @@ -245,6 +250,11 @@ private static CompositeResultElement ResolvePath( CompositeResultElement result, PathNode path) { + if (result.IsNullOrInvalidated) + { + return default; + } + if (result.ValueKind is not JsonValueKind.Object) { throw new InvalidOperationException("Only object results are supported."); @@ -266,11 +276,21 @@ private static CompositeResultElement ResolvePath( while (currentSegment is not null && currentValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) { + if (currentResult.IsNullOrInvalidated) + { + return default; + } + if (!currentResult.TryGetProperty(currentSegment.FieldName.Value, out var fieldResult)) { return default; } + if (fieldResult.IsInvalidated) + { + return default; + } + var fieldResultValueKind = fieldResult.ValueKind; if (fieldResultValueKind is JsonValueKind.Null) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs index 52fa7593a42..3ad1175d0f9 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs @@ -9,6 +9,7 @@ using HotChocolate.Fusion.Text.Json; using HotChocolate.Language; using HotChocolate.Types; +using FusionErrorHelper = HotChocolate.Fusion.Execution.ErrorHelper; namespace HotChocolate.Fusion.Execution.Results; @@ -50,20 +51,21 @@ public bool BuildResult( SourceResultElement source, CompositeResultElement target, ErrorTrie? errorTrie, - ResultSelectionSet resultSelectionSet) + ResultSelectionSet resultSelectionSet, + bool propagateNull) { var sourceValueKind = source.ValueKind; if (sourceValueKind is not JsonValueKind.Object) { - var error = errorTrie?.FindFirstError(); var canExecutionContinue = BuildResultForInvalidSource( source, sourceValueKind, target, resultSelectionSet, - error); + errorTrie, + propagateNull); if (!canExecutionContinue) { @@ -262,8 +264,11 @@ private bool BuildResultForInvalidSource( JsonValueKind sourceValueKind, CompositeResultElement target, ResultSelectionSet resultSelectionSet, - IError? error) + ErrorTrie? errorTrie, + bool propagateNull) { + var error = errorTrie?.FindFirstError(); + if (sourceValueKind is JsonValueKind.Null && IsValueType(target.Type)) { if (error is not null) @@ -274,6 +279,11 @@ private bool BuildResultForInvalidSource( return true; } + if (sourceValueKind is JsonValueKind.Null && propagateNull) + { + return CompletePropagatedNullSource(target, errorTrie); + } + // A clean null source without an associated error is a legitimate // "not found" state (e.g. a lookup that resolved to null), so each // selected field is completed with its own nullability semantics @@ -283,10 +293,7 @@ private bool BuildResultForInvalidSource( return CompleteNullSource(source, target, resultSelectionSet); } - var fallbackError = error ?? - ErrorBuilder.New() - .SetMessage("Unexpected Execution Error") - .Build(); + var fallbackError = error ?? FusionErrorHelper.UnexpectedExecutionError(); if (target.ValueKind is JsonValueKind.Undefined && !TryInitializeTargetObject(target)) @@ -301,6 +308,47 @@ private bool BuildResultForInvalidSource( return BuildErrorResult(target, resultSelectionSet, fallbackError, target.CompactPath); } + private bool CompletePropagatedNullSource( + CompositeResultElement target, + ErrorTrie? errorTrie) + { + var targetPath = target.Path; + var hasErrors = false; + + if (errorTrie is not null) + { + AddErrorsAtPath(errorTrie, targetPath, ref hasErrors); + } + + if (!hasErrors && !target.IsNullable) + { + var error = FusionErrorHelper.NonNullOutputFieldViolation(targetPath); + + _store.AddError(_errorHandler.Handle(error)); + } + + var didPropagateToRoot = PropagateNullValues(target); + return !didPropagateToRoot; + } + + private void AddErrorsAtPath(ErrorTrie errorTrie, Path targetPath, ref bool hasErrors) + { + if (errorTrie.Error is { } error) + { + var errorWithPath = ErrorBuilder.FromError(error) + .SetPath(targetPath) + .Build(); + + _store.AddError(_errorHandler.Handle(errorWithPath)); + hasErrors = true; + } + + foreach (var child in errorTrie.Values) + { + AddErrorsAtPath(child, targetPath, ref hasErrors); + } + } + /// /// Completes the selection set of for a null /// source, applying per-field nullability so that non-null fields produce a @@ -559,11 +607,7 @@ private bool TryCompleteValue( else { var path = target.CompactPath.ToPath(target.Operation); - error = ErrorBuilder.New() - .SetMessage("Cannot return null for non-nullable field.") - .SetCode(ErrorCodes.Execution.NonNullViolation) - .SetPath(path) - .Build(); + error = FusionErrorHelper.NonNullOutputFieldViolation(path); } error = _errorHandler.Handle(error); 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 f0b86520753..229c08036ee 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -450,6 +450,44 @@ private static void IndexDependencies( ctx.FallbackByNodeId.Add(nodePlanStep.Id, nodePlanStep.FallbackQuery.Id); } } + + AddPropagateNullLookupDependencies(planSteps, ctx); + } + + private static void AddPropagateNullLookupDependencies( + ImmutableList planSteps, + ExecutionPlanBuildContext ctx) + { + var operationSteps = planSteps.OfType().ToArray(); + + foreach (var propagateNullStep in operationSteps) + { + if (propagateNullStep.Lookup is not { PropagateNull: true }) + { + continue; + } + + foreach (var candidate in operationSteps) + { + if (candidate.Id == propagateNullStep.Id + || candidate.Lookup is null or { PropagateNull: true } + || !candidate.Target.Equals(propagateNullStep.Target) + || !candidate.Type.Name.Equals(propagateNullStep.Type.Name, StringComparison.Ordinal) + || propagateNullStep.DependsOn(candidate, planSteps) + || candidate.DependsOn(propagateNullStep, planSteps)) + { + continue; + } + + if (!ctx.DependenciesByStepId.TryGetValue(candidate.Id, out var dependencies)) + { + dependencies = []; + ctx.DependenciesByStepId[candidate.Id] = dependencies; + } + + dependencies.Add(propagateNullStep.Id); + } + } } private static void BuildExecutionNodes( @@ -564,7 +602,8 @@ private static OperationExecutionNode CreateOperationExecutionNode( forwardedVariables, resultSelectionSet, operationStep.Conditions, - requiresFileUpload); + requiresFileUpload, + operationStep.Lookup?.PropagateNull ?? false); foreach (var parentDependency in operationStep.ParentDependencies) { @@ -995,7 +1034,8 @@ private static BatchOperationDefinition CreateBatchOperationDefinition(MergeResu primary.ForwardedVariables.ToArray(), primary.ResultSelectionSet, primary.Conditions.ToArray(), - primary.RequiresFileUpload); + primary.RequiresFileUpload, + primary.PropagateNull); foreach (var parentDependency in primary.BufferedParentDependencies) { @@ -1017,7 +1057,8 @@ private static SingleOperationDefinition CreateSingleOperationDefinition(Operati member.ForwardedVariables.ToArray(), member.ResultSelectionSet, member.Conditions.ToArray(), - member.RequiresFileUpload); + member.RequiresFileUpload, + member.PropagateNull); foreach (var parentDependency in member.BufferedParentDependencies) { @@ -1375,7 +1416,7 @@ private static string ComputeCanonicalSignature(OperationExecutionNode node) .OrderBy(c => c.VariableName) .Select(c => $"{c.VariableName}:{c.PassingValue}")); - return $"{node.SchemaName}|{node.Source}|{conditions}|{bodyText}"; + return $"{node.SchemaName}|{node.Source}|{node.PropagateNull}|{conditions}|{bodyText}"; } private static (OperationSourceText operation, OperationRequirement[] requirements) CanonicalizeOperation( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs index 91035c0d99c..d1787034657 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs @@ -194,5 +194,35 @@ internal static string JsonOperationPlanParser_SingleOperationRequired { return ResourceManager.GetString("JsonOperationPlanParser_SingleOperationRequired", resourceCulture); } } + + internal static string ErrorHelper_RequestTimeout_Message { + get { + return ResourceManager.GetString("ErrorHelper_RequestTimeout_Message", resourceCulture); + } + } + + internal static string ErrorHelper_StateInvalidForOperationPlanCache_Message { + get { + return ResourceManager.GetString("ErrorHelper_StateInvalidForOperationPlanCache_Message", resourceCulture); + } + } + + internal static string ErrorHelper_StateInvalidForVariableCoercion_Message { + get { + return ResourceManager.GetString("ErrorHelper_StateInvalidForVariableCoercion_Message", resourceCulture); + } + } + + internal static string ErrorHelper_UnexpectedExecutionError_Message { + get { + return ResourceManager.GetString("ErrorHelper_UnexpectedExecutionError_Message", resourceCulture); + } + } + + internal static string ErrorHelper_NonNullOutputFieldViolation_Message { + get { + return ResourceManager.GetString("ErrorHelper_NonNullOutputFieldViolation_Message", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx index 3f46ae35af5..2fca8bc5672 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx @@ -93,4 +93,19 @@ There must be exactly one operation definition in the operation document of the operation plan. + + The request exceeded the configured timeout of `{0}`. + + + The operation plan cache requires a operation document hash. + + + The variable coercion requires an operation execution plan. + + + Unexpected Execution Error + + + Cannot return null for non-nullable field. + diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs index 0d9fbeade82..67b8d059fb0 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs @@ -253,6 +253,50 @@ type Product """); } + [Fact] + public void Merge_ObjectWithPropagateNullLookup_MatchesSnapshot() + { + AssertMatches( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup @propagateNull + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + productById(id: ID! @fusion__inputField(schema: A)): Product + @fusion__field(schema: A) + } + + type Product + @fusion__type(schema: A) + @fusion__lookup( + schema: A + key: "id" + field: "productById(id: ID!): Product" + map: ["id"] + path: null + internal: false + propagateNull: true + ) { + id: ID! @fusion__field(schema: A) + name: String! @fusion__field(schema: A) + } + """); + } + // Nested @lookup [Fact] public void Merge_ObjectWithNestedLookup_MatchesSnapshot() diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullNotRepeatableRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullNotRepeatableRuleTests.cs new file mode 100644 index 00000000000..31bacddeb90 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullNotRepeatableRuleTests.cs @@ -0,0 +1,55 @@ +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +public sealed class PropagateNullNotRepeatableRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new PropagateNullNotRepeatableRule(); + + [Fact] + public void Validate_SinglePropagateNullDirective_Succeeds() + { + AssertValid( + [ + """ + type Query { + productById(id: ID!): Product @lookup @propagateNull + } + + type Product { + id: ID! + name: String + } + """ + ]); + } + + [Fact] + public void Validate_DuplicatePropagateNullDirective_Fails() + { + AssertInvalid( + [ + """ + type Query { + productById(id: ID!): Product @lookup @propagateNull @propagateNull + } + + type Product { + id: ID! + name: String + } + """ + ], + [ + """ + { + "message": "The field 'Query.productById' in schema 'A' includes multiple @propagateNull directives, but @propagateNull is not repeatable.", + "code": "PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE", + "severity": "Error", + "coordinate": "Query.productById", + "member": "productById", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRuleTests.cs new file mode 100644 index 00000000000..36055e77565 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/PropagateNullOnNonLookupFieldRuleTests.cs @@ -0,0 +1,55 @@ +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +public sealed class PropagateNullOnNonLookupFieldRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new PropagateNullOnNonLookupFieldRule(); + + [Fact] + public void Validate_PropagateNullOnLookupField_Succeeds() + { + AssertValid( + [ + """ + type Query { + productById(id: ID!): Product @lookup @propagateNull + } + + type Product { + id: ID! + name: String + } + """ + ]); + } + + [Fact] + public void Validate_PropagateNullOnNonLookupField_Fails() + { + AssertInvalid( + [ + """ + type Query { + product: Product @propagateNull + } + + type Product { + id: ID! + name: String + } + """ + ], + [ + """ + { + "message": "The field 'Query.product' in schema 'A' includes a @propagateNull directive, but is not a lookup field.", + "code": "PROPAGATE_NULL_ON_NON_LOOKUP_FIELD", + "severity": "Error", + "coordinate": "Query.product", + "member": "product", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql index ec11b0be9b0..4cc37fcbb42 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql @@ -122,6 +122,8 @@ directive @fusion__lookup( map: [fusion__FieldSelectionMap!]! "The path to the lookup field relative to the Query type." path: fusion__FieldSelectionPath + "Specifies whether a null result from this lookup invalidates the resolved entity." + propagateNull: Boolean! = false "The name of the source schema where the annotated entity type can be looked up from." schema: fusion__Schema! ) repeatable on OBJECT | INTERFACE | UNION diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs new file mode 100644 index 00000000000..a1749c18d2d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs @@ -0,0 +1,472 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Text; +using HotChocolate.Execution; +using HotChocolate.Fusion.Configuration; +using HotChocolate.Fusion.Execution.Clients; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Execution; + +public sealed class PropagateNullLookupExecutionTests +{ + [Fact] + public async Task Execute_Should_NullEntityAndSkipDependentLookup_When_PropagateNullLookupReturnsCleanNull() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: true, listProduct: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("A")); + Assert.Single(clients.RequestsBySchema("B")); + Assert.Empty(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_LiftLookupErrorToEntityPath_When_PropagateNullLookupReturnsNullWithError() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null},"errors":[{"message":"Product missing.","path":["productById"]}]}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: true, listProduct: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("A")); + Assert.Single(clients.RequestsBySchema("B")); + Assert.Empty(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Product missing.", + "path": [ + "product" + ] + } + ], + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_BubbleFromEntity_When_CleanPropagateNullLookupInvalidatesNonNullEntity() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: false, listProduct: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("A")); + Assert.Single(clients.RequestsBySchema("B")); + Assert.Empty(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Cannot return null for non-nullable field.", + "path": [ + "product" + ], + "extensions": { + "code": "HC0018" + } + } + ], + "data": null + } + """); + } + + [Fact] + public async Task Execute_Should_NullOnlyListElementAndResolveSibling_When_PropagateNullLookupReturnsNullInList() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"products":[{"id":"1","name":"Chair"},{"id":"2","name":"Desk"}]}}"""), + ("B", """{"data":{"productById":null}}"""), + ("B", """{"data":{"productById":{"description":"desk-description"}}}"""), + ("C", """{"data":{"productByDescription":{"detail":"Ships tomorrow"}}}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: true, listProduct: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + products { + name + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("A")); + Assert.Single(clients.RequestsBySchema("B")); + var cRequest = Assert.Single(clients.RequestsBySchema("C")); + Assert.Equal(1, cRequest.VariableSetCount); + result.MatchInlineSnapshot( + """ + { + "data": { + "products": [ + null, + { + "name": "Desk", + "description": "desk-description", + "detail": "Ships tomorrow" + } + ] + } + } + """); + } + + [Fact] + public async Task Execute_Should_SkipKeyOnlyDownstreamLookup_When_PropagateNullLookupInvalidatesEntity() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}"""), + ("C", """{"data":{"productDetailsById":{"detail":"Ships tomorrow"}}}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: false, + downstreamLookupUsesEntityKey: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("A")); + Assert.Single(clients.RequestsBySchema("B")); + Assert.Empty(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "data": { + "product": null + } + } + """); + } + + private static async Task CreateExecutorAsync( + TestSourceSchemaClients clients, + bool nullableProduct, + bool listProduct, + bool downstreamLookupUsesEntityKey = false) + { + var services = new ServiceCollection(); + services.AddHttpClient(); + + var builder = services + .AddGraphQLGateway() + .AddInMemoryConfiguration( + CreateExecutionSchemaDocument( + nullableProduct, + listProduct, + downstreamLookupUsesEntityKey)); + + builder.Services.AddSingleton( + new TestSourceSchemaClientFactory(clients)); + + FusionSetupUtilities.Configure( + builder, + setup => + { + setup.ClientConfigurationModifiers.Add(_ => new TestSourceSchemaClientConfiguration("A")); + setup.ClientConfigurationModifiers.Add(_ => new TestSourceSchemaClientConfiguration("B")); + setup.ClientConfigurationModifiers.Add(_ => new TestSourceSchemaClientConfiguration("C")); + }); + + return await services.BuildGatewayAsync(TestContext.Current.CancellationToken); + } + + private static DocumentNode CreateExecutionSchemaDocument( + bool nullableProduct, + bool listProduct, + bool downstreamLookupUsesEntityKey) + { + var productFieldType = listProduct + ? "[Product]" + : nullableProduct + ? "Product" + : "Product!"; + + var productFieldName = listProduct ? "products" : "product"; + var downstreamLookupFieldDefinition = downstreamLookupUsesEntityKey + ? "productDetailsById(id: ID!): Product" + : "productByDescription(description: String!): Product"; + var downstreamLookupMap = downstreamLookupUsesEntityKey + ? "id" + : "description"; + + return Utf8GraphQLParser.Parse( + $$""" + type Query + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__type(schema: C) { + {{productFieldName}}: {{productFieldType}} + @fusion__field(schema: A) + productById(id: ID!): Product + @fusion__field(schema: B) + {{downstreamLookupFieldDefinition}} + @fusion__field(schema: C) + } + + type Product + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__type(schema: C) + @fusion__lookup( + schema: B + key: "{ id }" + field: "productById(id: ID!): Product" + map: ["id"] + propagateNull: true + internal: false + ) + @fusion__lookup( + schema: C + key: "{ {{downstreamLookupMap}} }" + field: "{{downstreamLookupFieldDefinition}}" + map: ["{{downstreamLookupMap}}"] + internal: false + ) { + id: ID! + @fusion__field(schema: A) + name: String + @fusion__field(schema: A) + description: String! + @fusion__field(schema: B) + detail: String + @fusion__field(schema: C) + } + + enum fusion__Schema { + A + B + C + } + + scalar fusion__FieldDefinition + scalar fusion__FieldSelectionMap + scalar fusion__FieldSelectionPath + scalar fusion__FieldSelectionSet + + directive @fusion__type( + schema: fusion__Schema! + ) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @fusion__field( + schema: fusion__Schema! + ) repeatable on FIELD_DEFINITION + + directive @fusion__lookup( + schema: fusion__Schema! + key: fusion__FieldSelectionSet! + field: fusion__FieldDefinition! + map: [fusion__FieldSelectionMap!]! + path: fusion__FieldSelectionPath + propagateNull: Boolean! = false + internal: Boolean! = false + ) repeatable on OBJECT | INTERFACE | UNION + """); + } + + private sealed class TestSourceSchemaClients(params (string SchemaName, string Response)[] responses) + : ISourceSchemaClient + { + private readonly Dictionary> _responses = responses + .GroupBy(t => t.SchemaName, StringComparer.Ordinal) + .ToDictionary( + t => t.Key, + t => new Queue(t.Select(r => Encoding.UTF8.GetBytes(r.Response))), + StringComparer.Ordinal); + private readonly List _requests = []; + + public SourceSchemaClientCapabilities Capabilities => SourceSchemaClientCapabilities.None; + + public IReadOnlyList RequestsBySchema(string schemaName) + => [.. _requests.Where(t => t.SchemaName.Equals(schemaName, StringComparison.Ordinal))]; + + public async IAsyncEnumerable ExecuteAsync( + OperationPlanContext context, + SourceSchemaClientRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + + _requests.Add(new RequestInfo(request.SchemaName, request.Variables.Length)); + + if (request.Variables.Length == 0) + { + yield return CreateResult(context, request.SchemaName, CompactPath.Root); + yield break; + } + + foreach (var variable in request.Variables) + { + yield return variable.AdditionalPaths.IsDefaultOrEmpty + ? CreateResult(context, request.SchemaName, variable.Path) + : CreateResult(context, request.SchemaName, variable.Path, variable.AdditionalPaths); + } + } + + public async IAsyncEnumerable ExecuteBatchAsync( + OperationPlanContext context, + ImmutableArray requests, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + + for (var i = 0; i < requests.Length; i++) + { + var request = requests[i]; + _requests.Add(new RequestInfo(request.SchemaName, request.Variables.Length)); + + if (request.Variables.Length == 0) + { + yield return new SourceSchemaBatchResult( + i, + CreateResult(context, request.SchemaName, CompactPath.Root)); + continue; + } + + foreach (var variable in request.Variables) + { + var result = variable.AdditionalPaths.IsDefaultOrEmpty + ? CreateResult(context, request.SchemaName, variable.Path) + : CreateResult(context, request.SchemaName, variable.Path, variable.AdditionalPaths); + + yield return new SourceSchemaBatchResult(i, result); + } + } + } + + public IAsyncEnumerable SubscribeAsync( + OperationPlanContext context, + SourceSchemaClientRequest request, + CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private SourceSchemaResult CreateResult( + OperationPlanContext context, + string schemaName, + CompactPath path, + CompactPathSegment additionalPaths = default) + { + if (!_responses.TryGetValue(schemaName, out var responses) || responses.Count == 0) + { + throw new InvalidOperationException($"No response configured for schema `{schemaName}`."); + } + + var response = responses.Dequeue(); + var arena = context.MemorySource.GetNextArena(); + var document = SourceResultDocument.Parse(arena, response, response.Length); + + return additionalPaths.IsDefaultOrEmpty + ? new SourceSchemaResult(path, document) + : new SourceSchemaResult(path, document, additionalPaths: additionalPaths); + } + } + + private sealed record RequestInfo(string SchemaName, int VariableSetCount); + + private sealed class TestSourceSchemaClientFactory(TestSourceSchemaClients clients) + : ISourceSchemaClientFactory + { + public bool CanHandle(ISourceSchemaClientConfiguration configuration) + => configuration is TestSourceSchemaClientConfiguration; + + public ISourceSchemaClient CreateClient( + FusionSchemaDefinition schema, + ISourceSchemaClientConfiguration configuration) + => clients; + } + + private sealed class TestSourceSchemaClientConfiguration(string name) + : ISourceSchemaClientConfiguration + { + public string Name { get; } = name; + + public SupportedOperationType SupportedOperations => SupportedOperationType.All; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs index 1f11723f5d6..9b09ca26229 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs @@ -63,7 +63,8 @@ type Query { SelectionPath.Root, results, node.ResultSelectionSet, - containsErrors)); + containsErrors, + propagateNull: false)); // assert Assert.Contains("Expected StartArray but found StartObject.", exception.Message); From 966700a0b7143d7cf7f4b1da2ce5aa93d5d666e4 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 29 Jun 2026 23:05:38 +0200 Subject: [PATCH 2/3] polish --- .../Logging/LogEntryCodes.cs | 3 +- .../Logging/LogEntryHelper.cs | 12 +- .../CompositionResources.Designer.cs | 12 +- .../Properties/CompositionResources.resx | 6 +- .../Execution/Results/FetchResultStore.cs | 17 +- .../Execution/Results/ValueCompletion.cs | 14 + .../OperationPlanner.BuildExecutionTree.cs | 38 -- .../PropagateNullLookupExecutionTests.cs | 429 +++++++++++++++++- 8 files changed, 458 insertions(+), 73 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs index 7bd7d3e6e23..fa3487e81eb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -58,8 +58,7 @@ public static class LogEntryCodes public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE"; public const string OverrideFromSelf = "OVERRIDE_FROM_SELF"; public const string OverrideOnInterface = "OVERRIDE_ON_INTERFACE"; - public const string PropagateNullDirectiveNotRepeatable = - "PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE"; + public const string PropagateNullDirectiveNotRepeatable = "PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE"; public const string PropagateNullOnNonLookupField = "PROPAGATE_NULL_ON_NON_LOOKUP_FIELD"; public const string ProvidesDirectiveInFieldsArgument = "PROVIDES_DIRECTIVE_IN_FIELDS_ARGUMENT"; public const string ProvidesFieldsHasArguments = "PROVIDES_FIELDS_HAS_ARGUMENTS"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs index c6b22e0da24..a518bb00f91 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -1166,32 +1166,32 @@ public static LogEntry ProvidesOnNonCompositeField( .Build(); } - public static LogEntry PropagateNullDirectiveNotRepeatable( + public static LogEntry PropagateNullOnNonLookupField( MutableOutputFieldDefinition field, MutableSchemaDefinition schema) { return LogEntryBuilder.New() .SetMessage( - LogEntryHelper_PropagateNullDirectiveNotRepeatable, + LogEntryHelper_PropagateNullOnNonLookupField, field.Coordinate.ToString(), schema.Name) - .SetCode(LogEntryCodes.PropagateNullDirectiveNotRepeatable) + .SetCode(LogEntryCodes.PropagateNullOnNonLookupField) .SetSeverity(LogSeverity.Error) .SetTypeSystemMember(field) .SetSchema(schema) .Build(); } - public static LogEntry PropagateNullOnNonLookupField( + public static LogEntry PropagateNullDirectiveNotRepeatable( MutableOutputFieldDefinition field, MutableSchemaDefinition schema) { return LogEntryBuilder.New() .SetMessage( - LogEntryHelper_PropagateNullOnNonLookupField, + LogEntryHelper_PropagateNullDirectiveNotRepeatable, field.Coordinate.ToString(), schema.Name) - .SetCode(LogEntryCodes.PropagateNullOnNonLookupField) + .SetCode(LogEntryCodes.PropagateNullDirectiveNotRepeatable) .SetSeverity(LogSeverity.Error) .SetTypeSystemMember(field) .SetSchema(schema) 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 ce407bd0c02..eb9e0d5b24a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -1295,20 +1295,20 @@ internal static string LogEntryHelper_OverrideOnInterface { } /// - /// Looks up a localized string similar to The field '{0}' in schema '{1}' includes multiple @propagateNull directives, but @propagateNull is not repeatable.. + /// Looks up a localized string similar to The field '{0}' in schema '{1}' includes a @propagateNull directive, but is not a lookup field.. /// - internal static string LogEntryHelper_PropagateNullDirectiveNotRepeatable { + internal static string LogEntryHelper_PropagateNullOnNonLookupField { get { - return ResourceManager.GetString("LogEntryHelper_PropagateNullDirectiveNotRepeatable", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_PropagateNullOnNonLookupField", resourceCulture); } } /// - /// Looks up a localized string similar to The field '{0}' in schema '{1}' includes a @propagateNull directive, but is not a lookup field.. + /// Looks up a localized string similar to The field '{0}' in schema '{1}' includes multiple @propagateNull directives, but @propagateNull is not repeatable.. /// - internal static string LogEntryHelper_PropagateNullOnNonLookupField { + internal static string LogEntryHelper_PropagateNullDirectiveNotRepeatable { get { - return ResourceManager.GetString("LogEntryHelper_PropagateNullOnNonLookupField", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_PropagateNullDirectiveNotRepeatable", resourceCulture); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx index ba15706b4e7..416adbf6171 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -426,12 +426,12 @@ The interface field '{0}' in schema '{1}' must not be annotated with the @override directive. - - The field '{0}' in schema '{1}' includes multiple @propagateNull directives, but @propagateNull is not repeatable. - The field '{0}' in schema '{1}' includes a @propagateNull directive, but is not a lookup field. + + The field '{0}' in schema '{1}' includes multiple @propagateNull directives, but @propagateNull is not repeatable. + The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 6d899e54e40..7ea7c429012 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -731,12 +731,11 @@ private bool SaveSafeResult( && errorTrie is not null && dataElement.ValueKind is JsonValueKind.Null) { - return _valueCompletion.BuildResult( - dataElement, - element, - errorTrie, - resultSelectionSet, - propagateNull); + // The entity (or one of its nullable ancestors) was already invalidated by + // an earlier @propagateNull lookup on this coordinate. The live element here + // may be a short-circuited ancestor, so the error is lifted to the entity + // coordinate computed from the lookup's target path. + _valueCompletion.AddPropagatedNullError(path, errorTrie, _operation); } return true; @@ -916,13 +915,13 @@ private ReadOnlySpan CollectTargetElements(SelectionPath continue; } - if (value.IsNullOrInvalidated || value.ValueKind is JsonValueKind.Undefined) + var valueKind = value.ValueKind; + + if (valueKind is JsonValueKind.Null or JsonValueKind.Undefined || value.IsInvalidated) { continue; } - var valueKind = value.ValueKind; - if (valueKind is JsonValueKind.Array) { AppendUnrolledLists(value, ref next, ref nextCount); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs index 3ad1175d0f9..a0e236efd1e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs @@ -331,6 +331,20 @@ private bool CompletePropagatedNullSource( return !didPropagateToRoot; } + /// + /// Records a @propagateNull lookup error at the entity coordinate when the entity + /// has already been invalidated by an earlier @propagateNull lookup on the same + /// coordinate. + /// + public void AddPropagatedNullError( + CompactPath entityPath, + ErrorTrie errorTrie, + Operation operation) + { + var hasErrors = false; + AddErrorsAtPath(errorTrie, entityPath.ToPath(operation), ref hasErrors); + } + private void AddErrorsAtPath(ErrorTrie errorTrie, Path targetPath, ref bool hasErrors) { if (errorTrie.Error is { } error) 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 229c08036ee..8489b4861ce 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -450,44 +450,6 @@ private static void IndexDependencies( ctx.FallbackByNodeId.Add(nodePlanStep.Id, nodePlanStep.FallbackQuery.Id); } } - - AddPropagateNullLookupDependencies(planSteps, ctx); - } - - private static void AddPropagateNullLookupDependencies( - ImmutableList planSteps, - ExecutionPlanBuildContext ctx) - { - var operationSteps = planSteps.OfType().ToArray(); - - foreach (var propagateNullStep in operationSteps) - { - if (propagateNullStep.Lookup is not { PropagateNull: true }) - { - continue; - } - - foreach (var candidate in operationSteps) - { - if (candidate.Id == propagateNullStep.Id - || candidate.Lookup is null or { PropagateNull: true } - || !candidate.Target.Equals(propagateNullStep.Target) - || !candidate.Type.Name.Equals(propagateNullStep.Type.Name, StringComparison.Ordinal) - || propagateNullStep.DependsOn(candidate, planSteps) - || candidate.DependsOn(propagateNullStep, planSteps)) - { - continue; - } - - if (!ctx.DependenciesByStepId.TryGetValue(candidate.Id, out var dependencies)) - { - dependencies = []; - ctx.DependenciesByStepId[candidate.Id] = dependencies; - } - - dependencies.Add(propagateNullStep.Id); - } - } } private static void BuildExecutionNodes( diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs index a1749c18d2d..0f59b460d7a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/PropagateNullLookupExecutionTests.cs @@ -184,7 +184,7 @@ public async Task Execute_Should_NullOnlyListElementAndResolveSibling_When_Propa } [Fact] - public async Task Execute_Should_SkipKeyOnlyDownstreamLookup_When_PropagateNullLookupInvalidatesEntity() + public async Task Execute_Should_NotApplyKeyOnlyDownstreamResult_When_PropagateNullLookupInvalidatesEntity() { // arrange var clients = new TestSourceSchemaClients( @@ -213,6 +213,40 @@ public async Task Execute_Should_SkipKeyOnlyDownstreamLookup_When_PropagateNullL // assert Assert.Single(clients.RequestsBySchema("A")); Assert.Single(clients.RequestsBySchema("B")); + Assert.Single(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_SuppressNonNullContributedFieldViolation_When_PropagateNullLookupReturnsCleanNull() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: true, listProduct: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); Assert.Empty(clients.RequestsBySchema("C")); result.MatchInlineSnapshot( """ @@ -224,11 +258,371 @@ public async Task Execute_Should_SkipKeyOnlyDownstreamLookup_When_PropagateNullL """); } + [Fact] + public async Task Execute_Should_LiftLookupErrorToEntityAndBubble_When_PropagateNullLookupErrorsOnNonNullEntity() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null},"errors":[{"message":"Product missing.","path":["productById"]}]}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: false, listProduct: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Product missing.", + "path": [ + "product" + ] + } + ], + "data": null + } + """); + } + + [Fact] + public async Task Execute_Should_RecordBothEntityErrors_When_BothPropagateNullLookupsError() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null},"errors":[{"message":"Product unavailable.","path":["productById"]}]}"""), + ("C", """{"data":{"productDetailsById":null},"errors":[{"message":"Product unavailable.","path":["productDetailsById"]}]}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: false, + propagateNullDownstream: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + Assert.Single(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Product unavailable.", + "path": [ + "product" + ] + }, + { + "message": "Product unavailable.", + "path": [ + "product" + ] + } + ], + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_RecordSingleEntityError_When_FirstLookupErrorsAndSecondReturnsCleanNull() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null},"errors":[{"message":"Product unavailable.","path":["productById"]}]}"""), + ("C", """{"data":{"productDetailsById":null}}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: false, + propagateNullDownstream: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + Assert.Single(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Product unavailable.", + "path": [ + "product" + ] + } + ], + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_RecordSingleEntityError_When_FirstLookupReturnsCleanNullAndSecondErrors() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}"""), + ("C", """{"data":{"productDetailsById":null},"errors":[{"message":"Detail unavailable.","path":["productDetailsById"]}]}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: false, + propagateNullDownstream: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + Assert.Single(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Detail unavailable.", + "path": [ + "product" + ] + } + ], + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_RecordNoError_When_BothPropagateNullLookupsReturnCleanNull() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}"""), + ("C", """{"data":{"productDetailsById":null}}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: false, + propagateNullDownstream: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + Assert.Single(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "data": { + "product": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_RecordBothEntityErrors_When_BothLookupsErrorOnNonNullListElement() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"products":[{"id":"1","name":"Chair"}]}}"""), + ("B", """{"data":{"productById":null},"errors":[{"message":"Product unavailable.","path":["productById"]}]}"""), + ("C", """{"data":{"productDetailsById":null},"errors":[{"message":"Product unavailable.","path":["productDetailsById"]}]}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: true, + propagateNullDownstream: true, + nonNullListElement: true); + + // act + var result = await executor.ExecuteAsync( + """ + query { + products { + description + detail + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + Assert.Single(clients.RequestsBySchema("C")); + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "Product unavailable.", + "path": [ + "products", + 0 + ] + }, + { + "message": "Product unavailable.", + "path": [ + "products", + 0 + ] + } + ], + "data": { + "products": null + } + } + """); + } + + [Fact] + public async Task Execute_Should_NullOnlyContributedField_When_LookupHasNoPropagateNull() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}"""), + ("B", """{"data":{"productById":null}}""")); + var executor = await CreateExecutorAsync( + clients, + nullableProduct: true, + listProduct: false, + propagateNullLookup: false, + nonNullDescription: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + description + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("B")); + result.MatchInlineSnapshot( + """ + { + "data": { + "product": { + "name": "Chair", + "description": null + } + } + } + """); + } + + [Fact] + public async Task Execute_Should_NotRequestLookup_When_PropagateNullFieldsNotSelected() + { + // arrange + var clients = new TestSourceSchemaClients( + ("A", """{"data":{"product":{"id":"1","name":"Chair"}}}""")); + var executor = await CreateExecutorAsync(clients, nullableProduct: true, listProduct: false); + + // act + var result = await executor.ExecuteAsync( + """ + query { + product { + name + } + } + """, + TestContext.Current.CancellationToken); + + // assert + Assert.Single(clients.RequestsBySchema("A")); + Assert.Empty(clients.RequestsBySchema("B")); + result.MatchInlineSnapshot( + """ + { + "data": { + "product": { + "name": "Chair" + } + } + } + """); + } + private static async Task CreateExecutorAsync( TestSourceSchemaClients clients, bool nullableProduct, bool listProduct, - bool downstreamLookupUsesEntityKey = false) + bool downstreamLookupUsesEntityKey = false, + bool propagateNullLookup = true, + bool propagateNullDownstream = false, + bool nonNullDescription = true, + bool nonNullListElement = false) { var services = new ServiceCollection(); services.AddHttpClient(); @@ -239,7 +633,11 @@ private static async Task CreateExecutorAsync( CreateExecutionSchemaDocument( nullableProduct, listProduct, - downstreamLookupUsesEntityKey)); + downstreamLookupUsesEntityKey, + propagateNullLookup, + propagateNullDownstream, + nonNullDescription, + nonNullListElement)); builder.Services.AddSingleton( new TestSourceSchemaClientFactory(clients)); @@ -259,22 +657,34 @@ private static async Task CreateExecutorAsync( private static DocumentNode CreateExecutionSchemaDocument( bool nullableProduct, bool listProduct, - bool downstreamLookupUsesEntityKey) + bool downstreamLookupUsesEntityKey, + bool propagateNullLookup, + bool propagateNullDownstream, + bool nonNullDescription, + bool nonNullListElement) { var productFieldType = listProduct - ? "[Product]" + ? nonNullListElement ? "[Product!]" : "[Product]" : nullableProduct ? "Product" : "Product!"; var productFieldName = listProduct ? "products" : "product"; - var downstreamLookupFieldDefinition = downstreamLookupUsesEntityKey + + // A second @propagateNull lookup is modeled by making the downstream lookup C + // resolve from the entity key (id) so it runs independently of lookup B. + var downstreamUsesEntityKey = downstreamLookupUsesEntityKey || propagateNullDownstream; + var downstreamLookupFieldDefinition = downstreamUsesEntityKey ? "productDetailsById(id: ID!): Product" : "productByDescription(description: String!): Product"; - var downstreamLookupMap = downstreamLookupUsesEntityKey + var downstreamLookupMap = downstreamUsesEntityKey ? "id" : "description"; + var lookupPropagateNull = propagateNullLookup ? "true" : "false"; + var downstreamPropagateNull = propagateNullDownstream ? "true" : "false"; + var descriptionType = nonNullDescription ? "String!" : "String"; + return Utf8GraphQLParser.Parse( $$""" type Query @@ -298,7 +708,7 @@ type Product key: "{ id }" field: "productById(id: ID!): Product" map: ["id"] - propagateNull: true + propagateNull: {{lookupPropagateNull}} internal: false ) @fusion__lookup( @@ -306,13 +716,14 @@ type Product key: "{ {{downstreamLookupMap}} }" field: "{{downstreamLookupFieldDefinition}}" map: ["{{downstreamLookupMap}}"] + propagateNull: {{downstreamPropagateNull}} internal: false ) { id: ID! @fusion__field(schema: A) name: String @fusion__field(schema: A) - description: String! + description: {{descriptionType}} @fusion__field(schema: B) detail: String @fusion__field(schema: C) From 5c11a0e6b16205aa1e3a055f41e5609d8effe09a Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 30 Jun 2026 10:33:45 +0200 Subject: [PATCH 3/3] adds docs --- .../docs/fusion/directives-reference.md | 77 +++++++++++++++---- .../docs/fusion/v16/directives-reference.md | 77 +++++++++++++++---- 2 files changed, 126 insertions(+), 28 deletions(-) diff --git a/website-next/content/docs/fusion/directives-reference.md b/website-next/content/docs/fusion/directives-reference.md index 4cc6923278e..da9a878366a 100644 --- a/website-next/content/docs/fusion/directives-reference.md +++ b/website-next/content/docs/fusion/directives-reference.md @@ -9,20 +9,21 @@ In HotChocolate, these directives are expressed using C# attributes. See the ind # Quick Reference -| Directive | Locations | Repeatable | Purpose | -| -------------------------------- | ----------------------------------------- | :--------: | ------------------------------------------------- | -| [`@key`](#key) | `OBJECT`, `INTERFACE` | Yes | Define entity identity | -| [`@lookup`](#lookup) | `FIELD_DEFINITION` | No | Mark entity lookup resolvers | -| [`@is`](#is) | `ARGUMENT_DEFINITION` | No | Map lookup arguments to entity fields | -| [`@require`](#require) | `ARGUMENT_DEFINITION` | No | Declare cross-subgraph data dependencies | -| [`@shareable`](#shareable) | `OBJECT`, `FIELD_DEFINITION` | Yes | Allow multiple subgraphs to define the same field | -| [`@provides`](#provides) | `FIELD_DEFINITION` | No | Declare locally-resolvable subfields | -| [`@external`](#external) | `FIELD_DEFINITION` | No | Mark field as owned by another subgraph | -| [`@override`](#override) | `FIELD_DEFINITION` | No | Migrate field ownership between subgraphs | -| [`@internal`](#internal) | `OBJECT`, `FIELD_DEFINITION` | No | Hide from composite schema and merge process | -| [`@inaccessible`](#inaccessible) | 10 locations | No | Hide from client-facing composite schema | -| [`@eventStream`](#eventstream) | `FIELD_DEFINITION` | No | Back a subscription field with a message broker | -| [`@eventCursor`](#eventcursor) | `ARGUMENT_DEFINITION`, `FIELD_DEFINITION` | No | Mark the resume cursor for resumable streams | +| Directive | Locations | Repeatable | Purpose | +| ---------------------------------- | ----------------------------------------- | :--------: | ------------------------------------------------- | +| [`@key`](#key) | `OBJECT`, `INTERFACE` | Yes | Define entity identity | +| [`@lookup`](#lookup) | `FIELD_DEFINITION` | No | Mark entity lookup resolvers | +| [`@propagateNull`](#propagatenull) | `FIELD_DEFINITION` | No | Invalidate entity when its lookup returns null | +| [`@is`](#is) | `ARGUMENT_DEFINITION` | No | Map lookup arguments to entity fields | +| [`@require`](#require) | `ARGUMENT_DEFINITION` | No | Declare cross-subgraph data dependencies | +| [`@shareable`](#shareable) | `OBJECT`, `FIELD_DEFINITION` | Yes | Allow multiple subgraphs to define the same field | +| [`@provides`](#provides) | `FIELD_DEFINITION` | No | Declare locally-resolvable subfields | +| [`@external`](#external) | `FIELD_DEFINITION` | No | Mark field as owned by another subgraph | +| [`@override`](#override) | `FIELD_DEFINITION` | No | Migrate field ownership between subgraphs | +| [`@internal`](#internal) | `OBJECT`, `FIELD_DEFINITION` | No | Hide from composite schema and merge process | +| [`@inaccessible`](#inaccessible) | 10 locations | No | Hide from client-facing composite schema | +| [`@eventStream`](#eventstream) | `FIELD_DEFINITION` | No | Back a subscription field with a message broker | +| [`@eventCursor`](#eventcursor) | `ARGUMENT_DEFINITION`, `FIELD_DEFINITION` | No | Mark the resume cursor for resumable streams | --- @@ -130,6 +131,54 @@ type Product { --- +## `@propagateNull` + +Marks a lookup field whose `null` result invalidates the entire entity it resolves, instead of only the fields that lookup contributes. + +```graphql +directive @propagateNull on FIELD_DEFINITION +``` + +`@propagateNull` may only be applied to a field that is also a lookup (`@lookup`). Applying it to a non-lookup field fails composition with `PROPAGATE_NULL_ON_NON_LOOKUP_FIELD`, and the directive is not repeatable, so applying it more than once on the same field fails with `PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE`. + +By default, when a lookup returns `null` for an entity the gateway treats it as "this source has nothing to contribute": the entity is kept and only the fields that lookup would have provided are set to `null`. With `@propagateNull`, a `null` lookup result instead means "this entity does not exist", so the gateway invalidates the whole entity node that the lookup resolved: + +- The entity is set to `null`. If it sits in a non-null position, the `null` propagates up the response following the standard GraphQL null-propagation rules. +- Dependent lookups that draw their input from the invalidated lookup are skipped, avoiding those subgraph requests. A dependent lookup keyed on the entity's already-known key may still be sent, but its result is discarded. +- If the lookup returned `null` together with an error, that error is re-pathed to the entity's location in the response. An entity may be resolved by more than one `@propagateNull` lookup, and each one that errors records its own error at that location. +- Inside a list, only the element backed by the `null` lookup is set to `null`; sibling elements continue to resolve. +- The invalidation only happens when the lookup actually runs. If the client selects no field backed by that lookup, the gateway never sends it and the entity is not invalidated, so `@propagateNull` is not an unconditional existence check. + +**Example:** + +```graphql +# Source schema +type Query { + productById(id: ID!): Product @lookup @propagateNull +} + +type Product @key(fields: "id") { + id: ID! + name: String! +} +``` + +```graphql +# Composed schema +type Query { + productById(id: ID!): Product +} + +type Product { + id: ID! + name: String! +} +``` + +> **In C#:** No dedicated attribute is available; apply `@propagateNull` in the source schema SDL on the lookup field. See [Entities and Lookups](./entities-and-lookups.md). + +--- + ## `@is` Maps a lookup argument to a field on the entity type when the argument name does not match the field name. diff --git a/website/src/docs/fusion/v16/directives-reference.md b/website/src/docs/fusion/v16/directives-reference.md index 108769b4509..4edc223c63b 100644 --- a/website/src/docs/fusion/v16/directives-reference.md +++ b/website/src/docs/fusion/v16/directives-reference.md @@ -8,20 +8,21 @@ In HotChocolate, these directives are expressed using C# attributes. See the ind # Quick Reference -| Directive | Locations | Repeatable | Purpose | -| -------------------------------- | ----------------------------------------- | :--------: | ------------------------------------------------- | -| [`@key`](#key) | `OBJECT`, `INTERFACE` | Yes | Define entity identity | -| [`@lookup`](#lookup) | `FIELD_DEFINITION` | No | Mark entity lookup resolvers | -| [`@is`](#is) | `ARGUMENT_DEFINITION` | No | Map lookup arguments to entity fields | -| [`@require`](#require) | `ARGUMENT_DEFINITION` | No | Declare cross-subgraph data dependencies | -| [`@shareable`](#shareable) | `OBJECT`, `FIELD_DEFINITION` | Yes | Allow multiple subgraphs to define the same field | -| [`@provides`](#provides) | `FIELD_DEFINITION` | No | Declare locally-resolvable subfields | -| [`@external`](#external) | `FIELD_DEFINITION` | No | Mark field as owned by another subgraph | -| [`@override`](#override) | `FIELD_DEFINITION` | No | Migrate field ownership between subgraphs | -| [`@internal`](#internal) | `OBJECT`, `FIELD_DEFINITION` | No | Hide from composite schema and merge process | -| [`@inaccessible`](#inaccessible) | 10 locations | No | Hide from client-facing composite schema | -| [`@eventStream`](#eventstream) | `FIELD_DEFINITION` | No | Back a subscription field with a message broker | -| [`@eventCursor`](#eventcursor) | `ARGUMENT_DEFINITION`, `FIELD_DEFINITION` | No | Mark the resume cursor for resumable streams | +| Directive | Locations | Repeatable | Purpose | +| ---------------------------------- | ----------------------------------------- | :--------: | ------------------------------------------------- | +| [`@key`](#key) | `OBJECT`, `INTERFACE` | Yes | Define entity identity | +| [`@lookup`](#lookup) | `FIELD_DEFINITION` | No | Mark entity lookup resolvers | +| [`@propagateNull`](#propagatenull) | `FIELD_DEFINITION` | No | Invalidate entity when its lookup returns null | +| [`@is`](#is) | `ARGUMENT_DEFINITION` | No | Map lookup arguments to entity fields | +| [`@require`](#require) | `ARGUMENT_DEFINITION` | No | Declare cross-subgraph data dependencies | +| [`@shareable`](#shareable) | `OBJECT`, `FIELD_DEFINITION` | Yes | Allow multiple subgraphs to define the same field | +| [`@provides`](#provides) | `FIELD_DEFINITION` | No | Declare locally-resolvable subfields | +| [`@external`](#external) | `FIELD_DEFINITION` | No | Mark field as owned by another subgraph | +| [`@override`](#override) | `FIELD_DEFINITION` | No | Migrate field ownership between subgraphs | +| [`@internal`](#internal) | `OBJECT`, `FIELD_DEFINITION` | No | Hide from composite schema and merge process | +| [`@inaccessible`](#inaccessible) | 10 locations | No | Hide from client-facing composite schema | +| [`@eventStream`](#eventstream) | `FIELD_DEFINITION` | No | Back a subscription field with a message broker | +| [`@eventCursor`](#eventcursor) | `ARGUMENT_DEFINITION`, `FIELD_DEFINITION` | No | Mark the resume cursor for resumable streams | --- @@ -131,6 +132,54 @@ type Product { --- +## `@propagateNull` + +Marks a lookup field whose `null` result invalidates the entire entity it resolves, instead of only the fields that lookup contributes. + +```graphql +directive @propagateNull on FIELD_DEFINITION +``` + +`@propagateNull` may only be applied to a field that is also a lookup (`@lookup`). Applying it to a non-lookup field fails composition with `PROPAGATE_NULL_ON_NON_LOOKUP_FIELD`, and the directive is not repeatable, so applying it more than once on the same field fails with `PROPAGATE_NULL_DIRECTIVE_NOT_REPEATABLE`. + +By default, when a lookup returns `null` for an entity the gateway treats it as "this source has nothing to contribute": the entity is kept and only the fields that lookup would have provided are set to `null`. With `@propagateNull`, a `null` lookup result instead means "this entity does not exist", so the gateway invalidates the whole entity node that the lookup resolved: + +- The entity is set to `null`. If it sits in a non-null position, the `null` propagates up the response following the standard GraphQL null-propagation rules. +- Dependent lookups that draw their input from the invalidated lookup are skipped, avoiding those subgraph requests. A dependent lookup keyed on the entity's already-known key may still be sent, but its result is discarded. +- If the lookup returned `null` together with an error, that error is re-pathed to the entity's location in the response. An entity may be resolved by more than one `@propagateNull` lookup, and each one that errors records its own error at that location. +- Inside a list, only the element backed by the `null` lookup is set to `null`; sibling elements continue to resolve. +- The invalidation only happens when the lookup actually runs. If the client selects no field backed by that lookup, the gateway never sends it and the entity is not invalidated, so `@propagateNull` is not an unconditional existence check. + +**Example:** + +```graphql +# Source schema +type Query { + productById(id: ID!): Product @lookup @propagateNull +} + +type Product @key(fields: "id") { + id: ID! + name: String! +} +``` + +```graphql +# Composed schema +type Query { + productById(id: ID!): Product +} + +type Product { + id: ID! + name: String! +} +``` + +> **In C#:** No dedicated attribute is available; apply `@propagateNull` in the source schema SDL on the lookup field. See [Entities and Lookups](/docs/fusion/v16/entities-and-lookups). + +--- + ## `@is` Maps a lookup argument to a field on the entity type when the argument name does not match the field name.