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
48 changes: 41 additions & 7 deletions docs/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ public class Filters<TDbContext>
public delegate bool Filter<in TEntity>(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input);

public delegate Task<bool> AsyncFilter<in TEntity>(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input);

public delegate Task<IReadOnlySet<TProjection>> BatchFilter<TProjection>(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, IReadOnlyCollection<TProjection> inputs);
```
<sup><a href='/src/GraphQL.EntityFramework/Filters/Filters.cs#L3-L12' title='Snippet source file'>snippet source</a> | <a href='#snippet-FiltersSignature' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/GraphQL.EntityFramework/Filters/Filters.cs#L3-L14' title='Snippet source file'>snippet source</a> | <a href='#snippet-FiltersSignature' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -285,6 +287,38 @@ EfGraphQLConventions.RegisterInContainer<MyDbContext>(
<!-- endSnippet -->


## Batch Filters

An async filter runs once per item, so a filter that queries the database makes one query for every item a response returns. A batch filter decides for many items in one call:

<!-- snippet: batch-filter -->
<a id='snippet-batch-filter'></a>
```cs
var filters = new Filters<MyDbContext>();
filters.For<Product>().AddBatch(
projection: _ => _.CategoryId,
filter: async (_, dbContext, _, categoryIds) =>
await dbContext.Categories
.Where(_ => categoryIds.Contains(_.Id) && _.IsVisible)
.Select(_ => _.Id)
.ToHashSetAsync());
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L224-L238' title='Snippet source file'>snippet source</a> | <a href='#snippet-batch-filter' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The filter is passed the distinct projections of the items, and returns the projections to include. An item whose projection is not returned is excluded.

Items are batched across the response, not only within one list. A field whose items have a batch filter resolves to a deferred result, and GraphQL.NET completes deferred results after the other fields at the same depth. So the items at one depth of the query share one call per batch filter, whichever row or field returned them. For example, the children of every parent in a list are filtered in one call, rather than one call per parent.

Notes:

* Per item filters on the same type run first, and only the items they include are passed to the batch filter.
* Batching across rows needs an execution to share, which every query run through `EfDocumentExecuter` has. A resolve context built outside an execution filters the items of each field in a separate call.


## Navigation Properties

Filters can project through navigation properties to access related entity data:
Expand All @@ -300,7 +334,7 @@ EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L224-L234' title='Snippet source file'>snippet source</a> | <a href='#snippet-navigation-property-filter' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L243-L253' title='Snippet source file'>snippet source</a> | <a href='#snippet-navigation-property-filter' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -325,7 +359,7 @@ EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L239-L255' title='Snippet source file'>snippet source</a> | <a href='#snippet-boolean-expression-filter' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L258-L274' title='Snippet source file'>snippet source</a> | <a href='#snippet-boolean-expression-filter' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

This shorthand is useful when:
Expand Down Expand Up @@ -359,7 +393,7 @@ EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L260-L277' title='Snippet source file'>snippet source</a> | <a href='#snippet-filter-without-projection' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L279-L296' title='Snippet source file'>snippet source</a> | <a href='#snippet-filter-without-projection' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

This overload is useful when:
Expand Down Expand Up @@ -399,7 +433,7 @@ EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L282-L305' title='Snippet source file'>snippet source</a> | <a href='#snippet-async-filter-without-projection' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L301-L324' title='Snippet source file'>snippet source</a> | <a href='#snippet-async-filter-without-projection' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

This is useful when:
Expand All @@ -424,7 +458,7 @@ public class Accommodation
public int Capacity { get; set; }
}
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L308-L318' title='Snippet source file'>snippet source</a> | <a href='#snippet-simplified-filter-api' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L327-L337' title='Snippet source file'>snippet source</a> | <a href='#snippet-simplified-filter-api' title='Start of snippet'>anchor</a></sup>
<a id='snippet-simplified-filter-api-1'></a>
```cs
var filters = new Filters<MyDbContext>();
Expand Down Expand Up @@ -471,7 +505,7 @@ EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);
```
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L322-L368' title='Snippet source file'>snippet source</a> | <a href='#snippet-simplified-filter-api-1' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/GlobalFilterSnippets.cs#L341-L387' title='Snippet source file'>snippet source</a> | <a href='#snippet-simplified-filter-api-1' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

### When to Use the Simplified API
Expand Down
16 changes: 16 additions & 0 deletions docs/mdsource/filters.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ Filters can be asynchronous when they need to perform database lookups or other
snippet: async-filter


## Batch Filters

