Skip to content

Commit 0821f68

Browse files
committed
Add async LoadFromQueryable
It is primarily based on the IAsyncEnumerable interface, but requires some hacks to get access to the CountAsync method.
1 parent 630c685 commit 0821f68

File tree

7 files changed

+293
-2
lines changed

7 files changed

+293
-2
lines changed

src/Framework/Core/Controls/GridViewDataSetExtensions.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
4+
using System.Threading;
35
using System.Threading.Tasks;
46

57
namespace DotVVM.Framework.Controls
@@ -35,6 +37,44 @@ public static void LoadFromQueryable<T>(this IGridViewDataSet<T> dataSet, IQuery
3537
dataSet.IsRefreshRequired = false;
3638
}
3739

40+
public static async Task LoadFromQueryableAsync<T>(this IGridViewDataSet<T> dataSet, IQueryable<T> queryable, CancellationToken cancellationToken = default)
41+
{
42+
if (dataSet.FilteringOptions is not IApplyToQueryable filteringOptions)
43+
{
44+
throw new ArgumentException($"The FilteringOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
45+
}
46+
if (dataSet.SortingOptions is not IApplyToQueryable sortingOptions)
47+
{
48+
throw new ArgumentException($"The SortingOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
49+
}
50+
if (dataSet.PagingOptions is not IApplyToQueryable pagingOptions)
51+
{
52+
throw new ArgumentException($"The PagingOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
53+
}
54+
55+
var filtered = filteringOptions.ApplyToQueryable(queryable);
56+
var sorted = sortingOptions.ApplyToQueryable(filtered);
57+
var paged = pagingOptions.ApplyToQueryable(sorted);
58+
if (paged is not IAsyncEnumerable<T> asyncPaged)
59+
{
60+
throw new ArgumentException($"The specified IQueryable ({queryable.GetType().FullName}), does not support async enumeration. Please use the LoadFromQueryable method.", nameof(queryable));
61+
}
62+
63+
var result = new List<T>();
64+
await foreach (var item in asyncPaged.WithCancellation(cancellationToken))
65+
{
66+
result.Add(item);
67+
}
68+
dataSet.Items = result;
69+
70+
if (pagingOptions is IPagingOptionsLoadingPostProcessor pagingOptionsLoadingPostProcessor)
71+
{
72+
await pagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync(filtered, result, cancellationToken);
73+
}
74+
75+
dataSet.IsRefreshRequired = false;
76+
}
77+
3878
public static void GoToFirstPageAndRefresh(this IPageableGridViewDataSet<IPagingFirstPageCapability> dataSet)
3979
{
4080
dataSet.PagingOptions.GoToFirstPage();
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
using System.Collections.Generic;
22
using System.Linq;
3+
using System.Threading;
4+
using System.Threading.Tasks;
35

46
namespace DotVVM.Framework.Controls;
57

68
/// <summary> Provides an extension point to the <see cref="GridViewDataSetExtensions.LoadFromQueryable{T}(DotVVM.Framework.Controls.IGridViewDataSet{T}, IQueryable{T})" /> method, which is invoked after the items are loaded from database. </summary>
79
public interface IPagingOptionsLoadingPostProcessor
810
{
911
void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> items);
12+
Task ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken);
1013
}

src/Framework/Core/Controls/Options/PagingImplementation.cs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
using System.Linq;
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Reflection;
5+
using System.Threading;
6+
using System.Threading.Tasks;
27

38
namespace DotVVM.Framework.Controls
49
{
510
public static class PagingImplementation
611
{
12+
public static Func<IQueryable, CancellationToken, Task<int?>>? CustomAsyncQueryableCountDelegate;
713

814
/// <summary>
915
/// Applies paging to the <paramref name="queryable" /> after the total number
@@ -17,6 +23,87 @@ public static IQueryable<T> ApplyPagingToQueryable<T, TPagingOptions>(IQueryable
1723
? queryable.Skip(options.PageSize * options.PageIndex).Take(options.PageSize)
1824
: queryable;
1925
}
20-
}
2126

27+
/// <summary> Attempts to count the queryable asynchronously. EF Core IQueryables are supported, and IQueryables which return IAsyncEnumerable from GroupBy operator also work correctly. Otherwise, a synchronous fallback is user, or <see cref="CustomAsyncQueryableCountDelegate" /> may be set to add support for an ORM mapper of choice. </summary>
28+
public static async Task<int> QueryableAsyncCount<T>(IQueryable<T> queryable, CancellationToken ct = default)
29+
{
30+
if (CustomAsyncQueryableCountDelegate is {} customDelegate)
31+
{
32+
var result = await customDelegate(queryable, ct);
33+
if (result.HasValue)
34+
{
35+
return result.Value;
36+
}
37+
}
38+
39+
var queryableType = queryable.GetType();
40+
return await (
41+
EfCoreAsyncCountHack(queryable, queryableType, ct) ?? // TODO: test this
42+
Ef6AsyncCountHack(queryable, ct) ?? // TODO: test this
43+
StandardAsyncCountHack(queryable, ct)
44+
);
45+
}
46+
47+
static MethodInfo? efMethodCache;
48+
static Task<int>? EfCoreAsyncCountHack<T>(IQueryable<T> queryable, Type queryableType, CancellationToken ct)
49+
{
50+
if (!(
51+
queryableType.Namespace == "Microsoft.EntityFrameworkCore.Query.Internal" && queryableType.Name == "EntityQueryable`1" ||
52+
queryableType.Namespace == "Microsoft.EntityFrameworkCore.Internal" && queryableType.Name == "InternalDbSet`1"
53+
))
54+
return null;
55+
56+
var countMethod = efMethodCache ?? queryableType.Assembly.GetType("Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions")!.GetMethods().SingleOrDefault(m => m.Name == "CountAsync" && m.GetParameters() is { Length: 2 } parameters && parameters[1].ParameterType == typeof(CancellationToken));
57+
if (countMethod is null)
58+
return null;
59+
60+
if (efMethodCache is null)
61+
Interlocked.CompareExchange(ref efMethodCache, countMethod, null);
62+
63+
var countMethodGeneric = countMethod.MakeGenericMethod(typeof(T));
64+
return (Task<int>)countMethodGeneric.Invoke(null, new object[] { queryable, ct })!;
65+
}
66+
67+
static readonly Type? ef6IDbAsyncQueryProvider = Type.GetType("System.Data.Entity.Infrastructure.IDbAsyncQueryProvider, EntityFramework"); // https://learn.microsoft.com/en-us/dotnet/api/system.data.entity.infrastructure.idbasyncqueryprovider?view=entity-framework-6.2.0
68+
static MethodInfo? ef6MethodCache;
69+
static Task<int>? Ef6AsyncCountHack<T>(IQueryable<T> queryable, CancellationToken ct)
70+
{
71+
if (ef6IDbAsyncQueryProvider is null)
72+
return null;
73+
if (!ef6IDbAsyncQueryProvider.IsInstanceOfType(queryable.Provider))
74+
return null;
75+
76+
var countMethod = ef6MethodCache ?? Type.GetType("System.Data.Entity.QueryableExtensions, EntityFramework")!.GetMethods().SingleOrDefault(m => m.Name == "CountAsync" && m.GetParameters() is { Length: 2 } parameters && parameters[1].ParameterType == typeof(CancellationToken))!;
77+
if (countMethod is null)
78+
return null;
79+
80+
if (ef6MethodCache is null)
81+
Interlocked.CompareExchange(ref ef6MethodCache, countMethod, null);
82+
83+
var countMethodGeneric = countMethod.MakeGenericMethod(typeof(T));
84+
return (Task<int>)countMethodGeneric.Invoke(null, new object[] { queryable, ct })!;
85+
}
86+
87+
static Task<int> StandardAsyncCountHack<T>(IQueryable<T> queryable, CancellationToken ct)
88+
{
89+
#if NETSTANDARD2_1_OR_GREATER
90+
var countGroupHack = queryable.GroupBy(_ => 1).Select(group => group.Count());
91+
// if not IAsyncEnumerable, just use synchronous Count
92+
if (countGroupHack is not IAsyncEnumerable<int> countGroupEnumerable)
93+
{
94+
return Task.FromResult(queryable.Count());
95+
}
96+
97+
return FirstOrDefaultAsync(countGroupEnumerable, ct);
98+
}
99+
100+
static async Task<T?> FirstOrDefaultAsync<T>(IAsyncEnumerable<T> enumerable, CancellationToken ct)
101+
{
102+
await using var enumerator = enumerable.GetAsyncEnumerator(ct);
103+
return await enumerator.MoveNextAsync() ? enumerator.Current : default;
104+
#else
105+
throw new Exception("IAsyncEnumerable is not supported on .NET Framework and the queryable does not support EntityFramework CountAsync.");
106+
#endif
107+
}
108+
}
22109
}

src/Framework/Core/Controls/Options/PagingOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
46
using DotVVM.Framework.ViewModel;
57

68
namespace DotVVM.Framework.Controls
@@ -125,5 +127,10 @@ public virtual void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList
125127
{
126128
TotalItemsCount = filteredQueryable.Count();
127129
}
130+
public async Task ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
131+
{
132+
TotalItemsCount = await PagingImplementation.QueryableAsyncCount(filteredQueryable, cancellationToken);
133+
}
134+
128135
}
129136
}

