WebApi
WebApi copied to clipboard
Make it possible/easier to use LongCountAsync when $count=true
In our project we use Entity Framework Core 3.1 and throw when a synchronous DB call is detected (through a DbCommandInterceptor
).
When doing a $count=true
the oData framework sets request.ODataFeature().TotalCountFunc
with Queryable.LongCount
and executes that synchronously.
We have worked around the synchronous call by applying a custom [EnableQueryAsync]
attribute instead of [EnableQuery]
on the controller methods. See code below.
This workaround took a lot of effort and the oData library could perhaps provide an easier way.
Assemblies affected
Microsoft.AspNetCore.OData v7.5.0
Reproduce steps
GET /api/MyEntities$count=true
[EnableQuery]
public ActionResult<IQueryAble<MyEntity>> Get()
{
var queryable = this.dbContext.MyEntities.AsQueryable();
return this.Ok(queryable);
}
Expected result
In the future the oData library should make an asynchronous call when a provided async LongCount method is configured for a given IQueryProvider
.
Actual result
A synchronous Queryable.LongCount
call is made.
Additional detail
This is the custom attribute:
public class EnableQueryAsyncAttribute : EnableQueryAttribute
{
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// note: don't call the base method. This method does the same as the base method in ActionFilterAttribute, except
// it executes await OnActionExecutedAsync(...) before executing this.OnActionExecuted(...)
this.OnActionExecuting(context);
if (context.Result == null)
{
var resultContext = await next().ConfigureAwait(false);
await this.OnActionExecutedAsync(resultContext).ConfigureAwait(false);
}
}
private async Task OnActionExecutedAsync(ActionExecutedContext resultContext)
{
if (resultContext.Result is ObjectResult objectResult &&
objectResult.Value is IQueryable queryable &&
queryable.Provider is IAsyncQueryProvider)
{
var request = resultContext.HttpContext.Request;
var queryContext = new ODataQueryContext(request.GetModel(), queryable.ElementType, request.ODataFeature().Path);
var queryOptions = new ODataQueryOptions(queryContext, request);
if (queryOptions.Count.Value)
{
var filteredQueryable = (queryOptions.Filter == null ? queryable : queryOptions.Filter.ApplyTo(queryable, new ODataQuerySettings()))
as IQueryable<dynamic>;
var cancellationToken = resultContext.HttpContext.RequestAborted;
var count = await filteredQueryable.LongCountAsync(cancellationToken).ConfigureAwait(false);
// Setting the TotalCount causes oData to not execute the TotalCountFunc.
request.ODataFeature().TotalCount = count;
if (count == 0)
{
// No need to have oData execute the queryable.
var instance = Activator.CreateInstance(typeof(List<>).MakeGenericType(queryable.ElementType));
resultContext.Result = new OkObjectResult(instance);
}
}
}
this.OnActionExecuted(resultContext);
}
}
I understand that the oData library can't know about the specific IQueryProvider's that's being used.
A nicer solution would be if we could configure the oData endpoint to use a specific asynchronous LongCount method for a given IQueryProvider
in Startup.cs
. Something like:
endPoints.
.RegisterLongCountAsync<IAsyncQueryProvider>(QueryableMethods.LongCountWithoutPredicate);
and then the EnableQueryAttribute
could use the registered LongCountAsync method and similar code as ^^^.
+1 on this issue. Its not currently possible to make queries OR counts asynchronous which is absolutely crucial for performance!
Currently OData looks rather messy in terms of using sync / async execution, at any given time you can't be sure if you will end up with a synchronous operation. I stumbled across this issue completely randomly and I was absolutely clueless that something like it could even be a thing.