Skip to content

Commit 72a0f1e

Browse files
author
Adrian Hall
committed
(#199) async OData query support in the table controller.
1 parent aab685b commit 72a0f1e

File tree

8 files changed

+259
-341
lines changed

8 files changed

+259
-341
lines changed

src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,24 @@ public interface IRepository<TEntity> where TEntity : ITableData
6666
/// <exception cref="HttpException">Thrown if the entity creation would produce a normal HTTP error.</exception>
6767
/// <exception cref="RepositoryException">Thrown is there is an error in the repository.</exception>
6868
ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default);
69+
70+
/// <summary>
71+
/// A method that is used to count the number of entities in an <see cref="IQueryable{T}"/> query using the
72+
/// preferred mechanism for the repository.
73+
/// </summary>
74+
/// <param name="query">The query to be executed.</param>
75+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
76+
/// <returns>The number of items matching the query.</returns>
77+
ValueTask<int> CountAsync(IQueryable<TEntity> query, CancellationToken cancellationToken = default)
78+
=> ValueTask.FromResult(query.Count());
79+
80+
/// <summary>
81+
/// A method that is used to execute the query and return the elements matches the query as a list using the
82+
/// preferred mechanism for the repository.
83+
/// </summary>
84+
/// <param name="query">The query to be executed.</param>
85+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
86+
/// <returns>The items matching the query.</returns>
87+
ValueTask<IList<TEntity>> ToListAsync(IQueryable<TEntity> query, CancellationToken cancellationToken = default)
88+
=> ValueTask.FromResult<IList<TEntity>>(query.ToList());
6989
}

src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs

Lines changed: 43 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
using Microsoft.OData;
1717
using System.Diagnostics.CodeAnalysis;
1818
using CommunityToolkit.Datasync.Server.OData;
19-
using Microsoft.AspNetCore.Http.Extensions;
2019

2120
namespace CommunityToolkit.Datasync.Server;
2221

@@ -38,6 +37,9 @@ public partial class TableController<TEntity> : ODataController where TEntity :
3837
/// - <c>$skip</c> is used to skip some entities
3938
/// - <c>$top</c> is used to limit the number of entities returned.
4039
/// </para>
40+
/// <para>
41+
/// In addition, the <c>__includeDeleted</c> parameter is used to decide whether to include soft-deleted items in the result.
42+
/// </para>
4143
/// </summary>
4244
/// <param name="cancellationToken">A cancellation token</param>
4345
/// <returns>An <see cref="OkObjectResult"/> response object with the items.</returns>
@@ -50,10 +52,6 @@ public virtual async Task<IActionResult> QueryAsync(CancellationToken cancellati
5052
await AuthorizeRequestAsync(TableOperation.Query, null, cancellationToken).ConfigureAwait(false);
5153
_ = BuildServiceProvider(Request);
5254

53-
IQueryable<TEntity> dataset = (await Repository.AsQueryableAsync(cancellationToken).ConfigureAwait(false))
54-
.ApplyDataView(AccessControlProvider.GetDataView())
55-
.ApplyDeletedView(Request, Options.EnableSoftDelete);
56-
5755
ODataValidationSettings validationSettings = new() { MaxTop = Options.MaxTop };
5856
ODataQuerySettings querySettings = new() { PageSize = Options.PageSize, EnsureStableOrdering = true };
5957
ODataQueryContext queryContext = new(EdmModel, typeof(TEntity), new ODataPath());
@@ -69,26 +67,28 @@ public virtual async Task<IActionResult> QueryAsync(CancellationToken cancellati
6967
return BadRequest(validationException.Message);
7068
}
7169

72-
// Note that some IQueryable providers cannot execute all queries against the data source, so we have
73-
// to switch to in-memory processing for those queries. This is done by calling ToListAsync() on the
74-
// IQueryable. This is not ideal, but it is the only way to support all of the OData query options.
75-
IEnumerable<object>? results = null;
76-
await ExecuteQueryWithClientEvaluationAsync(dataset, ds =>
77-
{
78-
results = (IEnumerable<object>)queryOptions.ApplyTo(ds, querySettings);
79-
return Task.CompletedTask;
80-
});
70+
// Determine the dataset to be queried for this user.
71+
IQueryable<TEntity> dataset = (await Repository.AsQueryableAsync(cancellationToken).ConfigureAwait(false))
72+
.ApplyDataView(AccessControlProvider.GetDataView())
73+
.ApplyDeletedView(Request, Options.EnableSoftDelete);
8174

82-
int count = 0;
83-
FilterQueryOption? filter = queryOptions.Filter;
84-
await ExecuteQueryWithClientEvaluationAsync(dataset, async ds =>
85-
{
86-
IQueryable<TEntity> q = (IQueryable<TEntity>)(filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds);
87-
count = await CountAsync(q, cancellationToken);
88-
});
75+
// Apply the requested filter from the OData transaction.
76+
IQueryable<TEntity> filteredDataset = dataset.ApplyODataFilter(queryOptions.Filter, querySettings);
77+
78+
// Count the number of items within the filtered dataset - this is used when $count is requested.
79+
int filteredCount = await Repository.CountAsync(filteredDataset, cancellationToken).ConfigureAwait(false);
80+
81+
// Now apply the OrderBy, Skip, and Top options to the dataset.
82+
IQueryable<TEntity> orderedDataset = filteredDataset
83+
.ApplyODataOrderBy(queryOptions.OrderBy, querySettings)
84+
.ApplyODataPaging(queryOptions, querySettings);
85+
86+
// Get the list of items within the dataset that need to be returned.
87+
IList<TEntity> entitiesInResultSet = await Repository.ToListAsync(orderedDataset, cancellationToken).ConfigureAwait(false);
8988

90-
PagedResult result = BuildPagedResult(queryOptions, results, count);
91-
Logger.LogInformation("Query: {Count} items being returned", result.Items.Count());
89+
// Produce the paged result.
90+
PagedResult result = BuildPagedResult(queryOptions, entitiesInResultSet.ApplyODataSelect(queryOptions.SelectExpand, querySettings), filteredCount);
91+
Logger.LogInformation("Query: {Count} items being returned", entitiesInResultSet.Count);
9292
return Ok(result);
9393
}
9494

