From aafb0544cbd82d8d90ea736ff3d31a50045ab0b5 Mon Sep 17 00:00:00 2001 From: Glen Date: Mon, 14 Sep 2026 11:21:17 +0200 Subject: [PATCH 1/3] Make the Fusion operation short hash GraphQL-name-safe --- .../Planning/OperationPlanner.cs | 3 + .../Planning/OperationShortHash.cs | 55 +++++++++++++++++++ .../Planning/GeneratedOperationNameTests.cs | 26 +++++++++ .../Planning/OperationShortHashTests.cs | 47 ++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 2549f3454d1..af0a47bcb5a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -79,6 +79,9 @@ public OperationPlan CreatePlan( ArgumentException.ThrowIfNullOrEmpty(shortHash); ArgumentNullException.ThrowIfNull(operationDefinition); + // The generated source schema operation names embed the short hash verbatim. + shortHash = OperationShortHash.ToNameSafe(shortHash); + // We make sire that the cancellation token is observed right at the beginning of the method, // so that if the caller passed in an already canceled token we don't do any unnecessary work. cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs new file mode 100644 index 00000000000..0a84cc266c8 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs @@ -0,0 +1,55 @@ +namespace HotChocolate.Fusion.Planning; + +/// +/// Provides the short operation document hash that generated source schema operation names carry. +/// +internal static class OperationShortHash +{ + /// + /// Replaces every character of a short hash that a GraphQL name cannot hold with an underscore. + /// Digits are kept in every position: a short hash is embedded in a name, never used as one. + /// + /// + /// The short hash of the operation document. + /// + /// + /// itself when it holds GraphQL name characters only. + /// + public static string ToNameSafe(string shortHash) + { + var index = IndexOfNonNameCharacter(shortHash); + + if (index < 0) + { + return shortHash; + } + + var buffer = shortHash.ToCharArray(); + + for (var i = index; i < buffer.Length; i++) + { + if (!IsNameCharacter(buffer[i])) + { + buffer[i] = '_'; + } + } + + return new string(buffer); + } + + private static int IndexOfNonNameCharacter(string shortHash) + { + for (var i = 0; i < shortHash.Length; i++) + { + if (!IsNameCharacter(shortHash[i])) + { + return i; + } + } + + return -1; + } + + private static bool IsNameCharacter(char value) + => value is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '_'; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs index 9a431d6f584..71b5da03b13 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs @@ -21,6 +21,32 @@ public void CreatePlan_Should_KeepTheShortHash_When_ItHoldsNameCharactersOnly() Assert.Equal("Op_abcdef12_1", GetOperationName(plan)); } + [Fact] + public void CreatePlan_Should_ReplaceTheCharacter_When_TheShortHashHoldsANonNameCharacter() + { + // arrange + var schema = CreateCompositeSchema(); + + // act + var plan = CreatePlan(schema, "BnjEJe8-"); + + // assert + Assert.Equal("Op_BnjEJe8__1", GetOperationName(plan)); + } + + [Fact] + public void CreatePlan_Should_ReplaceTheCharacter_When_TheOperationShortHashIsRead() + { + // arrange + var schema = CreateCompositeSchema(); + + // act + var plan = CreatePlan(schema, "BnjEJe8-"); + + // assert + Assert.Equal("BnjEJe8_", plan.Operation.ShortHash); + } + private static string GetOperationName(OperationPlan plan) => plan.AllNodes.OfType().First().Operation.Name; diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs new file mode 100644 index 00000000000..463da9a4fa0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs @@ -0,0 +1,47 @@ +namespace HotChocolate.Fusion.Planning; + +public sealed class OperationShortHashTests +{ + [Fact] + public void ToNameSafe_Should_ReturnTheSameInstance_When_TheShortHashHoldsNameCharactersOnly() + { + // arrange + const string shortHash = "f26ee48d"; + + // act + var nameSafe = OperationShortHash.ToNameSafe(shortHash); + + // assert + Assert.Same(shortHash, nameSafe); + } + + [Fact] + public void ToNameSafe_Should_KeepTheLeadingDigit_When_TheShortHashStartsWithOne() + { + // arrange + const string shortHash = "94a407d5"; + + // act + var nameSafe = OperationShortHash.ToNameSafe(shortHash); + + // assert + Assert.Equal("94a407d5", nameSafe); + } + + [Theory] + [InlineData("BnjEJe8-", "BnjEJe8_")] + [InlineData("-BnjEJe8", "_BnjEJe8")] + [InlineData("--------", "________")] + [InlineData("Bnj.Je8+", "Bnj_Je8_")] + [InlineData("BnjéJe8/", "Bnj_Je8_")] + public void ToNameSafe_Should_ReplaceTheCharacter_When_AGraphQLNameCannotHoldIt( + string shortHash, + string expected) + { + // act + var nameSafe = OperationShortHash.ToNameSafe(shortHash); + + // assert + Assert.Equal(expected, nameSafe); + } +} From 4bf5c575baaeaa44867ea70d87bd2ad767ef09e7 Mon Sep 17 00:00:00 2001 From: Glen Date: Mon, 14 Sep 2026 11:51:21 +0200 Subject: [PATCH 2/3] Normalize the Fusion short hash on the operation compilation path --- .../Execution/Nodes/OperationCompiler.cs | 4 ++++ .../Nodes}/OperationShortHash.cs | 2 +- .../Planning/OperationPlanner.cs | 6 +++--- .../Nodes}/OperationShortHashTests.cs | 2 +- .../Execution/OperationCompilerTests.cs | 18 ++++++++++++++++++ 5 files changed, 27 insertions(+), 5 deletions(-) rename src/HotChocolate/Fusion/src/Fusion.Execution/{Planning => Execution/Nodes}/OperationShortHash.cs (96%) rename src/HotChocolate/Fusion/test/Fusion.Execution.Tests/{Planning => Execution/Nodes}/OperationShortHashTests.cs (96%) 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 efeb144ffee..eca2036f505 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -61,8 +61,12 @@ public Operation Compile( OperationDefinitionNode operationDefinition) { ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(shortHash); ArgumentNullException.ThrowIfNull(operationDefinition); + // Operation.ShortHash is embedded in the alias-batched source schema operation name. + shortHash = OperationShortHash.ToNameSafe(shortHash); + var document = new DocumentNode(new IDefinitionNode[] { operationDefinition }); document = _documentRewriter.RewriteDocument(document); operationDefinition = (OperationDefinitionNode)document.Definitions[0]; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationShortHash.cs similarity index 96% rename from src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs rename to src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationShortHash.cs index 0a84cc266c8..b6c1802664a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationShortHash.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationShortHash.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Planning; +namespace HotChocolate.Fusion.Execution.Nodes; /// /// Provides the short operation document hash that generated source schema operation names carry. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index af0a47bcb5a..9b75a41ed0e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -79,13 +79,13 @@ public OperationPlan CreatePlan( ArgumentException.ThrowIfNullOrEmpty(shortHash); ArgumentNullException.ThrowIfNull(operationDefinition); - // The generated source schema operation names embed the short hash verbatim. - shortHash = OperationShortHash.ToNameSafe(shortHash); - // We make sire that the cancellation token is observed right at the beginning of the method, // so that if the caller passed in an already canceled token we don't do any unnecessary work. cancellationToken.ThrowIfCancellationRequested(); + // PlanNode.CreateOperationName embeds the short hash in each source schema operation name. + shortHash = OperationShortHash.ToNameSafe(shortHash); + var eventSource = PlannerEventSource.Log; var eventSourceEnabled = eventSource.IsEnabled(); var operationType = operationDefinition.Operation.ToString(); diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Nodes/OperationShortHashTests.cs similarity index 96% rename from src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs rename to src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Nodes/OperationShortHashTests.cs index 463da9a4fa0..d2bafca25dc 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationShortHashTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Nodes/OperationShortHashTests.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Planning; +namespace HotChocolate.Fusion.Execution.Nodes; public sealed class OperationShortHashTests { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs index 20483d65ae7..eb2a0e6ca2c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs @@ -354,6 +354,24 @@ interface Node { Assert.False(ValueCompletion.TryResolveType(default, interfaceType, out _)); } + [Fact] + public void Compile_Should_ReplaceTheCharacter_When_TheShortHashHoldsANonNameCharacter() + { + // arrange + var schema = CreateSchema(); + var operationDefinition = Utf8GraphQLParser.Parse("{ product { id } }") + .Definitions + .OfType() + .First(); + var compiler = new OperationCompiler(schema, _fieldMapPool); + + // act + var operation = compiler.Compile("1", "0123456789abcdef", "BnjEJe8-", operationDefinition); + + // assert + Assert.Equal("BnjEJe8_", operation.ShortHash); + } + public static FusionSchemaDefinition CreateSchema() { const string sourceText = From eff0c82c7071e468979462de99315ec52b3f49a7 Mon Sep 17 00:00:00 2001 From: Glen Date: Mon, 14 Sep 2026 12:15:00 +0200 Subject: [PATCH 3/3] Normalize the short hash when rendering Fusion operation names --- .../AliasBatching/AliasBatchedRequestBody.cs | 9 ++++---- .../Execution/Nodes/OperationCompiler.cs | 4 ---- .../Planning/OperationPlanner.cs | 3 --- .../src/Fusion.Execution/Planning/PlanNode.cs | 7 ++++-- .../AliasBatchedRequestBodyTests.cs | 23 +++++++++++++++++-- .../Execution/OperationCompilerTests.cs | 4 ++-- .../Planning/GeneratedOperationNameTests.cs | 4 ++-- 7 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/AliasBatching/AliasBatchedRequestBody.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/AliasBatching/AliasBatchedRequestBody.cs index 5f69fe7e337..7a2aa869579 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/AliasBatching/AliasBatchedRequestBody.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/AliasBatching/AliasBatchedRequestBody.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Text; using System.Text.Json; +using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Transport; using HotChocolate.Fusion.Transport.Serialization; using HotChocolate.Language; @@ -25,7 +26,7 @@ internal sealed class AliasBatchedRequestBody : IRequestBody private readonly int _itemCount; private readonly List _sharedVariables; private readonly string? _operationName; - private readonly string _operationShortHash; + private readonly string _nameSafeShortHash; private readonly ulong _compositionHash; private readonly ErrorHandlingMode? _onError; @@ -60,7 +61,7 @@ public AliasBatchedRequestBody( _itemCount = itemCount; _sharedVariables = sharedVariables; _operationName = operationName; - _operationShortHash = operationShortHash; + _nameSafeShortHash = OperationShortHash.ToNameSafe(operationShortHash); _compositionHash = compositionHash; _onError = onError; } @@ -138,7 +139,7 @@ private void WriteQuery(JsonWriter writer, ReadOnlySpan items) private int GetOperationNameLength() => (_operationName?.Length ?? AnonymousOperationName.Length) + 1 - + _operationShortHash.Length + + _nameSafeShortHash.Length + Batch.Length + CompositionHashLength; @@ -161,7 +162,7 @@ private ReadOnlySpan ComposeOperationName(Span buffer) } buffer[written++] = NameSeparator; - written += Encoding.ASCII.GetBytes(_operationShortHash, buffer[written..]); + written += Encoding.ASCII.GetBytes(_nameSafeShortHash, buffer[written..]); Batch.CopyTo(buffer[written..]); written += Batch.Length; WriteHexLower(buffer[written..], _compositionHash); 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 eca2036f505..efeb144ffee 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs @@ -61,12 +61,8 @@ public Operation Compile( OperationDefinitionNode operationDefinition) { ArgumentException.ThrowIfNullOrWhiteSpace(id); - ArgumentException.ThrowIfNullOrWhiteSpace(shortHash); ArgumentNullException.ThrowIfNull(operationDefinition); - // Operation.ShortHash is embedded in the alias-batched source schema operation name. - shortHash = OperationShortHash.ToNameSafe(shortHash); - var document = new DocumentNode(new IDefinitionNode[] { operationDefinition }); document = _documentRewriter.RewriteDocument(document); operationDefinition = (OperationDefinitionNode)document.Definitions[0]; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 9b75a41ed0e..2549f3454d1 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -83,9 +83,6 @@ public OperationPlan CreatePlan( // so that if the caller passed in an already canceled token we don't do any unnecessary work. cancellationToken.ThrowIfCancellationRequested(); - // PlanNode.CreateOperationName embeds the short hash in each source schema operation name. - shortHash = OperationShortHash.ToNameSafe(shortHash); - var eventSource = PlannerEventSource.Log; var eventSourceEnabled = eventSource.IsEnabled(); var operationType = operationDefinition.Operation.ToString(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanNode.cs index 98afe68666e..a332f2f9510 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanNode.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types.Directives; using HotChocolate.Language; @@ -138,11 +139,13 @@ public double BestCaseCost public string CreateOperationName(int stepId) { + var shortHash = OperationShortHash.ToNameSafe(ShortHash); + if (OperationDefinition.Name is null) { - return $"Op_{ShortHash}_{stepId}"; + return $"Op_{shortHash}_{stepId}"; } - return $"{OperationDefinition.Name.Value}_{ShortHash}_{stepId}"; + return $"{OperationDefinition.Name.Value}_{shortHash}_{stepId}"; } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Clients/AliasBatching/AliasBatchedRequestBodyTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Clients/AliasBatching/AliasBatchedRequestBodyTests.cs index 80e76c9a0ee..1dbd68260a8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Clients/AliasBatching/AliasBatchedRequestBodyTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Clients/AliasBatching/AliasBatchedRequestBodyTests.cs @@ -147,18 +147,37 @@ public void WriteTo_Should_IncludeOnError_When_TheModeIsConfigured() Assert.EndsWith("""},"onError":"NULL"}""", json, StringComparison.Ordinal); } + [Fact] + public void WriteTo_Should_ReplaceTheCharacter_When_TheShortHashHoldsANonNameCharacter() + { + // arrange + using var memory = new ChunkedArrayWriter(); + using var document = ParseLookup(Lookup); + var items = new[] { CreateItem(memory, document, """{"__fusion_1_id":"1"}""") }; + + // act + var json = Write(items, [], operationShortHash: "0123456-"); + + // assert + Assert.StartsWith( + "query Op_0123456__Batch_0123456789abcdef(", + ReadQuery(json), + StringComparison.Ordinal); + } + private static string Write( AliasBatchItem[] items, List sharedVariables, ErrorHandlingMode? onError = null, - string? operationName = null) + string? operationName = null, + string operationShortHash = OperationShortHash) { var body = new AliasBatchedRequestBody( items, items.Length, sharedVariables, operationName, - OperationShortHash, + operationShortHash, CompositionHash, onError); using var buffer = new PooledArrayWriter(); diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs index eb2a0e6ca2c..dc8afd8874d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationCompilerTests.cs @@ -355,7 +355,7 @@ interface Node { } [Fact] - public void Compile_Should_ReplaceTheCharacter_When_TheShortHashHoldsANonNameCharacter() + public void Compile_Should_KeepTheShortHash_When_ItHoldsANonNameCharacter() { // arrange var schema = CreateSchema(); @@ -369,7 +369,7 @@ public void Compile_Should_ReplaceTheCharacter_When_TheShortHashHoldsANonNameCha var operation = compiler.Compile("1", "0123456789abcdef", "BnjEJe8-", operationDefinition); // assert - Assert.Equal("BnjEJe8_", operation.ShortHash); + Assert.Equal("BnjEJe8-", operation.ShortHash); } public static FusionSchemaDefinition CreateSchema() diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs index 71b5da03b13..55317fd641b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs @@ -35,7 +35,7 @@ public void CreatePlan_Should_ReplaceTheCharacter_When_TheShortHashHoldsANonName } [Fact] - public void CreatePlan_Should_ReplaceTheCharacter_When_TheOperationShortHashIsRead() + public void CreatePlan_Should_KeepTheShortHash_When_TheOperationShortHashIsRead() { // arrange var schema = CreateCompositeSchema(); @@ -44,7 +44,7 @@ public void CreatePlan_Should_ReplaceTheCharacter_When_TheOperationShortHashIsRe var plan = CreatePlan(schema, "BnjEJe8-"); // assert - Assert.Equal("BnjEJe8_", plan.Operation.ShortHash); + Assert.Equal("BnjEJe8-", plan.Operation.ShortHash); } private static string GetOperationName(OperationPlan plan)