diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs index 405f5c84697..8c8351ed69c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs @@ -104,6 +104,25 @@ IReadOnlyInterfaceTypeDefinitionCollection IComplexTypeDefinition.Implements IReadOnlyFieldDefinitionCollection IComplexTypeDefinition.Fields => Fields; + /// + /// Gets a value indicating whether this type is shared across multiple source schemas. + /// + public abstract bool IsSharedType { get; } + + /// + /// Gets a value indicating whether this type is an entity type. + /// An entity type is shared and has lookups that allow it to be + /// resolved independently by source schemas. + /// + public abstract bool IsEntityType { get; } + + /// + /// Gets a value indicating whether this type is a value type. + /// A value type is shared across multiple source schemas but has no + /// entity lookups — it cannot be independently resolved. + /// + public bool IsValueType => IsSharedType && !IsEntityType; + /// /// Gets metadata about this complex type in its source schemas. /// Each entry in the collection provides information about this complex type diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionInterfaceTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionInterfaceTypeDefinition.cs index 889f57577e9..b41e5323813 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionInterfaceTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionInterfaceTypeDefinition.cs @@ -18,9 +18,17 @@ public sealed class FusionInterfaceTypeDefinition( : FusionComplexTypeDefinition(name, description, isInaccessible, fieldsDefinition) , IInterfaceTypeDefinition { + private FusionTypeFlags _flags; + /// public override TypeKind Kind => TypeKind.Interface; + /// + public override bool IsSharedType => (_flags & FusionTypeFlags.Shared) != 0; + + /// + public override bool IsEntityType => (_flags & FusionTypeFlags.Entity) != 0; + /// /// Gets metadata about this interface type in its source schemas. /// Each entry in the collection provides information about this interface type @@ -41,6 +49,7 @@ internal void Complete(CompositeInterfaceTypeCompletionContext context) Implements = context.Interfaces; base.Sources = context.Sources; Features = context.Features; + SetFlags(context.Sources); Complete(); } @@ -76,6 +85,23 @@ public override bool IsAssignableFrom(ITypeDefinition type) } } + private void SetFlags(ISourceComplexTypeCollection sources) + { + if (sources.Schemas.Length > 1) + { + _flags |= FusionTypeFlags.Shared; + } + + foreach (var source in sources) + { + if (source.Lookups.Length > 0) + { + _flags |= FusionTypeFlags.Entity; + break; + } + } + } + /// /// Creates a from a /// . diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionObjectTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionObjectTypeDefinition.cs index 94c588e3839..a0ba9218e1e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionObjectTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionObjectTypeDefinition.cs @@ -18,9 +18,17 @@ public sealed class FusionObjectTypeDefinition( : FusionComplexTypeDefinition(name, description, isInaccessible, fieldsDefinition) , IObjectTypeDefinition { + private FusionTypeFlags _flags; + /// public override TypeKind Kind => TypeKind.Object; + /// + public override bool IsSharedType => (_flags & FusionTypeFlags.Shared) != 0; + + /// + public override bool IsEntityType => (_flags & FusionTypeFlags.Entity) != 0; + /// /// Gets metadata about this object type in its source schemas. /// Each entry in the collection provides information about this object type @@ -43,6 +51,7 @@ internal void Complete(CompositeObjectTypeCompletionContext context) Implements = context.Interfaces; base.Sources = context.Sources; Features = context.Features; + SetFlags(context.Sources); Complete(); } @@ -72,6 +81,23 @@ public override bool IsAssignableFrom(ITypeDefinition type) return false; } + private void SetFlags(ISourceComplexTypeCollection sources) + { + if (sources.Schemas.Length > 1) + { + _flags |= FusionTypeFlags.Shared; + } + + foreach (var source in sources) + { + if (source.Lookups.Length > 0) + { + _flags |= FusionTypeFlags.Entity; + break; + } + } + } + /// /// Creates a from a /// . diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionTypeFlags.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionTypeFlags.cs new file mode 100644 index 00000000000..358eec9f4ed --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionTypeFlags.cs @@ -0,0 +1,24 @@ +namespace HotChocolate.Fusion.Types; + +/// +/// Defines classification flags for Fusion type definitions. +/// +[Flags] +internal enum FusionTypeFlags : byte +{ + /// + /// No flags are set. + /// + None = 0, + + /// + /// The type is shared across multiple source schemas. + /// + Shared = 1 << 0, + + /// + /// The type is an entity — it is shared and has lookups + /// that allow it to be resolved independently by source schemas. + /// + Entity = 1 << 1 +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionUnionTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionUnionTypeDefinition.cs index 660252aca80..502c3c6ae63 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionUnionTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionUnionTypeDefinition.cs @@ -14,6 +14,7 @@ namespace HotChocolate.Fusion.Types; /// public sealed class FusionUnionTypeDefinition : IUnionTypeDefinition, IFusionTypeDefinition { + private FusionTypeFlags _flags; private bool _completed; /// @@ -62,6 +63,25 @@ public FusionUnionTypeDefinition(string name, string? description, bool isInacce /// public bool IsInaccessible { get; } + /// + /// Gets a value indicating whether this type is shared across multiple source schemas. + /// + public bool IsSharedType => (_flags & FusionTypeFlags.Shared) != 0; + + /// + /// Gets a value indicating whether this type is an entity type. + /// An entity type is shared and has lookups that allow it to be + /// resolved independently by source schemas. + /// + public bool IsEntityType => (_flags & FusionTypeFlags.Entity) != 0; + + /// + /// Gets a value indicating whether this type is a value type. + /// A value type is shared across multiple source schemas but has no + /// entity lookups — it cannot be independently resolved. + /// + public bool IsValueType => IsSharedType && !IsEntityType; + /// /// Gets metadata about this union type in its source schemas. /// Each entry in the collection provides information about this union type @@ -144,6 +164,7 @@ internal void Complete(CompositeUnionTypeCompletionContext context) Types = context.Types; Sources = context.Sources; Features = context.Features; + SetFlags(context.Sources); _completed = true; } @@ -179,6 +200,23 @@ public bool IsAssignableFrom(ITypeDefinition type) } } + private void SetFlags(SourceUnionTypeCollection sources) + { + if (sources.Schemas.Length > 1) + { + _flags |= FusionTypeFlags.Shared; + } + + foreach (var source in sources) + { + if (source.Lookups.Length > 0) + { + _flags |= FusionTypeFlags.Entity; + break; + } + } + } + /// /// Gets the string representation of this union type definition. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs index fa2ac4d162a..4ce909b1878 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs @@ -1,5 +1,6 @@ using System.Text.Json; using HotChocolate.Fusion.Text.Json; +using HotChocolate.Language; using HotChocolate.Types; namespace HotChocolate.Fusion.Execution.Nodes; @@ -7,7 +8,7 @@ namespace HotChocolate.Fusion.Execution.Nodes; public sealed class IntrospectionExecutionNode : ExecutionNode { private readonly Selection[] _selections; - private readonly string[] _responseNames; + private readonly ResultSelectionSet _resultSelectionSet; private readonly ExecutionNodeCondition[] _conditions; public IntrospectionExecutionNode( @@ -26,7 +27,8 @@ public IntrospectionExecutionNode( Id = id; _selections = selections; - _responseNames = selections.Select(t => t.ResponseName).ToArray(); + var selectionSetNode = new SelectionSetNode(selections.Select(t => t.SyntaxNodes[0].Node).ToArray()); + _resultSelectionSet = ResultSelectionSet.Create(selectionSetNode); _conditions = conditions; } @@ -70,7 +72,7 @@ protected override ValueTask OnExecuteAsync( } ExecuteSelections(context, backlog); - context.AddPartialResults(resultBuilder.Build(), _responseNames); + context.AddPartialResults(resultBuilder.Build(), _resultSelectionSet); return new ValueTask(ExecutionStatus.Success); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/LargeResultSelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/LargeResultSelectionSet.cs new file mode 100644 index 00000000000..85a14ccb246 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/LargeResultSelectionSet.cs @@ -0,0 +1,31 @@ +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// A selection set with 8 or more direct selections. Uses a dictionary for O(1) child lookup. +/// Handles rare wide selection sets. +/// +internal sealed class LargeResultSelectionSet : ResultSelectionSet +{ + private readonly ResultSelection[] _selections; + private readonly Dictionary _childLookup; + + internal LargeResultSelectionSet( + ResultSelection[] selections, + ResultFragment[] fragments, + string[] allResponseNames) + : base(fragments, allResponseNames) + { + _selections = selections; + _childLookup = new Dictionary(selections.Length, StringComparer.Ordinal); + + for (var i = 0; i < selections.Length; i++) + { + _childLookup[selections[i].ResponseName] = selections[i].Child; + } + } + + protected override ReadOnlySpan DirectSelections => _selections; + + protected override bool TryGetDirectChild(string responseName, out ResultSelectionSet? child) + => _childLookup.TryGetValue(responseName, out child); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/NodeFieldExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/NodeFieldExecutionNode.cs index 88cb074adc3..bbfc129ea26 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/NodeFieldExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/NodeFieldExecutionNode.cs @@ -10,6 +10,7 @@ public sealed class NodeFieldExecutionNode : ExecutionNode { private readonly Dictionary _branches = []; private ExecutionNode _fallbackQuery = null!; + private readonly ResultSelectionSet _resultSelectionSet; private readonly string _responseName; private readonly IValueNode _idValue; private readonly ExecutionNodeCondition[] _conditions; @@ -21,6 +22,8 @@ internal NodeFieldExecutionNode( ExecutionNodeCondition[] conditions) { _responseName = responseName; + var resultSelectionSet = new SelectionSetNode([new FieldNode(responseName)]); + _resultSelectionSet = ResultSelectionSet.Create(resultSelectionSet); _idValue = idValue; Id = id; _conditions = conditions; @@ -82,14 +85,14 @@ protected override ValueTask OnExecuteAsync( .SetExtension("originalValue", id) .Build(); - context.AddErrors(error, [_responseName], Path.Root); + context.AddErrors(error, _resultSelectionSet, Path.Root); return ValueTask.FromResult(ExecutionStatus.Failed); } if (_branches.TryGetValue(typeName, out var operation)) { - // We have a branch and we select it for exclusive execution + // We have a branch, and we select it for exclusive execution EnqueueDependentForExecution(context, operation); return ValueTask.FromResult(ExecutionStatus.Success); @@ -115,7 +118,7 @@ string GetVariableValue(VariableNode variable) } } - protected override IDisposable? CreateScope(OperationPlanContext context) + protected override IDisposable CreateScope(OperationPlanContext context) => context.DiagnosticEvents.ExecuteNodeFieldNode(context, this); internal void AddBranch(string objectTypeName, ExecutionNode node) 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 194d165d791..2127bb4c254 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs @@ -11,7 +11,7 @@ public sealed class OperationBatchExecutionNode : ExecutionNode { private readonly OperationRequirement[] _requirements; private readonly string[] _forwardedVariables; - private readonly string[] _responseNames; + private readonly ResultSelectionSet _resultSelectionSet; private readonly ExecutionNodeCondition[] _conditions; private readonly bool _requiresFileUpload; private readonly OperationSourceText _operation; @@ -28,7 +28,7 @@ internal OperationBatchExecutionNode( SelectionPath source, OperationRequirement[] requirements, string[] forwardedVariables, - string[] responseNames, + ResultSelectionSet resultSelectionSet, ExecutionNodeCondition[] conditions, int? batchingGroupId, bool requiresFileUpload) @@ -41,7 +41,7 @@ internal OperationBatchExecutionNode( _source = source; _requirements = requirements; _forwardedVariables = forwardedVariables; - _responseNames = responseNames; + _resultSelectionSet = resultSelectionSet; _conditions = conditions; _requiresFileUpload = requiresFileUpload; } @@ -66,9 +66,9 @@ internal OperationBatchExecutionNode( public int? BatchingGroupId => _batchingGroupId; /// - /// Gets the response names of the target selection sets that are fulfilled by this operation. + /// Gets the result selection set fulfilled by this operation. /// - public ReadOnlySpan ResponseNames => _responseNames; + internal ResultSelectionSet ResultSelectionSet => _resultSelectionSet; /// public override string? SchemaName => _schemaName; @@ -211,7 +211,7 @@ protected override async ValueTask OnExecuteAsync( singleResult.Dispose(); } - AddErrors(context, exception, variables, _responseNames); + AddErrors(context, exception, variables, _resultSelectionSet); return ExecutionStatus.Failed; } @@ -222,7 +222,7 @@ protected override async ValueTask OnExecuteAsync( context.AddPartialResults( _source, buffer.AsSpan(0, index), - _responseNames, + _resultSelectionSet, hasSomeErrors); } else if (singleResult is not null) @@ -231,7 +231,7 @@ protected override async ValueTask OnExecuteAsync( context.AddPartialResults( _source, MemoryMarshal.CreateReadOnlySpan(ref firstResult, 1), - _responseNames, + _resultSelectionSet, hasSomeErrors); } else @@ -239,7 +239,7 @@ protected override async ValueTask OnExecuteAsync( context.AddPartialResults( _source, [], - _responseNames, + _resultSelectionSet, hasSomeErrors); } } @@ -253,7 +253,7 @@ protected override async ValueTask OnExecuteAsync( catch (Exception exception) { diagnosticEvents.SourceSchemaStoreError(context, this, schemaName, exception); - AddErrors(context, exception, variables, _responseNames); + AddErrors(context, exception, variables, _resultSelectionSet); return ExecutionStatus.Failed; } finally @@ -278,13 +278,13 @@ private static void AddErrors( OperationPlanContext context, Exception exception, ImmutableArray variables, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { var error = ErrorBuilder.FromException(exception).Build(); if (variables.Length == 0) { - context.AddErrors(error, responseNames, Path.Root); + context.AddErrors(error, resultSelectionSet, Path.Root); } else { @@ -311,7 +311,7 @@ private static void AddErrors( } } - context.AddErrors(error, responseNames, pathBuffer.AsSpan(0, pathBufferLength)); + context.AddErrors(error, resultSelectionSet, pathBuffer.AsSpan(0, pathBufferLength)); } finally { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs index 139c01e67d5..1970fcac583 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -30,6 +30,11 @@ public OperationCompiler( _typeNameField = new TypeNameField(nonNullStringType); } + /// + /// Gets the Fusion schema definition for which we can compile operations. + /// + public FusionSchemaDefinition Schema => _schema; + public Operation Compile(string id, string hash, OperationDefinitionNode operationDefinition) { ArgumentException.ThrowIfNullOrWhiteSpace(id); 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 bb749e11c3f..29e67ccf6da 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs @@ -14,7 +14,7 @@ public sealed class OperationExecutionNode : ExecutionNode { private readonly OperationRequirement[] _requirements; private readonly string[] _forwardedVariables; - private readonly string[] _responseNames; + private readonly ResultSelectionSet _resultSelectionSet; private readonly ExecutionNodeCondition[] _conditions; private readonly bool _requiresFileUpload; private readonly OperationSourceText _operation; @@ -31,7 +31,7 @@ internal OperationExecutionNode( SelectionPath source, OperationRequirement[] requirements, string[] forwardedVariables, - string[] responseNames, + ResultSelectionSet resultSelectionSet, ExecutionNodeCondition[] conditions, int? batchingGroupId, bool requiresFileUpload) @@ -44,7 +44,7 @@ internal OperationExecutionNode( _source = source; _requirements = requirements; _forwardedVariables = forwardedVariables; - _responseNames = responseNames; + _resultSelectionSet = resultSelectionSet; _conditions = conditions; _requiresFileUpload = requiresFileUpload; } @@ -69,9 +69,9 @@ internal OperationExecutionNode( public int? BatchingGroupId => _batchingGroupId; /// - /// Gets the response names of the selection set that are fulfilled by this operation. + /// Gets the result selection set fulfilled by this operation. /// - public ReadOnlySpan ResponseNames => _responseNames; + internal ResultSelectionSet ResultSelectionSet => _resultSelectionSet; /// public override string? SchemaName => _schemaName; @@ -215,7 +215,7 @@ protected override async ValueTask OnExecuteAsync( singleResult.Dispose(); } - AddErrors(context, exception, variables, _responseNames); + AddErrors(context, exception, variables, _resultSelectionSet); return ExecutionStatus.Failed; } @@ -226,7 +226,7 @@ protected override async ValueTask OnExecuteAsync( context.AddPartialResults( _source, buffer.AsSpan(0, index), - _responseNames, + _resultSelectionSet, hasSomeErrors); } else if (singleResult is not null) @@ -235,7 +235,7 @@ protected override async ValueTask OnExecuteAsync( context.AddPartialResults( _source, MemoryMarshal.CreateReadOnlySpan(ref firstResult, 1), - _responseNames, + _resultSelectionSet, hasSomeErrors); } else @@ -243,7 +243,7 @@ protected override async ValueTask OnExecuteAsync( context.AddPartialResults( _source, [], - _responseNames, + _resultSelectionSet, hasSomeErrors); } } @@ -257,7 +257,7 @@ protected override async ValueTask OnExecuteAsync( catch (Exception exception) { diagnosticEvents.SourceSchemaStoreError(context, this, schemaName, exception); - AddErrors(context, exception, variables, _responseNames); + AddErrors(context, exception, variables, _resultSelectionSet); return ExecutionStatus.Failed; } finally @@ -317,7 +317,7 @@ internal async Task SubscribeAsync( } catch (Exception ex) { - AddErrors(context, ex, variables, _responseNames); + AddErrors(context, ex, variables, _resultSelectionSet); context.DiagnosticEvents.SourceSchemaTransportError(context, this, schemaName, ex); return SubscriptionResult.Failed(subscriptionId, ex); } @@ -327,13 +327,13 @@ private static void AddErrors( OperationPlanContext context, Exception exception, ImmutableArray variables, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { var error = ErrorBuilder.FromException(exception).Build(); if (variables.Length == 0) { - context.AddErrors(error, responseNames, Path.Root); + context.AddErrors(error, resultSelectionSet, Path.Root); } else { @@ -360,7 +360,7 @@ private static void AddErrors( } } - context.AddErrors(error, responseNames, pathBuffer.AsSpan(0, pathBufferLength)); + context.AddErrors(error, resultSelectionSet, pathBuffer.AsSpan(0, pathBufferLength)); } finally { @@ -485,14 +485,14 @@ public async ValueTask MoveNextAsync() _node.SchemaName ?? _context.GetDynamicSchemaName(_node), _subscriptionId, exception); - _context.AddErrors(error, _node._responseNames); + _context.AddErrors(error, _node._resultSelectionSet); return false; } if (hasResult) { _resultBuffer[0] = _resultEnumerator.Current; - _context.AddPartialResults(_node._source, _resultBuffer, _node._responseNames); + _context.AddPartialResults(_node._source, _resultBuffer, _node._resultSelectionSet); Current = new EventMessageResult( _node.Id, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultFragment.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultFragment.cs new file mode 100644 index 00000000000..a49e6870e13 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultFragment.cs @@ -0,0 +1,12 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// Represents an inline fragment with an optional type condition and its body. +/// +internal readonly struct ResultFragment(ITypeDefinition? typeCondition, ResultSelectionSet body) +{ + public ITypeDefinition? TypeCondition { get; } = typeCondition; + public ResultSelectionSet Body { get; } = body; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelection.cs new file mode 100644 index 00000000000..ff77f612826 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelection.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// Represents a direct field selection with its response name and optional child selection set. +/// +internal readonly struct ResultSelection(string responseName, ResultSelectionSet? child) +{ + public string ResponseName { get; } = responseName; + public ResultSelectionSet? Child { get; } = child; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs new file mode 100644 index 00000000000..cf6b4811ac8 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResultSelectionSet.cs @@ -0,0 +1,200 @@ +using System.Runtime.CompilerServices; +using HotChocolate.Language; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// A pre-computed lookup structure that mirrors a +/// for tracking which fields in the result tree belong to an execution node. +/// +internal abstract class ResultSelectionSet( + ResultFragment[] fragments, + string[] allResponseNames) +{ + private const int SmallThreshold = 8; + + /// + /// The pre-computed union of ALL response names at this level, + /// including those inside inline fragments. Used by error pocketing + /// and error result building where over-approximation is safe. + /// + public ReadOnlySpan ResponseNames => allResponseNames; + + /// + /// Gets the direct selections at this level. + /// + protected abstract ReadOnlySpan DirectSelections { get; } + + /// + /// Gets the child selection set for a given response name (type-unaware). + /// Searches direct selections first, then fragments (first match wins). + /// Used at the BuildResult level where the runtime type isn't resolved yet. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ResultSelectionSet? TryGetChild(string responseName) + => TryGetDirectChild(responseName, out var selectionSet) + ? selectionSet + : TryGetFragmentChild(responseName); + + /// + /// Gets the child selection set for a given response name, filtered by type condition. + /// Searches direct selections first, then only fragments whose type condition + /// is null or is assignable from . + /// Used in TryCompleteObjectValue where the runtime type is known. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ResultSelectionSet? TryGetChild(string responseName, IObjectTypeDefinition objectType) + => TryGetDirectChild(responseName, out var selectionSet) + ? selectionSet + : TryGetFragmentChild(responseName, objectType); + + /// + /// Tries to find a child in direct selections. Implemented by subclasses + /// (linear scan for small sets, dictionary for large sets). + /// + /// true if the response name was found in direct selections. + protected abstract bool TryGetDirectChild(string responseName, out ResultSelectionSet? child); + + private ResultSelectionSet? TryGetFragmentChild(string responseName) + { + for (var i = 0; i < fragments.Length; i++) + { + ref readonly var fragment = ref fragments[i]; + + if (fragment.Body.TryGetChild(responseName) is { } result) + { + return result; + } + } + + return null; + } + + private ResultSelectionSet? TryGetFragmentChild(string responseName, IObjectTypeDefinition objectType) + { + for (var i = 0; i < fragments.Length; i++) + { + ref readonly var fragment = ref fragments[i]; + + if (fragment.TypeCondition?.IsAssignableFrom(objectType) == false) + { + continue; + } + + var result = fragment.Body.TryGetChild(responseName, objectType); + if (result is not null) + { + return result; + } + } + + return null; + } + + /// + /// Reconstructs a from this selection set. + /// + public SelectionSetNode ToSelectionSetNode() + { + var selections = new List(); + + foreach (var selection in DirectSelections) + { + selections.Add(new FieldNode( + selection.ResponseName, + selectionSet: selection.Child?.ToSelectionSetNode())); + } + + foreach (var fragment in fragments) + { + selections.Add(new InlineFragmentNode( + null, + fragment.TypeCondition is not null + ? new NamedTypeNode(fragment.TypeCondition.Name) + : null, + [], + fragment.Body.ToSelectionSetNode())); + } + + return new SelectionSetNode(selections); + } + + /// + /// Returns the GraphQL syntax representation of this selection set. + /// + public string ToString(bool indented) + => ToSelectionSetNode().ToString(indented); + + /// + public override string ToString() + => ToString(indented: false); + + /// + /// Creates a from a . + /// + /// The AST selection set to build from. + /// + /// Optional schema used to resolve inline fragment type conditions to + /// instances. When null, type conditions are not resolved. + /// + public static ResultSelectionSet Create(SelectionSetNode selectionSet, ISchemaDefinition? schema = null) + { + var directSelections = new List(); + var fragments = new List(); + var allResponseNames = new HashSet(StringComparer.Ordinal); + + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + var name = field.Alias?.Value ?? field.Name.Value; + allResponseNames.Add(name); + directSelections.Add(new ResultSelection( + name, + field.SelectionSet is { } childSet ? Create(childSet, schema) : null)); + break; + + case InlineFragmentNode inlineFragment: + var body = Create(inlineFragment.SelectionSet, schema); + + ITypeDefinition? typeCondition = null; + if (inlineFragment.TypeCondition is not null) + { + schema?.Types.TryGetType( + inlineFragment.TypeCondition.Name.Value, + out typeCondition); + } + + fragments.Add(new ResultFragment(typeCondition, body)); + + // Add the fragment body's response names to the union. + foreach (var responseName in body.ResponseNames) + { + allResponseNames.Add(responseName); + } + + break; + } + } + + var selectionsArray = directSelections.ToArray(); + var fragmentsArray = fragments.ToArray(); + var responseNamesArray = new string[allResponseNames.Count]; + allResponseNames.CopyTo(responseNamesArray); + + if (selectionsArray.Length < SmallThreshold) + { + return new SmallResultSelectionSet( + selectionsArray, + fragmentsArray, + responseNamesArray); + } + + return new LargeResultSelectionSet( + selectionsArray, + fragmentsArray, + responseNamesArray); + } +} 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 754909a140c..e28bb047f4e 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 @@ -173,14 +173,7 @@ private static void WriteOperationNode( jsonWriter.WriteString("shortHash", node.Operation.Hash[..8]); jsonWriter.WriteEndObject(); - jsonWriter.WriteStartArray("responseNames"); - - foreach (var responseName in node.ResponseNames) - { - jsonWriter.WriteStringValue(responseName); - } - - jsonWriter.WriteEndArray(); + jsonWriter.WriteString("resultSelectionSet", node.ResultSelectionSet.ToString(indented: false)); if (!node.Source.IsRoot) { @@ -275,14 +268,7 @@ private static void WriteOperationBatchNode( jsonWriter.WriteString("shortHash", node.Operation.Hash[..8]); jsonWriter.WriteEndObject(); - jsonWriter.WriteStartArray("responseNames"); - - foreach (var responseName in node.ResponseNames) - { - jsonWriter.WriteStringValue(responseName); - } - - jsonWriter.WriteEndArray(); + jsonWriter.WriteString("resultSelectionSet", node.ResultSelectionSet.ToString(indented: false)); if (!node.Source.IsRoot) { 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 34448429b22..cdb3decf17b 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 @@ -3,6 +3,7 @@ using HotChocolate.Execution; using HotChocolate.Fusion.Language; using HotChocolate.Language; +using HotChocolate.Types; namespace HotChocolate.Fusion.Execution.Nodes.Serialization; @@ -87,10 +88,11 @@ private ImmutableArray ParseNodes(JsonElement nodesElement, Opera var nodeType = nodeElement.GetProperty("type").GetString()!; var id = nodeElement.GetProperty("id").GetInt32(); + var schema = _operationCompiler.Schema; (ExecutionNode, int[]?, Dictionary?, int?) node = nodeType switch { - "Operation" => ParseOperationNode(nodeElement, id), - "OperationBatch" => ParseOperationBatchNode(nodeElement, id), + "Operation" => ParseOperationNode(nodeElement, id, schema), + "OperationBatch" => ParseOperationBatchNode(nodeElement, id, schema), "Introspection" => ParseIntrospectionNode(nodeElement, id, operation), "Node" => ParseNodeFieldNode(nodeElement, id, operation), _ => throw new NotSupportedException($"Unsupported node type: {nodeType}") @@ -162,7 +164,7 @@ private ImmutableArray ParseNodes(JsonElement nodesElement, Opera } private static (OperationExecutionNode, int[]?, Dictionary?, int?) ParseOperationNode( - JsonElement nodeElement, int id) + JsonElement nodeElement, int id, ISchemaDefinition schema) { string? schemaName = null; if (nodeElement.TryGetProperty("schema", out var schemaElement)) @@ -180,7 +182,7 @@ private static (OperationExecutionNode, int[]?, Dictionary?, int?) SelectionPath? target = null; List? requirements = null; string[]? forwardedVariables = null; - string[]? responseNames = null; + SelectionSetNode? resultSelectionSet = null; int[]? dependencies = null; int? batchingGroupId = null; @@ -221,12 +223,15 @@ private static (OperationExecutionNode, int[]?, Dictionary?, int?) .ToArray(); } - if (nodeElement.TryGetProperty("responseNames", out var responseNamesElement)) + if (nodeElement.TryGetProperty("resultSelectionSet", out var resultSelectionSetElement) + && resultSelectionSetElement.GetString() is { Length: > 0 } resultSelectionSetSyntax) { - responseNames = responseNamesElement - .EnumerateArray() - .Select(e => e.GetString()!) - .ToArray(); + resultSelectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(resultSelectionSetSyntax); + } + + if (resultSelectionSet is null) + { + throw new InvalidOperationException("The resultSelectionSet is required in a valid operation plan."); } if (nodeElement.TryGetProperty("dependencies", out var dependenciesElement)) @@ -259,7 +264,7 @@ private static (OperationExecutionNode, int[]?, Dictionary?, int?) source ?? SelectionPath.Root, requirements?.ToArray() ?? [], forwardedVariables ?? [], - responseNames ?? [], + ResultSelectionSet.Create(resultSelectionSet, schema), conditions, batchingGroupId, requiresFileUpload); @@ -268,7 +273,7 @@ private static (OperationExecutionNode, int[]?, Dictionary?, int?) } private static (OperationBatchExecutionNode, int[]?, Dictionary?, int?) ParseOperationBatchNode( - JsonElement nodeElement, int id) + JsonElement nodeElement, int id, ISchemaDefinition schema) { string? schemaName = null; if (nodeElement.TryGetProperty("schema", out var schemaElement)) @@ -285,7 +290,7 @@ private static (OperationBatchExecutionNode, int[]?, Dictionary?, i SelectionPath? source = null; List? requirements = null; string[]? forwardedVariables = null; - string[]? responseNames = null; + SelectionSetNode? resultSelectionSet = null; int[]? dependencies = null; int? batchingGroupId = null; @@ -325,12 +330,15 @@ private static (OperationBatchExecutionNode, int[]?, Dictionary?, i .ToArray(); } - if (nodeElement.TryGetProperty("responseNames", out var responseNamesElement)) + if (nodeElement.TryGetProperty("resultSelectionSet", out var resultSelectionSetElement) + && resultSelectionSetElement.GetString() is { Length: > 0 } resultSelectionSetSyntax) { - responseNames = responseNamesElement - .EnumerateArray() - .Select(e => e.GetString()!) - .ToArray(); + resultSelectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(resultSelectionSetSyntax); + } + + if (resultSelectionSet is null) + { + throw new InvalidOperationException("The resultSelectionSet is required in a valid operation plan."); } if (nodeElement.TryGetProperty("dependencies", out var dependenciesElement)) @@ -363,7 +371,7 @@ private static (OperationBatchExecutionNode, int[]?, Dictionary?, i source ?? SelectionPath.Root, requirements?.ToArray() ?? [], forwardedVariables ?? [], - responseNames ?? [], + ResultSelectionSet.Create(resultSelectionSet, schema), conditions, batchingGroupId, requiresFileUpload); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SmallResultSelectionSet.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SmallResultSelectionSet.cs new file mode 100644 index 00000000000..021bb4cfc7a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SmallResultSelectionSet.cs @@ -0,0 +1,29 @@ +namespace HotChocolate.Fusion.Execution.Nodes; + +/// +/// A selection set with fewer than 8 direct selections. Uses linear scan for lookups. +/// Cache-friendly and avoids hash overhead. Covers the vast majority of selection sets. +/// +internal sealed class SmallResultSelectionSet( + ResultSelection[] selections, + ResultFragment[] fragments, + string[] allResponseNames) + : ResultSelectionSet(fragments, allResponseNames) +{ + protected override ReadOnlySpan DirectSelections => selections; + + protected override bool TryGetDirectChild(string responseName, out ResultSelectionSet? child) + { + for (var i = 0; i < selections.Length; i++) + { + if (string.Equals(selections[i].ResponseName, responseName, StringComparison.Ordinal)) + { + child = selections[i].Child; + return true; + } + } + + child = null; + return false; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs index 12eb771ee89..ba02f1eeb11 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -324,15 +324,11 @@ private CompactPath ToResultPath(SelectionPath selectionSet) internal void AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, - ReadOnlySpan responseNames, + ResultSelectionSet resultSelectionSet, bool containsErrors = true) { var canExecutionContinue = - _resultStore.AddPartialResults( - sourcePath, - results, - responseNames, - containsErrors); + _resultStore.AddPartialResults(sourcePath, results, resultSelectionSet, containsErrors); if (!canExecutionContinue) { @@ -340,9 +336,9 @@ internal void AddPartialResults( } } - internal void AddPartialResults(SourceResultDocument result, ReadOnlySpan responseNames) + internal void AddPartialResults(SourceResultDocument result, ResultSelectionSet resultSelectionSet) { - var canExecutionContinue = _resultStore.AddPartialResults(result, responseNames); + var canExecutionContinue = _resultStore.AddPartialResults(result, resultSelectionSet); if (!canExecutionContinue) { @@ -350,9 +346,12 @@ internal void AddPartialResults(SourceResultDocument result, ReadOnlySpan responseNames, params ReadOnlySpan paths) + internal void AddErrors( + IError error, + ResultSelectionSet resultSelectionSet, + params ReadOnlySpan paths) { - var canExecutionContinue = _resultStore.AddErrors(error, responseNames, paths); + var canExecutionContinue = _resultStore.AddErrors(error, resultSelectionSet, paths); if (!canExecutionContinue) { @@ -360,9 +359,9 @@ internal void AddErrors(IError error, ReadOnlySpan responseNames, params } } - internal void AddErrors(IError error, ReadOnlySpan responseNames, ReadOnlySpan paths) + internal void AddErrors(IError error, ResultSelectionSet resultSelectionSet, ReadOnlySpan paths) { - var canExecutionContinue = _resultStore.AddErrors(error, responseNames, paths); + var canExecutionContinue = _resultStore.AddErrors(error, resultSelectionSet, paths); if (!canExecutionContinue) { @@ -386,6 +385,8 @@ internal void Begin(long? start = null, string? traceId = null) internal OperationResult Complete(bool reusable = false) { + _resultStore.FinalizePocketedErrors(); + var environment = Schema.TryGetEnvironment(); var trace = _collectTelemetry diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs index acb2c2f1f2d..ae12c809d1a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.Pooling.cs @@ -48,6 +48,7 @@ public void Reset() _result = new CompositeResultDocument(_operation, _includeFlags, _pathPool); _errors?.Clear(); + _pocketedErrors?.Clear(); _valueCompletion = new ValueCompletion( this, @@ -77,6 +78,7 @@ internal void Clean(int maxCollectTargetRetainLength, int maxDictionaryRetainCap // clear errors _errors?.Clear(); + _pocketedErrors?.Clear(); // clear collect target arrays to unroot CompositeResultDocument references; // if they grew too large during a burst, swap them for smaller ones. 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 af0902d5a39..0d389cc3a2b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -42,6 +42,7 @@ internal sealed partial class FetchResultStore : IDisposable private CompositeResultDocument _result = default!; private ValueCompletion _valueCompletion = default!; private List? _errors; + private Dictionary? _pocketedErrors; private bool _disposed; public CompositeResultDocument Result => _result; @@ -53,13 +54,13 @@ internal sealed partial class FetchResultStore : IDisposable public bool AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, - ReadOnlySpan responseNames) - => AddPartialResults(sourcePath, results, responseNames, containsErrors: true); + ResultSelectionSet resultSelectionSet) + => AddPartialResults(sourcePath, results, resultSelectionSet, containsErrors: true); public bool AddPartialResults( SelectionPath sourcePath, ReadOnlySpan results, - ReadOnlySpan responseNames, + ResultSelectionSet resultSelectionSet, bool containsErrors) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -75,13 +76,13 @@ public bool AddPartialResults( if (!containsErrors) { return results.Length == 1 - ? AddSinglePartialResultNoErrors(sourcePath, results[0], responseNames) - : AddPartialResultsNoErrors(sourcePath, results, responseNames); + ? AddSinglePartialResultNoErrors(sourcePath, results[0], resultSelectionSet) + : AddPartialResultsNoErrors(sourcePath, results, resultSelectionSet); } if (results.Length == 1) { - return AddSinglePartialResult(sourcePath, results[0], responseNames); + return AddSinglePartialResult(sourcePath, results[0], resultSelectionSet); } var dataElements = ArrayPool.Shared.Rent(results.Length); @@ -125,15 +126,13 @@ public bool AddPartialResults( { var result = results[i]; - var success = SaveSafeResult( - resultData, - result.Path, - result.AdditionalPaths.AsSpan(), - dataElementsSpan[i], - errorTriesSpan[i], - responseNames); - - if (!success) + if (!SaveSafeResult( + resultData, + result.Path, + result.AdditionalPaths.AsSpan(), + dataElementsSpan[i], + errorTriesSpan[i], + resultSelectionSet)) { return false; } @@ -159,7 +158,7 @@ public bool AddPartialResults( private bool AddPartialResultsNoErrors( SelectionPath sourcePath, ReadOnlySpan results, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { var dataElements = ArrayPool.Shared.Rent(results.Length); var dataElementsSpan = dataElements.AsSpan(0, results.Length); @@ -181,15 +180,13 @@ private bool AddPartialResultsNoErrors( { var result = results[i]; - var success = SaveSafeResult( - resultData, - result.Path, - result.AdditionalPaths.AsSpan(), - dataElementsSpan[i], - errorTrie: null, - responseNames); - - if (!success) + if (!SaveSafeResult( + resultData, + result.Path, + result.AdditionalPaths.AsSpan(), + dataElementsSpan[i], + errorTrie: null, + resultSelectionSet)) { return false; } @@ -213,7 +210,7 @@ private bool AddPartialResultsNoErrors( private bool AddSinglePartialResult( SelectionPath sourcePath, SourceSchemaResult result, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { _memory.Push(result); @@ -237,7 +234,7 @@ private bool AddSinglePartialResult( result.AdditionalPaths.AsSpan(), dataElement, errorTrie, - responseNames); + resultSelectionSet); } } finally @@ -252,7 +249,7 @@ private bool AddSinglePartialResult( private bool AddSinglePartialResultNoErrors( SelectionPath sourcePath, SourceSchemaResult result, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { _memory.Push(result); var dataElement = GetDataElement(sourcePath, result.Data); @@ -267,7 +264,7 @@ private bool AddSinglePartialResultNoErrors( result.AdditionalPaths.AsSpan(), dataElement, errorTrie: null, - responseNames); + resultSelectionSet); } } finally @@ -285,8 +282,8 @@ private bool AddSinglePartialResultNoErrors( /// /// The document that contains partial results that need to be merged into the `Data` segment of the result. /// - /// - /// The names of the root fields the document provides data for. + /// + /// The root selection set the document provides data for. /// /// /// true if the result was integrated. @@ -294,7 +291,7 @@ private bool AddSinglePartialResultNoErrors( /// /// is null. /// - public bool AddPartialResults(SourceResultDocument document, ReadOnlySpan responseNames) + public bool AddPartialResults(SourceResultDocument document, ResultSelectionSet resultSelectionSet) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(document); @@ -306,7 +303,7 @@ public bool AddPartialResults(SourceResultDocument document, ReadOnlySpan responseNames, params ReadOnlySpan paths) + public bool AddErrors( + IError error, + ResultSelectionSet resultSelectionSet, + params ReadOnlySpan paths) { lock (_lock) { @@ -340,7 +340,7 @@ public bool AddErrors(IError error, ReadOnlySpan responseNames, params R var canExecutionContinue = _valueCompletion.BuildErrorResult( element, - responseNames, + resultSelectionSet, error, element.CompactPath); if (!canExecutionContinue) @@ -357,7 +357,10 @@ public bool AddErrors(IError error, ReadOnlySpan responseNames, params R return true; } - public bool AddErrors(IError error, ReadOnlySpan responseNames, ReadOnlySpan paths) + public bool AddErrors( + IError error, + ResultSelectionSet resultSelectionSet, + ReadOnlySpan paths) { lock (_lock) { @@ -381,7 +384,7 @@ public bool AddErrors(IError error, ReadOnlySpan responseNames, ReadOnly var canExecutionContinue = _valueCompletion.BuildErrorResult( element, - responseNames, + resultSelectionSet, error, path); if (!canExecutionContinue) @@ -398,22 +401,121 @@ public bool AddErrors(IError error, ReadOnlySpan responseNames, ReadOnly return true; } + internal void PocketError(Path path, IError error) + { + _pocketedErrors ??= []; + _pocketedErrors.TryAdd(path, error); + } + + internal bool HasPocketedErrors + => _pocketedErrors?.Count > 0; + + internal List> GetPocketedErrorsSnapshot() + => _pocketedErrors is { Count: > 0 } errors + ? [.. errors] + : []; + + internal bool RemovePocketedError(Path path) + => _pocketedErrors?.Remove(path) ?? false; + + internal void RemovePocketedErrorsInSubtree(Path path) + { + if (_pocketedErrors is not { Count: > 0 }) + { + return; + } + + List? pathsToRemove = null; + + foreach (var errorPath in _pocketedErrors.Keys) + { + if (PathUtilities.IsPathInSubtree(errorPath, path, includeSelf: true)) + { + pathsToRemove ??= []; + pathsToRemove.Add(errorPath); + } + } + + if (pathsToRemove is null) + { + return; + } + + foreach (var pathToRemove in pathsToRemove) + { + _pocketedErrors.Remove(pathToRemove); + } + } + + internal bool TryGetResult(Path path, out CompositeResultElement element) + { + element = _result.Data; + + if (path.IsRoot) + { + return true; + } + + var segments = path.ToList(); + + for (var i = 0; i < segments.Count; i++) + { + switch (segments[i]) + { + case string fieldName: + if (element.ValueKind is not JsonValueKind.Object + || !element.TryGetProperty(fieldName, out element)) + { + return false; + } + + break; + + case int index: + if (element.ValueKind is not JsonValueKind.Array + || index < 0 + || element.GetArrayLength() <= index) + { + return false; + } + + element = element[index]; + break; + + default: + return false; + } + } + + return true; + } + + public void FinalizePocketedErrors() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + lock (_lock) + { + _valueCompletion.FinalizePocketedErrors(_result.Data); + } + } + private bool SaveSafeResult( CompositeResultElement resultData, CompactPath path, ReadOnlySpan additionalPaths, SourceResultElement dataElement, ErrorTrie? errorTrie, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { - if (!SaveSafeResult(resultData, path, dataElement, errorTrie, responseNames)) + if (!SaveSafeResult(resultData, path, dataElement, errorTrie, resultSelectionSet)) { return false; } for (var i = 0; i < additionalPaths.Length; i++) { - if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie, responseNames)) + if (!SaveSafeResult(resultData, additionalPaths[i], dataElement, errorTrie, resultSelectionSet)) { return false; } @@ -427,7 +529,7 @@ private bool SaveSafeResult( CompactPath path, SourceResultElement dataElement, ErrorTrie? errorTrie, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { if (resultData.IsNullOrInvalidated) { @@ -445,7 +547,7 @@ private bool SaveSafeResult( dataElement, element, errorTrie, - responseNames); + resultSelectionSet); if (canExecutionContinue) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs new file mode 100644 index 00000000000..55833062804 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/PathUtilities.cs @@ -0,0 +1,34 @@ +namespace HotChocolate.Fusion.Execution.Results; + +internal static class PathUtilities +{ + public static bool IsPathInSubtree(Path path, Path subtreeRoot, bool includeSelf) + { + if (path.Length < subtreeRoot.Length + || (!includeSelf && path.Length == subtreeRoot.Length)) + { + return false; + } + + if (subtreeRoot.IsRoot) + { + return includeSelf || !path.IsRoot; + } + + var pathSegments = path.ToList(); + var subtreeSegments = subtreeRoot.ToList(); + + for (var i = 0; i < subtreeSegments.Count; i++) + { + var left = subtreeSegments[i]; + var right = pathSegments[i]; + + if (!Equals(left, right)) + { + return false; + } + } + + return true; + } +} 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 3652401abc0..846eabdce63 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs @@ -4,6 +4,7 @@ using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Clients; using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Types; using HotChocolate.Fusion.Text.Json; using HotChocolate.Language; using HotChocolate.Types; @@ -50,16 +51,23 @@ public bool BuildResult( SourceResultElement source, CompositeResultElement target, ErrorTrie? errorTrie, - ReadOnlySpan responseNames) + ResultSelectionSet resultSelectionSet) { if (source is not { ValueKind: JsonValueKind.Object }) { - var error = errorTrie?.FindFirstError() ?? - ErrorBuilder.New() - .SetMessage("Unexpected Execution Error") - .Build(); + var error = errorTrie?.FindFirstError(); + var canExecutionContinue = BuildResultForInvalidSource( + source, + target, + resultSelectionSet, + error); + + if (!canExecutionContinue) + { + return false; + } - return BuildErrorResult(target, responseNames, error, target.CompactPath); + return ApplyPocketedErrors(target); } foreach (var property in source.EnumerateObject()) @@ -73,13 +81,26 @@ public bool BuildResult( ErrorTrie? errorTrieForResponseName = null; errorTrie?.TryGetValue(selection.ResponseName, out errorTrieForResponseName); - if (!TryCompleteValue(property.Value, resultField, errorTrieForResponseName, selection, selection.Type, 0)) + var childSet = resultSelectionSet.TryGetChild(selection.ResponseName); + if (!TryCompleteValue( + property.Value, + resultField, + errorTrieForResponseName, + selection, + selection.Type, + 0, + childSet)) { switch (_errorHandlingMode) { case ErrorHandlingMode.Propagate: var didPropagateToRoot = PropagateNullValues(resultField); - return !didPropagateToRoot; + if (didPropagateToRoot) + { + return false; + } + + return ApplyPocketedErrors(target); case ErrorHandlingMode.Halt: return false; @@ -87,12 +108,12 @@ public bool BuildResult( } } - return true; + return ApplyPocketedErrors(target); } /// /// Tries to null and assign the to the path - /// of each . + /// of each field selected by . /// /// /// true, if the execution can continue. @@ -100,14 +121,14 @@ public bool BuildResult( /// public bool BuildErrorResult( CompositeResultElement target, - ReadOnlySpan responseNames, + ResultSelectionSet resultSelectionSet, IError error, CompactPath path) { var operation = target.Operation; var errorPath = path.ToPath(operation); - foreach (var responseName in responseNames) + foreach (var responseName in resultSelectionSet.ResponseNames) { if (!target.TryGetProperty(responseName, out var fieldResult) || fieldResult.IsInternal) @@ -116,28 +137,70 @@ public bool BuildErrorResult( } var selection = fieldResult.AssertSelection(); - var errorWithPath = ErrorBuilder.FromError(error) - .SetPath(errorPath.Append(responseName)) - .AddLocation(selection.SyntaxNodes[0].Node) - .Build(); - errorWithPath = _errorHandler.Handle(errorWithPath); - - _store.AddError(errorWithPath); - switch (_errorHandlingMode) + if (!ApplyFieldError(fieldResult, selection, error, errorPath.Append(responseName))) { - case ErrorHandlingMode.Halt: - return false; - - case ErrorHandlingMode.Propagate when selection.Type.Kind is TypeKind.NonNull: - var didPropagateToRoot = PropagateNullValues(fieldResult); - return !didPropagateToRoot; + return false; } } return true; } + public void FinalizePocketedErrors(CompositeResultElement resultData) + { + if (!_store.HasPocketedErrors) + { + return; + } + + if (!ApplyPocketedErrors(resultData)) + { + return; + } + + if (!_store.HasPocketedErrors) + { + return; + } + + foreach (var (path, error) in _store.GetPocketedErrorsSnapshot()) + { + var parentPath = path.Parent; + + if (!_store.TryGetResult(parentPath, out var parentResult)) + { + _store.RemovePocketedError(path); + continue; + } + + if (parentResult.IsNullOrInvalidated) + { + _store.RemovePocketedErrorsInSubtree(parentPath); + continue; + } + + if (parentResult.ValueKind is JsonValueKind.Undefined) + { + var parentSelection = parentResult.Selection; + var promotedError = ErrorBuilder.FromError(error) + .SetPath(parentPath); + + if (parentSelection is not null) + { + promotedError = promotedError.AddLocation(parentSelection.SyntaxNodes[0].Node); + } + + _store.AddError(_errorHandler.Handle(promotedError.Build())); + parentResult.SetNullValue(); + _store.RemovePocketedErrorsInSubtree(parentPath); + continue; + } + + _store.RemovePocketedError(path); + } + } + /// /// Invalidates the current result and its parents, /// until reaching a parent that can be set to null. @@ -164,6 +227,132 @@ private static bool PropagateNullValues(CompositeResultElement result) return true; } + private bool BuildResultForInvalidSource( + SourceResultElement source, + CompositeResultElement target, + ResultSelectionSet resultSelectionSet, + IError? error) + { + if (source.ValueKind is JsonValueKind.Null && IsValueType(target.Type)) + { + if (error is not null) + { + PocketErrors(target.Path, resultSelectionSet, error); + } + + return true; + } + + var fallbackError = error ?? + ErrorBuilder.New() + .SetMessage("Unexpected Execution Error") + .Build(); + + return BuildErrorResult(target, resultSelectionSet, fallbackError, target.CompactPath); + } + + private bool ApplyPocketedErrors(CompositeResultElement target) + { + if (!_store.HasPocketedErrors) + { + return true; + } + + var targetPath = target.Path; + + foreach (var (path, error) in _store.GetPocketedErrorsSnapshot()) + { + if (!PathUtilities.IsPathInSubtree(path, targetPath, includeSelf: true)) + { + continue; + } + + if (!TryApplyPocketedError(path, error)) + { + return false; + } + } + + return true; + } + + private bool TryApplyPocketedError(Path path, IError error) + { + if (!_store.TryGetResult(path, out var fieldResult)) + { + return true; + } + + if (fieldResult.IsNullOrInvalidated) + { + _store.RemovePocketedError(path); + return true; + } + + if (fieldResult.Selection is not { } selection) + { + _store.RemovePocketedError(path); + return true; + } + + var canExecutionContinue = ApplyFieldError(fieldResult, selection, error, path); + _store.RemovePocketedError(path); + return canExecutionContinue; + } + + private bool ApplyFieldError( + CompositeResultElement fieldResult, + Selection selection, + IError error, + Path path) + { + var errorWithPath = ErrorBuilder.FromError(error) + .SetPath(path) + .AddLocation(selection.SyntaxNodes[0].Node) + .Build(); + errorWithPath = _errorHandler.Handle(errorWithPath); + + _store.AddError(errorWithPath); + + switch (_errorHandlingMode) + { + case ErrorHandlingMode.Halt: + return false; + + case ErrorHandlingMode.Propagate when selection.Type.Kind is TypeKind.NonNull: + var didPropagateToRoot = PropagateNullValues(fieldResult); + return !didPropagateToRoot; + } + + return true; + } + + private void PocketErrors(Path path, ResultSelectionSet resultSelectionSet, IError error) + { + foreach (var responseName in resultSelectionSet.ResponseNames) + { + _store.PocketError(path.Append(responseName), error); + } + } + + private static bool IsValueType(IType? type) + { + if (type is null) + { + return false; + } + + var namedType = type.NamedType(); + + return namedType switch + { + FusionObjectTypeDefinition { IsValueType: true } => true, + FusionInterfaceTypeDefinition { IsValueType: true } => true, + FusionUnionTypeDefinition { IsValueType: true } => true, + _ => false + }; + } + // TODO: When extracting an error from a path below the current field, // we should try to use the path of the original error if it's // part of what was selected. @@ -173,7 +362,8 @@ private bool TryCompleteValue( ErrorTrie? errorTrie, Selection selection, IType type, - int depth) + int depth, + ResultSelectionSet? resultSelectionSet) { if (type.Kind is TypeKind.NonNull) { @@ -216,15 +406,27 @@ private bool TryCompleteValue( if (source.IsNullOrUndefined()) { + if (source.ValueKind is JsonValueKind.Null && IsValueType(type)) + { + if (errorTrie?.FindFirstError() is { } error + && resultSelectionSet is not null) + { + PocketErrors(target.Path, resultSelectionSet, error); + } + + // For shared parent types we keep the target untouched so that + // sibling subgraph results can still initialize and populate it. + return true; + } + // If the value is null, it might've been nulled due to a // down-stream null propagation. // So we try to get an error that is associated with this field // or with a path below it. - if (errorTrie?.FindFirstError() is { } error) + if (errorTrie?.FindFirstError() is { } errorFromPath) { - var path = target.CompactPath.ToPath(target.Operation); - var errorWithPath = ErrorBuilder.FromError(error) - .SetPath(path) + var errorWithPath = ErrorBuilder.FromError(errorFromPath) + .SetPath(target.Path) .AddLocation(selection.SyntaxNodes[0].Node) .Build(); errorWithPath = _errorHandler.Handle(errorWithPath); @@ -247,13 +449,34 @@ private bool TryCompleteValue( switch (type.Kind) { case TypeKind.List: - return TryCompleteList(source, target, errorTrie, selection, type, depth); + return TryCompleteList( + source, + target, + errorTrie, + selection, + type, + depth, + resultSelectionSet); case TypeKind.Object: - return TryCompleteObjectValue(selection, type, source, errorTrie, depth, target); + return TryCompleteObjectValue( + selection, + type, + source, + errorTrie, + depth, + target, + resultSelectionSet); case TypeKind.Interface or TypeKind.Union: - return TryCompleteAbstractValue(source, target, errorTrie, selection, type, depth); + return TryCompleteAbstractValue( + source, + target, + errorTrie, + selection, + type, + depth, + resultSelectionSet); case TypeKind.Scalar or TypeKind.Enum: target.SetLeafValue(source); @@ -270,7 +493,8 @@ private bool TryCompleteList( ErrorTrie? errorTrie, Selection selection, IType type, - int depth) + int depth, + ResultSelectionSet? resultSelectionSet) { AssertDepthAllowed(ref depth); @@ -330,7 +554,8 @@ private bool TryCompleteList( errorTrieForIndex, selection, elementType, - depth); + depth, + resultSelectionSet); } else if (isLeaf) { @@ -345,7 +570,8 @@ private bool TryCompleteList( errorTrieForIndex, selection, elementType, - depth); + depth, + resultSelectionSet); } else { @@ -355,7 +581,8 @@ private bool TryCompleteList( element, errorTrieForIndex, depth, - targetElement); + targetElement, + resultSelectionSet); } if (!completed) @@ -382,12 +609,20 @@ private bool TryCompleteObjectValue( SourceResultElement source, ErrorTrie? errorTrie, int depth, - CompositeResultElement target) + CompositeResultElement target, + ResultSelectionSet? resultSelectionSet) { var namedType = type.NamedType(); var objectType = Unsafe.As(ref namedType); - return TryCompleteObjectValue(source, target, errorTrie, parentSelection, objectType, depth); + return TryCompleteObjectValue( + source, + target, + errorTrie, + parentSelection, + objectType, + depth, + resultSelectionSet); } private bool TryCompleteObjectValue( @@ -396,7 +631,8 @@ private bool TryCompleteObjectValue( ErrorTrie? errorTrie, Selection parentSelection, IObjectTypeDefinition objectType, - int depth) + int depth, + ResultSelectionSet? resultSelectionSet) { AssertDepthAllowed(ref depth); @@ -405,8 +641,8 @@ private bool TryCompleteObjectValue( if (target.ValueKind is JsonValueKind.Undefined) { var operation = parentSelection.DeclaringSelectionSet.DeclaringOperation; - var selectionSet = operation.GetSelectionSet(parentSelection, objectType); - target.SetObjectValue(selectionSet); + var objectSelectionSet = operation.GetSelectionSet(parentSelection, objectType); + target.SetObjectValue(objectSelectionSet); } foreach (var property in source.EnumerateObject()) @@ -421,8 +657,9 @@ private bool TryCompleteObjectValue( ErrorTrie? errorTrieForResponseName = null; errorTrie?.TryGetValue(selection.ResponseName, out errorTrieForResponseName); + var childSet = resultSelectionSet?.TryGetChild(selection.ResponseName, objectType); if (!TryCompleteValue(property.Value, - targetProperty, errorTrieForResponseName, selection, selection.Type, depth)) + targetProperty, errorTrieForResponseName, selection, selection.Type, depth, childSet)) { return false; } @@ -437,8 +674,9 @@ private bool TryCompleteAbstractValue( ErrorTrie? errorTrie, Selection selection, IType type, - int depth) - => TryCompleteObjectValue(source, target, errorTrie, selection, GetType(type, source), depth); + int depth, + ResultSelectionSet? resultSelectionSet) + => TryCompleteObjectValue(source, target, errorTrie, selection, GetType(type, source), depth, resultSelectionSet); [MethodImpl(MethodImplOptions.AggressiveInlining)] private IObjectTypeDefinition GetType(IType type, SourceResultElement data) 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 d9765f1c811..c43ea8db532 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -3,6 +3,7 @@ using System.Text; using HotChocolate.Execution; using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Types; using HotChocolate.Language; using HotChocolate.Language.Visitors; using HotChocolate.Types; @@ -309,6 +310,10 @@ private static void BuildExecutionNodes( var operationSource = operation.ToSourceText(); int? batchingGroupId = batchingGroupLookup.TryGetValue(step.Id, out var groupId) ? groupId : null; + var selectionSetNode = GetSelectionSetNodeFromPath(operationStep.Definition, operationStep.Source); + selectionSetNode = PruneNonValueTypeChildren(selectionSetNode, operationStep.Type, schema); + var resultSelectionSet = ResultSelectionSet.Create(selectionSetNode, schema); + var node = new OperationExecutionNode( operationStep.Id, operationSource, @@ -317,7 +322,7 @@ private static void BuildExecutionNodes( operationStep.Source, requirements, variables?.Count > 0 ? variables.ToArray() : [], - GetResponseNamesFromPath(operationStep.Definition, operationStep.Source), + resultSelectionSet, operationStep.Conditions, batchingGroupId, requiresFileUpload); @@ -623,7 +628,7 @@ private static void MergeEquivalentOperationNodes( primary.Source, canonicalRequirements, primary.ForwardedVariables.ToArray(), - primary.ResponseNames.ToArray(), + primary.ResultSelectionSet, primary.Conditions.ToArray(), primary.BatchingGroupId, primary.RequiresFileUpload); @@ -774,91 +779,145 @@ private static string ApplyPrefixReplacements( return text; } - private static string[] GetResponseNamesFromPath( + private static SelectionSetNode GetSelectionSetNodeFromPath( OperationDefinitionNode operationDefinition, SelectionPath path) { - var selectionSet = GetSelectionSetNodeFromPath(operationDefinition, path); + var current = operationDefinition.SelectionSet; - if (selectionSet is null) + if (path.IsRoot) { - return []; + return current; } - var responseNames = new List(); - - var stack = new Stack(selectionSet.Selections); - - while (stack.TryPop(out var selection)) + for (var i = 0; i < path.Length; i++) { - switch (selection) + var segment = path[i]; + + switch (segment.Kind) { - case FieldNode fieldNode: - responseNames.Add(fieldNode.Alias?.Value ?? fieldNode.Name.Value); + case SelectionPathSegmentKind.InlineFragment: + { + var selection = current.Selections + .OfType() + .FirstOrDefault(s => s.TypeCondition?.Name.Value == segment.Name) + ?? throw new InvalidOperationException( + $"Inline fragment on type '{segment.Name}' not found at path segment {i}."); + + current = selection.SelectionSet; break; + } + case SelectionPathSegmentKind.Field: + { + var selection = current.Selections + .OfType() + .FirstOrDefault(s => s.Alias?.Value == segment.Name || s.Name.Value == segment.Name); - case InlineFragmentNode inlineFragmentNode: - foreach (var child in inlineFragmentNode.SelectionSet.Selections) + if (selection?.SelectionSet is null) { - stack.Push(child); + throw new InvalidOperationException( + $"Field '{segment.Name}' not found or has no selection set at path segment {i}."); } + current = selection.SelectionSet; break; + } } } - return [.. responseNames]; + return current; } - private static SelectionSetNode? GetSelectionSetNodeFromPath( - OperationDefinitionNode operationDefinition, - SelectionPath path) + /// + /// Strips child selection sets from fields whose return type is not a value type. + /// This allows to only build the tree along value-type paths, + /// reducing memory for the common case where most fields are not value types. + /// + private static SelectionSetNode PruneNonValueTypeChildren( + SelectionSetNode selectionSet, + ITypeDefinition parentType, + ISchemaDefinition schema) { - var current = operationDefinition.SelectionSet; - - if (path.IsRoot) + if (parentType is not IComplexTypeDefinition complexType) { - return current; + return selectionSet; } - for (var i = 0; i < path.Length; i++) + var changed = false; + var selections = new ISelectionNode[selectionSet.Selections.Count]; + + for (var i = 0; i < selectionSet.Selections.Count; i++) { - var segment = path[i]; + var selection = selectionSet.Selections[i]; - switch (segment.Kind) + switch (selection) { - case SelectionPathSegmentKind.InlineFragment: + case FieldNode field when field.SelectionSet is not null: { - var selection = current.Selections - .OfType() - .FirstOrDefault(s => s.TypeCondition?.Name.Value == segment.Name); + var responseName = field.Alias?.Value ?? field.Name.Value; - if (selection is null) + if (complexType.Fields.TryGetField(responseName, out var fieldDef)) { - return null; + var fieldNamedType = fieldDef.Type.NamedType(); + + if (fieldNamedType is FusionComplexTypeDefinition { IsValueType: true } valueType) + { + // Recurse into value type children to prune their non-value-type descendants. + var pruned = PruneNonValueTypeChildren(field.SelectionSet, valueType, schema); + + if (!ReferenceEquals(pruned, field.SelectionSet)) + { + selections[i] = new FieldNode( + field.Name, field.Alias, field.Directives, field.Arguments, pruned); + changed = true; + continue; + } + } + else + { + // Not a value type — strip the child selection set. + selections[i] = new FieldNode( + field.Name, field.Alias, field.Directives, field.Arguments, null); + changed = true; + continue; + } } - current = selection.SelectionSet; + selections[i] = selection; break; } - case SelectionPathSegmentKind.Field: + + case InlineFragmentNode inlineFragment: { - var selection = current.Selections - .OfType() - .FirstOrDefault(s => s.Alias?.Value == segment.Name || s.Name.Value == segment.Name); + ITypeDefinition? fragmentType = inlineFragment.TypeCondition is not null + && schema.Types.TryGetType(inlineFragment.TypeCondition.Name.Value, out var resolvedType) + ? resolvedType + : parentType; - if (selection?.SelectionSet is null) + var pruned = PruneNonValueTypeChildren(inlineFragment.SelectionSet, fragmentType, schema); + + if (!ReferenceEquals(pruned, inlineFragment.SelectionSet)) { - return null; + selections[i] = new InlineFragmentNode( + inlineFragment.Location, + inlineFragment.TypeCondition, + inlineFragment.Directives, + pruned); + changed = true; + continue; } - current = selection.SelectionSet; + selections[i] = selection; break; } + + default: + selections[i] = selection; + break; } } - return current; + return changed ? new SelectionSetNode(selections) : selectionSet; } private static bool DoVariablesContainUploadScalar( diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs index 2a3d00b96f9..ac7e60c7fa6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.MatchSnapshot.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using HotChocolate.Buffers; using HotChocolate.Fusion.Execution; using HotChocolate.Fusion.Execution.Clients; @@ -63,7 +64,10 @@ protected async Task MatchSnapshotAsync( await TryWriteOperationPlanAsync(writer, gateway, results); - snapshot.Add(sb.ToString()); + // Strip source file line numbers from stack traces so that line shifts + // in production code do not cause snapshot mismatches. + var snapshotText = StackTraceLineRegex().Replace(sb.ToString(), " in $1"); + snapshot.Add(snapshotText); foreach (var result in results) { @@ -541,6 +545,9 @@ private static bool IsDefinitionIncluded(IDefinitionNode node) return true; } + [GeneratedRegex(@" in (\S+\.cs):line \d+", RegexOptions.CultureInvariant)] + private static partial Regex StackTraceLineRegex(); + protected class RawRequest { public required MemoryStream Body { get; init; } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SharedPathErrorPocketingTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SharedPathErrorPocketingTests.cs new file mode 100644 index 00000000000..c7fd150a06f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SharedPathErrorPocketingTests.cs @@ -0,0 +1,437 @@ +using System.Text.Json; +using HotChocolate.Transport.Http; +using OperationRequest = HotChocolate.Transport.OperationRequest; + +namespace HotChocolate.Fusion; + +public class SharedPathErrorPocketingTests : FusionTestBase +{ + [Fact] + public async Task Viewer_Null_With_Error_And_Nullable_Children_Pockets_Child_Error() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + viewer: Viewer @shareable @error + } + + type Viewer @shareable { + a: String + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + viewer: Viewer @shareable + } + + type Viewer @shareable { + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + viewer { + a + b + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Viewer_Null_Without_Error_And_Nullable_Children_Does_Not_Initialize_Parent() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + viewer: Viewer @shareable @null + } + + type Viewer @shareable { + a: String + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + viewer: Viewer @shareable + } + + type Viewer @shareable { + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + viewer { + a + b + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Viewer_NonNull_With_Error_Fails_Fast() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + viewer: Viewer! @shareable @error + } + + type Viewer @shareable { + a: String + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + viewer: Viewer! @shareable + } + + type Viewer @shareable { + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + viewer { + a + b + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + using var response = await result.ReadAsResultAsync(); + + Assert.True(response.Errors.ValueKind is JsonValueKind.Array); + Assert.Equal(1, response.Errors.GetArrayLength()); + Assert.Equal("Unexpected Execution Error", response.Errors[0].GetProperty("message").GetString()); + Assert.Equal("viewer", response.Errors[0].GetProperty("path")[0].GetString()); + Assert.True(response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null); + } + + [Fact] + public async Task Viewer_Null_With_Error_And_NonNull_Child_Propagates_To_Parent_On_Post_Process() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + viewer: Viewer @shareable @error + } + + type Viewer @shareable { + a: String! + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + viewer: Viewer @shareable + } + + type Viewer @shareable { + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + viewer { + a + b + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Entity_Interface_With_Shared_Value_Type_Under_Inline_Fragment_Pockets_Error() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + node: INode @shareable + productA(id: Int!): Product @lookup + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable @error + } + + type SharedData @shareable { + a: String + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + node: INode @shareable + productB(id: Int!): Product @lookup + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type SharedData @shareable { + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + node { + ... on Product { + shared { + a + b + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Viewer_Remains_Uninitialized_Promotes_One_Pocketed_Error_To_Parent() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + viewer: Viewer @shareable @error + } + + type Viewer @shareable { + a: String + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + viewer: Viewer @shareable @null + } + + type Viewer @shareable { + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + viewer { + a + b + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + await MatchSnapshotAsync(gateway, request, result); + } + + [Fact] + public async Task Inline_Fragments_With_Duplicate_Response_Name_Should_Not_Drop_Pocketed_Error() + { + using var serverA = CreateSourceSchema( + "A", + """ + type Query { + node: INode @shareable @returns(types: ["Product"]) + productA(id: Int!): Product @lookup + reviewA(id: Int!): Review @lookup + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable @error + } + + type Review implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type SharedData @shareable { + a: String + b: String + } + """); + + using var serverB = CreateSourceSchema( + "B", + """ + type Query { + node: INode @shareable @returns(types: ["Product"]) + productB(id: Int!): Product @lookup + reviewB(id: Int!): Review @lookup + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type Review implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type SharedData @shareable { + a: String + b: String + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", serverA), + ("B", serverB) + ]); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + var request = new OperationRequest( + """ + { + node { + ... on Product { + shared { + a + } + } + ... on Review { + shared { + b + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + await MatchSnapshotAsync(gateway, request, result); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Entity_Interface_With_Shared_Value_Type_Under_Inline_Fragment_Pockets_Error.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Entity_Interface_With_Shared_Value_Type_Under_Inline_Fragment_Pockets_Error.yaml new file mode 100644 index 00000000000..5476d431f0e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Entity_Interface_With_Shared_Value_Type_Under_Inline_Fragment_Pockets_Error.yaml @@ -0,0 +1,187 @@ +title: Entity_Interface_With_Shared_Value_Type_Under_Inline_Fragment_Pockets_Error +request: + document: | + { + node { + ... on Product { + shared { + a + b + } + } + } + } +response: + body: | + { + "data": { + "node": { + "shared": { + "a": null, + "b": "SharedData: U2hhcmVkRGF0YToy" + } + } + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "node", + "shared", + "a" + ] + } + ] + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable @error + } + + type Query { + node: INode @shareable + productA(id: Int!): Product @lookup + } + + type SharedData @shareable { + a: String + } + interactions: + - request: + document: | + query Op_854c0b21_1 { + node { + __typename + ... on Product { + shared { + a + } + } + } + } + response: + results: + - | + { + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "node", + "shared" + ] + } + ], + "data": { + "node": { + "__typename": "Product", + "shared": null + } + } + } + - name: B + schema: | + schema { + query: Query + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type Query { + node: INode @shareable + productB(id: Int!): Product @lookup + } + + type SharedData @shareable { + b: String + } + interactions: + - request: + document: | + query Op_854c0b21_2 { + node { + __typename + ... on Product { + shared { + b + } + } + } + } + response: + results: + - | + { + "data": { + "node": { + "__typename": "Product", + "shared": { + "b": "SharedData: U2hhcmVkRGF0YToy" + } + } + } + } +operationPlan: + operation: + - document: | + { + node { + __typename @fusion__requirement + ... on Product { + shared { + a + b + } + } + } + } + hash: 854c0b2123f005940cd054f30953d09d + searchSpace: 2 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_854c0b21_1 { + node { + __typename + ... on Product { + shared { + a + } + } + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_854c0b21_2 { + node { + __typename + ... on Product { + shared { + b + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Inline_Fragments_With_Duplicate_Response_Name_Should_Not_Drop_Pocketed_Error.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Inline_Fragments_With_Duplicate_Response_Name_Should_Not_Drop_Pocketed_Error.yaml new file mode 100644 index 00000000000..256b7d513fe --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Inline_Fragments_With_Duplicate_Response_Name_Should_Not_Drop_Pocketed_Error.yaml @@ -0,0 +1,175 @@ +title: Inline_Fragments_With_Duplicate_Response_Name_Should_Not_Drop_Pocketed_Error +request: + document: | + { + node { + ... on Product { + shared { + a + } + } + ... on Review { + shared { + b + } + } + } + } +response: + body: | + { + "data": { + "node": { + "shared": null + } + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "node", + "shared" + ] + } + ] + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable @error + } + + type Query { + node: INode @shareable @returns(types: ["Product"]) + productA(id: Int!): Product @lookup + reviewA(id: Int!): Review @lookup + } + + type Review implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type SharedData @shareable { + a: String + b: String + } + interactions: + - request: + document: | + query Op_e478e62b_1 { + node { + __typename + ... on Product { + shared { + a + } + } + ... on Review { + shared { + b + } + } + } + } + response: + results: + - | + { + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "node", + "shared" + ] + } + ], + "data": { + "node": { + "__typename": "Product", + "shared": null + } + } + } + - name: B + schema: | + schema { + query: Query + } + + interface INode @key(fields: "id") { + id: Int! + } + + type Product implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type Query { + node: INode @shareable @returns(types: ["Product"]) + productB(id: Int!): Product @lookup + reviewB(id: Int!): Review @lookup + } + + type Review implements INode @key(fields: "id") { + id: Int! + shared: SharedData @shareable + } + + type SharedData @shareable { + a: String + b: String + } +operationPlan: + operation: + - document: | + { + node { + __typename @fusion__requirement + ... on Product { + shared { + a + } + } + ... on Review { + shared { + b + } + } + } + } + hash: e478e62b445cdeba5f97f95b02771a77 + searchSpace: 2 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_e478e62b_1 { + node { + __typename + ... on Product { + shared { + a + } + } + ... on Review { + shared { + b + } + } + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_With_Error_And_NonNull_Child_Propagates_To_Parent_On_Post_Process.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_With_Error_And_NonNull_Child_Propagates_To_Parent_On_Post_Process.yaml new file mode 100644 index 00000000000..bb928f7f734 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_With_Error_And_NonNull_Child_Propagates_To_Parent_On_Post_Process.yaml @@ -0,0 +1,125 @@ +title: Viewer_Null_With_Error_And_NonNull_Child_Propagates_To_Parent_On_Post_Process +request: + document: | + { + viewer { + a + b + } + } +response: + body: | + { + "data": { + "viewer": null + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "viewer", + "a" + ] + } + ] + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable @error + } + + type Viewer @shareable { + a: String! + } + interactions: + - request: + document: | + query Op_12d223a8_1 { + viewer { + a + } + } + response: + results: + - | + { + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "viewer" + ] + } + ], + "data": { + "viewer": null + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable + } + + type Viewer @shareable { + b: String + } + interactions: + - request: + document: | + query Op_12d223a8_2 { + viewer { + b + } + } + response: + results: + - | + { + "data": { + "viewer": { + "b": "Viewer" + } + } + } +operationPlan: + operation: + - document: | + { + viewer { + a + b + } + } + hash: 12d223a8df5f7a82a2908360ccf05953 + searchSpace: 2 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_12d223a8_1 { + viewer { + a + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_12d223a8_2 { + viewer { + b + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_With_Error_And_Nullable_Children_Pockets_Child_Error.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_With_Error_And_Nullable_Children_Pockets_Child_Error.yaml new file mode 100644 index 00000000000..b24820154e0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_With_Error_And_Nullable_Children_Pockets_Child_Error.yaml @@ -0,0 +1,128 @@ +title: Viewer_Null_With_Error_And_Nullable_Children_Pockets_Child_Error +request: + document: | + { + viewer { + a + b + } + } +response: + body: | + { + "data": { + "viewer": { + "a": null, + "b": "Viewer" + } + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "viewer", + "a" + ] + } + ] + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable @error + } + + type Viewer @shareable { + a: String + } + interactions: + - request: + document: | + query Op_12d223a8_1 { + viewer { + a + } + } + response: + results: + - | + { + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "viewer" + ] + } + ], + "data": { + "viewer": null + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable + } + + type Viewer @shareable { + b: String + } + interactions: + - request: + document: | + query Op_12d223a8_2 { + viewer { + b + } + } + response: + results: + - | + { + "data": { + "viewer": { + "b": "Viewer" + } + } + } +operationPlan: + operation: + - document: | + { + viewer { + a + b + } + } + hash: 12d223a8df5f7a82a2908360ccf05953 + searchSpace: 2 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_12d223a8_1 { + viewer { + a + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_12d223a8_2 { + viewer { + b + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_Without_Error_And_Nullable_Children_Does_Not_Initialize_Parent.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_Without_Error_And_Nullable_Children_Does_Not_Initialize_Parent.yaml new file mode 100644 index 00000000000..4c1324f392c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Null_Without_Error_And_Nullable_Children_Does_Not_Initialize_Parent.yaml @@ -0,0 +1,111 @@ +title: Viewer_Null_Without_Error_And_Nullable_Children_Does_Not_Initialize_Parent +request: + document: | + { + viewer { + a + b + } + } +response: + body: | + { + "data": { + "viewer": { + "a": null, + "b": "Viewer" + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable @null + } + + type Viewer @shareable { + a: String + } + interactions: + - request: + document: | + query Op_12d223a8_1 { + viewer { + a + } + } + response: + results: + - | + { + "data": { + "viewer": null + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable + } + + type Viewer @shareable { + b: String + } + interactions: + - request: + document: | + query Op_12d223a8_2 { + viewer { + b + } + } + response: + results: + - | + { + "data": { + "viewer": { + "b": "Viewer" + } + } + } +operationPlan: + operation: + - document: | + { + viewer { + a + b + } + } + hash: 12d223a8df5f7a82a2908360ccf05953 + searchSpace: 2 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_12d223a8_1 { + viewer { + a + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_12d223a8_2 { + viewer { + b + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Remains_Uninitialized_Promotes_One_Pocketed_Error_To_Parent.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Remains_Uninitialized_Promotes_One_Pocketed_Error_To_Parent.yaml new file mode 100644 index 00000000000..c82380f54ea --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SharedPathErrorPocketingTests.Viewer_Remains_Uninitialized_Promotes_One_Pocketed_Error_To_Parent.yaml @@ -0,0 +1,122 @@ +title: Viewer_Remains_Uninitialized_Promotes_One_Pocketed_Error_To_Parent +request: + document: | + { + viewer { + a + b + } + } +response: + body: | + { + "data": { + "viewer": null + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "viewer" + ] + } + ] + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable @error + } + + type Viewer @shareable { + a: String + } + interactions: + - request: + document: | + query Op_12d223a8_1 { + viewer { + a + } + } + response: + results: + - | + { + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "viewer" + ] + } + ], + "data": { + "viewer": null + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + viewer: Viewer @shareable @null + } + + type Viewer @shareable { + b: String + } + interactions: + - request: + document: | + query Op_12d223a8_2 { + viewer { + b + } + } + response: + results: + - | + { + "data": { + "viewer": null + } + } +operationPlan: + operation: + - document: | + { + viewer { + a + b + } + } + hash: 12d223a8df5f7a82a2908360ccf05953 + searchSpace: 2 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_12d223a8_1 { + viewer { + a + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_12d223a8_2 { + viewer { + b + } + } diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs index 73d6efe9eac..f975dab1525 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs @@ -119,8 +119,7 @@ private static void SerializeActivity(Activity activity) StackTracePathRegex().Replace(stackTrace, match => { var fileName = System.IO.Path.GetFileName(match.Groups["path"].Value); - var lineNumber = match.Groups["line"].Value; - return $" in {fileName}:line {lineNumber}"; + return $" in {fileName}"; })); } else diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.AllScopes_IncludesAllSpans.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.AllScopes_IncludesAllSpans.snap index 3fef61eea55..bae9f3fb95b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.AllScopes_IncludesAllSpans.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.AllScopes_IncludesAllSpans.snap @@ -120,7 +120,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Allow_Document_To_Be_Captured.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Allow_Document_To_Be_Captured.snap index fd10dc4ecf5..b1b30bde9f6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Allow_Document_To_Be_Captured.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Allow_Document_To_Be_Captured.snap @@ -140,7 +140,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "1334fb0da1250c6db5db84b6c98ccb2556f066942f8836d6ebd18fd870172787" + "Value": "350468e4cc5182c2db3af1558b7e18d325546f77603413233fbe3940fa53cc86" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Cause_A_Resolver_Error_That_Deletes_The_Whole_Result.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Cause_A_Resolver_Error_That_Deletes_The_Whole_Result.snap index f00d97076dc..fc34bb96640 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Cause_A_Resolver_Error_That_Deletes_The_Whole_Result.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Cause_A_Resolver_Error_That_Deletes_The_Whole_Result.snap @@ -140,7 +140,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "5f75eb886568e255310bed3eb3e1f7f1c91f1a22f71ac7c36f00d8df27400d8e" + "Value": "0603facae28091144394d7f12fe4398f22cd6f9d94884b9dd24086829a446e39" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DefaultScopes_ExcludesExecuteRequestAndParseDocumentSpans.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DefaultScopes_ExcludesExecuteRequestAndParseDocumentSpans.snap index bfbb9fd1ca2..ba0624d5a22 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DefaultScopes_ExcludesExecuteRequestAndParseDocumentSpans.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DefaultScopes_ExcludesExecuteRequestAndParseDocumentSpans.snap @@ -63,7 +63,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DocumentCache_SecondExecution_RecordsCacheHitEvent.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DocumentCache_SecondExecution_RecordsCacheHitEvent.snap index 8f8fb30f6f4..02a3e0724c8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DocumentCache_SecondExecution_RecordsCacheHitEvent.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.DocumentCache_SecondExecution_RecordsCacheHitEvent.snap @@ -68,7 +68,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.MultipleSources_SourceSchemaResolverError_RecordsDeeplyNestedError.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.MultipleSources_SourceSchemaResolverError_RecordsDeeplyNestedError.snap index 1f496ab6824..c1133be6d47 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.MultipleSources_SourceSchemaResolverError_RecordsDeeplyNestedError.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.MultipleSources_SourceSchemaResolverError_RecordsDeeplyNestedError.snap @@ -124,7 +124,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "6034e1863f163f1cf2ced76832d1f0f496fa230f34e6ea0bb0b1a9de5b0f7db5" + "Value": "7e050d004b624ec0c905bfba84445a17dd0cc54657187f55d6c3b5ee61931c69" }, { "Key": "graphql.source.name", @@ -172,7 +172,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "6034e1863f163f1cf2ced76832d1f0f496fa230f34e6ea0bb0b1a9de5b0f7db5" + "Value": "7e050d004b624ec0c905bfba84445a17dd0cc54657187f55d6c3b5ee61931c69" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.ParsingError_InvalidGraphQLDocument_ReportsErrorStatus.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.ParsingError_InvalidGraphQLDocument_ReportsErrorStatus.snap index aec02c711c2..405ebcecca0 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.ParsingError_InvalidGraphQLDocument_ReportsErrorStatus.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.ParsingError_InvalidGraphQLDocument_ReportsErrorStatus.snap @@ -20,7 +20,7 @@ }, { "Key": "exception.stacktrace", - "Value": "HotChocolate.Language.SyntaxException: Expected a `RightBrace`-token, but found a `EndOfFile`-token.\n at HotChocolate.Language.Utf8GraphQLParser.ParseSelectionSet() in Utf8GraphQLParser.Operations.cs:line 221\n at HotChocolate.Language.Utf8GraphQLParser.ParseShortOperationDefinition() in Utf8GraphQLParser.Operations.cs:line 73\n at HotChocolate.Language.Utf8GraphQLParser.ParseDefinition() in Utf8GraphQLParser.cs:line 215\n at HotChocolate.Language.Utf8GraphQLParser.Parse() in Utf8GraphQLParser.cs:line 98\n at HotChocolate.Language.Utf8GraphQLParser.Parse(String sourceText, ParserOptions options) in Utf8GraphQLParser.cs:line 326\n at HotChocolate.Execution.Pipeline.DocumentParserMiddleware.InvokeAsync(RequestContext context) in DocumentParserMiddleware.cs:line 63" + "Value": "HotChocolate.Language.SyntaxException: Expected a `RightBrace`-token, but found a `EndOfFile`-token.\n at HotChocolate.Language.Utf8GraphQLParser.ParseSelectionSet() in Utf8GraphQLParser.Operations.cs\n at HotChocolate.Language.Utf8GraphQLParser.ParseShortOperationDefinition() in Utf8GraphQLParser.Operations.cs\n at HotChocolate.Language.Utf8GraphQLParser.ParseDefinition() in Utf8GraphQLParser.cs\n at HotChocolate.Language.Utf8GraphQLParser.Parse() in Utf8GraphQLParser.cs\n at HotChocolate.Language.Utf8GraphQLParser.Parse(String sourceText, ParserOptions options) in Utf8GraphQLParser.cs\n at HotChocolate.Execution.Pipeline.DocumentParserMiddleware.InvokeAsync(RequestContext context) in DocumentParserMiddleware.cs" }, { "Key": "exception.type", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.PersistedOperation_LoadsFromStorage_DefaultScopes.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.PersistedOperation_LoadsFromStorage_DefaultScopes.snap index 2021a373452..e69217c9ce1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.PersistedOperation_LoadsFromStorage_DefaultScopes.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.PersistedOperation_LoadsFromStorage_DefaultScopes.snap @@ -75,7 +75,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Source_Schema_Transport_Error.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Source_Schema_Transport_Error.snap index e2812d61e7e..ac6c1561f23 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Source_Schema_Transport_Error.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Source_Schema_Transport_Error.snap @@ -124,7 +124,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", @@ -153,7 +153,7 @@ }, { "Key": "exception.stacktrace", - "Value": "System.Net.Http.HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).\n at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()\n at HotChocolate.Fusion.Transport.Http.GraphQLHttpResponse.ReadAsResultAsync(CancellationToken cancellationToken) in GraphQLHttpResponse.cs:line 292\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+MoveNext() in SourceSchemaHttpClient.cs:line 578\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+System.Threading.Tasks.Sources.IValueTaskSource.GetResult()\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs:line 160\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs:line 160" + "Value": "System.Net.Http.HttpRequestException: Response status code does not indicate success: 500 (Internal Server Error).\n at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()\n at HotChocolate.Fusion.Transport.Http.GraphQLHttpResponse.ReadAsResultAsync(CancellationToken cancellationToken) in GraphQLHttpResponse.cs\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+MoveNext() in SourceSchemaHttpClient.cs\n at HotChocolate.Fusion.Execution.Clients.SourceSchemaHttpClient.Response.ReadAsResultStreamAsync(CancellationToken cancellationToken)+System.Threading.Tasks.Sources.IValueTaskSource.GetResult()\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs\n at HotChocolate.Fusion.Execution.Nodes.OperationExecutionNode.OnExecuteAsync(OperationPlanContext context, CancellationToken cancellationToken) in OperationExecutionNode.cs" }, { "Key": "exception.type", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.SubscriptionEvent_Records_Subscription_Event_Span.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.SubscriptionEvent_Records_Subscription_Event_Span.snap index 255ef236318..0242b71faa0 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.SubscriptionEvent_Records_Subscription_Event_Span.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.SubscriptionEvent_Records_Subscription_Event_Span.snap @@ -138,7 +138,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "2e566740582971b4b247348976bca6cb8a84255526b80e73273aa94781bca0fc" + "Value": "99ac66222461a285be3d03f60e60dd5cb63990ed42e28cc8e7f1dacc35ed429a" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Query_With_Multiple_Sources.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Query_With_Multiple_Sources.snap index 5e45bdc3399..5622c3eb3eb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Query_With_Multiple_Sources.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Query_With_Multiple_Sources.snap @@ -120,7 +120,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "9babcd211d7b162261fa15a119462370a3f30c61ea319946c30bc4051a265a5d" + "Value": "705a22da1ff57dc3647c63b657d8c9a64922ea6bd90d3009ea51765837161df8" }, { "Key": "graphql.source.name", @@ -168,7 +168,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "9babcd211d7b162261fa15a119462370a3f30c61ea319946c30bc4051a265a5d" + "Value": "705a22da1ff57dc3647c63b657d8c9a64922ea6bd90d3009ea51765837161df8" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Simple_Query_Default.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Simple_Query_Default.snap index bfbb9fd1ca2..ba0624d5a22 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Simple_Query_Default.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityExecutionDiagnosticListenerTests.Track_Events_Of_A_Simple_Query_Default.snap @@ -63,7 +63,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Get_Single_Request.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Get_Single_Request.snap index 9ff8b608f7b..f8fdfd74dab 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Get_Single_Request.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Get_Single_Request.snap @@ -139,7 +139,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Add_Variables_To_Http_Activity.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Add_Variables_To_Http_Activity.snap index c85efc6ffab..4df6cdc30a5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Add_Variables_To_Http_Activity.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Add_Variables_To_Http_Activity.snap @@ -167,7 +167,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "d58281f7cf44ca2751c4a435c0249e686bd1c146f6ddae23ed35ec6e4b83eb77" + "Value": "2e2004d467c0224bdda1498e2e0928dfb9ff9d09fab2308b114252a0857aea80" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request.snap index 879f232de5c..6c53faa340e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request.snap @@ -143,7 +143,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request_Default.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request_Default.snap index 4676e5c2059..018779474b5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request_Default.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Single_Request_Default.snap @@ -115,7 +115,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Variables_Are_Not_Automatically_Added_To_Activities.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Variables_Are_Not_Automatically_Added_To_Activities.snap index 5363d19dcde..ebe1a3f0c69 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Variables_Are_Not_Automatically_Added_To_Activities.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_Variables_Are_Not_Automatically_Added_To_Activities.snap @@ -163,7 +163,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "d58281f7cf44ca2751c4a435c0249e686bd1c146f6ddae23ed35ec6e4b83eb77" + "Value": "2e2004d467c0224bdda1498e2e0928dfb9ff9d09fab2308b114252a0857aea80" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_With_Extensions_Map.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_With_Extensions_Map.snap index 3fa877086c4..29caf9b4f5b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_With_Extensions_Map.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.Http_Post_With_Extensions_Map.snap @@ -167,7 +167,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "d58281f7cf44ca2751c4a435c0249e686bd1c146f6ddae23ed35ec6e4b83eb77" + "Value": "2e2004d467c0224bdda1498e2e0928dfb9ff9d09fab2308b114252a0857aea80" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_All_IncludesAllDetails.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_All_IncludesAllDetails.snap index 90f30afb640..2a3b4873336 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_All_IncludesAllDetails.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_All_IncludesAllDetails.snap @@ -195,7 +195,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "12136be79ce453a7feac5df83310a20585dbdb9c9675bc55b25c2c2ea1f871e1" + "Value": "593f10ca34b44a1dc334a8064a790d9c81a80225d0bbe7a4aec8e092adec046d" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_Default_IncludesIdHashOperationNameExtensions.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_Default_IncludesIdHashOperationNameExtensions.snap index d4b1970c371..d1ca26442d2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_Default_IncludesIdHashOperationNameExtensions.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_Default_IncludesIdHashOperationNameExtensions.snap @@ -163,7 +163,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "f274a33c8d687ab897e52e6be6346ef3ccdd10a864a4088c571073d755df2d92" + "Value": "38b496e1881ca158155c453e43c3fff8115ca61f1aaed0c170ef0ae5a5539379" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_DocumentOnly_IncludesDocumentTag.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_DocumentOnly_IncludesDocumentTag.snap index b222a1e8ef9..f51d4ecacc7 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_DocumentOnly_IncludesDocumentTag.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_DocumentOnly_IncludesDocumentTag.snap @@ -139,7 +139,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "456132b93ebaf15a39534753bf72f9f4bfa1152a08d04bc8a88539feec1cb52c" + "Value": "f6d09983b9640d3dc20ac4774cc4e3cc75407a02d5135467b9ba3ec3d0fa7fbb" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_None_ExcludesAllDetails.snap b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_None_ExcludesAllDetails.snap index 271d5d3e822..835497a7399 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_None_ExcludesAllDetails.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/__snapshots__/FusionActivityServerDiagnosticListenerTests.RequestDetails_None_ExcludesAllDetails.snap @@ -171,7 +171,7 @@ }, { "Key": "graphql.operation.step.plan.id", - "Value": "12136be79ce453a7feac5df83310a20585dbdb9c9675bc55b25c2c2ea1f871e1" + "Value": "593f10ca34b44a1dc334a8064a790d9c81a80225d0bbe7a4aec8e092adec046d" }, { "Key": "graphql.source.name", diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs index af6f56047e9..9a51affccbd 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs @@ -42,6 +42,26 @@ fragment Product on Product { }); formatter.Format(buffer, originalPlan); + var json = JsonNode.Parse(buffer.WrittenSpan)!; + var operationNodes = json["nodes"]! + .AsArray() + .Select(t => t!.AsObject()) + .Where(t => + { + var type = t["type"]?.GetValue(); + return type is "Operation" or "OperationBatch"; + }) + .ToList(); + + Assert.NotEmpty(operationNodes); + Assert.All( + operationNodes, + node => + { + Assert.True(node.ContainsKey("resultSelectionSet")); + Assert.False(node.ContainsKey("responseNames")); + }); + // act var compiler = new OperationCompiler( compositeSchema, @@ -55,6 +75,60 @@ fragment Product on Product { parsedPlanFormatted.MatchInlineSnapshot(Encoding.UTF8.GetString(buffer.WrittenSpan)); } + [Fact] + public void Parse_Plan_Uses_SelectionSet_Syntax_When_Present() + { + // arrange + var compositeSchema = CreateCompositeSchema(); + var originalPlan = PlanOperation( + compositeSchema, + """ + { + productBySlug(slug: "1") { + id + name + } + } + """); + + var formatter = new JsonOperationPlanFormatter( + new JsonWriterOptions + { + Indented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + var json = JsonNode.Parse(formatter.Format(originalPlan))!; + var operationNode = json["nodes"]! + .AsArray() + .Select(t => t!.AsObject()) + .First(t => t["type"]?.GetValue() is "Operation"); + var operationNodeId = operationNode["id"]!.GetValue(); + + operationNode["resultSelectionSet"] = "{ __typename }"; + + var planSource = Encoding.UTF8.GetBytes( + json.ToJsonString( + new JsonSerializerOptions + { + WriteIndented = true + })); + + var compiler = new OperationCompiler( + compositeSchema, + new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>())); + var parser = new JsonOperationPlanParser(compiler); + + // act + var parsedPlan = parser.Parse(planSource); + var parsedOperationNode = parsedPlan.AllNodes + .OfType() + .Single(t => t.Id == operationNodeId); + + // assert + Assert.Equal("{ __typename }", parsedOperationNode.ResultSelectionSet.ToString(indented: false)); + } + [Fact] public void Parse_Plan_With_Node() {