src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewStaticCommandViewModel.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading;
45
using System.Threading.Tasks;
56
using DotVVM.Framework.Controls;
67
using DotVVM.Framework.ViewModel;
@@ -123,6 +124,12 @@ public void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> item
123124
NextPageToken = lastToken.ToString();
124125
}
125126
}
127+
128+
Task IPagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
129+
{
130+
ProcessLoadedItems(filteredQueryable, items);
131+
return Task.CompletedTask;
132+
}
126133
}
127134

128135
public class NextTokenHistoryGridViewDataSet() : GenericGridViewDataSet<CustomerData, NoFilteringOptions, SortingOptions, CustomerDataNextTokenHistoryPagingOptions, RowInsertOptions<CustomerData>, RowEditOptions>(
@@ -165,6 +172,12 @@ public void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> item
165172
TokenHistory.Add((lastToken ?? 0).ToString());
166173
}
167174
}
175+
176+
Task IPagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
177+
{
178+
ProcessLoadedItems(filteredQueryable, items);
179+
return Task.CompletedTask;
180+
}
168181
}
169182

170183
public class MultiSortGridViewDataSet() : GenericGridViewDataSet<CustomerData, NoFilteringOptions, MultiCriteriaSortingOptions, PagingOptions, RowInsertOptions<CustomerData>, RowEditOptions>(

src/Tests/DotVVM.Framework.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
</ItemGroup>
4343
<ItemGroup Condition="'$(TargetFramework)' != '$(OldFrameworkTargetVersion)'">
4444
<ProjectReference Include="../Framework/Hosting.AspNetCore/DotVVM.Framework.Hosting.AspNetCore.csproj" />
45+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
46+
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
4547
<PackageReference Include="CheckTestOutput" Version="0.6.3" />
4648
</ItemGroup>
4749
<ItemGroup>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#if NET8_0_OR_GREATER
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using DotVVM.Framework.Controls;
7+
using DotVVM.Framework.ViewModel;
8+
using Microsoft.EntityFrameworkCore;
9+
using Microsoft.EntityFrameworkCore.Diagnostics;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.VisualStudio.TestTools.UnitTesting;
12+
13+
namespace DotVVM.Framework.Tests.ViewModel
14+
{
15+
[TestClass]
16+
public class EFCoreGridViewDataSetTests
17+
{
18+
private readonly DbContextOptions<MyDbContext> contextOptions;
19+
20+
public EFCoreGridViewDataSetTests()
21+
{
22+
contextOptions = new DbContextOptionsBuilder<MyDbContext>()
23+
.UseInMemoryDatabase("BloggingControllerTest")
24+
.ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
25+
.Options;
26+
}
27+
28+
class MyDbContext: DbContext
29+
{
30+
public MyDbContext(DbContextOptions options) : base(options)
31+
{
32+
}
33+
34+
public DbSet<Entry> Entries { get; set; }
35+
}
36+
37+
record Entry(int Id, string Name, int SomethingElse = 0);
38+
39+
MyDbContext Init()
40+
{
41+
var context = new MyDbContext(contextOptions);
42+
context.Database.EnsureDeleted();
43+
context.Database.EnsureCreated();
44+
context.Entries.AddRange([
45+
new (1, "Z"),
46+
new (2, "Y"),
47+
new (3, "X"),
48+
new (4, "W"),
49+
new (5, "V"),
50+
new (6, "U", 5),
51+
new (7, "T", 5),
52+
new (8, "S", 5),
53+
new (9, "R", 3),
54+
new (10, "Q", 3),
55+
]);
56+
context.SaveChanges();
57+
return context;
58+
}
59+
60+
[TestMethod]
61+
public void LoadData_PagingSorting()
62+
{
63+
using var context = Init();
64+
65+
var dataSet = new GridViewDataSet<Entry>()
66+
{
67+
PagingOptions = { PageSize = 3, PageIndex = 0 },
68+
SortingOptions = { SortExpression = nameof(Entry.Name), SortDescending = false },
69+
};
70+
71+
dataSet.LoadFromQueryable(context.Entries);
72+
73+
Assert.AreEqual(3, dataSet.Items.Count);
74+
Assert.AreEqual(10, dataSet.PagingOptions.TotalItemsCount);
75+
Assert.AreEqual(10, dataSet.Items[0].Id);
76+
Assert.AreEqual(9, dataSet.Items[1].Id);
77+
Assert.AreEqual(8, dataSet.Items[2].Id);
78+
}
79+
80+
[TestMethod]
81+
public void LoadData_PagingSorting_PreFiltered()
82+
{
83+
using var context = Init();
84+
85+
var dataSet = new GridViewDataSet<Entry>()
86+
{
87+
PagingOptions = { PageSize = 3, PageIndex = 0 },
88+
SortingOptions = { SortExpression = nameof(Entry.Name), SortDescending = false },
89+
};
90+
91+
dataSet.LoadFromQueryable(context.Entries.Where(e => e.SomethingElse == 3));
92+
93+
Assert.AreEqual(2, dataSet.Items.Count);
94+
Assert.AreEqual(2, dataSet.PagingOptions.TotalItemsCount);
95+
Assert.AreEqual(10, dataSet.Items[0].Id);
96+
Assert.AreEqual(9, dataSet.Items[1].Id);
97+
}
98+
99+
[TestMethod]
100+
public async Task LoadData_PagingSortingAsync()
101+
{
102+
using var context = Init();
103+
104+
var dataSet = new GridViewDataSet<Entry>()
105+
{
106+
PagingOptions = { PageSize = 3, PageIndex = 0 },
107+
SortingOptions = { SortExpression = nameof(Entry.Name), SortDescending = false },
108+
};
109+
110+
await dataSet.LoadFromQueryableAsync(context.Entries);
111+
112+
Assert.AreEqual(3, dataSet.Items.Count);
113+
Assert.AreEqual(10, dataSet.PagingOptions.TotalItemsCount);
114+
Assert.AreEqual(10, dataSet.Items[0].Id);
115+
Assert.AreEqual(9, dataSet.Items[1].Id);
116+
Assert.AreEqual(8, dataSet.Items[2].Id);
117+
}
118+
119+
[TestMethod]
120+
public async Task LoadData_PagingSorting_PreFilteredAsync()
121+
{
122+
using var context = Init();
123+
124+
var dataSet = new GridViewDataSet<Entry>()
125+
{
126+
PagingOptions = { PageSize = 3, PageIndex = 0 },
127+
SortingOptions = { SortExpression = nameof(Entry.Name), SortDescending = false },
128+
};
129+
130+
await dataSet.LoadFromQueryableAsync(context.Entries.Where(e => e.SomethingElse == 3));
131+
132+
Assert.AreEqual(2, dataSet.Items.Count);
133+
Assert.AreEqual(2, dataSet.PagingOptions.TotalItemsCount);
134+
Assert.AreEqual(10, dataSet.Items[0].Id);
135+
Assert.AreEqual(9, dataSet.Items[1].Id);
136+
}
137+
}
138+
}
139+
#endif

0 commit comments

Comments
 (0)