-
Notifications
You must be signed in to change notification settings - Fork 332
Expand file tree
/
Copy pathExecutionHelper.cs
More file actions
853 lines (757 loc) · 40.7 KB
/
ExecutionHelper.cs
File metadata and controls
853 lines (757 loc) · 40.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Models;
using Azure.DataApiBuilder.Core.Resolvers;
using Azure.DataApiBuilder.Core.Resolvers.Factories;
using Azure.DataApiBuilder.Core.Telemetry;
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.DataApiBuilder.Service.GraphQLBuilder;
using Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars;
using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
using HotChocolate.Execution;
using HotChocolate.Execution.Processing;
using HotChocolate.Language;
using HotChocolate.Resolvers;
using NodaTime.Text;
using Kestral = Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpMethod;
namespace Azure.DataApiBuilder.Service.Services
{
/// <summary>
/// This helper class provides the various resolvers and middlewares used
/// during query execution.
/// </summary>
public sealed class ExecutionHelper
{
internal readonly IQueryEngineFactory _queryEngineFactory;
internal readonly IMutationEngineFactory _mutationEngineFactory;
internal readonly RuntimeConfigProvider _runtimeConfigProvider;
private const string PURE_RESOLVER_CONTEXT_SUFFIX = "_PURE_RESOLVER_CTX";
public ExecutionHelper(
IQueryEngineFactory queryEngineFactory,
IMutationEngineFactory mutationEngineFactory,
RuntimeConfigProvider runtimeConfigProvider)
{
_queryEngineFactory = queryEngineFactory;
_mutationEngineFactory = mutationEngineFactory;
_runtimeConfigProvider = runtimeConfigProvider;
}
/// <summary>
/// Represents the root query resolver and fetches the initial data from the query engine.
/// </summary>
/// <param name="context">
/// The middleware context.
/// </param>
public async ValueTask ExecuteQueryAsync(IMiddlewareContext context)
{
using Activity? activity = StartQueryActivity(context);
string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, _runtimeConfigProvider.GetConfig());
DataSource ds = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName);
IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(ds.DatabaseType);
IDictionary<string, object?> parameters = GetParametersFromContext(context);
if (context.Selection.Type.IsListType())
{
Tuple<IEnumerable<JsonDocument>, IMetadata?> result =
await queryEngine.ExecuteListAsync(context, parameters, dataSourceName);
// this will be run after the query / mutation has completed.
context.RegisterForCleanup(
() =>
{
foreach (JsonDocument document in result.Item1)
{
document.Dispose();
}
return ValueTask.CompletedTask;
},
cleanAfter: CleanAfter.Request);
context.Result = result.Item1.Select(t => t.RootElement).ToArray();
SetNewMetadata(context, result.Item2);
}
else
{
Tuple<JsonDocument?, IMetadata?> result =
await queryEngine.ExecuteAsync(context, parameters, dataSourceName);
SetContextResult(context, result.Item1);
SetNewMetadata(context, result.Item2);
}
}
/// <summary>
/// Represents the root mutation resolver and invokes the mutation on the query engine.
/// </summary>
/// <param name="context">
/// The middleware context.
/// </param>
public async ValueTask ExecuteMutateAsync(IMiddlewareContext context)
{
using Activity? activity = StartQueryActivity(context);
string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, _runtimeConfigProvider.GetConfig());
DataSource ds = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName);
IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(ds.DatabaseType);
IDictionary<string, object?> parameters = GetParametersFromContext(context);
// Only Stored-Procedure has ListType as returnType for Mutation
if (context.Selection.Type.IsListType())
{
// Both Query and Mutation execute the same SQL statement for Stored Procedure.
Tuple<IEnumerable<JsonDocument>, IMetadata?> result =
await queryEngine.ExecuteListAsync(context, parameters, dataSourceName);
// this will be run after the query / mutation has completed.
context.RegisterForCleanup(
() =>
{
foreach (JsonDocument document in result.Item1)
{
document.Dispose();
}
return ValueTask.CompletedTask;
},
cleanAfter: CleanAfter.Request);
context.Result = result.Item1.Select(t => t.RootElement).ToArray();
SetNewMetadata(context, result.Item2);
}
else
{
IMutationEngine mutationEngine = _mutationEngineFactory.GetMutationEngine(ds.DatabaseType);
Tuple<JsonDocument?, IMetadata?> result =
await mutationEngine.ExecuteAsync(context, parameters, dataSourceName);
SetContextResult(context, result.Item1);
SetNewMetadata(context, result.Item2);
}
}
/// <summary>
/// Starts the activity for the query
/// </summary>
/// <param name="context">
/// The middleware context.
/// </param>
private Activity? StartQueryActivity(IMiddlewareContext context)
{
string route = _runtimeConfigProvider.GetConfig().GraphQLPath.Trim('/');
Kestral method = Kestral.Post;
Activity? activity = TelemetryTracesHelper.DABActivitySource.StartActivity($"{method} /{route}");
if (activity is not null)
{
string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, _runtimeConfigProvider.GetConfig());
DataSource ds = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName);
activity.TrackQueryActivityStarted(
databaseType: ds.DatabaseType,
dataSourceName: dataSourceName);
}
return activity;
}
/// <summary>
/// Represents a pure resolver for a leaf field.
/// This resolver extracts the field value from the json object.
/// </summary>
/// <param name="context">
/// The pure resolver context.
/// </param>
/// <returns>
/// Returns the runtime field value.
/// </returns>
public static object? ExecuteLeafField(IResolverContext context)
{
// This means this field is a scalar, so we don't need to do
// anything for it.
if (TryGetPropertyFromParent(context, out JsonElement fieldValue) &&
fieldValue.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null))
{
// The selection type can be a wrapper type like NonNullType or ListType.
// To get the most inner type (aka the named type) we use our named type helper.
ITypeDefinition namedType = context.Selection.Field.Type.NamedType();
// Each scalar in HotChocolate has a runtime type representation.
// In order to let scalar values flow through the GraphQL type completion
// efficiently we want the leaf types to match the runtime type.
// If that is not the case a value will go through the type converter to try to
// transform it into the runtime type.
// We also want to ensure here that we do not unnecessarily convert values to
// strings and then force the conversion to parse them.
try
{
return namedType switch
{
StringType => fieldValue.GetString(), // spec
ByteType => fieldValue.GetByte(),
ShortType => fieldValue.GetInt16(),
IntType => fieldValue.GetInt32(), // spec
LongType => fieldValue.GetInt64(),
FloatType => fieldValue.GetDouble(), // spec
SingleType => fieldValue.GetSingle(),
DecimalType => fieldValue.GetDecimal(),
DateTimeType => DateTimeOffset.TryParse(fieldValue.GetString()!, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out DateTimeOffset date) ? date : null, // for DW when datetime is null it will be in "" (double quotes) due to stringagg parsing and hence we need to ensure parsing is correct.
DateType => DateTimeOffset.TryParse(fieldValue.GetString()!, out DateTimeOffset date) ? date : null,
HotChocolate.Types.NodaTime.LocalTimeType => fieldValue.GetString()!.Equals("null", StringComparison.OrdinalIgnoreCase) ? null : LocalTimePattern.ExtendedIso.Parse(fieldValue.GetString()!).Value,
ByteArrayType => fieldValue.GetBytesFromBase64(),
BooleanType => fieldValue.GetBoolean(), // spec
UrlType => new Uri(fieldValue.GetString()!),
UuidType => fieldValue.GetGuid(),
TimeSpanType => TimeSpan.Parse(fieldValue.GetString()!),
AnyType => fieldValue.ToString(),
_ => fieldValue.GetString()
};
}
catch (Exception ex) when (ex is InvalidOperationException or FormatException)
{
// this usually means that database column type was changed since generating the GraphQL schema
// for e.g. System.FormatException - One of the identified items was in an invalid format
// System.InvalidOperationException - The requested operation requires an element of type 'Number', but the target element has type 'String'.
throw new DataApiBuilderException(
message: $"The {context.Selection.Field.Name} value could not be parsed for configured GraphQL data type {namedType.Name}",
statusCode: HttpStatusCode.Conflict,
subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping,
ex);
}
}
return null;
}
/// <summary>
/// Represents a pure resolver for an object field.
/// This resolver extracts another json object from the parent json property.
/// </summary>
/// <param name="context">
/// The pure resolver context.
/// </param>
/// <returns>
/// Returns a new json object.
/// </returns>
public object? ExecuteObjectField(IResolverContext context)
{
string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, _runtimeConfigProvider.GetConfig());
DataSource ds = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName);
IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(ds.DatabaseType);
if (TryGetPropertyFromParent(context, out JsonElement objectValue) &&
objectValue.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
IMetadata metadata = GetMetadataObjectField(context);
objectValue = queryEngine.ResolveObject(objectValue, context.Selection.Field, ref metadata);
// Since the query engine could null the object out we need to check again
// if it's null.
if (objectValue.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return null;
}
SetNewMetadataChildren(context, metadata);
return objectValue;
}
return null;
}
/// <summary>
/// The ListField pure resolver is executed when processing "list" fields.
/// For example, when executing the query { myEntity { items { entityField1 } } }
/// this pure resolver will be executed when processing the field "items" because
/// it will contain the "list" of results.
/// </summary>
/// <param name="context">PureResolver context provided by HC middleware.</param>
/// <returns>The resolved list, a JSON array, returned as type 'object?'.</returns>
/// <remarks>Return type is 'object?' instead of a 'List of JsonElements' because when this function returns JsonElement,
/// the HC12 engine doesn't know how to handle the JsonElement and results in requests failing at runtime.</remarks>
public object? ExecuteListField(IResolverContext context)
{
string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, _runtimeConfigProvider.GetConfig());
DataSource ds = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName);
IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(ds.DatabaseType);
if (TryGetPropertyFromParent(context, out JsonElement listValue) &&
listValue.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
IMetadata? metadata = GetMetadata(context);
object result = queryEngine.ResolveList(listValue, context.Selection.Field, ref metadata);
SetNewMetadataChildren(context, metadata);
return result;
}
return null;
}
/// <summary>
/// Set the context's result and dispose properly. If result is not null
/// clone root and dispose, otherwise set to null.
/// </summary>
/// <param name="context">Context to store result.</param>
/// <param name="result">Result to store in context.</param>
private static void SetContextResult(IMiddlewareContext context, JsonDocument? result)
{
if (result is not null)
{
context.RegisterForCleanup(() =>
{
result.Dispose();
return ValueTask.CompletedTask;
});
// The disposal could occur before we were finished using the value from the jsondocument,
// thus needing to ensure copying the root element. Hence, we clone the root element.
context.Result = result.RootElement.Clone();
}
else
{
context.Result = null;
}
}
private static bool TryGetPropertyFromParent(
IResolverContext context,
out JsonElement propertyValue)
{
JsonElement parent = context.Parent<JsonElement>();
if (parent.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
propertyValue = default;
return false;
}
else if (context.Path is NamePathSegment namePathSegment && namePathSegment.Parent is NamePathSegment parentSegment && parentSegment.Name == QueryBuilder.GROUP_BY_AGGREGATE_FIELD_NAME &&
parentSegment.Parent?.Parent is NamePathSegment grandParentSegment && grandParentSegment.Name.StartsWith(QueryBuilder.GROUP_BY_FIELD_NAME, StringComparison.OrdinalIgnoreCase))
{
// verify that current selection is part of a groupby query and within that an aggregation and then get the key which would be the operation name or its alias (eg: max, max_price etc)
string propertyName = namePathSegment.Name;
return parent.TryGetProperty(propertyName, out propertyValue);
}
return parent.TryGetProperty(context.Selection.Field.Name, out propertyValue);
}
/// <summary>
/// Extracts the value from an IValueNode. That includes extracting the value of the variable
/// if the IValueNode is a variable and extracting the correct type from the IValueNode
/// </summary>
/// <param name="value">the IValueNode from which to extract the value</param>
/// <param name="argumentSchema">describes the schema of the argument that the IValueNode represents</param>
/// <param name="variables">the request context variable values needed to resolve value nodes represented as variables</param>
public static object? ExtractValueFromIValueNode(
IValueNode value,
IInputValueDefinition argumentSchema,
IVariableValueCollection variables)
{
// extract value from the variable if the IValueNode is a variable
if (value.Kind == SyntaxKind.Variable)
{
string variableName = ((VariableNode)value).Name.Value;
IValueNode? variableValue = variables.GetValue<IValueNode>(variableName);
if (variableValue is null)
{
return null;
}
return ExtractValueFromIValueNode(variableValue, argumentSchema, variables);
}
if (value is NullValueNode)
{
return null;
}
// In case of ListType on scalar types(except string), argumentSchema.Type.TypeName() unwraps down to the namednode type and returns the type of the value node.
// For example, if the argumentSchema is a list of Ints, the type name will be "Int" and not "[Int]".
if (value.Value is List<IValueNode> && argumentSchema.Type.IsListType())
{
return value.Value;
}
return argumentSchema.Type.TypeName() switch
{
SupportedHotChocolateTypes.BYTE_TYPE => ((IntValueNode)value).ToByte(),
SupportedHotChocolateTypes.SHORT_TYPE => ((IntValueNode)value).ToInt16(),
SupportedHotChocolateTypes.INT_TYPE => ((IntValueNode)value).ToInt32(),
SupportedHotChocolateTypes.LONG_TYPE => ((IntValueNode)value).ToInt64(),
SupportedHotChocolateTypes.SINGLE_TYPE => value is IntValueNode intValueNode ? intValueNode.ToSingle() : ((FloatValueNode)value).ToSingle(),
SupportedHotChocolateTypes.FLOAT_TYPE => value is IntValueNode intValueNode ? intValueNode.ToDouble() : ((FloatValueNode)value).ToDouble(),
SupportedHotChocolateTypes.DECIMAL_TYPE => value is IntValueNode intValueNode ? intValueNode.ToDecimal() : ((FloatValueNode)value).ToDecimal(),
SupportedHotChocolateTypes.DATETIME_TYPE => ParseDateTimeValue(value.Value),
SupportedHotChocolateTypes.DATETIMEOFFSET_TYPE => ParseDateTimeOffsetValue(value.Value),
SupportedHotChocolateTypes.UUID_TYPE => Guid.TryParse(value.Value!.ToString(), out Guid guidValue) ? guidValue : value.Value,
_ => value.Value
};
static object? ParseDateTimeValue(object? raw)
{
if (raw is null)
{
return null;
}
if (raw is DateTime dt)
{
return dt;
}
if (raw is DateTimeOffset dto)
{
return dto.UtcDateTime;
}
if (raw is string s)
{
// HotChocolate DateTime inputs are supplied as strings; parse them so DB providers
// (notably PostgreSQL) receive a typed parameter instead of text.
if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset parsedDto))
{
return parsedDto.UtcDateTime;
}
if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsedDt))
{
return parsedDt;
}
}
return raw;
}
static object? ParseDateTimeOffsetValue(object? raw)
{
if (raw is null)
{
return null;
}
if (raw is DateTimeOffset dto)
{
return dto;
}
if (raw is DateTime dt)
{
return new DateTimeOffset(dt);
}
if (raw is string s)
{
if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset parsedDto))
{
return parsedDto;
}
}
return raw;
}
}
/// <summary>
/// First: Creates parameters using the GraphQL schema's ObjectTypeDefinition metadata
/// and metadata from the request's (query) field.
/// Then: Creates parameters from schema argument fields when they have default values.
/// Lastly: Gets the user provided argument values from the query to either:
/// 1. Overwrite the parameter value if it exists in the collectedParameters dictionary
/// or
/// 2. Adds the parameter/parameter value to the dictionary.
/// </summary>
/// <returns>
/// Dictionary of parameters
/// Key: (string) argument field name
/// Value: (object) argument value
/// </returns>
public static IDictionary<string, object?> GetParametersFromSchemaAndQueryFields(
ObjectField schema,
FieldNode query,
IVariableValueCollection variables)
{
IDictionary<string, object?> collectedParameters = new Dictionary<string, object?>();
// Fill the parameters dictionary with the default argument values
ArgumentCollection schemaArguments = schema.Arguments;
// Example 'argumentSchemas' IInputValueDefinition objects of type 'HotChocolate.Types.Argument':
// These are all default arguments defined in the schema for queries.
// {first:int}
// {after:String}
// {filter:entityFilterInput}
// {orderBy:entityOrderByInput}
// The values in schemaArguments will have default values when the backing
// entity is a stored procedure with runtime config defined default parameter values.
foreach (IInputValueDefinition argument in schemaArguments)
{
if (argument.DefaultValue != null)
{
collectedParameters.Add(
argument.Name,
ExtractValueFromIValueNode(
value: argument.DefaultValue,
argumentSchema: argument,
variables: variables));
}
}
// Overwrite the default values with the passed in arguments
// Example: { myEntity(first: $first, orderBy: {entityField: ASC) { items { entityField } } }
// User supplied $first filter variable overwrites the default value of 'first'.
// User supplied 'orderBy' filter overwrites the default value of 'orderBy'.
IReadOnlyList<ArgumentNode> passedArguments = query.Arguments;
foreach (ArgumentNode argument in passedArguments)
{
string argumentName = argument.Name.Value;
IInputValueDefinition argumentSchema = schemaArguments[argumentName];
object? nodeValue = ExtractValueFromIValueNode(
value: argument.Value,
argumentSchema: argumentSchema,
variables: variables);
if (!collectedParameters.TryAdd(argumentName, nodeValue))
{
collectedParameters[argumentName] = nodeValue;
}
}
return collectedParameters;
}
/// <summary>
/// InnerMostType is innermost type of the passed Graph QL type.
/// This strips all modifiers, such as List and Non-Null.
/// So the following GraphQL types would all have the underlyingType Book:
/// - Book
/// - [Book]
/// - Book!
/// - [Book]!
/// - [Book!]!
/// </summary>
internal static IType InnerMostType(IType type)
{
if (type.ToString() == type.InnerType().ToString())
{
return type;
}
return InnerMostType(type.InnerType());
}
public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefinition field)
{
return (InputObjectType)InnerMostType(field.Type);
}
/// <summary>
/// Creates a dictionary of parameters and associated values from
/// the GraphQL request's MiddlewareContext from arguments provided
/// in the request. e.g. first, after, filter, orderBy, and stored procedure
/// parameters.
/// </summary>
/// <param name="context">GraphQL HotChocolate MiddlewareContext</param>
/// <returns>Dictionary of parameters and their values.</returns>
private static IDictionary<string, object?> GetParametersFromContext(
IMiddlewareContext context)
{
return GetParametersFromSchemaAndQueryFields(
context.Selection.Field,
context.Selection.SyntaxNode,
context.Variables);
}
/// <summary>
/// Get metadata from HotChocolate's GraphQL request MiddlewareContext.
/// The metadata key is the root field name + _PURE_RESOLVER_CTX + :: + PathDepth.
/// CosmosDB does not utilize pagination metadata. So this function will return null
/// when executing GraphQl queries against CosmosDB.
/// </summary>
private static IMetadata? GetMetadata(IResolverContext context)
{
if (context.Selection.ResponseName == QueryBuilder.PAGINATION_FIELD_NAME && !context.Path.IsRootField())
{
// entering this block means that:
// context.Selection.ResponseName: items
// context.Path: /entityA/items (Depth: 1)
// context.Path.Parent: /entityA (Depth: 0)
// The parent's metadata will be stored in ContextData with a depth of context.Path minus 1. -> "::0"
// The resolved metadata key is entityA_PURE_RESOLVER_CTX and is appended with "::0"
// Another case would be:
// context.Path: /books/items[0]/authors/items
// context.Path.Parent: /books/items[0]/authors
// The nuance here is that HC counts the depth when the path is expanded as
// /books/items/items[idx]/authors -> Depth: 3 (0-indexed) which maps to the
// pagination metadata for the "authors/items" subquery.
string paginationObjectParentName = GetMetadataKey(context.Path) + "::" + context.Path.Parent.Depth();
// For nested list fields under relationships (e.g. reviews.items, authors.items),
// include the relationship path suffix so we look up the same key that
// SetNewMetadataChildren stored ("::depth::relationshipPath").
string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent);
if (!string.IsNullOrEmpty(relationshipPath))
{
paginationObjectParentName = paginationObjectParentName + "::" + relationshipPath;
}
if (context.ContextData.TryGetValue(key: paginationObjectParentName, out object? itemsPaginationMetadata) && itemsPaginationMetadata is not null)
{
return (IMetadata)itemsPaginationMetadata;
}
// If metadata is missing (e.g. Cosmos DB or pruned relationship), return an empty
// pagination metadata object to avoid KeyNotFoundException.
return PaginationMetadata.MakeEmptyPaginationMetadata();
}
// This section would be reached when processing a Cosmos query of the form:
// { planet_by_pk (id: $id, _partitionKeyValue: $partitionKeyValue) { tags } }
// where nested entities like the entity 'tags' are not nested within an "items" field
// like for SQL databases.
string metadataKey = GetMetadataKey(context.Path) + "::" + context.Path.Depth();
if (context.ContextData.TryGetValue(key: metadataKey, out object? paginationMetadata) && paginationMetadata is not null)
{
return (IMetadata)paginationMetadata;
}
else
{
// CosmosDB database type does not utilize pagination metadata.
return PaginationMetadata.MakeEmptyPaginationMetadata();
}
}
/// <summary>
/// Get the pagination metadata object for the field represented by the
/// pure resolver context.
/// e.g. when Context.Path is "/books/items[0]/authors", this function gets
/// the pagination metadata for authors, which is stored in the global middleware
/// context under key: "books_PURE_RESOLVER_CTX::1", where "books" is the parent object
/// and depth of "1" implicitly represents the path "/books/items". When "/books/items"
/// is processed by the pure resolver, the available pagination metadata maps to the object
/// type that enumerated in "items"
/// </summary>
/// <param name="context">Pure resolver context</param>
/// <returns>Pagination metadata</returns>
private static IMetadata GetMetadataObjectField(IResolverContext context)
{
// Depth Levels: / 0 / 1 / 2 / 3
// Example Path: /books/items/items[0]/publishers
// Depth of 1 should have key in context.ContextData
// Depth of 2 will not have context.ContextData entry because non-Indexed path element is the path that is cached.
// PaginationMetadata for items will be consistent across each subitem. So we can use the same metadata for each subitem.
// An indexer path segment is a segment that looks like -> items[n]
if (context.Path.Parent is IndexerPathSegment)
{
// When context.Path is "/books/items[0]/authors"
// Parent -> "/books/items[0]"
// Parent -> "/books/items" -> Depth of this path is used to create the key to get
// pagination metadata from context.ContextData
// The PaginationMetadata fetched has subquery metadata for "authors" from path "/books/items/authors"
string objectParentName = GetMetadataKey(context.Path) + "::" + context.Path.Parent.Parent.Depth();
// Include relationship path suffix (for example, "addresses" or "phoneNumbers") so
// we look up the same key that SetNewMetadataChildren stored
// ("::depth::relationshipPath").
string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent.Parent);
if (!string.IsNullOrEmpty(relationshipPath))
{
objectParentName = objectParentName + "::" + relationshipPath;
}
if (context.ContextData.TryGetValue(objectParentName, out object? indexerMetadata) && indexerMetadata is not null)
{
return (IMetadata)indexerMetadata;
}
// If no metadata is present (for example, for non-paginated relationships or when
// RBAC prunes a branch), return an empty pagination metadata object.
return PaginationMetadata.MakeEmptyPaginationMetadata();
}
if (!context.Path.IsRootField() && ((NamePathSegment)context.Path.Parent).Name != PURE_RESOLVER_CONTEXT_SUFFIX)
{
// This check handles when the current selection is a relationship field because in that case,
// there will be no context data entry.
// e.g. metadata for index 4 will not exist. only 3.
// Depth: / 0 / 1 / 2 / 3 / 4
// Path: /books/items/items[0]/publishers/books
//
// To handle arbitrary nesting depths with sibling relationships, we need to include
// the relationship field path in the key. For example:
// - /entity/items[0]/rel1/nested uses key ::3::rel1
// - /entity/items[0]/rel2/nested uses key ::3::rel2
// - /entity/items[0]/rel1/nested/deeper uses key ::4::rel1::nested
// - /entity/items[0]/rel1/nested2/deeper uses key ::4::rel1::nested2
string objectParentName = GetMetadataKey(context.Path.Parent) + "::" + context.Path.Parent.Depth();
string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent);
if (!string.IsNullOrEmpty(relationshipPath))
{
objectParentName = objectParentName + "::" + relationshipPath;
}
if (context.ContextData.TryGetValue(objectParentName, out object? nestedMetadata) && nestedMetadata is not null)
{
return (IMetadata)nestedMetadata;
}
return PaginationMetadata.MakeEmptyPaginationMetadata();
}
string metadataKey = GetMetadataKey(context.Path) + "::" + context.Path.Depth();
if (context.ContextData.TryGetValue(metadataKey, out object? rootMetadata) && rootMetadata is not null)
{
return (IMetadata)rootMetadata;
}
return PaginationMetadata.MakeEmptyPaginationMetadata();
}
private static string GetMetadataKey(HotChocolate.Path path)
{
if (path.Parent.IsRoot)
{
// current: "/entity/items -> "items"
return ((NamePathSegment)path).Name + PURE_RESOLVER_CONTEXT_SUFFIX;
}
// If execution reaches this point, the state of currentPath looks something
// like the following where there exists a Parent path element:
// "/entity/items -> current.Parent: "entity"
return GetMetadataKey(path: path.Parent);
}
/// <summary>
/// Builds a suffix representing the relationship path from the IndexerPathSegment (items[n])
/// up to (but not including) the current path segment. This is used to create unique metadata
/// keys for sibling relationships at any nesting depth.
/// </summary>
/// <param name="path">The path to build the suffix for</param>
/// <returns>
/// A string like "rel1" for /entity/items[0]/rel1,
/// or "rel1::nested" for /entity/items[0]/rel1/nested,
/// or empty string if no IndexerPathSegment is found in the path ancestry.
/// </returns>
private static string GetRelationshipPathSuffix(HotChocolate.Path path)
{
List<string> pathParts = new();
HotChocolate.Path? current = path;
// Walk up the path collecting relationship field names until we hit an IndexerPathSegment
while (current is not null && !current.IsRoot)
{
if (current is IndexerPathSegment)
{
// We've reached items[n], stop here
break;
}
if (current is NamePathSegment nameSegment)
{
pathParts.Add(nameSegment.Name);
}
current = current.Parent;
}
// If we didn't find an IndexerPathSegment, return empty (this handles root-level queries)
if (current is not IndexerPathSegment)
{
return string.Empty;
}
// Reverse because we walked up the tree, but we want the path from root to leaf
pathParts.Reverse();
return string.Join("::", pathParts);
}
/// <summary>
/// Resolves the name of the root object of a selection set to
/// use as the beginning of a key used to index pagination metadata in the
/// global HC middleware context.
/// </summary>
/// <param name="rootSelection">Root object field of query.</param>
/// <returns>"rootObjectName_PURE_RESOLVER_CTX"</returns>
private static string GetMetadataKey(ISelection rootSelection)
{
return rootSelection.ResponseName + PURE_RESOLVER_CONTEXT_SUFFIX;
}
/// <summary>
/// Persist new metadata with a key denoting the depth of the current path.
/// The pagination metadata persisted here correlates to the top-level object type
/// denoted in the request.
/// e.g. books_PURE_RESOLVER_CTX::0 for:
/// context.Path -> /books depth(0)
/// context.Selection -> books { items {id, title}}
/// </summary>
private static void SetNewMetadata(IResolverContext context, IMetadata? metadata)
{
string metadataKey = GetMetadataKey(context.Selection) + "::" + context.Path.Depth();
context.ContextData.Add(metadataKey, metadata);
}
/// <summary>
/// Stores the pagination metadata in the global context.ContextData accessible to
/// all pure resolvers for query fields referencing nested entities.
/// </summary>
/// <param name="context">Pure resolver context</param>
/// <param name="metadata">Pagination metadata</param>
private static void SetNewMetadataChildren(IResolverContext context, IMetadata? metadata)
{
// When context.Path is /entity/items the metadata key is "entity"
// The context key will use the depth of "items" so that the provided
// pagination metadata (which holds the subquery metadata for "/entity/items/nestedEntity")
// can be stored for future access when the "/entity/items/nestedEntity" pure resolver executes.
// When context.Path takes the form: "/entity/items[index]/nestedEntity" HC counts the depth as
// if the path took the form: "/entity/items/items[index]/nestedEntity" -> Depth of "nestedEntity"
// is 3 because depth is 0-indexed.
StringBuilder contextKeyBuilder = new();
contextKeyBuilder
.Append(GetMetadataKey(context.Path))
.Append("::")
.Append(context.Path.Depth());
// For relationship fields at any depth, include the relationship path suffix to distinguish
// between sibling relationships. This handles arbitrary nesting depths.
// e.g., "/entity/items[0]/rel1" gets key ::3::rel1
// e.g., "/entity/items[0]/rel1/nested" gets key ::4::rel1::nested
string relationshipPath = GetRelationshipPathSuffix(context.Path);
if (!string.IsNullOrEmpty(relationshipPath))
{
contextKeyBuilder
.Append("::")
.Append(relationshipPath);
}
string contextKey = contextKeyBuilder.ToString();
// It's okay to overwrite the context when we are visiting a different item in items e.g. books/items/items[1]/publishers since
// context for books/items/items[0]/publishers processing is done and that context isn't needed anymore.
if (!context.ContextData.TryAdd(contextKey, metadata))
{
context.ContextData[contextKey] = metadata;
}
}
}
}