Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/Framework/Core/Controls/GridViewDataSetExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace DotVVM.Framework.Controls
{
public static class GridViewDataSetExtensions
{

/// <summary>
/// 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.
/// </summary>
public static void LoadFromQueryable<T>(this IGridViewDataSet<T> dataSet, IQueryable<T> queryable)
{
if (dataSet.FilteringOptions is not IApplyToQueryable filteringOptions)
Expand Down Expand Up @@ -35,30 +39,85 @@ public static void LoadFromQueryable<T>(this IGridViewDataSet<T> dataSet, IQuery
dataSet.IsRefreshRequired = false;
}

#if NET6_0_OR_GREATER
/// <summary>
/// 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.
/// </summary>
/// <exception cref="ArgumentException">The specified IQueryable does not support async enumeration.</exception>
public static async Task LoadFromQueryableAsync<T>(this IGridViewDataSet<T> dataSet, IQueryable<T> queryable, CancellationToken cancellationToken = default)
{
if (dataSet.FilteringOptions is not IApplyToQueryable filteringOptions)
{
throw new ArgumentException($"The FilteringOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
}
if (dataSet.SortingOptions is not IApplyToQueryable sortingOptions)
{
throw new ArgumentException($"The SortingOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
}
if (dataSet.PagingOptions is not IApplyToQueryable pagingOptions)
{
throw new ArgumentException($"The PagingOptions of {dataSet.GetType()} must implement IApplyToQueryable!");
}

var filtered = filteringOptions.ApplyToQueryable(queryable);
var sorted = sortingOptions.ApplyToQueryable(filtered);
var paged = pagingOptions.ApplyToQueryable(sorted);
if (paged is not IAsyncEnumerable<T> asyncPaged)
{
throw new ArgumentException($"The specified IQueryable ({queryable.GetType().FullName}), does not support async enumeration. Please use the LoadFromQueryable method.", nameof(queryable));
}

var result = new List<T>();
await foreach (var item in asyncPaged.WithCancellation(cancellationToken))
{
result.Add(item);
}
dataSet.Items = result;

if (pagingOptions is IPagingOptionsLoadingPostProcessor pagingOptionsLoadingPostProcessor)
{
cancellationToken.ThrowIfCancellationRequested();
await pagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync(filtered, result, cancellationToken);
}

dataSet.IsRefreshRequired = false;
}
#endif

/// <summary> Sets <see cref="IPagingOptions"/> to the first page, and sets the <see cref="IRefreshableGridViewDataSet.IsRefreshRequired"/> property to true. </summary>
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
public static void GoToFirstPageAndRefresh(this IPageableGridViewDataSet<IPagingFirstPageCapability> dataSet)
{
dataSet.PagingOptions.GoToFirstPage();
(dataSet as IRefreshableGridViewDataSet)?.RequestRefresh();
}

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

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

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

/// <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>
/// <remarks> To reload the dataset, you must then call <see cref="LoadFromQueryable{T}(IGridViewDataSet{T}, IQueryable{T})"/> or a similar method. </remarks>
public static void GoToPageAndRefresh(this IPageableGridViewDataSet<IPagingPageIndexCapability> dataSet, int pageIndex)
{
dataSet.PagingOptions.GoToPage(pageIndex);
Expand Down
5 changes: 5 additions & 0 deletions src/Framework/Core/Controls/GridViewDataSetResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace DotVVM.Framework.Controls
{

public class GridViewDataSetResult<TItem, TFilteringOptions, TSortingOptions, TPagingOptions>
where TFilteringOptions : IFilteringOptions
where TSortingOptions : ISortingOptions
Expand All @@ -23,12 +24,16 @@ public GridViewDataSetResult(IReadOnlyList<TItem> items, GridViewDataSetOptions<
PagingOptions = options.PagingOptions;
}

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

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

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

/// <summary> New paging options to replace the old <see cref="IPageableGridViewDataSet{T}.PagingOptions"/>, if null the old options are left unchanged. </summary>
public TPagingOptions? PagingOptions { get; }
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace DotVVM.Framework.Controls;

/// <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>
public interface IPagingOptionsLoadingPostProcessor
{
void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> items);
Task ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken);
}
78 changes: 76 additions & 2 deletions src/Framework/Core/Controls/Options/PagingImplementation.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;

namespace DotVVM.Framework.Controls
{
public static class PagingImplementation
{
public static Func<IQueryable, CancellationToken, Task<int?>>? CustomAsyncQueryableCountDelegate;

/// <summary>
/// Applies paging to the <paramref name="queryable" /> after the total number
Expand All @@ -17,6 +23,74 @@ public static IQueryable<T> ApplyPagingToQueryable<T, TPagingOptions>(IQueryable
? queryable.Skip(options.PageSize * options.PageIndex).Take(options.PageSize)
: queryable;
}
}

#if NET6_0_OR_GREATER
/// <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>
public static async Task<int> QueryableAsyncCount<T>(IQueryable<T> queryable, CancellationToken ct = default)
{
if (CustomAsyncQueryableCountDelegate is {} customDelegate)
{
var result = await customDelegate(queryable, ct);
if (result.HasValue)
{
return result.Value;
}
}

var queryableType = queryable.GetType();
// Note: there is not a standard way to get a count from IAsyncEnumerable instance, without enumerating it.
// we use two heuristics to try to get the count using the query provider:
// * if we detect usage of EF Core, call its CountAsync method
// * otherwise, do it as .GroupBy(_ => 1).Select(group => group.Count()).SingleOrDefault()
// (if you are reading this and need a separate hack for your favorite ORM, you can set
// CustomAsyncQueryableCountDelegate, and we do accept PRs adding new heuristics ;) )
return await (
EfCoreAsyncCountHack(queryable, queryableType, ct) ??
StandardAsyncCountHack(queryable, ct)
);
}

static MethodInfo? efMethodCache;
static Task<int>? EfCoreAsyncCountHack<T>(IQueryable<T> queryable, Type queryableType, CancellationToken ct)
{
if (!(
queryableType.Namespace == "Microsoft.EntityFrameworkCore.Query.Internal" && queryableType.Name == "EntityQueryable`1" ||
queryableType.Namespace == "Microsoft.EntityFrameworkCore.Internal" && queryableType.Name == "InternalDbSet`1"
))
return null;

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));
if (countMethod is null)
return null;

if (efMethodCache is null)
Interlocked.CompareExchange(ref efMethodCache, countMethod, null);

var countMethodGeneric = countMethod.MakeGenericMethod(typeof(T));
return (Task<int>)countMethodGeneric.Invoke(null, new object[] { queryable, ct })!;
}

static Task<int> StandardAsyncCountHack<T>(IQueryable<T> queryable, CancellationToken ct)
{
#if NETSTANDARD2_1_OR_GREATER
var countGroupHack = queryable.GroupBy(_ => 1).Select(group => group.Count());
// if not IAsyncEnumerable, just use synchronous Count on a new thread
if (countGroupHack is not IAsyncEnumerable<int> countGroupEnumerable)
{
return Task.Factory.StartNew(() => queryable.Count(), TaskCreationOptions.LongRunning);
}

return FirstOrDefaultAsync(countGroupEnumerable, ct);
}

static async Task<T?> FirstOrDefaultAsync<T>(IAsyncEnumerable<T> enumerable, CancellationToken ct)
{
await using var enumerator = enumerable.GetAsyncEnumerator(ct);
return await enumerator.MoveNextAsync() ? enumerator.Current : default;
#else
throw new Exception("IAsyncEnumerable is not supported on .NET Framework and the queryable does not support EntityFramework CountAsync.");
#endif
}
#endif
}
}
27 changes: 26 additions & 1 deletion src/Framework/Core/Controls/Options/PagingOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotVVM.Framework.ViewModel;

namespace DotVVM.Framework.Controls
Expand Down Expand Up @@ -123,7 +125,30 @@ public virtual IQueryable<T> ApplyToQueryable<T>(IQueryable<T> queryable)

public virtual void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> items)
{
TotalItemsCount = filteredQueryable.Count();
if (items.Count < PageSize)
{
TotalItemsCount = PageIndex * PageSize + items.Count;
}
else
{
TotalItemsCount = filteredQueryable.Count();
}
}
public async Task ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
{
#if NET6_0_OR_GREATER
if (items.Count < PageSize)
{
TotalItemsCount = PageIndex * PageSize + items.Count;
}
else
{
TotalItemsCount = await PagingImplementation.QueryableAsyncCount(filteredQueryable, cancellationToken);
}
#else
throw new NotSupportedException("LoadFromQueryableAsync and ProcessLoadedItemsAsync methods are not supported on .NET Framework.");
#endif
}

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotVVM.Framework.Controls;
using DotVVM.Framework.ViewModel;
Expand Down Expand Up @@ -123,6 +124,12 @@ public void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> item
NextPageToken = lastToken.ToString();
}
}

