Skip to content

Commit d90921a

Browse files
authored
Merge pull request #1852 from riganti/LoadFromQueryableAsync
Add async LoadFromQueryable
2 parents 779b2f9 + 1171290 commit d90921a

File tree

8 files changed

+326
-6
lines changed

8 files changed

+326
-6
lines changed

src/Framework/Core/Controls/GridViewDataSetExtensions.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
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
68
{
79
public static class GridViewDataSetExtensions
810
{
9-
11+
/// <summary>
12+
/// Loads dataset from the specified <paramref name="queryable" />: Applies filtering, sorting and paging options, and collects the results. If <see cref="PagingOptions"/> is used, the total number of items (after applying filtering) is retrieved and stored in the PagingOptions property.
13+
/// </summary>
1014
public static void LoadFromQueryable<T>(this IGridViewDataSet<T> dataSet, IQueryable<T> queryable)
1115
{
1216
if (dataSet.FilteringOptions is not IApplyToQueryable filteringOptions)
@@ -35,30 +39,85 @@ public static void LoadFromQueryable<T>(this IGridViewDataSet<T> dataSet, IQuery
3539
dataSet.IsRefreshRequired = false;
3640
}
3741

42+
#if NET6_0_OR_GREATER
43+
/// <summary>
44+
/// Loads dataset from the specified <paramref name="queryable" />: Applies filtering, sorting and paging options, and collects the results using IAsyncEnumerable. If <see cref="PagingOptions"/> is used, the total number of items (after applying filtering) is retrieved and stored in the PagingOptions property.
45+
/// </summary>
46+
/// <exception cref="ArgumentException">The specified IQueryable does not support async enumeration.</exception>
47+
public static async Task LoadFromQueryableAsync<T>(this IGridViewDataSet<T> dataSet, IQueryable<T> queryable, CancellationToken cancellationToken = default)
48+
{
49+
if (dataSet.FilteringOptions is not IApplyToQueryable filteringOptions)
50+
{
51+
throw new ArgumentException($"The FilteringOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
52+
}
53+
if (dataSet.SortingOptions is not IApplyToQueryable sortingOptions)
54+
{
55+
throw new ArgumentException($"The SortingOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
56+
}
57+
if (dataSet.PagingOptions is not IApplyToQueryable pagingOptions)
58+
{
59+
throw new ArgumentException($"The PagingOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
60+
}
61+
62+
var filtered = filteringOptions.ApplyToQueryable(queryable);
63+
var sorted = sortingOptions.ApplyToQueryable(filtered);
64+
var paged = pagingOptions.ApplyToQueryable(sorted);
65+
if (paged is not IAsyncEnumerable<T> asyncPaged)
66+
{
67+
throw new ArgumentException($"The specified IQueryable ({queryable.GetType().FullName}), does not support async enumeration. Please use the LoadFromQueryable method.", nameof(queryable));
68+
}
69+
70+
var result = new List<T>();
71+
await foreach (var item in asyncPaged.WithCancellation(cancellationToken))
72+
{
73+
result.Add(item);
74+
}
75+
dataSet.Items = result;
76+
77+
if (pagingOptions is IPagingOptionsLoadingPostProcessor pagingOptionsLoadingPostProcessor)
78+
{
79+
cancellationToken.ThrowIfCancellationRequested();
80+
await pagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync(filtered, result, cancellationToken);
81+
}
82+
83+
dataSet.IsRefreshRequired = false;
84+
}
85+
#endif
86+
87+
/// <summary> Sets <see cref="IPagingOptions"/> to the first page, and sets the <see cref="IRefreshableGridViewDataSet.IsRefreshRequired"/> property to true. </summary>
88+
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
3889
public static void GoToFirstPageAndRefresh(this IPageableGridViewDataSet<IPagingFirstPageCapability> dataSet)
3990
{
4091
dataSet.PagingOptions.GoToFirstPage();
4192
(dataSet as IRefreshableGridViewDataSet)?.RequestRefresh();
4293
}
4394

95+
/// <summary> Sets <see cref="IPagingOptions"/> to the last page, and sets the <see cref="IRefreshableGridViewDataSet.IsRefreshRequired"/> property to true. </summary>
96+
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
4497
public static void GoToLastPageAndRefresh(this IPageableGridViewDataSet<IPagingLastPageCapability> dataSet)
4598
{
4699
dataSet.PagingOptions.GoToLastPage();
47100
(dataSet as IRefreshableGridViewDataSet)?.RequestRefresh();
48101
}
49102

103+
/// <summary> Sets <see cref="IPagingOptions"/> to the previous page, and sets the <see cref="IRefreshableGridViewDataSet.IsRefreshRequired"/> property to true. </summary>
104+
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
50105
public static void GoToPreviousPageAndRefresh(this IPageableGridViewDataSet<IPagingPreviousPageCapability> dataSet)
51106
{
52107
dataSet.PagingOptions.GoToPreviousPage();
53108
(dataSet as IRefreshableGridViewDataSet)?.RequestRefresh();
54109
}
55110

111+
/// <summary> Sets <see cref="IPagingOptions"/> to the next page, and sets the <see cref="IRefreshableGridViewDataSet.IsRefreshRequired"/> property to true. </summary>
112+
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
56113
public static void GoToNextPageAndRefresh(this IPageableGridViewDataSet<IPagingNextPageCapability> dataSet)
57114
{
58115
dataSet.PagingOptions.GoToNextPage();
59116
(dataSet as IRefreshableGridViewDataSet)?.RequestRefresh();
60117
}
61118

119+
/// <summary> Sets <see cref="IPagingOptions"/> to the page number <paramref name="pageIndex"/> (indexed from 0), and sets the <see cref="IRefreshableGridViewDataSet.IsRefreshRequired"/> property to true. </summary>
120+
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
62121
public static void GoToPageAndRefresh(this IPageableGridViewDataSet<IPagingPageIndexCapability> dataSet, int pageIndex)
63122
{
64123
dataSet.PagingOptions.GoToPage(pageIndex);

src/Framework/Core/Controls/GridViewDataSetResult.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace DotVVM.Framework.Controls
44
{
5+
56
public class GridViewDataSetResult<TItem, TFilteringOptions, TSortingOptions, TPagingOptions>
67
where TFilteringOptions : IFilteringOptions
78
where TSortingOptions : ISortingOptions
@@ -23,12 +24,16 @@ public GridViewDataSetResult(IReadOnlyList<TItem> items, GridViewDataSetOptions<
2324
PagingOptions = options.PagingOptions;
2425
}
2526

27+
/// <summary> New items to replace the old <see cref="IBaseGridViewDataSet{T}.Items"/> </summary>
2628
public IReadOnlyList<TItem> Items { get; }
2729

30+
/// <summary> New filtering options to replace the old <see cref="IFilterableGridViewDataSet{T}.FilteringOptions"/>, if null the old options are left unchanged. </summary>
2831
public TFilteringOptions? FilteringOptions { get; }
2932

33+
/// <summary> New sorting options to replace the old <see cref="ISortableGridViewDataSet{T}.SortingOptions"/>, if null the old options are left unchanged. </summary>
3034
public TSortingOptions? SortingOptions { get; }
3135

36+
/// <summary> New paging options to replace the old <see cref="IPageableGridViewDataSet{T}.PagingOptions"/>, if null the old options are left unchanged. </summary>
3237
public TPagingOptions? PagingOptions { get; }
3338
}
3439
}
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: 76 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,74 @@ public static IQueryable<T> ApplyPagingToQueryable<T, TPagingOptions>(IQueryable
1723
? queryable.Skip(options.PageSize * options.PageIndex).Take(options.PageSize)
1824
: queryable;
1925
}
20-
}
2126

27+
#if NET6_0_OR_GREATER
28+
/// <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 used, or <see cref="CustomAsyncQueryableCountDelegate" /> may be set to add support for an ORM mapper of choice. </summary>
29+
public static async Task<int> QueryableAsyncCount<T>(IQueryable<T> queryable, CancellationToken ct = default)
30+
{
31+
if (CustomAsyncQueryableCountDelegate is {} customDelegate)
32+
{
33+
var result = await customDelegate(queryable, ct);
34+
if (result.HasValue)
35+
{
36+
return result.Value;
37+
}
38+
}
39+
40+
var queryableType = queryable.GetType();
41+
// Note: there is not a standard way to get a count from IAsyncEnumerable instance, without enumerating it.
42+
// we use two heuristics to try to get the count using the query provider:
43+
// * if we detect usage of EF Core, call its CountAsync method
44+
// * otherwise, do it as .GroupBy(_ => 1).Select(group => group.Count()).SingleOrDefault()
45+
// (if you are reading this and need a separate hack for your favorite ORM, you can set
46+
// CustomAsyncQueryableCountDelegate, and we do accept PRs adding new heuristics ;) )
47+
return await (
48+
EfCoreAsyncCountHack(queryable, queryableType, ct) ??
49+
StandardAsyncCountHack(queryable, ct)
50+
);
51+
}
52+
53+
static MethodInfo? efMethodCache;
54+
static Task<int>? EfCoreAsyncCountHack<T>(IQueryable<T> queryable, Type queryableType, CancellationToken ct)
55+
{
56+
if (!(
57+
queryableType.Namespace == "Microsoft.EntityFrameworkCore.Query.Internal" && queryableType.Name == "EntityQueryable`1" ||
58+
queryableType.Namespace == "Microsoft.EntityFrameworkCore.Internal" && queryableType.Name == "InternalDbSet`1"
59+
))
60+
return null;
61+
62+
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));
63+
if (countMethod is null)
64+
return null;
65+
66+
if (efMethodCache is null)
67+
Interlocked.CompareExchange(ref efMethodCache, countMethod, null);
68+
69+
var countMethodGeneric = countMethod.MakeGenericMethod(typeof(T));
70+
return (Task<int>)countMethodGeneric.Invoke(null, new object[] { queryable, ct })!;
71+
}
72+
73+
static Task<int> StandardAsyncCountHack<T>(IQueryable<T> queryable, CancellationToken ct)
74+
{
75+
#if NETSTANDARD2_1_OR_GREATER
76+
var countGroupHack = queryable.GroupBy(_ => 1).Select(group => group.Count());
77+
// if not IAsyncEnumerable, just use synchronous Count on a new thread
78+
if (countGroupHack is not IAsyncEnumerable<int> countGroupEnumerable)
79+
{
80+
return Task.Factory.StartNew(() => queryable.Count(), TaskCreationOptions.LongRunning);
81+
}
82+
83+
return FirstOrDefaultAsync(countGroupEnumerable, ct);
84+
}
85+
86+
static async Task<T?> FirstOrDefaultAsync<T>(IAsyncEnumerable<T> enumerable, CancellationToken ct)
87+
{
88+
await using var enumerator = enumerable.GetAsyncEnumerator(ct);
89+
return await enumerator.MoveNextAsync() ? enumerator.Current : default;
90+
#else
91+
throw new Exception("IAsyncEnumerable is not supported on .NET Framework and the queryable does not support EntityFramework CountAsync.");
92+
#endif
93+
}
94+
#endif
95+
}
2296
}

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

Lines changed: 26 additions & 1 deletion
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
@@ -123,7 +125,30 @@ public virtual IQueryable<T> ApplyToQueryable<T>(IQueryable<T> queryable)
123125

124126
public virtual void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> items)
125127
{
126-
TotalItemsCount = filteredQueryable.Count();
128+
if (items.Count < PageSize)
129+
{
130+
TotalItemsCount = PageIndex * PageSize + items.Count;
131+
}
132+
else
133+
{
134+
TotalItemsCount = filteredQueryable.Count();
135+
}
127136
}
137+
public async Task ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
138+
{
139+
#if NET6_0_OR_GREATER
140+
if (items.Count < PageSize)
141+
{
142+
TotalItemsCount = PageIndex * PageSize + items.Count;
143+
}
144+
else
145+
{
146+
TotalItemsCount = await PagingImplementation.QueryableAsyncCount(filteredQueryable, cancellationToken);
147+
}
148+
#else
149+
throw new NotSupportedException("LoadFromQueryableAsync and ProcessLoadedItemsAsync methods are not supported on .NET Framework.");
150+
#endif
151+
}
152+
128153
}
129154
}

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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@
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>
4850
<PackageReference Include="AngleSharp" Version="0.17.0" />
4951
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
50-
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
51-
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
52+
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
53+
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
5254
<PackageReference Include="Newtonsoft.Json" Version="13.*" />
5355
<PackageReference Include="coverlet.collector" Version="3.1.2">
5456
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

0 commit comments

Comments
 (0)