@@ -135,136 +135,39 @@ internal PagedResult BuildPagedResult(ODataQueryOptions queryOptions, IEnumerabl
135135
int resultCount = results?.Count() ?? 0;
136136
int skip = (queryOptions.Skip?.Value ?? 0) + resultCount;
137137
int top = (queryOptions.Top?.Value ?? 0) - resultCount;
138-
if (results is IEnumerable<ISelectExpandWrapper> wrapper)
139-
{
140-
results = wrapper.Select(x => x.ToDictionary());
141-
}
142138

143-
PagedResult result = new(results ?? []) { Count = queryOptions.Count != null ? count : null };
144-
if (queryOptions.Top != null)
145-
{
146-
result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, skip, top);
147-
}
148-
else
139+
// Internal function to create the nextLink property for the paged result.
140+
static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0)
149141
{
150-
result.NextLink = skip >= count ? null : CreateNextLink(Request, skip, 0);
151-
}
142+
string? queryString = request.QueryString.Value;
143+
List<string> query = (queryString ?? "").TrimStart('?')
144+
.Split('&')
145+
.Where(q => !q.StartsWith($"{SkipParameterName}=") && !q.StartsWith($"{TopParameterName}="))
146+
.ToList();
152147

153-
return result;
154-
}
155-
156-
/// <summary>
157-
/// Given a very specific URI, creates a new query string with the same query, but with a different value for the <c>$skip</c> parameter.
158-
/// </summary>
159-
/// <param name="request">The original request.</param>
160-
/// <param name="skip">The new skip value.</param>
161-
/// <param name="top">The new top value.</param>
162-
/// <returns>The new URI for the next page of items.</returns>
163-
[NonAction]
164-
[SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter", Justification = "Static method in generic non-static class")]
165-
internal static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0)
166-
=> CreateNextLink(new UriBuilder(request.GetDisplayUrl()).Query, skip, top);
167-
168-
/// <summary>
169-
/// Given a very specific query string, creates a new query string with the same query, but with a different value for the <c>$skip</c> parameter.
170-
/// </summary>
171-
/// <param name="queryString">The original query string.</param>
172-
/// <param name="skip">The new skip value.</param>
173-
/// <param name="top">The new top value.</param>
174-
/// <returns>The new URI for the next page of items.</returns>
175-
[NonAction]
176-
[SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter", Justification = "Static method in generic non-static class")]
177-
internal static string CreateNextLink(string queryString, int skip = 0, int top = 0)
178-
{
179-
List<string> query = (queryString ?? "").TrimStart('?')
180-
.Split('&')
181-
.Where(q => !q.StartsWith($"{SkipParameterName}=") && !q.StartsWith($"{TopParameterName}="))
182-
.ToList();
183-
184-
if (skip > 0)
185-
{
186-
query.Add($"{SkipParameterName}={skip}");
187-
}
188-
189-
if (top > 0)
190-
{
191-
query.Add($"{TopParameterName}={top}");
192-
}
193-
194-
return string.Join('&', query).TrimStart('&');
195-
}
196-
197-
/// <summary>
198-
/// When doing a query evaluation, certain providers (e.g. Entity Framework) require some things
199-
/// to be done client side. We use a client side evaluator to handle this case when it happens.
200-
/// </summary>
201-
/// <param name="ex">The exception thrown by the service-side evaluator</param>
202-
/// <param name="reason">The reason if the client-side evaluator throws.</param>
203-
/// <param name="clientSideEvaluator">The client-side evaluator</param>
204-
[NonAction]
205-
internal async Task CatchClientSideEvaluationExceptionAsync(Exception ex, string reason, Func<Task> clientSideEvaluator)
206-
{
207-
if (IsClientSideEvaluationException(ex) || IsClientSideEvaluationException(ex.InnerException))
208-
{
209-
try
148+
if (skip > 0)
210149
{
211-
await clientSideEvaluator.Invoke();
150+
query.Add($"{SkipParameterName}={skip}");
212151
}
213-
catch (Exception err)
152+
153+
if (top > 0)
214154
{
215-
Logger.LogError("Error while {reason}: {Message}", reason, err.Message);
216-
throw;
155+
query.Add($"{TopParameterName}={top}");
217156
}
157+
158+
return string.Join('&', query).TrimStart('&');
218159
}
219-
else
220-
{
221-
throw ex;
222-
}
223-
}
224160

225-
/// <summary>
226-
/// Executes an evaluation of a query, using a client-side evaluation if necessary.
227-
/// </summary>
228-
/// <param name="dataset">The dataset to be evaluated.</param>
229-
/// <param name="evaluator">The base evaluation to be performed.</param>
230-
[NonAction]
231-
internal async Task ExecuteQueryWithClientEvaluationAsync(IQueryable<TEntity> dataset, Func<IQueryable<TEntity>, Task> evaluator)
232-
{
233-
try
161+
PagedResult result = new(results ?? []) { Count = queryOptions.Count != null ? count : null };
162+
if (queryOptions.Top is not null)
234163
{
235-
await evaluator.Invoke(dataset);
164+
result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, skip, top);
236165
}
237-
catch (Exception ex) when (!Options.DisableClientSideEvaluation)
166+
else
238167
{
239-
await CatchClientSideEvaluationExceptionAsync(ex, "executing query", async () =>
240-
{
241-
Logger.LogWarning("Error while executing query: possible client-side evaluation ({Message})", ex.InnerException?.Message ?? ex.Message);
242-
await evaluator.Invoke(dataset.ToList().AsQueryable());
243-
});
168+
result.NextLink = skip >= count ? null : CreateNextLink(Request, skip, 0);
244169
}
245-
}
246170

