diff --git a/docs/filters.md b/docs/filters.md index 75ce20b89..8a87fe241 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -32,8 +32,10 @@ public class Filters public delegate bool Filter(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input); public delegate Task AsyncFilter(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input); + + public delegate Task> BatchFilter(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, IReadOnlyCollection inputs); ``` -snippet source | anchor +snippet source | anchor @@ -285,6 +287,38 @@ EfGraphQLConventions.RegisterInContainer( +## 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: + + + +```cs +var filters = new Filters(); +filters.For().AddBatch( + projection: _ => _.CategoryId, + filter: async (_, dbContext, _, categoryIds) => + await dbContext.Categories + .Where(_ => categoryIds.Contains(_.Id) && _.IsVisible) + .Select(_ => _.Id) + .ToHashSetAsync()); +EfGraphQLConventions.RegisterInContainer( + services, + resolveFilters: _ => filters); +``` +snippet source | anchor + + +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: @@ -300,7 +334,7 @@ EfGraphQLConventions.RegisterInContainer( services, resolveFilters: _ => filters); ``` -snippet source | anchor +snippet source | anchor @@ -325,7 +359,7 @@ EfGraphQLConventions.RegisterInContainer( services, resolveFilters: _ => filters); ``` -snippet source | anchor +snippet source | anchor This shorthand is useful when: @@ -359,7 +393,7 @@ EfGraphQLConventions.RegisterInContainer( services, resolveFilters: _ => filters); ``` -snippet source | anchor +snippet source | anchor This overload is useful when: @@ -399,7 +433,7 @@ EfGraphQLConventions.RegisterInContainer( services, resolveFilters: _ => filters); ``` -snippet source | anchor +snippet source | anchor This is useful when: @@ -424,7 +458,7 @@ public class Accommodation public int Capacity { get; set; } } ``` -snippet source | anchor +snippet source | anchor ```cs var filters = new Filters(); @@ -471,7 +505,7 @@ EfGraphQLConventions.RegisterInContainer( services, resolveFilters: _ => filters); ``` -snippet source | anchor +snippet source | anchor ### When to Use the Simplified API diff --git a/docs/mdsource/filters.source.md b/docs/mdsource/filters.source.md index 42d5cdbbe..52946ebdd 100644 --- a/docs/mdsource/filters.source.md +++ b/docs/mdsource/filters.source.md @@ -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: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ee0d02859..b26b2afea 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 35.1.0 + 35.3.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/GraphQL.EntityFramework/ConnectionConverter.cs b/src/GraphQL.EntityFramework/ConnectionConverter.cs index 6454d6cc0..2bbc5d043 100644 --- a/src/GraphQL.EntityFramework/ConnectionConverter.cs +++ b/src/GraphQL.EntityFramework/ConnectionConverter.cs @@ -79,7 +79,11 @@ last is null && return (start, end - start); } - public static Task> ApplyConnectionContext( + /// + /// The connection field's value: the , or a deferred result for it + /// when a batch filter applies to the page. + /// + public static async Task ApplyConnectionContext( this IQueryable queryable, int? first, string afterString, @@ -93,7 +97,22 @@ public static Task> ApplyConnectionContext + { + cancel.ThrowIfCancellationRequested(); + return new(page.Build(_)); + }); } public static async Task> ApplyConnectionContext( @@ -108,6 +127,36 @@ public static async Task> ApplyConnectionContext result = page.Rows; + if (filters != null) + { + result = await filters.ApplyFilter(result, context.UserContext, data, context.User); + } + + cancel.ThrowIfCancellationRequested(); + return page.Build(result); + } + + /// + /// One page of rows and where it sits, before any filter has run. + /// + record Page(int Skip, int? Count, bool HasPreviousPage, bool HasNextPage, List Rows) + { + public Connection Build(IEnumerable result) => + ConnectionConverter.Build(Skip, Count, HasPreviousPage, HasNextPage, result); + } + + static async Task> LoadPage( + IQueryable queryable, + int? first, + int? after, + int? last, + int? before, + IResolveFieldContext context, + Cancel cancel) + where TItem : class { if (queryable is not IOrderedQueryable && !HasOrderingInExpressionTree(queryable.Expression)) { @@ -156,14 +205,7 @@ public static async Task> ApplyConnectionContext 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); } /// diff --git a/src/GraphQL.EntityFramework/Filters/BatchFilterEntry.cs b/src/GraphQL.EntityFramework/Filters/BatchFilterEntry.cs new file mode 100644 index 000000000..7ad12cd68 --- /dev/null +++ b/src/GraphQL.EntityFramework/Filters/BatchFilterEntry.cs @@ -0,0 +1,63 @@ +class BatchFilterEntry : + IBatchFilterEntry + where TDbContext : DbContext + where TEntity : class +{ + Filters.BatchFilter filter; + Func 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 requirements; + + public BatchFilterEntry( + Filters.BatchFilter filter, + Expression> projection) + { + this.filter = filter; + compiledProjection = projection.Compile(); + requirements = new((_, _, _, _) => Task.FromResult(true), projection); + } + + public FieldProjectionInfo AddRequirements( + FieldProjectionInfo projection, + IReadOnlyDictionary? navigationProperties) => + requirements.AddRequirements(projection, navigationProperties); + + public object? Project(object entity) => + compiledProjection((TEntity)entity); + + public async Task 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> Filter( + object userContext, + TDbContext data, + ClaimsPrincipal? userPrincipal, + IReadOnlyCollection projections) + { + var inputs = projections + .Select(_ => (TProjection)_!) + .ToList(); + + IReadOnlySet 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)_!); + } +} diff --git a/src/GraphQL.EntityFramework/Filters/FilterBatch.cs b/src/GraphQL.EntityFramework/Filters/FilterBatch.cs new file mode 100644 index 000000000..83cf873dd --- /dev/null +++ b/src/GraphQL.EntityFramework/Filters/FilterBatch.cs @@ -0,0 +1,88 @@ +using GraphQL.DataLoader; + +/// +/// Items waiting on batch filters. Resolvers add their items and return a . +/// 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. +/// +class FilterBatch(object gate) + where TDbContext : DbContext +{ + Dictionary, HashSet> inputs = []; + Task, Func>>? 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 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, Func>> Run( + object userContext, + TDbContext data, + ClaimsPrincipal? userPrincipal) + { + lock (gate) + { + return run ??= RunEntries(userContext, data, userPrincipal); + } + } + + async Task, Func>> RunEntries( + object userContext, + TDbContext data, + ClaimsPrincipal? userPrincipal) + { + var results = new Dictionary, Func>(inputs.Count); + foreach (var (entry, projections) in inputs) + { + results[entry] = await entry.Filter(userContext, data, userPrincipal, projections); + } + + return results; + } +} + +/// +/// 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. +/// +class OpenFilterBatch + where TDbContext : DbContext +{ + FilterBatch? batch; + + public FilterBatch Add(IReadOnlyList<(IBatchFilterEntry Entry, object? Projection)> items) + { + lock (this) + { + if (batch is null || batch.Started) + { + batch = new(this); + } + + batch.Add(items); + return batch; + } + } +} + +sealed class DeferredFilterResult(Func> resolve) : + IDataLoaderResult +{ + public Task GetResultAsync(Cancel cancel = default) => + resolve(); +} diff --git a/src/GraphQL.EntityFramework/Filters/FilterBuilder.cs b/src/GraphQL.EntityFramework/Filters/FilterBuilder.cs index a3a2e13bc..cefb37dbf 100644 --- a/src/GraphQL.EntityFramework/Filters/FilterBuilder.cs +++ b/src/GraphQL.EntityFramework/Filters/FilterBuilder.cs @@ -57,6 +57,34 @@ public void Add( Filters.AsyncFilter filter) => filters.Add(projection, filter); + /// + /// Add a filter that decides for many items in one call, such as one database query for a whole + /// response rather than one per item. + /// + /// The projection type (inferred from the projection expression). + /// Expression projecting the entity to the value the filter decides on. + /// + /// Passed the distinct projections of the items to filter, and returns those to include. An item + /// whose projection is not returned is excluded. + /// + /// + /// The items a response returns at the same depth are passed in one call, whichever row or field + /// returned them: + /// + /// filters.For<Product>().AddBatch( + /// projection: _ => _.CategoryId, + /// filter: async (_, dbContext, _, categoryIds) => + /// await dbContext.Categories + /// .Where(_ => categoryIds.Contains(_.Id) && _.IsVisible) + /// .Select(_ => _.Id) + /// .ToHashSetAsync()); + /// + /// + public void AddBatch( + Expression> projection, + Filters.BatchFilter filter) => + filters.AddBatch(projection, filter); + /// /// Add a filter using a boolean expression. /// diff --git a/src/GraphQL.EntityFramework/Filters/Filters.cs b/src/GraphQL.EntityFramework/Filters/Filters.cs index 79a306338..1a5ee1ea5 100644 --- a/src/GraphQL.EntityFramework/Filters/Filters.cs +++ b/src/GraphQL.EntityFramework/Filters/Filters.cs @@ -9,6 +9,8 @@ public class Filters public delegate Task AsyncFilter(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input); + public delegate Task> BatchFilter(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, IReadOnlyCollection inputs); + #endregion /// @@ -74,6 +76,12 @@ internal void Add( }, projection)); + internal void AddBatch( + Expression> projection, + BatchFilter filter) + where TEntity : class => + AddEntry(new BatchFilterEntry(filter, projection)); + Dictionary>> entries = []; /// @@ -102,15 +110,26 @@ void AddEntry(IFilterEntry entry) /// list, and a field typed as object is filtered the same as a typed one. Looked up per item, /// so the result is cached per type; the cache is reset when a filter is added. /// - ConcurrentDictionary>> filtersByType = new(); + ConcurrentDictionary filtersByType = new(); - List> GetFilters(Type entityType) => + TypeFilters GetFilters(Type entityType) => filtersByType.GetOrAdd( entityType, - type => entries - .Where(_ => _.Key.IsAssignableFrom(type)) - .SelectMany(_ => _.Value) - .ToList()); + type => + { + var forType = entries + .Where(_ => _.Key.IsAssignableFrom(type)) + .SelectMany(_ => _.Value) + .ToList(); + return new( + forType.Where(_ => _ is not IBatchFilterEntry).ToList(), + forType.OfType>().ToList()); + }); + + // Per item filters run on each item as it is filtered. Batch filters run once over many items. + sealed record TypeFilters( + IReadOnlyList> PerItem, + IReadOnlyList> Batch); /// /// The filters whose projection requirements a query for has to @@ -134,6 +153,9 @@ internal IReadOnlyList> GetFiltersForHierarchy(Type ent /// internal bool HasFilters => entries.Count > 0; + /// + /// Without an execution to share, batch filters run once over . + /// internal virtual async Task> ApplyFilter( IEnumerable result, object userContext, @@ -146,25 +168,143 @@ internal virtual async Task> ApplyFilter( return result; } - var list = new List(); - foreach (var item in result) + var filtered = await Apply(userContext, userPrincipal, null, data, result, _ => new(_)); + return (IEnumerable)filtered!; + } + + // One per execution, so the rows an execution resolves share a batch + ConditionalWeakTable> openBatches = new(); + + /// + /// Filters the items a resolver returns and passes the included ones, in order, to + /// , whose result is the field's value. Null items are passed through. + /// Per item filters run now. Batch filters do not: the items join the execution's open + /// and a deferred result is returned, so the rows of a + /// response share one call per batch filter rather than making one each. + /// + internal ValueTask Apply( + IResolveFieldContext context, + TDbContext data, + IEnumerable items, + Func, ValueTask> complete) => + Apply(context.UserContext, context.User, context.ExecutionContext, data, items, complete); + + async ValueTask Apply( + object userContext, + ClaimsPrincipal? userPrincipal, + IExecutionContext? execution, + TDbContext data, + IEnumerable items, + Func, ValueTask> complete) + { + if (entries.Count == 0) + { + return await complete(items as List ?? [.. items]); + } + + var candidates = new List>(); + List<(IBatchFilterEntry Entry, object? Projection)>? batchInputs = null; + foreach (var item in items) + { + if (item is null) + { + candidates.Add(new(item, null)); + continue; + } + + var filters = GetFilters(item.GetType()); + if (!await IncludedByPerItemFilters(filters.PerItem, userContext, data, userPrincipal, item)) + { + continue; + } + + if (filters.Batch.Count == 0) + { + candidates.Add(new(item, null)); + continue; + } + + var checks = new (IBatchFilterEntry Entry, object? Projection)[filters.Batch.Count]; + for (var index = 0; index < checks.Length; index++) + { + var entry = filters.Batch[index]; + checks[index] = (entry, entry.Project(item)); + } + + candidates.Add(new(item, checks)); + batchInputs ??= []; + batchInputs.AddRange(checks); + } + + if (batchInputs is null) + { + return await complete(candidates.Select(_ => _.Item).ToList()); + } + + if (execution is null) + { + var batch = new FilterBatch(new()); + batch.Add(batchInputs); + return await CompleteBatch(batch); + } + + var shared = openBatches.GetValue(execution, _ => new()).Add(batchInputs); + return new DeferredFilterResult(async () => await CompleteBatch(shared)); + + async ValueTask CompleteBatch(FilterBatch batch) + { + var results = await batch.Run(userContext, data, userPrincipal); + var included = new List(candidates.Count); + foreach (var (item, checks) in candidates) + { + if (checks is null || + checks.All(_ => results[_.Entry](_.Projection))) + { + included.Add(item); + } + } + + return await complete(included); + } + } + + // An item that passed the per item filters, and the batch filter checks it still has to pass + readonly record struct Candidate( + TItem Item, + (IBatchFilterEntry Entry, object? Projection)[]? Checks); + + static async Task IncludedByPerItemFilters( + IReadOnlyList> filters, + object userContext, + TDbContext data, + ClaimsPrincipal? userPrincipal, + object item) + { + foreach (var entry in filters) { - if (await ShouldIncludeItem(userContext, data, userPrincipal, item)) + if (!await entry.ShouldIncludeWithProjection(userContext, data, userPrincipal, item)) { - list.Add(item); + return false; } } - return list; + return true; } + // A single item on its own, so batch filters run over just this item async Task ShouldIncludeItem( object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, object item) { - foreach (var entry in GetFilters(item.GetType())) + var filters = GetFilters(item.GetType()); + if (!await IncludedByPerItemFilters(filters.PerItem, userContext, data, userPrincipal, item)) + { + return false; + } + + foreach (var entry in filters.Batch) { if (!await entry.ShouldIncludeWithProjection(userContext, data, userPrincipal, item)) { diff --git a/src/GraphQL.EntityFramework/Filters/IFilterEntry.cs b/src/GraphQL.EntityFramework/Filters/IFilterEntry.cs index 53840fbf7..4651b1fb2 100644 --- a/src/GraphQL.EntityFramework/Filters/IFilterEntry.cs +++ b/src/GraphQL.EntityFramework/Filters/IFilterEntry.cs @@ -18,3 +18,23 @@ Task ShouldIncludeWithProjection( ClaimsPrincipal? userPrincipal, object entity); } + +/// +/// A filter that decides for many items in one call. The items are projected first, and the +/// filter is passed the distinct projections. +/// +interface IBatchFilterEntry : + IFilterEntry + where TDbContext : DbContext +{ + object? Project(object entity); + + /// + /// Returns whether each of is included. + /// + Task> Filter( + object userContext, + TDbContext data, + ClaimsPrincipal? userPrincipal, + IReadOnlyCollection projections); +} diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_First.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_First.cs index f5114e1bd..651ab40d2 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_First.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_First.cs @@ -141,7 +141,8 @@ FieldType BuildFirstField( { Name = name, Type = graphType, - Resolver = new FuncFieldResolver( + // object rather than TReturn, since a batch filter makes the value a deferred result + Resolver = new FuncFieldResolver( async context => { var fieldContext = BuildContext(context); @@ -205,21 +206,39 @@ FieldType BuildFirstField( exception); } - if (first is not null) + if (first is null) { - if (fieldContext.Filters == null || - await fieldContext.Filters.ShouldInclude(context.UserContext, fieldContext.DbContext, context.User, first)) + return ReturnNullable(query); + } + + if (fieldContext.Filters == null) + { + return await Complete(first); + } + + return await fieldContext.Filters.Apply( + context, + fieldContext.DbContext, + [first], + async included => { - if (mutate is not null) + if (included.Count == 0) { - await mutate.Invoke(fieldContext, first); + return ReturnNullable(query); } - return first; + return await Complete(first); + }); + + async ValueTask Complete(TReturn item) + { + if (mutate is not null) + { + await mutate.Invoke(fieldContext, item); } - } - return ReturnNullable(query); + return item; + } }) }; diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs index a2883d643..41129e3a2 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Navigation.cs @@ -25,7 +25,8 @@ public FieldBuilder AddNavigationField( + // object rather than TReturn, since a batch filter makes the value a deferred result + field.Resolver = new FuncFieldResolver( async context => { // Runs once per parent row. Building a ResolveEfFieldContext here copied every property @@ -67,12 +68,12 @@ public FieldBuilder AddNavigationField new(_.FirstOrDefault())); }); graph.AddField(field); diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs index ba6eba742..b5eeed04a 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationConnection.cs @@ -72,19 +72,20 @@ public ConnectionBuilder AddNavigationConnectionField new(Connection(_))); - return ConnectionConverter.ApplyConnectionContext( - page, - context.First, - context.After, - context.Last, - context.Before); + Connection Connection(List page) => + ConnectionConverter.ApplyConnectionContext( + page, + context.First, + context.After, + context.Last, + context.Before); }); //TODO: works around https://github.com/graphql-dotnet/graphql-dotnet/pull/2581/ diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs index f1383a55d..3655a59b0 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_NavigationList.cs @@ -27,7 +27,8 @@ public FieldBuilder AddNavigationListField>(async context => + // object rather than the list, since a batch filter makes the value a deferred result + field.Resolver = new FuncFieldResolver(async context => { // Runs once per parent row. Building a ResolveEfFieldContext here copied every property // of the GraphQL.NET context, which forced the lazily computed ones, SubFields, Path, @@ -68,7 +69,7 @@ public FieldBuilder AddNavigationListField new(_)); }); graph.AddField(field); diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Queryable.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Queryable.cs index 3559c4a02..b0168ee02 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Queryable.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Queryable.cs @@ -87,7 +87,8 @@ FieldType BuildQueryField( var names = GetKeyNames(); if (resolve is not null) { - fieldType.Resolver = new FuncFieldResolver>( + // object rather than the list, since a batch filter makes the value a deferred result + fieldType.Resolver = new FuncFieldResolver( async context => { var fieldContext = BuildContext(context); @@ -95,13 +96,13 @@ FieldType BuildQueryField( var task = resolve(fieldContext); if (task == null) { - return []; + return Array.Empty(); } var query = await task; if (query == null) { - return []; + return Array.Empty(); } if (disableTracking) @@ -160,7 +161,7 @@ FieldType BuildQueryField( return list; } - return await fieldContext.Filters.ApplyFilter(list, context.UserContext, fieldContext.DbContext, context.User); + return await fieldContext.Filters.Apply(context, fieldContext.DbContext, list, _ => new(_)); }); } diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Single.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Single.cs index 13af2229a..c1ecb2c08 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Single.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Single.cs @@ -141,7 +141,8 @@ FieldType BuildSingleField( { Name = name, Type = graphType, - Resolver = new FuncFieldResolver( + // object rather than TReturn, since a batch filter makes the value a deferred result + Resolver = new FuncFieldResolver( async context => { var fieldContext = BuildContext(context); @@ -205,21 +206,39 @@ FieldType BuildSingleField( exception); } - if (single is not null) + if (single is null) { - if (fieldContext.Filters == null || - await fieldContext.Filters.ShouldInclude(context.UserContext, fieldContext.DbContext, context.User, single)) + return ReturnNullable(query); + } + + if (fieldContext.Filters == null) + { + return await Complete(single); + } + + return await fieldContext.Filters.Apply( + context, + fieldContext.DbContext, + [single], + async included => { - if (mutate is not null) + if (included.Count == 0) { - await mutate.Invoke(fieldContext, single); + return ReturnNullable(query); } - return single; + return await Complete(single); + }); + + async ValueTask Complete(TReturn item) + { + if (mutate is not null) + { + await mutate.Invoke(fieldContext, item); } - } - return ReturnNullable(query); + return item; + } }) }; diff --git a/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs b/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs index 613cb2402..762514772 100644 --- a/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs +++ b/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs @@ -40,7 +40,7 @@ public static FieldBuilder Resolve( + field.Resolver = new FuncFieldResolver( context => { var projectionContext = BuildProjectionContext(graphQlService, compiledProjection, context); @@ -101,7 +101,7 @@ public static FieldBuilder ResolveAsync( + field.Resolver = new FuncFieldResolver( async context => { var projectionContext = BuildProjectionContext(graphQlService, compiledProjection, context); @@ -162,7 +162,7 @@ public static FieldBuilder> ResolveList>( + field.Resolver = new FuncFieldResolver( context => { var projectionContext = BuildProjectionContext(graphQlService, compiledProjection, context); @@ -223,7 +223,7 @@ public static FieldBuilder> ResolveListAsync>( + field.Resolver = new FuncFieldResolver( async context => { var projectionContext = BuildProjectionContext(graphQlService, compiledProjection, context); @@ -265,7 +265,9 @@ static ResolveProjectionContext BuildProjectionContext< FieldContext = context }; - static ValueTask ApplyFilters( + // The resolvers return object rather than TReturn, since a batch filter makes the value a + // deferred result + static ValueTask ApplyFilters( Filters? filters, IResolveFieldContext context, TDbContext dbContext, @@ -280,14 +282,17 @@ static ResolveProjectionContext BuildProjectionContext< return new(result); } - return ApplyFiltersAsync(filters, context, dbContext, result); + // Matched on the runtime type of the result, so a field typed as object is filtered the + // same as a typed one + return filters.Apply(context, dbContext, [result], _ => new(_.FirstOrDefault())); } /// /// The items of a list resolve are filtered the same way every other list path filters them. /// They were returned as is, so a filter that excluded an item elsewhere let it through here. + /// Null items are kept. /// - static ValueTask?> ApplyListFilters( + static ValueTask ApplyListFilters( Filters? filters, IResolveFieldContext context, TDbContext dbContext, @@ -300,44 +305,7 @@ static ResolveProjectionContext BuildProjectionContext< return new(result); } - return ApplyListFiltersAsync(filters, context, dbContext, result); - } - - static async ValueTask?> ApplyListFiltersAsync( - Filters filters, - IResolveFieldContext context, - TDbContext dbContext, - IEnumerable result) - where TDbContext : DbContext - { - var list = new List(); - foreach (var item in result) - { - if (item is null || - await filters.ShouldInclude(context.UserContext, dbContext, context.User, item)) - { - list.Add(item); - } - } - - return list; - } - - static async ValueTask ApplyFiltersAsync( - Filters filters, - IResolveFieldContext context, - TDbContext dbContext, - TReturn result) - where TDbContext : DbContext - { - // For reference types, apply filters if available. Matched on the runtime type of the - // result, so a field typed as object is filtered the same as a typed one. - if (!await filters.ShouldInclude(context.UserContext, dbContext, context.User, result!)) - { - return default; - } - - return result; + return filters.Apply(context, dbContext, result, _ => new(_)); } /// diff --git a/src/Snippets/GlobalFilterSnippets.cs b/src/Snippets/GlobalFilterSnippets.cs index 8a719f130..022400788 100644 --- a/src/Snippets/GlobalFilterSnippets.cs +++ b/src/Snippets/GlobalFilterSnippets.cs @@ -219,6 +219,25 @@ public static void AddAsyncFilter(ServiceCollection services) #endregion } + public static void AddBatchFilter(ServiceCollection services) + { + #region batch-filter + + var filters = new Filters(); + filters.For().AddBatch( + projection: _ => _.CategoryId, + filter: async (_, dbContext, _, categoryIds) => + await dbContext.Categories + .Where(_ => categoryIds.Contains(_.Id) && _.IsVisible) + .Select(_ => _.Id) + .ToHashSetAsync()); + EfGraphQLConventions.RegisterInContainer( + services, + resolveFilters: _ => filters); + + #endregion + } + public static void AddNavigationPropertyFilter(ServiceCollection services) { #region navigation-property-filter diff --git a/src/Tests/BatchFilterTests.cs b/src/Tests/BatchFilterTests.cs new file mode 100644 index 000000000..30f63b569 --- /dev/null +++ b/src/Tests/BatchFilterTests.cs @@ -0,0 +1,79 @@ +// Outside an execution there is no batch to share, so a batch filter runs over the items it is given +public class BatchFilterTests +{ + [Fact] + public async Task ApplyFilter_passes_the_items_in_one_call() + { + var calls = new List>(); + var filters = BuildFilters(calls); + ParentEntity[] items = + [ + new() + { + Property = "Value1" + }, + new() + { + Property = "Ignore" + }, + new() + { + Property = "Value1" + }, + new() + { + Property = "Value2" + } + ]; + + var result = await filters.ApplyFilter(items, new(), null!, null); + + Assert.Equal(["Value1", "Value1", "Value2"], result.Select(_ => _.Property)); + var call = Assert.Single(calls); + Assert.Equal(["Ignore", "Value1", "Value2"], call); + } + + [Fact] + public async Task ShouldInclude_passes_the_item() + { + var calls = new List>(); + var filters = BuildFilters(calls); + + var kept = await filters.ShouldInclude( + new(), + null!, + null, + new ParentEntity + { + Property = "Value1" + }); + var ignored = await filters.ShouldInclude( + new(), + null!, + null, + new ParentEntity + { + Property = "Ignore" + }); + + Assert.True(kept); + Assert.False(ignored); + Assert.Equal([["Value1"], ["Ignore"]], calls); + } + + static Filters BuildFilters(List> calls) + { + var filters = new Filters(); + filters.For().AddBatch( + projection: _ => _.Property, + filter: (_, _, _, properties) => + { + calls.Add(properties.Order().ToList()); + IReadOnlySet included = properties + .Where(_ => _ != "Ignore") + .ToHashSet(); + return Task.FromResult(included); + }); + return filters; + } +} diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_exception.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_exception.verified.txt new file mode 100644 index 000000000..5277a8670 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_exception.verified.txt @@ -0,0 +1,18 @@ +{ + target: { + Type: Exception, + Message: Failed to execute batch filter. TEntity: ParentEntity., + InnerException: { + $type: Exception, + Type: Exception, + Message: Batch filter failed + } + }, + sql: { + Text: +select p.Id, + p.Property +from ParentEntities as p +order by p.Property + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_fields_share_one_call.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_fields_share_one_call.verified.txt new file mode 100644 index 000000000..011aade5a --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_fields_share_one_call.verified.txt @@ -0,0 +1,54 @@ +{ + target: { + Data: { + first: [ + { + property: Value1 + } + ], + second: [ + { + property: Value3 + } + ] + } + }, + sql: [ + { + Text: +select p.Id, + p.Property +from ParentEntities as p +where p.Property = @p +order by p.Property, + Parameters: { + @p: { + Value: Value1, + Size: 4000, + IsNullable: true + } + } + }, + { + Text: +select p.Id, + p.Property +from ParentEntities as p +where p.Property <> @p + or p.Property is null +order by p.Property, + Parameters: { + @p: { + Value: Value1, + Size: 4000, + IsNullable: true + } + } + } + ], + parentBatch: [ + Ignore, + Value1, + Value3 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first.verified.txt new file mode 100644 index 000000000..327807ba5 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first.verified.txt @@ -0,0 +1,23 @@ +{ + target: { + Data: { + parentEntityFirst: { + property: Parent1 + } + } + }, + sql: { + Text: +select top (1) p.Id, + p.Property +from ParentEntities as p +where p.Id = @p1 +order by p.Property, + Parameters: { + @p1: Guid_1 + } + }, + parentBatch: [ + Parent1 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first_excluded.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first_excluded.verified.txt new file mode 100644 index 000000000..13e69c48a --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first_excluded.verified.txt @@ -0,0 +1,20 @@ +{ + target: { + Type: FirstEntityNotFoundException, + Message: Not found + }, + sql: { + Text: +select top (1) p.Id, + p.Property +from ParentEntities as p +where p.Id = @p1 +order by p.Property, + Parameters: { + @p1: Guid_1 + } + }, + parentBatch: [ + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first_nullable_excluded.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first_nullable_excluded.verified.txt new file mode 100644 index 000000000..668eb47b0 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_first_nullable_excluded.verified.txt @@ -0,0 +1,21 @@ +{ + target: { + Data: { + parentEntityNullableFirst: null + } + }, + sql: { + Text: +select top (1) p.Id, + p.Property +from ParentEntities as p +where p.Id = @p1 +order by p.Property, + Parameters: { + @p1: Guid_1 + } + }, + parentBatch: [ + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_connection_rows_share_one_call.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_connection_rows_share_one_call.verified.txt new file mode 100644 index 000000000..27e9d8193 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_connection_rows_share_one_call.verified.txt @@ -0,0 +1,55 @@ +{ + target: { + Data: { + parentEntities: [ + { + property: Parent1, + childrenConnection: { + totalCount: 1, + items: [ + { + property: Child1 + } + ] + } + }, + { + property: Parent2, + childrenConnection: { + totalCount: 1, + items: [ + { + property: Child2 + } + ] + } + } + ] + } + }, + sql: { + Text: +select p.Id, + c.Id, + c.ParentId, + c.Property, + p.Property +from ParentEntities as p + left outer join + ChildEntities as c + on p.Id = c.ParentId +order by p.Property, + p.Id, + c.Id + }, + parentBatch: [ + Ignore, + Parent1, + Parent2 + ], + childBatch: [ + Child1, + Child2, + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_list_rows_share_one_call.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_list_rows_share_one_call.verified.txt new file mode 100644 index 000000000..c0a344c73 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_list_rows_share_one_call.verified.txt @@ -0,0 +1,49 @@ +{ + target: { + Data: { + parentEntities: [ + { + property: Parent1, + children: [ + { + property: Child1 + } + ] + }, + { + property: Parent2, + children: [ + { + property: Child2 + } + ] + } + ] + } + }, + sql: { + Text: +select p.Id, + c.Id, + c.ParentId, + c.Property, + p.Property +from ParentEntities as p + left outer join + ChildEntities as c + on p.Id = c.ParentId +order by p.Property, + p.Id, + c.Id + }, + parentBatch: [ + Ignore, + Parent1, + Parent2 + ], + childBatch: [ + Child1, + Child2, + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_rows_share_one_call.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_rows_share_one_call.verified.txt new file mode 100644 index 000000000..457c0e4fc --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_navigation_rows_share_one_call.verified.txt @@ -0,0 +1,47 @@ +{ + target: { + Data: { + childEntities: [ + { + property: Child1, + parent: { + property: Value1 + } + }, + { + property: Child2, + parent: null + }, + { + property: Child3, + parent: { + property: Value1 + } + } + ] + } + }, + sql: { + Text: +select c.Id, + case when p.Id is null then cast (1 as bit) else cast (0 as bit) end, + p.Id, + p.Property, + c.ParentId, + c.Property +from ChildEntities as c + left outer join + ParentEntities as p + on c.ParentId = p.Id +order by c.Property + }, + childBatch: [ + Child1, + Child2, + Child3 + ], + parentBatch: [ + Ignore, + Value1 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_on_derived_type_applies_to_base_typed_list.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_on_derived_type_applies_to_base_typed_list.verified.txt new file mode 100644 index 000000000..b6f7a691c --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_on_derived_type_applies_to_base_typed_list.verified.txt @@ -0,0 +1,27 @@ +{ + target: { + Data: { + baseEntities: [ + { + property: Value1 + }, + { + property: Value2 + } + ] + } + }, + sql: { + Text: +select b.Id, + b.Discriminator, + b.Property, + b.Status +from BaseEntities as b +order by b.Property + }, + derivedBatch: [ + Ignore, + Value1 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_one_call_per_depth.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_one_call_per_depth.verified.txt new file mode 100644 index 000000000..04ed9a591 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_one_call_per_depth.verified.txt @@ -0,0 +1,99 @@ +{ + target: { + Data: { + childEntities: [ + { + property: Child1, + parent: { + property: Parent1, + children: [ + { + property: Child1, + parent: { + property: Parent1 + } + } + ] + } + }, + { + property: Child2, + parent: { + property: Parent2, + children: [ + { + property: Child2, + parent: { + property: Parent2 + } + } + ] + } + }, + { + property: Child3, + parent: null + } + ] + } + }, + sql: { + Text: +select c.Id, + case when p.Id is null then cast (1 as bit) else cast (0 as bit) end, + p.Id, + s.Id, + s.c, + s.Id0, + s.Property, + s.ParentId, + s.Property0, + p.Property, + c.ParentId, + c.Property +from ChildEntities as c + left outer join + ParentEntities as p + on c.ParentId = p.Id + left outer join + (select c0.Id, + case when p0.Id is null then cast (1 as bit) else cast (0 as bit) end as c, + p0.Id as Id0, + p0.Property, + c0.ParentId, + c0.Property as Property0 + from ChildEntities as c0 + left outer join + ParentEntities as p0 + on c0.ParentId = p0.Id) as s + on p.Id = s.ParentId +order by c.Property, + c.Id, + p.Id, + s.Id + }, + childBatch: [ + [ + Child1, + Child2, + Child3, + Ignore + ], + [ + Child1, + Child2, + Ignore + ] + ], + parentBatch: [ + [ + Ignore, + Parent1, + Parent2 + ], + [ + Parent1, + Parent2 + ] + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_resolve_list_rows_share_one_call.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_resolve_list_rows_share_one_call.verified.txt new file mode 100644 index 000000000..6bf6fbbdb --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_resolve_list_rows_share_one_call.verified.txt @@ -0,0 +1,49 @@ +{ + target: { + Data: { + parentEntities: [ + { + property: Parent1, + childrenViaResolveList: [ + { + property: Child1 + } + ] + }, + { + property: Parent2, + childrenViaResolveList: [ + { + property: Child2 + } + ] + } + ] + } + }, + sql: { + Text: +select p.Id, + c.Id, + c.ParentId, + c.Property, + p.Property +from ParentEntities as p + left outer join + ChildEntities as c + on p.Id = c.ParentId +order by p.Property, + p.Id, + c.Id + }, + parentBatch: [ + Ignore, + Parent1, + Parent2 + ], + childBatch: [ + Child1, + Child2, + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_resolve_rows_share_one_call.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_resolve_rows_share_one_call.verified.txt new file mode 100644 index 000000000..c49c6c77e --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_resolve_rows_share_one_call.verified.txt @@ -0,0 +1,40 @@ +{ + target: { + Data: { + childEntities: [ + { + property: Child1, + parentObject: { + property: Value1 + } + }, + { + property: Child2, + parentObject: null + } + ] + } + }, + sql: { + Text: +select c.Id, + case when p.Id is null then cast (1 as bit) else cast (0 as bit) end, + p.Id, + p.Property, + c.ParentId, + c.Property +from ChildEntities as c + left outer join + ParentEntities as p + on c.ParentId = p.Id +order by c.Property + }, + childBatch: [ + Child1, + Child2 + ], + parentBatch: [ + Ignore, + Value1 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_root_connection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_root_connection.verified.txt new file mode 100644 index 000000000..66d2cef40 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_root_connection.verified.txt @@ -0,0 +1,67 @@ +{ + target: { + Data: { + parentEntitiesConnection: { + totalCount: 3, + items: [ + { + property: Parent1, + children: [ + { + property: Child1 + } + ] + }, + { + property: Parent2, + children: [ + { + property: Child2 + } + ] + } + ] + } + } + }, + sql: [ + { + Text: +select COUNT(*) +from ParentEntities as p + }, + { + Text: +select p0.Id, + c.Id, + c.ParentId, + c.Property, + p0.Property +from (select p.Id, + p.Property + from ParentEntities as p + order by p.Property + offset @p rows fetch next @p1 rows only) as p0 + left outer join + ChildEntities as c + on p0.Id = c.ParentId +order by p0.Property, + p0.Id, + c.Id, + Parameters: { + @p: 0, + @p1: 10 + } + } + ], + parentBatch: [ + Ignore, + Parent1, + Parent2 + ], + childBatch: [ + Child1, + Child2, + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_root_list.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_root_list.verified.txt new file mode 100644 index 000000000..12c1d27d5 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_root_list.verified.txt @@ -0,0 +1,26 @@ +{ + target: { + Data: { + parentEntities: [ + { + property: Value1 + }, + { + property: Value3 + } + ] + } + }, + sql: { + Text: +select p.Id, + p.Property +from ParentEntities as p +order by p.Property + }, + parentBatch: [ + Ignore, + Value1, + Value3 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_runs_after_per_item_filters.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_runs_after_per_item_filters.verified.txt new file mode 100644 index 000000000..796913272 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_runs_after_per_item_filters.verified.txt @@ -0,0 +1,22 @@ +{ + target: { + Data: { + parentEntities: [ + { + property: Value1 + } + ] + } + }, + sql: { + Text: +select p.Id, + p.Property +from ParentEntities as p +order by p.Property + }, + parentBatch: [ + Ignore, + Value1 + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single.verified.txt new file mode 100644 index 000000000..804970ff8 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single.verified.txt @@ -0,0 +1,41 @@ +{ + target: { + Data: { + parentEntity: { + property: Parent1, + children: [ + { + property: Child1 + } + ] + } + } + }, + sql: { + Text: +select p0.Id, + c.Id, + c.ParentId, + c.Property, + p0.Property +from (select top (2) p.Id, + p.Property + from ParentEntities as p + where p.Id = @p1) as p0 + left outer join + ChildEntities as c + on p0.Id = c.ParentId +order by p0.Id, + c.Id, + Parameters: { + @p1: Guid_1 + } + }, + parentBatch: [ + Parent1 + ], + childBatch: [ + Child1, + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single_excluded.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single_excluded.verified.txt new file mode 100644 index 000000000..ef413c677 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single_excluded.verified.txt @@ -0,0 +1,19 @@ +{ + target: { + Type: SingleEntityNotFoundException, + Message: Not found + }, + sql: { + Text: +select top (2) p.Id, + p.Property +from ParentEntities as p +where p.Id = @p1, + Parameters: { + @p1: Guid_1 + } + }, + parentBatch: [ + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single_nullable_excluded.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single_nullable_excluded.verified.txt new file mode 100644 index 000000000..261b722f6 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Batch_filter_single_nullable_excluded.verified.txt @@ -0,0 +1,20 @@ +{ + target: { + Data: { + parentEntityNullable: null + } + }, + sql: { + Text: +select top (2) p.Id, + p.Property +from ParentEntities as p +where p.Id = @p1, + Parameters: { + @p1: Guid_1 + } + }, + parentBatch: [ + Ignore + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests_batch_filters.cs b/src/Tests/IntegrationTests/IntegrationTests_batch_filters.cs new file mode 100644 index 000000000..21f2a564d --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests_batch_filters.cs @@ -0,0 +1,574 @@ +// Batch filters: the items of a response are filtered with one call per batch filter per query +// depth, whichever row or field returned them. Each call is recorded, so the snapshots show how +// many calls the items shared. +public partial class IntegrationTests +{ + [Fact] + public async Task Batch_filter_root_list() + { + var query = + """ + { + parentEntities + { + property + } + } + """; + + var parent1 = new ParentEntity + { + Property = "Value1" + }; + var parent2 = new ParentEntity + { + Property = "Ignore" + }; + var parent3 = new ParentEntity + { + Property = "Value3" + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), [parent1, parent2, parent3]); + } + + // Two root fields returning the same type share the call + [Fact] + public async Task Batch_filter_fields_share_one_call() + { + var query = + """ + { + first: parentEntities(where: {property: {equal: "Value1"}}) + { + property + } + second: parentEntities(where: {property: {notEqual: "Value1"}}) + { + property + } + } + """; + + var parent1 = new ParentEntity + { + Property = "Value1" + }; + var parent2 = new ParentEntity + { + Property = "Ignore" + }; + var parent3 = new ParentEntity + { + Property = "Value3" + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), [parent1, parent2, parent3]); + } + + // The children of each parent resolve separately, and are filtered in one call + [Fact] + public async Task Batch_filter_navigation_list_rows_share_one_call() + { + var query = + """ + { + parentEntities + { + property + children + { + property + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + // The parent of each child resolves separately, and is filtered in one call + [Fact] + public async Task Batch_filter_navigation_rows_share_one_call() + { + var query = + """ + { + childEntities + { + property + parent + { + property + } + } + } + """; + + var kept = new ParentEntity + { + Property = "Value1" + }; + var ignored = new ParentEntity + { + Property = "Ignore" + }; + var child1 = new ChildEntity + { + Property = "Child1", + Parent = kept + }; + var child2 = new ChildEntity + { + Property = "Child2", + Parent = ignored + }; + var child3 = new ChildEntity + { + Property = "Child3", + Parent = kept + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), [kept, ignored, child1, child2, child3]); + } + + [Fact] + public async Task Batch_filter_navigation_connection_rows_share_one_call() + { + var query = + """ + { + parentEntities + { + property + childrenConnection + { + totalCount + items + { + property + } + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + // The total count is taken before any filter runs, as it is for a per item filter + [Fact] + public async Task Batch_filter_root_connection() + { + var query = + """ + { + parentEntitiesConnection(first: 10) + { + totalCount + items + { + property + children + { + property + } + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_resolve_list_rows_share_one_call() + { + var query = + """ + { + parentEntities + { + property + childrenViaResolveList + { + property + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_resolve_rows_share_one_call() + { + var query = + """ + { + childEntities + { + property + parentObject + { + property + } + } + } + """; + + var kept = new ParentEntity + { + Property = "Value1" + }; + var ignored = new ParentEntity + { + Property = "Ignore" + }; + var child1 = new ChildEntity + { + Property = "Child1", + Parent = kept + }; + var child2 = new ChildEntity + { + Property = "Child2", + Parent = ignored + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), [kept, ignored, child1, child2]); + } + + // Each depth of the query shares one call per batch filter, however many rows it has + [Fact] + public async Task Batch_filter_one_call_per_depth() + { + var query = + """ + { + childEntities + { + property + parent + { + property + children + { + property + parent + { + property + } + } + } + } + } + """; + + var (entities, _, _) = BuildParentsWithChildren(); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_single() + { + var (entities, kept, _) = BuildParentsWithChildren(); + var query = + $$""" + { + parentEntity(id: "{{kept.Id}}") + { + property + children + { + property + } + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_single_excluded() + { + var (entities, _, ignored) = BuildParentsWithChildren(); + var query = + $$""" + { + parentEntity(id: "{{ignored.Id}}") + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_single_nullable_excluded() + { + var (entities, _, ignored) = BuildParentsWithChildren(); + var query = + $$""" + { + parentEntityNullable(id: "{{ignored.Id}}") + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_first() + { + var (entities, kept, _) = BuildParentsWithChildren(); + var query = + $$""" + { + parentEntityFirst(id: "{{kept.Id}}") + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_first_excluded() + { + var (entities, _, ignored) = BuildParentsWithChildren(); + var query = + $$""" + { + parentEntityFirst(id: "{{ignored.Id}}") + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + [Fact] + public async Task Batch_filter_first_nullable_excluded() + { + var (entities, _, ignored) = BuildParentsWithChildren(); + var query = + $$""" + { + parentEntityNullableFirst(id: "{{ignored.Id}}") + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, BuildBatchFilters(), entities); + } + + // Items a per item filter excludes are not passed to the batch filter + [Fact] + public async Task Batch_filter_runs_after_per_item_filters() + { + var query = + """ + { + parentEntities + { + property + } + } + """; + + var parent1 = new ParentEntity + { + Property = "Value1" + }; + var parent2 = new ParentEntity + { + Property = "Ignore" + }; + var parent3 = new ParentEntity + { + Property = "PerItemIgnore" + }; + + var filters = BuildBatchFilters(); + filters.For().Add( + projection: _ => _.Property, + filter: (_, _, _, property) => property != "PerItemIgnore"); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, filters, [parent1, parent2, parent3]); + } + + [Fact] + public async Task Batch_filter_on_derived_type_applies_to_base_typed_list() + { + var query = + """ + { + baseEntities + { + property + } + } + """; + + var derived = new DerivedEntity + { + Property = "Ignore" + }; + var derivedKept = new DerivedEntity + { + Property = "Value1" + }; + var derivedWithNavigation = new DerivedWithNavigationEntity + { + Property = "Value2" + }; + + var filters = new Filters(); + filters.For().AddBatch( + projection: _ => _.Property, + filter: (_, _, _, properties) => ExcludeIgnored("derivedBatch", properties)); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, filters, [derived, derivedKept, derivedWithNavigation]); + } + + [Fact] + public async Task Batch_filter_exception() + { + var query = + """ + { + parentEntities + { + property + } + } + """; + + var parent = new ParentEntity + { + Property = "Value1" + }; + + var filters = new Filters(); + filters.For().AddBatch( + projection: _ => _.Property, + filter: (_, _, _, _) => throw new("Batch filter failed")); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, filters, [parent]); + } + + static Filters BuildBatchFilters() + { + var filters = new Filters(); + filters.For().AddBatch( + projection: _ => _.Property, + filter: (_, _, _, properties) => ExcludeIgnored("parentBatch", properties)); + filters.For().AddBatch( + projection: _ => _.Property, + filter: (_, _, _, properties) => ExcludeIgnored("childBatch", properties)); + return filters; + } + + // Records the call, so the snapshot shows how many calls there were and what each was passed + static Task> ExcludeIgnored(string name, IReadOnlyCollection properties) + { + Recording.Add(name, properties.Order().ToList()); + IReadOnlySet included = properties + .Where(_ => _ != "Ignore") + .ToHashSet(); + return Task.FromResult(included); + } + + // Two parents, each with a kept and an ignored child, and an ignored parent with a kept child + static (object[] Entities, ParentEntity Kept, ParentEntity Ignored) BuildParentsWithChildren() + { + var parent1 = new ParentEntity + { + Property = "Parent1" + }; + var parent2 = new ParentEntity + { + Property = "Parent2" + }; + var ignored = new ParentEntity + { + Property = "Ignore" + }; + var child1 = new ChildEntity + { + Property = "Child1", + Parent = parent1 + }; + var child1Ignored = new ChildEntity + { + Property = "Ignore", + Parent = parent1 + }; + var child2 = new ChildEntity + { + Property = "Child2", + Parent = parent2 + }; + var child2Ignored = new ChildEntity + { + Property = "Ignore", + Parent = parent2 + }; + var child3 = new ChildEntity + { + Property = "Child3", + Parent = ignored + }; + parent1.Children.Add(child1); + parent1.Children.Add(child1Ignored); + parent2.Children.Add(child2); + parent2.Children.Add(child2Ignored); + ignored.Children.Add(child3); + + return ( + [parent1, parent2, ignored, child1, child1Ignored, child2, child2Ignored, child3], + parent1, + ignored); + } +}