Skip to content

Commit d729fc2

Browse files
committed
Add support for relative cursors. (#8048)
1 parent a83e3df commit d729fc2

File tree

236 files changed

+10034
-3659
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

236 files changed

+10034
-3659
lines changed

src/Directory.Packages.props

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Core" Version="1.4.0" />
2727
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Abstractions" Version="1.1.0" />
2828
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
29-
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
30-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
31-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.9.2" />
29+
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
30+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
31+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />
3232
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="7.0.3" />
3333
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
3434
<PackageVersion Include="Microsoft.OpenApi" Version="1.6.14" />
@@ -93,6 +93,7 @@
9393
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.2" />
9494
<PackageVersion Include="System.IO.Packaging" Version="9.0.2" />
9595
<PackageVersion Include="System.IO.Pipelines" Version="9.0.2" />
96+
<PackageVersion Include="System.IO.Hashing" Version="9.0.2" />
9697
</ItemGroup>
9798
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
9899
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.0" />
@@ -117,6 +118,7 @@
117118
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="8.0.0" />
118119
<PackageVersion Include="System.IO.Packaging" Version="8.0.1" />
119120
<PackageVersion Include="System.IO.Pipelines" Version="8.0.0" />
121+
<PackageVersion Include="System.IO.Hashing" Version="8.0.0" />
120122
</ItemGroup>
121123
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
122124
<PackageVersion Include="Microsoft.Extensions.ObjectPool" Version="6.0.0" />

src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,25 @@ internal static class ExpressionHelpers
4040
/// <exception cref="ArgumentException">
4141
/// If the number of keys does not match the number of values.
4242
/// </exception>
43-
public static Expression<Func<T, bool>> BuildWhereExpression<T>(
43+
public static (Expression<Func<T, bool>> WhereExpression, int Offset, bool ReverseOrder) BuildWhereExpression<T>(
4444
ReadOnlySpan<CursorKey> keys,
45-
ReadOnlySpan<object?> cursor,
45+
Cursor cursor,
4646
bool forward)
4747
{
4848
if (keys.Length == 0)
4949
{
5050
throw new ArgumentException("At least one key must be specified.", nameof(keys));
5151
}
5252

53-
if (keys.Length != cursor.Length)
53+
if (keys.Length != cursor.Values.Length)
5454
{
55-
throw new ArgumentException("The number of keys must match the number of values.", nameof(cursor));
55+
throw new ArgumentException("The number of keys must match the number of values.", nameof(cursor.Values));
5656
}
5757

58-
var cursorExpr = new Expression[cursor.Length];
59-
for (var i = 0; i < cursor.Length; i++)
58+
var cursorExpr = new Expression[cursor.Values.Length];
59+
for (var i = 0; i < cursor.Values.Length; i++)
6060
{
61-
cursorExpr[i] = CreateParameter(cursor[i], keys[i].Expression.ReturnType);
61+
cursorExpr[i] = CreateParameter(cursor.Values[i], keys[i].Expression.ReturnType);
6262
}
6363

6464
var handled = new List<CursorKey>();
@@ -118,7 +118,10 @@ public static Expression<Func<T, bool>> BuildWhereExpression<T>(
118118
handled.Add(key);
119119
}
120120

121-
return Expression.Lambda<Func<T, bool>>(expression!, parameter);
121+
var offset = cursor.Offset ?? 0;
122+
var reverseOrder = offset < 0; // Reverse order if offset is negative
123+
124+
return (Expression.Lambda<Func<T, bool>>(expression!, parameter), Math.Abs(offset), reverseOrder);
122125
}
123126

124127
/// <summary>
@@ -153,7 +156,7 @@ public static Expression<Func<T, bool>> BuildWhereExpression<T>(
153156
/// If the number of keys is less than one or
154157
/// the number of order expressions does not match the number of order methods.
155158
/// </exception>
156-
public static Expression<Func<IGrouping<TK, TV>, Group<TK, TV>>> BuildBatchSelectExpression<TK, TV>(
159+
public static (Expression<Func<IGrouping<TK, TV>, Group<TK, TV>>> SelectExpression, bool ReverseOrder) BuildBatchSelectExpression<TK, TV>(
157160
PagingArguments arguments,
158161
ReadOnlySpan<CursorKey> keys,
159162
ReadOnlySpan<LambdaExpression> orderExpressions,
@@ -181,14 +184,8 @@ public static Expression<Func<IGrouping<TK, TV>, Group<TK, TV>>> BuildBatchSelec
181184

182185
for (var i = 0; i < orderExpressions.Length; i++)
183186
{
184-
var methodName = orderMethods[i];
187+
var methodName = forward ? orderMethods[i] : ReverseOrder(orderMethods[i]);
185188
var orderExpression = orderExpressions[i];
186-
187-
if (!forward)
188-
{
189-
methodName = ReverseOrder(methodName);
190-
}
191-
192189
var delegateType = typeof(Func<,>).MakeGenericType(typeof(TV), orderExpression.Body.Type);
193190
var typedOrderExpression = Expression.Lambda(delegateType, orderExpression.Body, orderExpression.Parameters);
194191

@@ -200,16 +197,44 @@ public static Expression<Func<IGrouping<TK, TV>, Group<TK, TV>>> BuildBatchSelec
200197
typedOrderExpression);
201198
}
202199

200+
var reverseOrder = false;
201+
var offset = 0;
202+
203203
if (arguments.After is not null)
204204
{
205205
var cursor = CursorParser.Parse(arguments.After, keys);
206-
source = BuildBatchWhereExpression<TV>(source, keys, cursor, forward);
206+
var (whereExpr, cursorOffset, reverse) = BuildWhereExpression<TV>(keys, cursor, forward: true);
207+
source = Expression.Call(typeof(Enumerable), "Where", [typeof(TV)], source, whereExpr);
208+
offset = cursorOffset;
209+
reverseOrder = reverse;
207210
}
208211

209212
if (arguments.Before is not null)
210213
{
211214
var cursor = CursorParser.Parse(arguments.Before, keys);
212-
source = BuildBatchWhereExpression<TV>(source, keys, cursor, forward);
215+
var (whereExpr, cursorOffset, reverse) = BuildWhereExpression<TV>(keys, cursor, forward: false);
216+
source = Expression.Call(typeof(Enumerable), "Where", [typeof(TV)], source, whereExpr);
217+
offset = cursorOffset;
218+
reverseOrder = reverse;
219+
}
220+
221+
if (reverseOrder)
222+
{
223+
source = Expression.Call(
224+
typeof(Enumerable),
225+
"Reverse",
226+
[typeof(TV)],
227+
source);
228+
}
229+
230+
if (offset > 0)
231+
{
232+
source = Expression.Call(
233+
typeof(Enumerable),
234+
"Skip",
235+
[typeof(TV)],
236+
source,
237+
Expression.Constant(offset));
213238
}
214239

215240
if (arguments.First is not null)
@@ -248,7 +273,7 @@ public static Expression<Func<IGrouping<TK, TV>, Group<TK, TV>>> BuildBatchSelec
248273
};
249274

250275
var createGroup = Expression.MemberInit(Expression.New(groupType), bindings);
251-
return Expression.Lambda<Func<IGrouping<TK, TV>, Group<TK, TV>>>(createGroup, group);
276+
return (Expression.Lambda<Func<IGrouping<TK, TV>, Group<TK, TV>>>(createGroup, group), reverseOrder);
252277

253278
static string ReverseOrder(string method)
254279
=> method switch
@@ -261,12 +286,10 @@ static string ReverseOrder(string method)
261286
};
262287

263288
static MethodInfo GetEnumerableMethod(string methodName, Type elementType, LambdaExpression keySelector)
264-
{
265-
return typeof(Enumerable)
289+
=> typeof(Enumerable)
266290
.GetMethods(BindingFlags.Static | BindingFlags.Public)
267291
.First(m => m.Name == methodName && m.GetParameters().Length == 2)
268292
.MakeGenericMethod(elementType, keySelector.Body.Type);
269-
}
270293
}
271294

272295
private static MethodCallExpression BuildBatchWhereExpression<T>(
@@ -355,7 +378,7 @@ private static MethodCallExpression BuildBatchWhereExpression<T>(
355378
/// <exception cref="ArgumentNullException"></exception>
356379
public static OrderRewriterResult ExtractAndRemoveOrder(Expression expression)
357380
{
358-
if(expression is null)
381+
if (expression is null)
359382
{
360383
throw new ArgumentNullException(nameof(expression));
361384
}

src/GreenDonut/src/GreenDonut.Data.EntityFramework/Extensions/PagingQueryableExtensions.cs

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,7 @@ public static async ValueTask<Page<T>> ToPageAsync<T>(
7575
bool includeTotalCount,
7676
CancellationToken cancellationToken = default)
7777
{
78-
if (source == null)
79-
{
80-
throw new ArgumentNullException(nameof(source));
81-
}
78+
ArgumentNullException.ThrowIfNull(source);
8279

8380
source = QueryHelpers.EnsureOrderPropsAreSelected(source);
8481

@@ -98,36 +95,79 @@ public static async ValueTask<Page<T>> ToPageAsync<T>(
9895
nameof(arguments));
9996
}
10097

98+
if (arguments.EnableRelativeCursors
99+
&& string.IsNullOrEmpty(arguments.After)
100+
&& string.IsNullOrEmpty(arguments.Before))
101+
{
102+
includeTotalCount = true;
103+
}
104+
101105
var originalQuery = source;
102106
var forward = arguments.Last is null;
103107
var requestedCount = int.MaxValue;
108+
var reverseOrder = false;
109+
var offset = 0;
110+
int? totalCount = null;
104111

105112
if (arguments.After is not null)
106113
{
107114
var cursor = CursorParser.Parse(arguments.After, keys);
108-
source = source.Where(BuildWhereExpression<T>(keys, cursor, true));
115+
var (whereExpr, cursorOffset, reverse) = BuildWhereExpression<T>(keys, cursor, true);
116+
117+
source = source.Where(whereExpr);
118+
offset = cursorOffset;
119+
reverseOrder = reverse;
120+
121+
if (!includeTotalCount)
122+
{
123+
totalCount ??= cursor.TotalCount;
124+
}
109125
}
110126

111127
if (arguments.Before is not null)
112128
{
113129
var cursor = CursorParser.Parse(arguments.Before, keys);
114-
source = source.Where(BuildWhereExpression<T>(keys, cursor, false));
130+
var (whereExpr, cursorOffset, reverse) = BuildWhereExpression<T>(keys, cursor, false);
131+
132+
source = source.Where(whereExpr);
133+
offset = cursorOffset;
134+
reverseOrder = reverse;
135+
136+
if (!includeTotalCount)
137+
{
138+
totalCount ??= cursor.TotalCount;
139+
}
140+
}
141+
142+
// Reverse order if offset is negative
143+
if (reverseOrder)
144+
{
145+
source = source.Reverse();
115146
}
116147

117148
if (arguments.First is not null)
118149
{
150+
if (offset > 0)
151+
{
152+
source = source.Skip(offset * arguments.First.Value);
153+
}
154+
119155
source = source.Take(arguments.First.Value + 1);
120156
requestedCount = arguments.First.Value;
121157
}
122158

123159
if (arguments.Last is not null)
124160
{
161+
if (offset > 0)
162+
{
163+
source = source.Skip(offset * arguments.Last.Value);
164+
}
165+
125166
source = source.Reverse().Take(arguments.Last.Value + 1);
126167
requestedCount = arguments.Last.Value;
127168
}
128169

129170
var builder = ImmutableArray.CreateBuilder<T>();
130-
int? totalCount = null;
131171
var fetchCount = 0;
132172

133173
if (includeTotalCount)
@@ -142,12 +182,12 @@ public static async ValueTask<Page<T>> ToPageAsync<T>(
142182
totalCount ??= item.TotalCount;
143183
fetchCount++;
144184

185+
builder.Add(item.Item);
186+
145187
if (fetchCount > requestedCount)
146188
{
147189
break;
148190
}
149-
150-
builder.Add(item.Item);
151191
}
152192
}
153193
else
@@ -159,12 +199,12 @@ public static async ValueTask<Page<T>> ToPageAsync<T>(
159199
{
160200
fetchCount++;
161201

202+
builder.Add(item);
203+
162204
if (fetchCount > requestedCount)
163205
{
164206
break;
165207
}
166-
167-
builder.Add(item);
168208
}
169209
}
170210

@@ -173,11 +213,23 @@ public static async ValueTask<Page<T>> ToPageAsync<T>(
173213
return Page<T>.Empty;
174214
}
175215

176-
if (!forward)
216+
if (!forward ^ reverseOrder)
177217
{
178218
builder.Reverse();
179219
}
180220

221+
if (builder.Count > requestedCount)
222+
{
223+
if (!forward ^ reverseOrder)
224+
{
225+
builder.RemoveAt(0);
226+
}
227+
else
228+
{
229+
builder.RemoveAt(requestedCount);
230+
}
231+
}
232+
181233
return CreatePage(builder.ToImmutable(), arguments, keys, fetchCount, totalCount);
182234
}
183235

@@ -367,6 +419,13 @@ public static async ValueTask<Dictionary<TKey, Page<TValue>>> ToBatchPageAsync<T
367419
nameof(arguments));
368420
}
369421