Task IPagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
{
ProcessLoadedItems(filteredQueryable, items);
return Task.CompletedTask;
}
}

public class NextTokenHistoryGridViewDataSet() : GenericGridViewDataSet<CustomerData, NoFilteringOptions, SortingOptions, CustomerDataNextTokenHistoryPagingOptions, RowInsertOptions<CustomerData>, RowEditOptions>(
Expand Down Expand Up @@ -165,6 +172,12 @@ public void ProcessLoadedItems<T>(IQueryable<T> filteredQueryable, IList<T> item
TokenHistory.Add((lastToken ?? 0).ToString());
}
}

Task IPagingOptionsLoadingPostProcessor.ProcessLoadedItemsAsync<T>(IQueryable<T> filteredQueryable, IList<T> items, CancellationToken cancellationToken)
{
ProcessLoadedItems(filteredQueryable, items);
return Task.CompletedTask;
}
}

public class MultiSortGridViewDataSet() : GenericGridViewDataSet<CustomerData, NoFilteringOptions, MultiCriteriaSortingOptions, PagingOptions, RowInsertOptions<CustomerData>, RowEditOptions>(
Expand Down
6 changes: 4 additions & 2 deletions src/Tests/DotVVM.Framework.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != '$(OldFrameworkTargetVersion)'">
<ProjectReference Include="../Framework/Hosting.AspNetCore/DotVVM.Framework.Hosting.AspNetCore.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="CheckTestOutput" Version="0.6.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.17.0" />
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.*" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Loading
Loading