Skip to content

Commit 4445ea9

Browse files
authored
Mark node and nodes field shareable when in source schema mode (#8857)
1 parent 2f182b7 commit 4445ea9

File tree

21 files changed

+303
-49
lines changed

21 files changed

+303
-49
lines changed

src/HotChocolate/AspNetCore/benchmarks/k6/dataloader.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import exec from 'k6/execution';
44

55
export const options = {
66
stages: [
7-
{ duration: '10s', target: 10 },
8-
{ duration: '20s', target: 100 },
9-
{ duration: '1m', target: 100 },
7+
{ duration: '10s', target: 50 },
8+
{ duration: '20s', target: 500 },
9+
{ duration: '1m', target: 500 },
1010
],
1111
thresholds: {
1212
'http_req_duration{phase:measurement}': ['p(95)<500', 'p(99)<1000'],

src/HotChocolate/AspNetCore/benchmarks/k6/performance-data.json

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
{
2-
"timestamp": "2025-10-30T15:41:52Z",
2+
"timestamp": "2025-10-31T06:56:49Z",
33
"tests": {
44
"single-fetch": {
55
"name": "Single Fetch (50 products, names only)",
66
"response_time": {
7-
"min": 0.8121,
8-
"p50": 2.019409,
9-
"max": 34.259858,
10-
"avg": 2.016368485854345,
11-
"p90": 2.7201433000000006,
12-
"p95": 2.9610656499999988,
13-
"p99": 6.065892389999998
7+
"min": 0.747831,
8+
"p50": 1.510499,
9+
"max": 61.819994,
10+
"avg": 2.1072153114973546,
11+
"p90": 3.5390032,
12+
"p95": 4.891518000000001,
13+
"p99": 9.782994420000009
1414
},
1515
"throughput": {
16-
"requests_per_second": 78.82362001703528,
17-
"total_iterations": 7171
16+
"requests_per_second": 394.21092824371044,
17+
"total_iterations": 35874
1818
},
1919
"reliability": {
2020
"error_rate": 0
@@ -23,17 +23,17 @@
2323
"dataloader": {
2424
"name": "DataLoader (50 products with brands)",
2525
"response_time": {
26-
"min": 1.666098,
27-
"p50": 3.1525455,
28-
"max": 16.3474,
29-
"avg": 3.2837058709677325,
30-
"p90": 4.254632300000001,
31-
"p95": 4.88343795,
32-
"p99": 9.383130000000001
26+
"min": 1.656751,
27+
"p50": 3.009085,
28+
"max": 47.328823,
29+
"avg": 4.203039777054027,
30+
"p90": 8.029613,
31+
"p95": 12.6258765,
32+
"p99": 17.829473200000006
3333
},
3434
"throughput": {
35-
"requests_per_second": 78.69012270107648,
36-
"total_iterations": 7161
35+
"requests_per_second": 393.37936798247773,
36+
"total_iterations": 35802
3737
},
3838
"reliability": {
3939
"error_rate": 0

src/HotChocolate/AspNetCore/benchmarks/k6/single-fetch.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import exec from 'k6/execution';
44

55
export const options = {
66
stages: [
7-
{ duration: '10s', target: 10 },
8-
{ duration: '20s', target: 100 },
9-
{ duration: '1m', target: 100 },
7+
{ duration: '10s', target: 50 },
8+
{ duration: '20s', target: 500 },
9+
{ duration: '1m', target: 500 },
1010
],
1111
thresholds: {
1212
'http_req_duration{phase:measurement}': ['p(95)<500', 'p(99)<1000'],

src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Composite.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ public static IRequestExecutorBuilder AddSourceSchemaDefaults(
2222
{
2323
o.ApplyShareableToConnections = true;
2424
o.ApplyShareableToPageInfo = true;
25+
o.ApplyShareableToNodeFields = true;
2526
});
2627
}

src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,10 @@ public interface IReadOnlySchemaOptions
216216
/// Applies the @sharable directive to all connection and edge types.
217217
/// </summary>
218218
bool ApplyShareableToConnections { get; }
219+
220+
/// <summary>
221+
/// Applies the @sharable directive to the `node(id)` and `nodes(id)`
222+
/// field when Global Object Identification is turned on.
223+
/// </summary>
224+
bool ApplyShareableToNodeFields { get; }
219225
}

src/HotChocolate/Core/src/Types/SchemaOptions.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ public int OperationDocumentCacheSize
183183
/// <inheritdoc cref="IReadOnlySchemaOptions.ApplyShareableToConnections"/>
184184
public bool ApplyShareableToConnections { get; set; }
185185

186+
/// <inheritdoc cref="IReadOnlySchemaOptions.ApplyShareableToNodeFields"/>
187+
public bool ApplyShareableToNodeFields { get; set; }
188+
186189
/// <summary>
187190
/// Creates a mutable options object from a read-only options object.
188191
/// </summary>
@@ -222,6 +225,7 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options)
222225
PreparedOperationCacheSize = options.PreparedOperationCacheSize,
223226
OperationDocumentCacheSize = options.OperationDocumentCacheSize,
224227
ApplyShareableToPageInfo = options.ApplyShareableToPageInfo,
225-
ApplyShareableToConnections = options.ApplyShareableToConnections
228+
ApplyShareableToConnections = options.ApplyShareableToConnections,
229+
ApplyShareableToNodeFields = options.ApplyShareableToNodeFields
226230
};
227231
}

src/HotChocolate/Core/src/Types/Types/Relay/NodeFieldTypeInterceptor.cs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using HotChocolate.Types.Composite;
66
using HotChocolate.Types.Descriptors;
77
using HotChocolate.Types.Descriptors.Configurations;
8-
using HotChocolate.Types.Helpers;
98
using HotChocolate.Utilities;
109
using static HotChocolate.Properties.TypeResources;
1110
using static HotChocolate.Types.Relay.NodeConstants;
@@ -22,8 +21,10 @@ internal sealed class NodeFieldTypeInterceptor : TypeInterceptor
2221
private ObjectTypeConfiguration? _queryTypeConfig;
2322
private TypeReference _nodeType = null!;
2423
private TypeReference _lookupRef = null!;
24+
private TypeReference _shareableRef = null!;
2525
private bool _registeredTypes;
2626
private GlobalObjectIdentificationOptions _options = null!;
27+
private IReadOnlySchemaOptions _schemaOptions = null!;
2728

2829
internal override uint Position => uint.MaxValue - 100;
2930

@@ -42,7 +43,9 @@ internal override void InitializeContext(
4243
{
4344
_nodeType = context.TypeInspector.GetTypeRef(typeof(NodeType));
4445
_lookupRef = context.TypeInspector.GetTypeRef(typeof(Lookup));
46+
_shareableRef = context.TypeInspector.GetTypeRef(typeof(Shareable));
4547
_options = context.Features.GetRequired<NodeSchemaFeature>().Options;
48+
_schemaOptions = context.Options;
4649
}
4750

4851
public override IEnumerable<TypeReference> RegisterMoreTypes(
@@ -57,6 +60,11 @@ public override IEnumerable<TypeReference> RegisterMoreTypes(
5760
yield return _lookupRef;
5861
}
5962

63+
if (_options.MarkNodeFieldAsLookup || _schemaOptions.ApplyShareableToNodeFields)
64+
{
65+
yield return _shareableRef;
66+
}
67+
6068
_registeredTypes = true;
6169
}
6270
}
@@ -78,7 +86,6 @@ public override void OnBeforeCompleteTypes()
7886
if (_queryContext is not null && _queryTypeConfig is not null)
7987
{
8088
var typeInspector = _queryContext.TypeInspector;
81-
8289
var serializer = _queryContext.DescriptorContext.NodeIdSerializerAccessor;
8390

8491
// the nodes fields shall be chained in after the introspection fields,
@@ -94,7 +101,8 @@ public override void OnBeforeCompleteTypes()
94101
serializer,
95102
_queryTypeConfig.Fields,
96103
index + 1,
97-
_options.MarkNodeFieldAsLookup);
104+
_options.MarkNodeFieldAsLookup,
105+
_schemaOptions.ApplyShareableToNodeFields);
98106

99107
if (_options.AddNodesField)
100108
{
@@ -103,7 +111,8 @@ public override void OnBeforeCompleteTypes()
103111
serializer,
104112
_queryTypeConfig.Fields,
105113
index + 2,
106-
maxAllowedNodes);
114+
maxAllowedNodes,
115+
_schemaOptions.ApplyShareableToNodeFields);
107116
}
108117
}
109118
}
@@ -113,7 +122,8 @@ private static void CreateNodeField(
113122
INodeIdSerializerAccessor serializerAccessor,
114123
IList<ObjectFieldConfiguration> fields,
115124
int index,
116-
bool markNodeFieldAsLookup)
125+
bool markNodeFieldAsLookup,
126+
bool markNodeFieldSharable)
117127
{
118128
var node = typeInspector.GetTypeRef(typeof(NodeType));
119129
var id = typeInspector.GetTypeRef(typeof(NonNullType<IdType>));
@@ -123,7 +133,10 @@ private static void CreateNodeField(
123133
Relay_NodeField_Description,
124134
node)
125135
{
126-
Arguments = { new ArgumentConfiguration(Id, Relay_NodeField_Id_Description, id) },
136+
Arguments =
137+
{
138+
new ArgumentConfiguration(Id, Relay_NodeField_Id_Description, id)
139+
},
127140
MiddlewareConfigurations =
128141
{
129142
new FieldMiddlewareConfiguration(_ =>
@@ -144,6 +157,11 @@ private static void CreateNodeField(
144157
field.AddDirective(Lookup.Instance, typeInspector);
145158
}
146159

160+
if (markNodeFieldSharable || markNodeFieldAsLookup)
161+
{
162+
field.AddDirective(Shareable.Instance, typeInspector);
163+
}
164+
147165
// In the projection interceptor we want to change the context data on this field
148166
// after the field is completed. We need at least 1 element on the context data to avoid
149167
// it being replaced with ReadOnlyFeatureCollection.Default
@@ -157,7 +175,8 @@ private static void CreateNodesField(
157175
INodeIdSerializerAccessor serializerAccessor,
158176
IList<ObjectFieldConfiguration> fields,
159177
int index,
160-
int maxAllowedNodes)
178+
int maxAllowedNodes,
179+
bool markNodeFieldSharable)
161180
{
162181
var nodes = typeInspector.GetTypeRef(typeof(NonNullType<ListType<NodeType>>));
163182
var ids = typeInspector.GetTypeRef(typeof(NonNullType<ListType<NonNullType<IdType>>>));
@@ -167,7 +186,10 @@ private static void CreateNodesField(
167186
Relay_NodesField_Description,
168187
nodes)
169188
{
170-
Arguments = { new ArgumentConfiguration(Ids, Relay_NodesField_Ids_Description, ids) },
189+
Arguments =
190+
{
191+
new ArgumentConfiguration(Ids, Relay_NodesField_Ids_Description, ids)
192+
},
171193
MiddlewareConfigurations =
172194
{
173195
new FieldMiddlewareConfiguration(_ =>
@@ -183,6 +205,11 @@ private static void CreateNodesField(
183205
Flags = CoreFieldFlags.ParallelExecutable | CoreFieldFlags.GlobalIdNodesField
184206
};
185207

208+
if (markNodeFieldSharable)
209+
{
210+
field.AddDirective(Shareable.Instance, typeInspector);
211+
}
212+
186213
// In the projection interceptor we want to change the context data on this field
187214
// after the field is completed. We need at least 1 element on the context data to avoid
188215
// it being replaced with ReadOnlyFeatureCollection.Default.

src/HotChocolate/Core/test/Authorization.Tests/__snapshots__/AnnotationBasedAuthorizationTests.Authorize_Node_Field_Schema.graphql

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type Person implements Node @authorize(policy: "READ_PERSON", apply: AFTER_RESOL
2626

2727
type Query @foo @authorize(policy: "QUERY", apply: VALIDATION) @authorize(policy: "QUERY2") {
2828
"Fetches an object given its ID."
29-
node("ID of the object." id: ID!): Node @lookup @cost(weight: "10") @authorize(policy: "READ_NODE", apply: VALIDATION)
29+
node("ID of the object." id: ID!): Node @lookup @shareable @cost(weight: "10") @authorize(policy: "READ_NODE", apply: VALIDATION)
3030
"Lookup nodes by a list of IDs."
3131
nodes("The list of node IDs." ids: [ID!]!): [Node]! @cost(weight: "10") @authorize(policy: "READ_NODE", apply: VALIDATION)
3232
null: String @authorize(policy: "NULL", apply: AFTER_RESOLVER)
@@ -71,3 +71,21 @@ that can be used by the distributed GraphQL executor to resolve an entity by
7171
a stable key.
7272
"""
7373
directive @lookup on FIELD_DEFINITION
74+
75+
"""
76+
By default, only a single source schema is allowed to contribute
77+
a particular field to an object type.
78+
79+
80+
This prevents source schemas from inadvertently defining similarly named
81+
fields that are not semantically equivalent.
82+
83+
84+
Fields must be explicitly marked as @shareable to allow multiple source
85+
schemas to define them, ensuring that the decision to serve a field from
86+
more than one source schema is intentional and coordinated.
87+
88+
89+
directive @shareable repeatable on OBJECT | FIELD_DEFINITION
90+
"""
91+
directive @shareable repeatable on OBJECT | FIELD_DEFINITION

src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/IntegrationTests.Schema.graphql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ type PageInfo {
133133

134134
type Query {
135135
"Fetches an object given its ID."
136-
node("ID of the object." id: ID!): Node @lookup
136+
node("ID of the object." id: ID!): Node @lookup @shareable
137137
"Lookup nodes by a list of IDs."
138138
nodes("The list of node IDs." ids: [ID!]!): [Node]!
139139
authors("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): AuthorsConnection

src/HotChocolate/Core/test/Types.Tests/Types/Relay/__snapshots__/NodeTypeTests.Infer_Node_From_Query_Field_Schema.snap

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type Foo implements Node {
1414

1515
type Query {
1616
"Fetches an object given its ID."
17-
node("ID of the object." id: ID!): Node @lookup
17+
node("ID of the object." id: ID!): Node @lookup @shareable
1818
"Lookup nodes by a list of IDs."
1919
nodes("The list of node IDs." ids: [ID!]!): [Node]!
2020
fooById(id: ID!): Foo!
@@ -26,3 +26,21 @@ that can be used by the distributed GraphQL executor to resolve an entity by
2626
a stable key.
2727
"""
2828
directive @lookup on FIELD_DEFINITION
29+
30+
"""
31+
By default, only a single source schema is allowed to contribute
32+
a particular field to an object type.
33+
34+
35+
This prevents source schemas from inadvertently defining similarly named
36+
fields that are not semantically equivalent.
37+
38+
39+
Fields must be explicitly marked as @shareable to allow multiple source
40+
schemas to define them, ensuring that the decision to serve a field from
41+
more than one source schema is intentional and coordinated.
42+
43+
44+
directive @shareable repeatable on OBJECT | FIELD_DEFINITION
45+
"""
46+
directive @shareable repeatable on OBJECT | FIELD_DEFINITION

0 commit comments

Comments
 (0)