422+
if (arguments.EnableRelativeCursors
423+
&& string.IsNullOrEmpty(arguments.After)
424+
&& string.IsNullOrEmpty(arguments.Before))
425+
{
426+
includeTotalCount = true;
427+
}
428+
370429
Dictionary<TKey, int>? counts = null;
371430
if (includeTotalCount)
372431
{
@@ -383,7 +442,7 @@ public static async ValueTask<Dictionary<TKey, Page<TValue>>> ToBatchPageAsync<T
383442

384443
var forward = arguments.Last is null;
385444
var requestedCount = int.MaxValue;
386-
var selectExpression =
445+
var (selectExpression, reverseOrder) =
387446
BuildBatchSelectExpression<TKey, TElement>(
388447
arguments,
389448
keys,
@@ -405,6 +464,11 @@ public static async ValueTask<Dictionary<TKey, Page<TValue>>> ToBatchPageAsync<T
405464
.WithCancellation(cancellationToken)
406465
.ConfigureAwait(false))
407466
{
467+
if (reverseOrder)
468+
{
469+
item.Items.Reverse();
470+
}
471+
408472
if (item.Items.Count == 0)
409473
{
410474
map.Add(item.Key, Page<TValue>.Empty);
@@ -520,6 +584,17 @@ private static Page<T> CreatePage<T>(
520584
hasNext = true;
521585
}
522586

587+
if (arguments.EnableRelativeCursors && totalCount is not null)
588+
{
589+
return new Page<T>(
590+
items,
591+
hasNext,
592+
hasPrevious,
593+
(item, o, p, c) => CursorFormatter.Format(item, keys, new CursorPageInfo(o, p, c)),
594+
0,
595+
totalCount.Value);
596+
}
597+
523598
return new Page<T>(
524599
items,
525600
hasNext,
@@ -532,7 +607,7 @@ private static CursorKey[] ParseDataSetKeys<T>(IQueryable<T> source)
532607
{
533608
var parser = new CursorKeyParser();
534609
parser.Visit(source.Expression);
535-
return parser.Keys.ToArray();
610+
return [.. parser.Keys];
536611
}
537612

538613
private sealed class InterceptorHolder

src/GreenDonut/src/GreenDonut.Data.Primitives/GreenDonut.Data.Primitives.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@
77
<Description>This package contains the basic building blocks of the DataLoader linq query integration.</Description>
88
</PropertyGroup>
99

10+
<ItemGroup>
11+
<InternalsVisibleTo Include="GreenDonut.Data.EntityFramework" />
12+
</ItemGroup>
13+
1014
</Project>

0 commit comments

Comments
 (0)