Repository navigation
Expand file tree
/
Copy pathOffsetPagingExtension.cs
More file actions
266 lines (229 loc) · 14 KB
/
Copy pathOffsetPagingExtension.cs
File metadata and controls
266 lines (229 loc) · 14 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
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using EntityGraphQL.Compiler;
using EntityGraphQL.Compiler.Util;
using EntityGraphQL.Extensions;
namespace EntityGraphQL.Schema.FieldExtensions;
/// <summary>
/// Sets up a few extensions to modify a collection expression (e.g. db.Movies.OrderBy()) into a paging graph
/// </summary>
public class OffsetPagingExtension : BaseFieldExtension
{
public Expression? OriginalFieldExpression { get; private set; }
public List<IFieldExtension> Extensions { get; private set; } = [];
private bool isQueryable;
private Type? listType;
private Type? returnType;
private readonly int? defaultPageSize;
private readonly int? maxPageSize;
public OffsetPagingExtension(int? defaultPageSize, int? maxPageSize)
{
this.defaultPageSize = defaultPageSize;
this.maxPageSize = maxPageSize;
}
/// <summary>
/// True when the resolver interleaves service calls with context expressions
/// (e.g. ctx.People.Where(p => service.Filter(p))).
/// In this case the service and DB calls cannot be split across two passes,
/// so the field executes in the first pass with the service included.
/// </summary>
public bool IsInterleavedServiceField { get; private set; }
/// <summary>
/// Configure the field for a offset style paging field. Do as much as we can here as it is only executed once.
/// </summary>
/// <param name="schema"></param>
/// <param name="field"></param>
public override void Configure(ISchemaProvider schema, IField field)
{
if (field.ResolveExpression == null)
throw new EntityGraphQLSchemaException($"{nameof(OffsetPagingExtension)} requires a Resolve function set on the field");
if (!field.ResolveExpression.Type.IsEnumerableOrArray())
throw new EntityGraphQLSchemaException($"Expression for field {field.Name} must be a collection to use {nameof(OffsetPagingExtension)}. Found type {field.ReturnType.TypeDotnet}");
if (field.FieldType == GraphQLQueryFieldType.Mutation)
throw new EntityGraphQLSchemaException($"{nameof(OffsetPagingExtension)} cannot be used on a mutation field {field.Name}");
listType =
field.ReturnType.TypeDotnet.GetEnumerableOrArrayType()
?? throw new EntityGraphQLSchemaException($"Expression for field {field.Name} must be a collection to use {nameof(OffsetPagingExtension)}. Found type {field.ReturnType.TypeDotnet}");
ISchemaType returnSchemaType;
var page = $"{field.ReturnType.SchemaType.Name}Page";
var pageType = typeof(OffsetPage<>).MakeGenericType(listType);
if (!schema.HasType(pageType))
{
returnSchemaType = schema.AddType(pageType, page, $"Metadata about a {field.ReturnType.SchemaType.Name} page (paging over people)").AddAllFields();
}
else
{
returnSchemaType = schema.Type(page);
}
returnType = returnSchemaType.TypeDotnet;
field.Returns(SchemaBuilder.MakeGraphQlType(schema, false, returnType, returnSchemaType, field.Name, field.FromType));
// Save the resolve expression BEFORE AddArguments, which may replace it with a new
// object when the field already has arguments (arg params are merged/renamed).
// We need this pre-merge reference to detect interleaved service fields below.
var preAddArgsExpression = field.ResolveExpression;
// Update field arguments
field.AddArguments(new OffsetArgs());
if (defaultPageSize.HasValue)
field.Arguments["take"].DefaultValue = new DefaultArgValue(true, defaultPageSize.Value);
isQueryable = field.ResolveExpression.Type.IsGenericTypeQueryable();
// We steal any previous extensions as they were expected to work on the original Resolve which we moved to Edges
Extensions = field.Extensions.Take(field.Extensions.FindIndex(e => e is OffsetPagingExtension)).ToList();
field.Extensions = field.Extensions.Skip(Extensions.Count).ToList();
// update the Items field before we update the field.Resolve below
var itemsField = returnSchemaType.GetField("items", null);
OriginalFieldExpression = field.ResolveExpression!;
// Detect interleaved service: ExpressionExtractor extracts the whole resolver expression
// when a service is used inside the resolver body (e.g. ctx.People.Where(service.Filter)).
// In that case we cannot split into two passes and fall back to single-pass execution.
// Compare against preAddArgsExpression because AddArguments may have replaced the expression
// with a new object (when the field already had arguments whose params were merged/renamed).
IsInterleavedServiceField = field.ExtractedFieldsFromServices?.Any(f => f.FieldExpressions.Any(e => ReferenceEquals(e, preAddArgsExpression))) ?? false;
if (IsInterleavedServiceField && field.ExtractedFieldsFromServices != null && field.FieldParam != null)
{
field.ExtractedFieldsFromServices.Clear();
field.ExtractedFieldsFromServices.AddRange(BuildInterleavedServiceInputs(schema, field, OriginalFieldExpression));
}
// if they have 2 fields with the type and paging we don't want to add extension multiple times
// See OffsetPagingTests.TestMultiUseWithArgs
if (!itemsField.Extensions.Any(e => e is OffsetPagingItemsExtension))
itemsField.AddExtension(new OffsetPagingItemsExtension(isQueryable, listType!));
// set up the field's expression so the types are all good
var fieldExpression = BuildTotalCountExpression(null, returnType, field.ResolveExpression, field.ArgumentsParameter!);
field.UpdateExpression(fieldExpression);
}
private static List<GraphQLExtractedField> BuildInterleavedServiceInputs(ISchemaProvider schema, IField field, Expression? fieldExpression)
{
var fieldParam = field.FieldParam ?? throw new EntityGraphQLSchemaException($"Field {field.Name} must have a field parameter");
var extracted = new List<GraphQLExtractedField> { new(schema, BuildExtractedName($"{fieldParam}_ctx"), [fieldParam], fieldParam) };
var baseCollection = FindBaseCollectionExpression(fieldExpression!);
if (!ReferenceEquals(baseCollection, fieldParam))
extracted.Add(new(schema, BuildExtractedName(baseCollection.ToString()), [baseCollection], fieldParam));
return extracted;
}
private static Expression FindBaseCollectionExpression(Expression expression)
{
Expression current = expression;
while (current is MethodCallExpression methodCall)
{
current = methodCall.Object ?? methodCall.Arguments[0];
}
return current;
}
private static string BuildExtractedName(string value)
{
return "egql__" + new string(value.Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray());
}
private MemberInitExpression BuildTotalCountExpression(BaseGraphQLField? fieldNode, Type returnType, Expression resolve, ParameterExpression argumentParam)
{
// A COUNT query is only needed when the total itself is requested. hasNextPage alone is answered with a
// cheap EXISTS query (skip past the current page, Any()) instead of counting the whole collection.
var needsTotal = fieldNode?.QueryFields?.Any(f => f.Field?.Name == "totalItems") ?? true;
var needsHasNext = !needsTotal && (fieldNode?.QueryFields?.Any(f => f.Field?.Name == "hasNextPage") ?? false);
var skipExp = Expression.PropertyOrField(argumentParam!, "skip");
var takeExp = Expression.PropertyOrField(argumentParam!, "take");
if (needsHasNext)
{
Expression hasNextExp = isQueryable
? Expression.Call(typeof(QueryableExtensions), nameof(QueryableExtensions.PageHasNext), [listType!], resolve, skipExp, takeExp)
// A collection on a parent object (e.g. a navigation property) is part of the parent's projection, so EF has
// to translate this. It does not know PageHasNext and would load the whole collection to call it, so spell
// it out with System.Linq: take.HasValue && source.Skip((skip ?? 0) + take.Value).Any()
: Expression.AndAlso(
Expression.Property(takeExp, nameof(Nullable<int>.HasValue)),
Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Any),
[listType!],
Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Skip),
[listType!],
resolve,
Expression.Add(Expression.Coalesce(skipExp, Expression.Constant(0)), Expression.Property(takeExp, nameof(Nullable<int>.Value)))
)
)
);
return Expression.MemberInit(Expression.New(returnType.GetConstructor([typeof(int?), typeof(int?), typeof(bool?)])!, skipExp, takeExp, Expression.Convert(hasNextExp, typeof(bool?))));
}
var bindings = new List<MemberBinding>();
if (needsTotal)
{
var totalCountExp = Expression.Call(isQueryable ? typeof(Queryable) : typeof(Enumerable), nameof(Enumerable.Count), [listType!], resolve);
bindings.Add(Expression.Bind(returnType.GetProperty("TotalItems")!, totalCountExp));
}
var expression = Expression.MemberInit(Expression.New(returnType.GetConstructor([typeof(int?), typeof(int?)])!, skipExp, takeExp), bindings);
return expression;
}
public override (Expression? expression, ParameterExpression? originalArgParam, ParameterExpression? newArgParam, object? argumentValue) GetExpressionAndArguments(
IField field,
FieldExtensionExpressionContext context
)
{
var fieldNode = context.FieldNode;
var expression = context.Expression;
var argumentParam = context.ArgumentParameter;
var arguments = context.Arguments;
var fieldContext = context.Context;
var servicesPass = context.ServicesPass;
var withoutServiceFields = context.WithoutServiceFields;
var parameterReplacer = context.ParameterReplacer;
var originalArgParam = context.OriginalArgumentParameter;
var compileContext = context.CompileContext;
// For fields WITHOUT services: skip rebuild in second pass (paging was done in first pass).
// For service-backed paging fields, GraphQLObjectProjectionField returns early from GetFieldExpression
// in the first pass before calling Field.GetExpression, so this extension is never reached then.
// Paging is therefore always built in the second pass for service-backed fields.
if (servicesPass && field.Services.Count == 0)
return (expression, originalArgParam, argumentParam, arguments);
if (argumentParam == null)
throw new EntityGraphQLException(GraphQLErrorCategory.ExecutionError, $"{nameof(OffsetPagingExtension)} requires argumentParams to be set");
if (maxPageSize != null && arguments?.Take > maxPageSize.Value)
throw new EntityGraphQLException(GraphQLErrorCategory.DocumentError, $"Field '{fieldNode.Name}' - Argument take can not be greater than {maxPageSize}.");
// other extensions expect to run on the collection not our new shape
var newItemsExp = OriginalFieldExpression!;
// In the services pass, replace context-dependent expressions (e.g. dir.Id → anonElem.egql__dir_Id)
// so that the TotalItems expression uses the correct values from the anonymous type.
// Note: `context` here is the OffsetPage<T> MemberInit (already processed by ReplaceContext),
// not the anonymous element parameter. We look up the actual anonymous element via the parent
// field node's NextFieldContext, which was stored by GraphQLListSelectionField in the second pass.
if (servicesPass && field.Services.Count > 0)
{
compileContext.AddServices(field.Services);
if (field.ExtractedFieldsFromServices != null)
{
var anonElement = fieldNode.ParentNode?.NextFieldContext is ParameterExpression parentNextCtx ? compileContext.GetFieldContextReplacement(parentNextCtx) : null;
var replacementCtx = (Expression?)anonElement ?? fieldContext;
var expReplacer = new ExpressionReplacer(field.ExtractedFieldsFromServices, replacementCtx, false, false, null);
newItemsExp = expReplacer.Replace(newItemsExp);
if (field.FieldParam != null)
newItemsExp = parameterReplacer.Replace(newItemsExp, field.FieldParam, replacementCtx);
}
}
// update the context
foreach (var extension in Extensions)
{
var res = extension.GetExpressionAndArguments(
field,
new FieldExtensionExpressionContext
{
FieldNode = fieldNode,
Expression = newItemsExp,
ArgumentParameter = argumentParam,
Arguments = arguments,
Context = fieldContext,
ServicesPass = servicesPass,
WithoutServiceFields = withoutServiceFields,
ParameterReplacer = parameterReplacer,
OriginalArgumentParameter = originalArgParam,
CompileContext = compileContext,
}
);
(newItemsExp, originalArgParam, argumentParam, arguments) = (res.Item1!, res.Item2, res.Item3!, res.Item4);
}
// Build our field expression and hold it for use in the next step
Expression fieldExpression = BuildTotalCountExpression(fieldNode, returnType!, newItemsExp, argumentParam);
return (fieldExpression, originalArgParam, argumentParam, arguments);
}
}