An async filter runs once per item, so a filter that queries the database makes one query for every item a response returns. A batch filter decides for many items in one call:

snippet: batch-filter

The filter is passed the distinct projections of the items, and returns the projections to include. An item whose projection is not returned is excluded.

Items are batched across the response, not only within one list. A field whose items have a batch filter resolves to a deferred result, and GraphQL.NET completes deferred results after the other fields at the same depth. So the items at one depth of the query share one call per batch filter, whichever row or field returned them. For example, the children of every parent in a list are filtered in one call, rather than one call per parent.

Notes:

* Per item filters on the same type run first, and only the items they include are passed to the batch filter.
* Batching across rows needs an execution to share, which every query run through `EfDocumentExecuter` has. A resolve context built outside an execution filters the items of each field in a separate call.


## Navigation Properties

Filters can project through navigation properties to access related entity data:
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;NU5104;CS1573;CS9107;NU1608;NU1109</NoWarn>
<Version>35.1.0</Version>
<Version>35.3.0</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<PackageTags>EntityFrameworkCore, EntityFramework, GraphQL</PackageTags>
Expand Down
62 changes: 52 additions & 10 deletions src/GraphQL.EntityFramework/ConnectionConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ last is null &&
return (start, end - start);
}

public static Task<Connection<TItem>> ApplyConnectionContext<TDbContext, TSource, TItem>(
/// <summary>
/// The connection field's value: the <see cref="Connection{TItem}"/>, or a deferred result for it
/// when a batch filter applies to the page.
/// </summary>
public static async Task<object?> ApplyConnectionContext<TDbContext, TSource, TItem>(
this IQueryable<TItem> queryable,
int? first,
string afterString,
Expand All @@ -93,7 +97,22 @@ public static Task<Connection<TItem>> ApplyConnectionContext<TDbContext, TSource
where TDbContext : DbContext
{
Parse(afterString, beforeString, out var after, out var before);
return ApplyConnectionContext(queryable, first, after, last, before, context, filters, cancel, data);
var page = await LoadPage(queryable, first, after, last, before, context, cancel);
if (filters == null)
{
cancel.ThrowIfCancellationRequested();
return page.Build(page.Rows);
}

return await filters.Apply(
context,
data,
page.Rows,
_ =>
{
cancel.ThrowIfCancellationRequested();
return new(page.Build(_));
});
}

public static async Task<Connection<TItem>> ApplyConnectionContext<TDbContext, TSource, TItem>(
Expand All @@ -108,6 +127,36 @@ public static async Task<Connection<TItem>> ApplyConnectionContext<TDbContext, T
TDbContext data)
where TItem : class
where TDbContext : DbContext
{
var page = await LoadPage(queryable, first, after, last, before, context, cancel);
IEnumerable<TItem> result = page.Rows;
if (filters != null)
{
result = await filters.ApplyFilter(result, context.UserContext, data, context.User);
}

cancel.ThrowIfCancellationRequested();
return page.Build(result);
}

/// <summary>
/// One page of rows and where it sits, before any filter has run.
/// </summary>
record Page<TItem>(int Skip, int? Count, bool HasPreviousPage, bool HasNextPage, List<TItem> Rows)
{
public Connection<TItem> Build(IEnumerable<TItem> result) =>
ConnectionConverter.Build(Skip, Count, HasPreviousPage, HasNextPage, result);
}

static async Task<Page<TItem>> LoadPage<TSource, TItem>(
IQueryable<TItem> queryable,
int? first,
int? after,
int? last,
int? before,
IResolveFieldContext<TSource> context,
Cancel cancel)
where TItem : class
{
if (queryable is not IOrderedQueryable<TItem> && !HasOrderingInExpressionTree(queryable.Expression))
{
Expand Down Expand Up @@ -156,14 +205,7 @@ public static async Task<Connection<TItem>> ApplyConnectionContext<TDbContext, T
}
}

IEnumerable<TItem> result = rows;
if (filters != null)
{
result = await filters.ApplyFilter(result, context.UserContext, data, context.User);
}

cancel.ThrowIfCancellationRequested();
return Build(skip, count, hasPreviousPage, hasNextPage, result);
return new(skip, count, hasPreviousPage, hasNextPage, rows);
}

/// <summary>
Expand Down
63 changes: 63 additions & 0 deletions src/GraphQL.EntityFramework/Filters/BatchFilterEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
class BatchFilterEntry<TDbContext, TEntity, TProjection> :
IBatchFilterEntry<TDbContext>
where TDbContext : DbContext
where TEntity : class
{
Filters<TDbContext>.BatchFilter<TProjection> filter;
Func<TEntity, TProjection> compiledProjection;

// A batch filter loads what a per item filter on the same projection loads, so the
// requirements come from one of those rather than a second copy of that logic
FilterEntry<TDbContext, TEntity, TProjection> requirements;

public BatchFilterEntry(
Filters<TDbContext>.BatchFilter<TProjection> filter,
Expression<Func<TEntity, TProjection>> projection)
{
this.filter = filter;
compiledProjection = projection.Compile();
requirements = new((_, _, _, _) => Task.FromResult(true), projection);
}

public FieldProjectionInfo AddRequirements(
FieldProjectionInfo projection,
IReadOnlyDictionary<string, Navigation>? navigationProperties) =>
requirements.AddRequirements(projection, navigationProperties);

public object? Project(object entity) =>
compiledProjection((TEntity)entity);

public async Task<bool> ShouldIncludeWithProjection(
object userContext,
TDbContext data,
ClaimsPrincipal? userPrincipal,
object entity)
{
var projection = Project(entity);
var included = await Filter(userContext, data, userPrincipal, [projection]);
return included(projection);
}

public async Task<Func<object?, bool>> Filter(
object userContext,
TDbContext data,
ClaimsPrincipal? userPrincipal,
IReadOnlyCollection<object?> projections)
{
var inputs = projections
.Select(_ => (TProjection)_!)
.ToList();

IReadOnlySet<TProjection> included;
try
{
included = await filter(userContext, data, userPrincipal, inputs);
}
catch (Exception exception)
{
throw new($"Failed to execute batch filter. {nameof(TEntity)}: {typeof(TEntity)}.", exception);
}

return _ => included.Contains((TProjection)_!);
}
}
88 changes: 88 additions & 0 deletions src/GraphQL.EntityFramework/Filters/FilterBatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using GraphQL.DataLoader;

/// <summary>
/// Items waiting on batch filters. Resolvers add their items and return a <see cref="DeferredFilterResult"/>.
/// GraphQL.NET completes deferred results only once every other pending field has resolved, so the
/// first of them to complete runs each batch filter once, over the items of every row, and the rest
/// read that outcome.
/// </summary>
class FilterBatch<TDbContext>(object gate)
where TDbContext : DbContext
{
Dictionary<IBatchFilterEntry<TDbContext>, HashSet<object?>> inputs = [];
Task<Dictionary<IBatchFilterEntry<TDbContext>, Func<object?, bool>>>? run;

// Called under the gate. Once running, the batch takes no more items.
public bool Started => run is not null;

// Called under the gate
public void Add(IReadOnlyList<(IBatchFilterEntry<TDbContext> Entry, object? Projection)> items)
{
foreach (var (entry, projection) in items)
{
if (!inputs.TryGetValue(entry, out var projections))
{
projections = [];
inputs[entry] = projections;
}

projections.Add(projection);
}
}

public Task<Dictionary<IBatchFilterEntry<TDbContext>, Func<object?, bool>>> Run(
object userContext,
TDbContext data,
ClaimsPrincipal? userPrincipal)
{
lock (gate)
{
return run ??= RunEntries(userContext, data, userPrincipal);
}
}

async Task<Dictionary<IBatchFilterEntry<TDbContext>, Func<object?, bool>>> RunEntries(
object userContext,
TDbContext data,
ClaimsPrincipal? userPrincipal)
{
var results = new Dictionary<IBatchFilterEntry<TDbContext>, Func<object?, bool>>(inputs.Count);
foreach (var (entry, projections) in inputs)
{
results[entry] = await entry.Filter(userContext, data, userPrincipal, projections);
}

return results;
}
}

/// <summary>
/// The batch that items join during one execution. Once that batch starts running, items start a new
/// one. A resolver's items are added together, so they always land in the same batch.
/// </summary>
class OpenFilterBatch<TDbContext>
where TDbContext : DbContext
{
FilterBatch<TDbContext>? batch;

public FilterBatch<TDbContext> Add(IReadOnlyList<(IBatchFilterEntry<TDbContext> Entry, object? Projection)> items)
{
lock (this)
{
if (batch is null || batch.Started)
{
batch = new(this);
}

batch.Add(items);
return batch;
}
}
}

sealed class DeferredFilterResult(Func<Task<object?>> resolve) :
IDataLoaderResult
{
public Task<object?> GetResultAsync(Cancel cancel = default) =>
resolve();
}
Loading
Loading