247-
/// <summary>
248-
/// Determines if a particular exception indicates a client-side evaluation is required.
249-
/// </summary>
250-
/// <param name="ex">The exception that was thrown by the service-side evaluator</param>
251-
/// <returns>true if a client-side evaluation is required.</returns>
252-
[NonAction]
253-
[SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter.")]
254-
internal static bool IsClientSideEvaluationException(Exception? ex)
255-
=> ex is not null and (InvalidOperationException or NotSupportedException);
256-
257-
/// <summary>
258-
/// This is an overridable method that calls Count() on the provided queryable. You can override
259-
/// this to calls a provider-specific count mechanism (e.g. CountAsync().
260-
/// </summary>
261-
/// <param name="query"></param>
262-
/// <param name="cancellationToken"></param>
263-
/// <returns></returns>
264-
[NonAction]
265-
public virtual Task<int> CountAsync(IQueryable<TEntity> query, CancellationToken cancellationToken)
266-
{
267-
int result = query.Count();
268-
return Task.FromResult(result);
171+
return result;
269172
}
270173
}

src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
using CommunityToolkit.Datasync.Server.Abstractions.Json;
66
using Microsoft.AspNetCore.Http;
77
using Microsoft.AspNetCore.Http.Headers;
8+
using Microsoft.AspNetCore.OData.Query;
9+
using Microsoft.AspNetCore.OData.Query.Wrapper;
810
using Microsoft.Extensions.Primitives;
911
using Microsoft.Net.Http.Headers;
1012
using System.Globalization;
13+
using System.Linq;
1114
using System.Linq.Expressions;
1215
using System.Text.Json;
1316
using System.Text.Json.Serialization;
@@ -35,14 +38,90 @@ internal static IQueryable<T> ApplyDataView<T>(this IQueryable<T> query, Express
3538
/// <summary>
3639
/// Filters out the deleted entities unless the request includes an optional parameter to include them.
3740
/// </summary>
38-
/// <typeparam name="T">The type of entity being queries.</typeparam>
41+
/// <typeparam name="T">The type of entity being queried.</typeparam>
3942
/// <param name="query">The current <see cref="IQueryable{T}"/> representing the query.</param>
4043
/// <param name="request">The current <see cref="HttpRequest"/> being processed.</param>
4144
/// <param name="enableSoftDelete">A flag to indicate if soft-delete is enabled on the table being queried.</param>
4245
/// <returns>An updated <see cref="IQueryable{T}"/> representing the new query.</returns>
4346
internal static IQueryable<T> ApplyDeletedView<T>(this IQueryable<T> query, HttpRequest request, bool enableSoftDelete) where T : ITableData
4447
=> !enableSoftDelete || request.ShouldIncludeDeletedEntities() ? query : query.Where(e => !e.Deleted);
4548

49+
/// <summary>
50+
/// Applies the <c>$filter</c> OData query option to the provided query.
51+
/// </summary>
52+
/// <typeparam name="T">The type of entity being queried.</typeparam>
53+
/// <param name="query">The current <see cref="IQueryable{T}"/> representing the query.</param>
54+
/// <param name="filterQueryOption">The filter query option to apply.</param>
55+
/// <param name="settings">The query settings being used.</param>
56+
/// <returns>A modified <see cref="IQueryable{T}"/> representing the filtered data.</returns>
57+
internal static IQueryable<T> ApplyODataFilter<T>(this IQueryable<T> query, FilterQueryOption? filterQueryOption, ODataQuerySettings settings)
58+
{
59+
if (filterQueryOption is null)
60+
{
61+
return query;
62+
}
63+
64+
return ((IQueryable<T>?)filterQueryOption.ApplyTo(query, settings)) ?? query;
65+
}
66+
67+
/// <summary>
68+
/// Applies the <c>$orderBy</c> OData query option to the provided query.
69+
/// </summary>
70+
/// <typeparam name="T">The type of entity being queried.</typeparam>
71+
/// <param name="query">The current <see cref="IQueryable{T}"/> representing the query.</param>
72+
/// <param name="orderingQueryOption">The ordering query option to apply.</param>
73+
/// <param name="settings">The query settings being used.</param>
74+
/// <returns>A modified <see cref="IQueryable{T}"/> representing the ordered data.</returns>
75+
internal static IQueryable<T> ApplyODataOrderBy<T>(this IQueryable<T> query, OrderByQueryOption? orderingQueryOption, ODataQuerySettings settings) where T : ITableData
76+
{
77+
// Note that we ALWAYS do a sort so that the ordering is consistent across all queries.
78+
if (orderingQueryOption is null)
79+
{
80+
return query.OrderBy(e => e.Id);
81+
}
82+
83+
IOrderedQueryable<T>? orderedQuery = orderingQueryOption.ApplyTo(query, settings);
84+
if (orderedQuery is null)
85+
{
86+
return query.OrderBy(e => e.Id);
87+
}
88+
89+
return orderedQuery.ThenBy(x => x.Id);
90+
}
91+
92+
/// <summary>
93+
/// Applies the <c>$skip</c> and <c>$top</c> OData query options to the provided query.
94+
/// </summary>
95+
/// <typeparam name="T">The type of entity being queried.</typeparam>
96+
/// <param name="query">The current <see cref="IQueryable{T}"/> representing the query.</param>
97+
/// <param name="options">The query options to apply.</param>
98+
/// <param name="settings">The query settings being used.</param>
99+
/// <returns>A modified <see cref="IQueryable{T}"/> representing the paged data.</returns>
100+
internal static IQueryable<T> ApplyODataPaging<T>(this IQueryable<T> query, ODataQueryOptions<T> options, ODataQuerySettings settings)
101+
{
102+
int takeValue = Math.Min(options.Top?.Value ?? int.MaxValue, settings.PageSize ?? 100);
103+
int skipValue = Math.Max(options.Skip?.Value ?? 0, 0);
104+
return query.Skip(skipValue).Take(takeValue);
105+
}
106+
107+
/// <summary>
108+
/// Applies the <c>$select</c> OData query option to the provided query.
109+
/// </summary>
110+
/// <typeparam name="T">The type of entity being queried.</typeparam>
111+
/// <param name="dataset">The datset to apply the <c>$select</c> option to.</param>
112+
/// <param name="queryOption">The <see cref="SelectExpandQueryOption"/> to apply.</param>
113+
/// <param name="settings">The query settings being used.</param>
114+
/// <returns>The resulting dataset after property selection.</returns>
115+
internal static IEnumerable<object> ApplyODataSelect<T>(this IList<T> dataset, SelectExpandQueryOption? queryOption, ODataQuerySettings settings)
116+
{
117+
if (dataset.Count == 0 || queryOption is null)
118+
{
119+
return dataset.Cast<object>().AsEnumerable();
120+
}
121+
122+
return queryOption.ApplyTo(dataset.AsQueryable(), settings).Cast<object>().ToList().Select(x => ((ISelectExpandWrapper)x).ToDictionary());
123+
}
124+
46125
/// <summary>
47126
/// Determines if the provided entity is in the view of the current user.
48127
/// </summary>

0 commit comments

Comments
 (0)