Skip to content

Commit 72261de

Browse files
authored
[Fusion] Resolve abstract types from raw UTF-8 type names for small implementer sets (#10084)
1 parent 5814674 commit 72261de

8 files changed

Lines changed: 453 additions & 1 deletion

File tree

src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionInterfaceTypeDefinition.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Immutable;
12
using System.Runtime.CompilerServices;
23
using HotChocolate.Fusion.Types.Collections;
34
using HotChocolate.Fusion.Types.Completion;
@@ -18,11 +19,29 @@ public sealed class FusionInterfaceTypeDefinition(
1819
: FusionComplexTypeDefinition(name, description, isInaccessible, fieldsDefinition)
1920
, IInterfaceTypeDefinition
2021
{
22+
internal const int MaxTypeNameLookupTypes = 4;
23+
24+
private ImmutableArray<FusionObjectTypeDefinition> _typeNameLookupTypes;
2125
private FusionTypeFlags _flags;
2226

2327
/// <inheritdoc />
2428
public override TypeKind Kind => TypeKind.Interface;
2529

30+
internal ImmutableArray<FusionObjectTypeDefinition> TypeNameLookupTypes
31+
=> _typeNameLookupTypes;
32+
33+
internal void PrepareTypeNameLookupTypes(
34+
ImmutableArray<FusionObjectTypeDefinition> possibleTypes)
35+
{
36+
var lookupTypes = possibleTypes.Length is > 0 and <= MaxTypeNameLookupTypes
37+
? possibleTypes
38+
: [];
39+
40+
ImmutableInterlocked.InterlockedInitialize(
41+
ref _typeNameLookupTypes,
42+
lookupTypes);
43+
}
44+
2645
/// <inheritdoc />
2746
public override bool IsSharedType => (_flags & FusionTypeFlags.Shared) != 0;
2847

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,13 @@ private SelectionSet BuildSelectionSet(
330330
? _typeNameField
331331
: typeContext.Fields.GetField(first.Node.Name.Value, allowInaccessibleFields: true);
332332

333+
if (field.Type.NamedType() is FusionInterfaceTypeDefinition interfaceType
334+
&& interfaceType.TypeNameLookupTypes.IsDefault)
335+
{
336+
interfaceType.PrepareTypeNameLookupTypes(
337+
_schema.GetPossibleTypes(interfaceType));
338+
}
339+
333340
var selection = new Selection(
334341
++lastId,
335342
responseName,

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
using System.Collections.Immutable;
12
using System.Diagnostics;
23
using System.Diagnostics.CodeAnalysis;
34
using System.Runtime.CompilerServices;
5+
using System.Text;
46
using System.Text.Json;
57
using HotChocolate.Execution;
68
using HotChocolate.Fusion.Execution.Clients;
79
using HotChocolate.Fusion.Execution.Nodes;
810
using HotChocolate.Fusion.Types;
11+
using HotChocolate.Fusion.Types.Collections;
912
using HotChocolate.Fusion.Text.Json;
1013
using HotChocolate.Language;
1114
using HotChocolate.Types;
@@ -1057,10 +1060,101 @@ private IComplexTypeDefinition GetType(IType type, SourceResultElement data, boo
10571060
return (IComplexTypeDefinition)namedType;
10581061
}
10591062

1060-
var typeName = data.GetProperty(IntrospectionFieldNames.TypeNameSpan).AssertString();
1063+
var typeNameElement = data.GetProperty(IntrospectionFieldNames.TypeNameSpan);
1064+
1065+
// Small implementer sets resolve the type by comparing the raw UTF-8 __typename
1066+
// bytes, which is allocation free. Beyond 4 candidates the linear scan loses to
1067+
// the dictionary lookup, so larger sets, escaped values, non-string values, and
1068+
// values that span document chunks use the existing fallback below.
1069+
if (TryResolveType(typeNameElement, namedType, out var resolvedType))
1070+
{
1071+
return resolvedType;
1072+
}
1073+
1074+
var typeName = typeNameElement.AssertString();
10611075
return _schema.Types.GetType<IObjectTypeDefinition>(typeName);
10621076
}
10631077

1078+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
1079+
internal static bool TryResolveType(
1080+
SourceResultElement typeNameElement,
1081+
ITypeDefinition abstractType,
1082+
[NotNullWhen(true)] out FusionObjectTypeDefinition? objectType)
1083+
{
1084+
// Fusion type names are validated GraphQL names, so ASCII equality is equivalent to
1085+
// comparing their UTF-8 encoding with ordinal semantics.
1086+
if (abstractType is FusionUnionTypeDefinition unionType)
1087+
{
1088+
var possibleTypes = unionType.Types;
1089+
1090+
if (possibleTypes.Count is > 0
1091+
and <= FusionInterfaceTypeDefinition.MaxTypeNameLookupTypes)
1092+
{
1093+
return TryResolveUnionType(typeNameElement, possibleTypes, out objectType);
1094+
}
1095+
}
1096+
else if (abstractType is FusionInterfaceTypeDefinition interfaceType)
1097+
{
1098+
var possibleTypes = interfaceType.TypeNameLookupTypes;
1099+
1100+
if (!possibleTypes.IsDefaultOrEmpty)
1101+
{
1102+
return TryResolveInterfaceType(typeNameElement, possibleTypes, out objectType);
1103+
}
1104+
}
1105+
1106+
objectType = null;
1107+
return false;
1108+
}
1109+
1110+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
1111+
private static bool TryResolveUnionType(
1112+
SourceResultElement typeNameElement,
1113+
FusionObjectTypeDefinitionCollection possibleTypes,
1114+
[NotNullWhen(true)] out FusionObjectTypeDefinition? objectType)
1115+
{
1116+
if (typeNameElement.TryGetRawStringValue(out var typeName))
1117+
{
1118+
for (var i = 0; i < possibleTypes.Count; i++)
1119+
{
1120+
var possibleType = possibleTypes[i];
1121+
1122+
if (Ascii.Equals(typeName, possibleType.Name))
1123+
{
1124+
objectType = possibleType;
1125+
return true;
1126+
}
1127+
}
1128+
}
1129+
1130+
objectType = null;
1131+
return false;
1132+
}
1133+
1134+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
1135+
private static bool TryResolveInterfaceType(
1136+
SourceResultElement typeNameElement,
1137+
ImmutableArray<FusionObjectTypeDefinition> possibleTypes,
1138+
[NotNullWhen(true)] out FusionObjectTypeDefinition? objectType)
1139+
{
1140+
if (typeNameElement.TryGetRawStringValue(out var typeName))
1141+
{
1142+
for (var i = 0; i < possibleTypes.Length; i++)
1143+
{
1144+
var possibleType = possibleTypes[i];
1145+
1146+
if (Ascii.Equals(typeName, possibleType.Name))
1147+
{
1148+
objectType = possibleType;
1149+
return true;
1150+
}
1151+
}
1152+
}
1153+
1154+
objectType = null;
1155+
return false;
1156+
}
1157+
10641158
[MethodImpl(MethodImplOptions.AggressiveInlining)]
10651159
private void AssertDepthAllowed(ref int depth)
10661160
{

src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.Text.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,44 @@ public sealed partial class SourceResultDocument
3030
: JsonReaderHelper.TranscodeHelper(segment);
3131
}
3232

33+
internal bool TryGetRawStringValue(Cursor cursor, out ReadOnlySpan<byte> utf8Value)
34+
{
35+
ObjectDisposedException.ThrowIf(_disposed != 0, this);
36+
37+
var row = _parsedData.Get(cursor);
38+
39+
if (row.TokenType is not JsonTokenType.String || row.HasComplexChildren)
40+
{
41+
utf8Value = default;
42+
return false;
43+
}
44+
45+
var chunkIndex = row.Location >>> DataOffsetBits;
46+
var offset = (row.Location & DataOffsetMask) + 1;
47+
var segment = _segments[chunkIndex];
48+
var segmentLength = _usedChunks == 1
49+
? segment.Length
50+
: GetDataChunkSize(chunkIndex);
51+
52+
if (offset == segmentLength)
53+
{
54+
segment = _segments[++chunkIndex];
55+
offset = 0;
56+
segmentLength = GetDataChunkSize(chunkIndex);
57+
}
58+
59+
var length = row.SizeOrLength - 2;
60+
61+
if (length > segmentLength - offset)
62+
{
63+
utf8Value = default;
64+
return false;
65+
}
66+
67+
utf8Value = segment.Buffer.AsSpan(segment.Offset + offset, length);
68+
return true;
69+
}
70+
3371
internal bool TextEquals(Cursor cursor, ReadOnlySpan<char> otherText, bool isPropertyName)
3472
{
3573
ObjectDisposedException.ThrowIf(_disposed != 0, this);

src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElement.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,20 @@ internal SourceResultDocument.DbRow GetValueRow()
496496
return _parent.GetValueRow(_cursor);
497497
}
498498

499+
/// <summary>
500+
/// Tries to get the raw UTF-8 bytes of a JSON string value that contains no escape sequences.
501+
/// </summary>
502+
/// <param name="utf8Value">Receives the raw UTF-8 span when successful.</param>
503+
/// <returns>
504+
/// <see langword="false"/> when the value is not a string, contains escape sequences,
505+
/// or is not backed by contiguous memory.
506+
/// </returns>
507+
internal bool TryGetRawStringValue(out ReadOnlySpan<byte> utf8Value)
508+
{
509+
CheckValidInstance();
510+
return _parent.TryGetRawStringValue(_cursor, out utf8Value);
511+
}
512+
499513
/// <summary>
500514
/// Compares the string value of this element to <paramref name="text"/> without allocation.
501515
/// </summary>

src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/UnionTests.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
using System.Net;
2+
using System.Text;
13
using HotChocolate.Transport;
24
using HotChocolate.Transport.Http;
5+
using Microsoft.Extensions.DependencyInjection;
36

47
namespace HotChocolate.Fusion;
58

@@ -12,6 +15,112 @@ public class UnionTests : FusionTestBase
1215
{
1316
#region union { ... }
1417

18+
[Fact]
19+
public async Task Union_Field_Should_ResolveType_When_TypeNameIsEscaped()
20+
{
21+
using var server = CreateSourceSchema(
22+
"A",
23+
"""
24+
type Query {
25+
post: Post
26+
}
27+
28+
union Post = Photo | Discussion
29+
30+
type Photo {
31+
imageUrl: String!
32+
}
33+
34+
type Discussion {
35+
title: String
36+
}
37+
""",
38+
mockHttpResponse: _ => Task.FromResult(
39+
new HttpResponseMessage(HttpStatusCode.OK)
40+
{
41+
Content = new StringContent(
42+
"""{"data":{"post":{"__typename":"Ph\u006fto","imageUrl":"image.jpg"}}}""",
43+
Encoding.UTF8,
44+
"application/json")
45+
}));
46+
47+
using var gateway = await CreateCompositeSchemaAsync(
48+
[("A", server)],
49+
configureGatewayBuilder: b =>
50+
b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = false));
51+
using var client = GraphQLHttpClient.Create(gateway.CreateClient());
52+
53+
using var result = await client.PostAsync(
54+
"{ post { __typename ... on Photo { imageUrl } } }",
55+
new Uri("http://localhost:5000/graphql"),
56+
TestContext.Current.CancellationToken);
57+
58+
using var response = await result.ReadAsResultAsync(TestContext.Current.CancellationToken);
59+
response.MatchInlineSnapshot(
60+
"""
61+
{
62+
"data": {
63+
"post": {
64+
"__typename": "Photo",
65+
"imageUrl": "image.jpg"
66+
}
67+
}
68+
}
69+
""");
70+
}
71+
72+
[Fact]
73+
public async Task Union_Field_Should_UseFallback_When_UnionHasMoreThanFourMembers()
74+
{
75+
using var server = CreateSourceSchema(
76+
"A",
77+
"""
78+
type Query {
79+
post: Post
80+
}
81+
82+
union Post = Post1 | Post2 | Post3 | Post4 | Post5
83+
84+
type Post1 { value: String }
85+
type Post2 { value: String }
86+
type Post3 { value: String }
87+
type Post4 { value: String }
88+
type Post5 { value: String }
89+
""",
90+
mockHttpResponse: _ => Task.FromResult(
91+
new HttpResponseMessage(HttpStatusCode.OK)
92+
{
93+
Content = new StringContent(
94+
"""{"data":{"post":{"__typename":"Post5","value":"five"}}}""",
95+
Encoding.UTF8,
96+
"application/json")
97+
}));
98+
99+
using var gateway = await CreateCompositeSchemaAsync(
100+
[("A", server)],
101+
configureGatewayBuilder: b =>
102+
b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = false));
103+
using var client = GraphQLHttpClient.Create(gateway.CreateClient());
104+
105+
using var result = await client.PostAsync(
106+
"{ post { __typename ... on Post5 { value } } }",
107+
new Uri("http://localhost:5000/graphql"),
108+
TestContext.Current.CancellationToken);
109+
110+
using var response = await result.ReadAsResultAsync(TestContext.Current.CancellationToken);
111+
response.MatchInlineSnapshot(
112+
"""
113+
{
114+
"data": {
115+
"post": {
116+
"__typename": "Post5",
117+
"value": "five"
118+
}
119+
}
120+
}
121+
""");
122+
}
123+
15124
[Fact]
16125
public async Task Union_Field_Just_Typename_Selected()
17126
{

0 commit comments

Comments
 (0)