Skip to content

Commit 548924a

Browse files
authored
Merge pull request #23 from mrahhal/nested
Support nested properties when defining a keyset
2 parents 4d6ad42 + 6401afa commit 548924a

File tree

15 files changed

+429
-47
lines changed

15 files changed

+429
-47
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,38 @@ var admins = await keysetContext.Query
200200
keysetContext.EnsureCorrectOrder(admins);
201201
```
202202

203+
## Nested properties
204+
205+
Nested properties are also supported when defining a keyset. Just make sure the reference contains the same nested chain of properties.
206+
207+
```cs
208+
// If you're using a loaded entity for the reference.
209+
var reference = await dbContext.Users
210+
// Load it, otherwise you won't get the correct result.
211+
.Include(x => x.Nested)
212+
.FirstOrDefaultAsync(x => x.Id == id);
213+
214+
// If you're using another type for the reference.
215+
var reference = new
216+
{
217+
Nested = new
218+
{
219+
Created = ...,
220+
},
221+
};
222+
223+
var keysetContext = dbContext.Users.KeysetPaginate(
224+
// Defining the keyset using a nested property.
225+
b => b.Ascending(entity => entity.Nested.Created),
226+
direction,
227+
reference);
228+
var result = await keysetContext.Query
229+
// You'll want to load it here too if you plan on calling any context methods.
230+
.Include(x => x.Nested)
231+
.Take(20)
232+
.ToListAsync();
233+
```
234+
203235
## Avoiding skipping over data
204236

205237
You'll want to make sure the combination of the columns you configure uniquely identify an entity, otherwise you might skip over data while navigating pages. This is a general rule to keep in mind when doing keyset pagination.

samples/Basic/Models/User.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,15 @@ public class User
1010
public string Name { get; set; }
1111

1212
public DateTime Created { get; set; }
13+
14+
public UserDetails Details { get; set; }
15+
}
16+
17+
[Index(nameof(Created))]
18+
public class UserDetails
19+
{
20+
public int Id { get; set; }
21+
22+
public DateTime Created { get; set; }
1323
}
1424
}

samples/Basic/Pages/Nested.cshtml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
@page
2+
@model NestedModel
3+
4+
<div class="text-center">
5+
@if (Model.Users.Any())
6+
{
7+
<div class="users">
8+
@foreach (var user in Model.Users)
9+
{
10+
<div class="user">
11+
@user.Name (@user.Details.Created.ToLongDateString())
12+
</div>
13+
}
14+
</div>
15+
16+
<div class="mt-3" style="text-align: center">
17+
(@Model.Count items) (@Model.Elapsed | total with prev/next: @Model.ElapsedTotal)
18+
</div>
19+
20+
<div class="pagination mt-3">
21+
<a asp-page="/Nested" asp-route-first="@true" disabled="@(!Model.HasPrevious ? "disabled" : null)">First</a>
22+
<a asp-page="/Nested" asp-route-before="@Model.Users.First().Id" disabled="@(!Model.HasPrevious ? "disabled" : null)">Previous</a>
23+
<a asp-page="/Nested" asp-route-after="@Model.Users.Last().Id" disabled="@(!Model.HasNext ? "disabled" : null)">Next</a>
24+
<a asp-page="/Nested" asp-route-last="@true" disabled="@(!Model.HasNext ? "disabled" : null)">Last</a>
25+
</div>
26+
}
27+
else
28+
{
29+
<div>Nothing</div>
30+
}
31+
</div>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using System.Diagnostics;
2+
using Basic.Models;
3+
using Microsoft.AspNetCore.Mvc.RazorPages;
4+
using Microsoft.EntityFrameworkCore;
5+
using MR.EntityFrameworkCore.KeysetPagination;
6+
7+
namespace Basic.Pages
8+
{
9+
public class NestedModel : PageModel
10+
{
11+
private readonly AppDbContext _dbContext;
12+
13+
public NestedModel(
14+
AppDbContext dbContext)
15+
{
16+
_dbContext = dbContext;
17+
}
18+
19+
public int Count { get; set; }
20+
21+
public List<User> Users { get; set; }
22+
23+
public bool HasPrevious { get; set; }
24+
25+
public bool HasNext { get; set; }
26+
27+
public string Elapsed { get; set; }
28+
29+
public string ElapsedTotal { get; set; }
30+
31+
public async Task OnGet(int? after, int? before, bool first = false, bool last = false)
32+
{
33+
var size = 20;
34+
35+
var keysetBuilderAction = (KeysetPaginationBuilder<User> b) =>
36+
{
37+
b.Descending(x => x.Details.Created);
38+
};
39+
40+
var sw = Stopwatch.StartNew();
41+
42+
var query = _dbContext.Users.AsQueryable();
43+
Count = await query.CountAsync();
44+
KeysetPaginationContext<User> keysetContext;
45+
if (first)
46+
{
47+
keysetContext = query.KeysetPaginate(keysetBuilderAction, KeysetPaginationDirection.Forward);
48+
Users = await keysetContext.Query
49+
.Include(x => x.Details)
50+
.Take(size)
51+
.ToListAsync();
52+
}
53+
else if (last)
54+
{
55+
keysetContext = query.KeysetPaginate(keysetBuilderAction, KeysetPaginationDirection.Backward);
56+
Users = await keysetContext.Query
57+
.Include(x => x.Details)
58+
.Take(size)
59+
.ToListAsync();
60+
}
61+
else if (after != null)
62+
{
63+
var reference = await _dbContext.Users.Include(x => x.Details).FirstOrDefaultAsync(x => x.Id == after.Value);
64+
keysetContext = query.KeysetPaginate(keysetBuilderAction, KeysetPaginationDirection.Forward, reference);
65+
Users = await keysetContext.Query
66+
.Include(x => x.Details)
67+
.Take(size)
68+
.ToListAsync();
69+
}
70+
else if (before != null)
71+
{
72+
var reference = await _dbContext.Users.Include(x => x.Details).FirstOrDefaultAsync(x => x.Id == before.Value);
73+
keysetContext = query.KeysetPaginate(keysetBuilderAction, KeysetPaginationDirection.Backward, reference);
74+
Users = await keysetContext.Query
75+
.Include(x => x.Details)
76+
.Take(size)
77+
.ToListAsync();
78+
}
79+
else
80+
{
81+
keysetContext = query.KeysetPaginate(keysetBuilderAction);
82+
Users = await keysetContext.Query
83+
.Include(x => x.Details)
84+
.Take(size)
85+
.ToListAsync();
86+
}
87+
88+
keysetContext.EnsureCorrectOrder(Users);
89+
90+
Elapsed = sw.ElapsedMilliseconds.ToString();
91+
92+
HasPrevious = await keysetContext.HasPreviousAsync(Users);
93+
HasNext = await keysetContext.HasNextAsync(Users);
94+
95+
ElapsedTotal = sw.ElapsedMilliseconds.ToString();
96+
}
97+
}
98+
}

samples/Basic/Pages/Shared/_Layout.cshtml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
<li class="nav-item">
2525
<a class="nav-link text-dark" asp-area="" asp-page="/Example2">Example 2</a>
2626
</li>
27+
<li class="nav-item">
28+
<a class="nav-link text-dark" asp-area="" asp-page="/Nested">Nested</a>
29+
</li>
2730
</ul>
2831
</div>
2932
</div>

samples/Basic/Startup.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
4141
Id = i,
4242
Name = i.ToString(),
4343
Created = created,
44+
Details = new UserDetails
45+
{
46+
Created = created,
47+
},
4448
});
4549
}
4650
_dbContext.AddRange(users);
Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,74 @@
1-
using System.Linq.Expressions;
1+
using System.Diagnostics;
2+
using System.Linq.Expressions;
23
using System.Reflection;
34

45
namespace MR.EntityFrameworkCore.KeysetPagination;
56

67
internal static class ExpressionHelper
78
{
8-
public static PropertyInfo GetPropertyInfoFromMemberAccess<T, TProp>(
9+
public static Expression UnwrapConvertAndLambda<T, TProp>(
910
Expression<Func<T, TProp>> expression)
1011
{
11-
MemberExpression memberExpression;
12-
if (expression.Body.NodeType == ExpressionType.Convert)
12+
if (expression.Body.NodeType != ExpressionType.Convert)
1313
{
14-
memberExpression = (MemberExpression)((UnaryExpression)expression.Body).Operand;
14+
return expression.Body;
1515
}
16-
else
16+
17+
return ((UnaryExpression)expression.Body).Operand;
18+
}
19+
20+
public static bool IsSimpleMemberAccess(
21+
Expression expression)
22+
{
23+
ValidateExpressionUnwrapped(expression);
24+
25+
return expression is MemberExpression memberExpression
26+
&& memberExpression.Expression is not MemberExpression;
27+
}
28+
29+
public static PropertyInfo GetSimplePropertyFromMemberAccess(
30+
Expression expression)
31+
{
32+
ValidateExpressionUnwrapped(expression);
33+
34+
var memberExpression = (MemberExpression)expression;
35+
return GetPropertyInfoMember(memberExpression);
36+
}
37+
38+
public static List<PropertyInfo> GetNestedPropertiesFromMemberAccess(
39+
Expression expression)
40+
{
41+
ValidateExpressionUnwrapped(expression);
42+
43+
var properties = new List<PropertyInfo>();
44+
45+
var next = expression;
46+
while (next is MemberExpression memberExpression)
47+
{
48+
properties.Add(GetPropertyInfoMember(memberExpression));
49+
next = memberExpression.Expression;
50+
}
51+
52+
properties.Reverse();
53+
return properties;
54+
}
55+
56+
[Conditional("DEBUG")]
57+
private static void ValidateExpressionUnwrapped(Expression expression)
58+
{
59+
if (expression.NodeType is ExpressionType.Lambda or ExpressionType.Convert)
60+
{
61+
throw new Exception("Expression should have been unwrapped by now.");
62+
}
63+
}
64+
65+
private static PropertyInfo GetPropertyInfoMember(MemberExpression memberExpression)
66+
{
67+
if (memberExpression.Member is PropertyInfo prop)
1768
{
18-
memberExpression = (MemberExpression)expression.Body;
69+
return prop;
1970
}
2071

21-
var propertyInfo = (PropertyInfo)memberExpression.Member;
22-
return propertyInfo;
72+
throw new InvalidOperationException($"Expected a property access, got '{memberExpression.Member.MemberType}'.");
2373
}
2474
}

src/MR.EntityFrameworkCore.KeysetPagination/KeysetPaginationBuilder.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,22 @@ private KeysetPaginationBuilder<T> Item<TProp>(
2525
Expression<Func<T, TProp>> propertyExpression,
2626
bool isDescending)
2727
{
28-
var property = ExpressionHelper.GetPropertyInfoFromMemberAccess(propertyExpression);
29-
_items.Add(new KeysetPaginationItem<T, TProp>(
30-
property,
31-
isDescending));
28+
var unwrapped = ExpressionHelper.UnwrapConvertAndLambda(propertyExpression);
29+
if (ExpressionHelper.IsSimpleMemberAccess(unwrapped))
30+
{
31+
var property = ExpressionHelper.GetSimplePropertyFromMemberAccess(unwrapped);
32+
_items.Add(new KeysetPaginationItemSimple<T, TProp>(
33+
property,
34+
isDescending));
35+
}
36+
else
37+
{
38+
var properties = ExpressionHelper.GetNestedPropertiesFromMemberAccess(unwrapped);
39+
_items.Add(new KeysetPaginationItemNested<T, TProp>(
40+
properties,
41+
isDescending));
42+
}
43+
3244
return this;
3345
}
3446
}

src/MR.EntityFrameworkCore.KeysetPagination/KeysetPaginationExtensions.cs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,10 @@ private static List<object> GetValues<T>(
175175
object reference)
176176
where T : class
177177
{
178-
var accessor = Accessor.Obtain(reference.GetType());
179178
var referenceValues = new List<object>(capacity: items.Count);
180179
foreach (var item in items)
181180
{
182-
var propertyName = item.Property.Name;
183-
if (!accessor.TryGetPropertyValue(reference, propertyName, out var value))
184-
{
185-
throw new KeysetPaginationIncompatibleObjectException($"Property '{propertyName}' not found on this object.");
186-
}
181+
var value = item.ObtainValue(reference);
187182
referenceValues.Add(value);
188183
}
189184
return referenceValues;
@@ -272,7 +267,7 @@ private static Expression<Func<T, bool>> BuildKeysetFilterPredicateExpression<T>
272267
{
273268
var isInnerLastOperation = j + 1 == innerLimit;
274269
var item = items[j];
275-
var memberAccess = Expression.MakeMemberAccess(param, item.Property);
270+
var memberAccess = item.MakeMemberAccessExpression(param);
276271
var referenceValue = referenceValues[j];
277272
Expression<Func<object>> referenceValueFunc = () => referenceValue;
278273
var referenceValueExpression = referenceValueFunc.Body;

0 commit comments

Comments
 (0)