-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathQueryForgeQueryableExtensions.cs
More file actions
333 lines (275 loc) · 13.9 KB
/
Copy pathQueryForgeQueryableExtensions.cs
File metadata and controls
333 lines (275 loc) · 13.9 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
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using PepperX.QueryForge.EFCore.Translation;
using PepperX.QueryForge.Querying;
namespace PepperX.QueryForge.EFCore;
/// <summary>
/// Applies a QueryForge <see cref="Query"/> to an EF Core <see cref="IQueryable{T}"/>.
/// </summary>
/// <remarks>
/// These compose with whatever you already have, so the query you hand in keeps its includes,
/// projections, and any restriction you have already applied:
/// <code>
/// var result = await db.Users
/// .Where(u => u.TenantId == tenantId)
/// .ToQueryResultAsync<User>(query);
/// </code>
/// Because EF Core generates the SQL, this works on every database EF Core supports and honours the
/// model's global query filters, value converters and owned types.
/// </remarks>
public static class QueryForgeQueryableExtensions
{
/// <summary>Applies only the filtering criteria.</summary>
public static IQueryable<TModel> ApplyFilter<TModel>(this IQueryable<TModel> source, Query query)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(query);
var predicate = ExpressionCompiler.BuildPredicate<TModel>(query.Criteria);
return predicate is null ? source : source.Where(predicate);
}
/// <summary>Applies only the sort columns, in order.</summary>
public static IQueryable<TModel> ApplySort<TModel>(this IQueryable<TModel> source, Query query)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(query);
IOrderedQueryable<TModel>? ordered = null;
foreach (var sort in query.SortColumns)
{
// Orderable rather than merely present: a navigation property resolves like any other, but
// a collection one cannot be translated at all and a reference one orders by a surrogate
// key nobody asked for.
var property = ExpressionCompiler.ResolveOrderableProperty<TModel>(sort.ColumnName);
if (property is null)
continue;
var descending = sort.SortOrder == SortOrder.Descending;
// Databases disagree on where nulls sort — PostgreSQL and Oracle put them last ascending,
// SQL Server, MySQL and SQLite put them first. EF Core hands ORDER BY straight to the
// database, so an explicit null-rank key is what makes the same query return the same
// order everywhere. Only nullable columns need it.
var nullRank = ExpressionCompiler.BuildNullRank<TModel>(property, nullsFirst: !descending);
if (nullRank is not null)
{
ordered = (IOrderedQueryable<TModel>)ApplyOrdering(
ordered ?? source, nullRank, ordered is null ? "OrderBy" : "ThenBy");
}
var selector = ExpressionCompiler.BuildSelector<TModel>(sort.ColumnName)!;
var method = ordered is null
? descending ? "OrderByDescending" : "OrderBy"
: descending ? "ThenByDescending" : "ThenBy";
ordered = (IOrderedQueryable<TModel>)ApplyOrdering(ordered ?? source, selector, method);
}
return ordered ?? source;
}
/// <summary>Applies only the paging window.</summary>
public static IQueryable<TModel> ApplyPaging<TModel>(this IQueryable<TModel> source, Query query)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(query);
var size = EffectiveSize(query.Paging);
var number = query.Paging.Number > 0 ? query.Paging.Number : 1;
return source.Skip((number - 1) * size).Take(size);
}
/// <summary>
/// Applies only the column projection, narrowing the SELECT list so unselected columns are never
/// fetched.
/// </summary>
/// <param name="source">The query to project.</param>
/// <param name="query">The query intent, whose <see cref="Query.SelectColumns"/> is used.</param>
/// <param name="alsoKeep">
/// Extra columns to keep regardless of the selection — used internally for grouping columns,
/// which the hierarchy is rebuilt from.
/// </param>
/// <remarks>
/// The results are untracked: EF Core does not track instances constructed inside a projection,
/// which is the behaviour you want here — a partially-populated entity is not something the
/// change tracker should ever write back. Models without a parameterless constructor and settable
/// properties are left unprojected rather than failing the query.
/// </remarks>
public static IQueryable<TModel> ApplyProjection<TModel>(
this IQueryable<TModel> source,
Query query,
IReadOnlyList<string>? alsoKeep = null)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(query);
if (query.SelectColumns.Count == 0)
return source;
// EF Core drops every Include from a query whose final shape is a constructed instance rather
// than the tracked entity. Projecting here would therefore return rows whose navigations are
// empty — a silent loss of the data the caller explicitly asked to load, with nothing in the
// response to show for it. The projection is the input that cannot be honoured, so it is the
// one that gets dropped, which is the rule the rest of QueryForge follows.
if (HasIncludes(source.Expression))
return source;
var selector = ExpressionCompiler.BuildProjection<TModel>(query.SelectColumns, alsoKeep);
return selector is null ? source : source.Select(selector);
}
/// <summary>
/// Whether an <c>Include</c> or <c>ThenInclude</c> already appears in the query.
/// </summary>
/// <remarks>
/// Walks the source chain rather than the whole tree: the operators are composed left to right,
/// so an include is always reachable through the first argument of each call — including through
/// the <c>AsNoTracking</c> and <c>AsSplitQuery</c> that usually follow it.
/// </remarks>
private static bool HasIncludes(Expression expression)
{
while (expression is MethodCallExpression call)
{
// A projection the caller wrote themselves has already cost them the includes, so anything
// further down is moot and QueryForge's own projection is free to apply. Without this, a
// query that includes and then selects into a DTO would have SelectColumns quietly ignored
// for no benefit.
if (call.Method.DeclaringType == typeof(Queryable)
&& call.Method.Name == nameof(Queryable.Select))
{
return false;
}
// Matched by name because ThenInclude is declared against IIncludableQueryable, so the
// two do not share a single closed generic definition to compare against.
if (call.Method.DeclaringType == typeof(EntityFrameworkQueryableExtensions)
&& call.Method.Name is "Include" or "ThenInclude")
{
return true;
}
if (call.Arguments.Count == 0)
break;
expression = call.Arguments[0];
}
return false;
}
/// <summary>Applies filtering, sorting and paging, leaving the query unexecuted.</summary>
/// <remarks>
/// Grouping is not applied here, because a hierarchy is a shape rather than a queryable. Use
/// <see cref="ToQueryResultAsync{TModel}"/> for grouped queries.
/// </remarks>
public static IQueryable<TModel> ApplyQuery<TModel>(this IQueryable<TModel> source, Query query)
=> source.ApplyFilter(query).ApplySort(query).ApplyPaging(query).ApplyProjection(query);
/// <summary>
/// Executes the query and returns the standard QueryForge result, flat or grouped.
/// </summary>
public static async Task<QueryResult<TModel>> ToQueryResultAsync<TModel>(
this IQueryable<TModel> source,
Query query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(query);
var filtered = source.ApplyFilter(query);
// A grouping level has to be orderable, not merely present: its keys are ordered and paged
// in the database, which a navigation property cannot be.
var grouping = query.GroupByColumns
.FirstOrDefault(g => ExpressionCompiler.ResolveOrderableProperty<TModel>(g.ColumnName) is not null);
return grouping is null
? await FlatAsync(filtered, query, cancellationToken)
: await GroupedAsync(filtered, query, grouping, cancellationToken);
}
private static async Task<QueryResult<TModel>> FlatAsync<TModel>(
IQueryable<TModel> filtered,
Query query,
CancellationToken cancellationToken)
{
var total = await filtered.CountAsync(cancellationToken);
var models = await filtered
.ApplySort(query)
.ApplyPaging(query)
.ApplyProjection(query)
.ToListAsync(cancellationToken);
var size = EffectiveSize(query.Paging);
return new QueryResult<TModel>
{
Meta = new QueryResultMeta(new QueryResultMetaTotal(total, PageCount(total, size)), QueryResultType.Flat),
Models = models
};
}
/// <summary>
/// Runs a grouped query. Paging applies to the outermost grouping level, so the keys are paged
/// first and only then are the rows belonging to those groups fetched.
/// </summary>
private static Task<QueryResult<TModel>> GroupedAsync<TModel>(
IQueryable<TModel> filtered,
Query query,
GroupByDescriptor grouping,
CancellationToken cancellationToken)
{
var property = ExpressionCompiler.ResolveOrderableProperty<TModel>(grouping.ColumnName)!;
// The key type is only known at run time, so the typed implementation is reached reflectively.
var method = typeof(QueryForgeQueryableExtensions)
.GetMethod(nameof(GroupedCoreAsync), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(typeof(TModel), property.PropertyType);
return (Task<QueryResult<TModel>>)method.Invoke(
null, [filtered, query, grouping, cancellationToken])!;
}
private static async Task<QueryResult<TModel>> GroupedCoreAsync<TModel, TKey>(
IQueryable<TModel> filtered,
Query query,
GroupByDescriptor grouping,
CancellationToken cancellationToken)
{
var selector = (Expression<Func<TModel, TKey>>)ExpressionCompiler.BuildSelector<TModel>(grouping.ColumnName)!;
var keys = filtered.Select(selector).Distinct();
var totalGroups = await keys.CountAsync(cancellationToken);
var descending = grouping.SortOrder == SortOrder.Descending;
// Grouping keys need the same null-placement guarantee the sort columns get, or a paged
// grouped result would start on a different group depending on the database.
var keyNullRank = ExpressionCompiler.BuildKeyNullRank<TKey>(nullsFirst: !descending);
IOrderedQueryable<TKey> orderedKeys;
if (keyNullRank is not null)
{
orderedKeys = descending
? keys.OrderBy(keyNullRank).ThenByDescending(k => k)
: keys.OrderBy(keyNullRank).ThenBy(k => k);
}
else
{
orderedKeys = descending ? keys.OrderByDescending(k => k) : keys.OrderBy(k => k);
}
var size = EffectiveSize(query.Paging);
var number = query.Paging.Number > 0 ? query.Paging.Number : 1;
var pagedKeys = await orderedKeys
.Skip((number - 1) * size)
.Take(size)
.ToListAsync(cancellationToken);
var meta = new QueryResultMeta(
new QueryResultMetaTotal(totalGroups, PageCount(totalGroups, size)),
QueryResultType.Grouped);
if (pagedKeys.Count == 0)
return new QueryResult<TModel> { Meta = meta, Groups = Array.Empty<HierarchyNode<TModel>>() };
// Every row of the paged groups is needed, because a node's count is the number of leaf rows
// beneath it. The page size bounds the number of groups, not the rows inside them.
var parameter = selector.Parameters[0];
var contains = Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Contains),
[typeof(TKey)],
Expression.Constant(pagedKeys),
selector.Body);
var groups = query.GroupByColumns
.Where(g => ExpressionCompiler.ResolveOrderableProperty<TModel>(g.ColumnName) is not null)
.ToList();
// Grouping columns survive the projection: the hierarchy is rebuilt from them after the rows
// come back, so dropping them would leave nothing to group by.
var rows = await filtered
.Where(Expression.Lambda<Func<TModel, bool>>(contains, parameter))
.ApplySort(query)
.ApplyProjection(query, groups.Select(g => g.ColumnName).ToList())
.ToListAsync(cancellationToken);
return new QueryResult<TModel>
{
Meta = meta,
Groups = HierarchyBuilder.Build(rows, groups)
};
}
private static IQueryable<TModel> ApplyOrdering<TModel>(
IQueryable<TModel> source,
LambdaExpression selector,
string method)
=> (IQueryable<TModel>)typeof(Queryable)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.First(m => m.Name == method && m.GetParameters().Length == 2)
.MakeGenericMethod(typeof(TModel), selector.ReturnType)
.Invoke(null, [source, selector])!;
private static int EffectiveSize(QueryPaging paging) => paging.Size > 0 ? paging.Size : 12;
private static int PageCount(int total, int size) => size <= 0 ? 0 : (int)Math.Ceiling(total / (double)size);
}