Skip to content

Commit 3e0684d

Browse files
committed
prep for 6 release and fix #378
1 parent 94fd68e commit 3e0684d

8 files changed

Lines changed: 174 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1+
# 6.0.0
2+
3+
6.0.0 includes everything from the `6.0.0-beta1` – `6.0.0-beta8` releases — see the beta sections below for full details and the complete list of breaking changes. A summary of the big items:
4+
5+
## Highlights
6+
7+
- **Partial results & spec compliance** (beta1) — top-level fields execute independently; if one fails you get the successful fields' data plus error details for the failed ones, and `data` is `null` when an error prevents a valid response, per the GraphQL spec. `MapGraphQL` follows the [GraphQL-over-HTTP spec](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md) by default.
8+
- **New async model & cancellation** (beta1) — `.ResolveAsync<>()` for async fields, end-to-end `CancellationToken` support (passed through `ExecuteRequestAsync` and into your resolvers), and async subscription setup (`Task<IObservable<T>>`).
9+
- **Query protection** (beta3) — opt-in pre-execution guards (`MaxQueryDepth`, `MaxFieldSelections`, `MaxFieldAliases`, `MaxQueryComplexity`), per-field complexity scoring via `field.SetComplexity(...)`, and per-field rate limiting via `field.AddRateLimit(...)` with a `PartitionedRateLimiter`-backed implementation in `EntityGraphQL.AspNet`.
10+
- **New `UseAggregate()` field extension** (beta8) — expose `count`/`min`/`max`/`sum`/`average` over a collection field, translated to a single SQL query under EF. Placement adapts to the field (attaches to a paging wrapper, wraps as `{ items, aggregate }`, or an additive sibling field), and it supports aggregating service-backed and bulk-resolved fields via the two-pass execution model.
11+
- **Filtering improvements** — reference service fields in filters (#303), GraphQL variables in filters (`filter: "name == $searchTerm"`, #396), `selectMany` for nested collections, generic custom type converters (#476), better nullable numeric comparisons (#484), and filtering by paged child fields (#378, below).
12+
- **Paging & EF performance** — the `COUNT` query only runs when the selection needs the total, `hasNextPage` alone is answered with a cheap `EXISTS` query, and connection cursor assignment computes its argument math once per request. Page queries select only the requested columns with parameterized paging.
13+
- **New GraphQL document parser** (beta3) — replaced the third-party parser; adds document descriptions from the September 2025 spec; structured error source `locations` in responses.
14+
- **Targets** — adds `net10.0`; `EntityGraphQL.AspNet` drops `net6.0`/`net7.0` (the core `EntityGraphQL` package still targets `netstandard2.1` for older runtimes).
15+
16+
## Breaking changes summary
17+
18+
Each is detailed in the beta section it shipped in:
19+
20+
- Partial-results behavior and `AddGraphQLValidator` now registering `IGraphQLValidator` as transient (beta1)
21+
- `MapGraphQL` spec-following behavior is the default (beta1); removed previously-obsolete members; filter support via `ArgumentHelper.EntityQuery` removed — use `UseFilter()` (beta1)
22+
- `AddGraphQLOptions<T>` restructured to composition (`options.Builder` / `options.Schema`), `ConfigureSchema(...)` methods, and `AddGraphQLSchema()` returning a `GraphQLSchemaBuilder<T>` for chaining `ConfigureGraphQLSchema(...)` (beta3/beta5)
23+
- Authorization refactored to a keyed data structure; role/policy helpers replace the roles constructor (beta3)
24+
- `IFieldExtension` hooks take context objects instead of positional parameters (beta4)
25+
- Renames: `SchemaBuilderSchemaOptions` → `SchemaProviderOptions` (beta3), `ExecutionOptions.MaxQueryNodes` → `MaxFieldSelections`, `UserKeySelector` → `RateLimitUserKeySelector` (beta6), `ExecutableDirectiveLocation` enum fields to C# style (beta3)
26+
- Default serializers use `JsonSerializerDefaults.Web` (beta6)
27+
28+
## Changes since beta8
29+
30+
- #378 - Filtering by a paged child field now works. Within a filter expression a field using `UseOffsetPaging()`/`UseConnectionPaging()` resolves to its underlying collection (the page wrapper only exists in the GraphQL schema), so `enquiries(filter: "enquirerDaps.count() > 0")`, `"enquirerDaps.items.count() > 0"` (`items`/`edges` are the collection) and `"enquirerDaps.totalItems > 0"` (`totalItems`/`totalCount` map to `Count()`) all compile and translate to SQL under EF. Previously these failed with `Method 'count' not found on current context 'OffsetPage'` or a `NullReferenceException`. Other page metadata (e.g. `hasNextPage`) raises a clear error when used in a filter.
31+
32+
133
# 6.0.0-beta8
234

335
## New Features

‎docs/docs/field-extensions/filtering.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,19 @@ With variables:
345345
}
346346
```
347347

348+
## Paged Fields in Filters
349+
350+
A field using `UseOffsetPaging()`/`UseConnectionPaging()` returns a page wrapper type in the GraphQL schema, but that wrapper can't execute inside a filter (or translate to SQL). So within a filter expression a paged field resolves to its **underlying collection**:
351+
352+
```graphql
353+
# enquirerDaps uses UseOffsetPaging - all of these are equivalent in a filter:
354+
enquiries(filter: "enquirerDaps.count() > 0")
355+
enquiries(filter: "enquirerDaps.items.count() > 0") # items/edges are the collection itself
356+
enquiries(filter: "enquirerDaps.totalItems > 0") # totalItems/totalCount map to Count()
357+
```
358+
359+
Note the collection is the *unfiltered* source — any `filter` argument passed to the paged field in the query applies to that field's own results, not to the parent's filter. Other page metadata (e.g. `hasNextPage`) is not available in filters.
360+
348361
## Service Fields in Filters
349362
350363
When using the filter extension with fields that resolve data from services (using `Resolve<TService>()`) and have two-pass execution enabled (`ExecuteServiceFieldsSeparately = true`, which is the default), EntityGraphQL automatically handles filter splitting to optimize query performance.

‎src/EntityGraphQL.AspNet/EntityGraphQL.AspNet.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<AssemblyName>EntityGraphQL.AspNet</AssemblyName>
55
<PackageId>EntityGraphQL.AspNet</PackageId>
66
<LangVersion>13</LangVersion>
7-
<PackageVersion>6.0.0-beta8</PackageVersion>
7+
<PackageVersion>6.0.0</PackageVersion>
88
<Description>Contains ASP.NET extensions and middleware for EntityGraphQL</Description>
99
<Authors>Luke Murray</Authors>
1010
<PackageProjectUrl>https://github.com/lukemurray/EntityGraphQL</PackageProjectUrl>

‎src/EntityGraphQL/Compiler/EntityQuery/EqlCompileContext.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,12 @@ public EqlCompileContext(CompileContext compileContext)
1818

1919
public List<IField> ServiceFieldDependencies { get; } = new();
2020
public Expression? OriginalContext { get; set; }
21+
22+
/// <summary>
23+
/// Expressions produced by resolving a paged field (UseOffsetPaging/UseConnectionPaging) in a filter -
24+
/// they are the field's underlying collection (the Page/Connection wrapper only exists in the GraphQL
25+
/// schema). Tracked so a following items/edges access can be treated as identity and totalItems/totalCount
26+
/// as Count(). See IdentityExpression.
27+
/// </summary>
28+
public HashSet<Expression> PagedFieldExpressions { get; } = new();
2129
}

‎src/EntityGraphQL/Compiler/EntityQuery/Grammar/IdentityExpression.cs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
using System.Collections.Generic;
33
using System.Linq;
44
using System.Linq.Expressions;
5+
using EntityGraphQL.Extensions;
56
using EntityGraphQL.Schema;
7+
using EntityGraphQL.Schema.FieldExtensions;
68

79
namespace EntityGraphQL.Compiler.EntityQuery.Grammar;
810

@@ -30,6 +32,24 @@ internal static Expression MakePropertyCall(Expression context, ISchemaProvider?
3032
return MakeConstantFromIdentity(context, schema, name, requestContext);
3133
}
3234
}
35+
36+
// the context is the underlying collection of a paged field (see below). The Page/Connection wrapper
37+
// fields only exist in the GraphQL schema - map them onto the collection
38+
if (compileContext.PagedFieldExpressions.Contains(context))
39+
{
40+
if (string.Equals(name, "items", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "edges", StringComparison.OrdinalIgnoreCase))
41+
return context; // identity - the collection is the items
42+
if (string.Equals(name, "totalItems", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "totalCount", StringComparison.OrdinalIgnoreCase))
43+
{
44+
var elementType = context.Type.GetEnumerableOrArrayType()!;
45+
return Expression.Call(context.Type.IsGenericTypeQueryable() ? typeof(Queryable) : typeof(Enumerable), nameof(Enumerable.Count), [elementType], context);
46+
}
47+
throw new EntityGraphQLException(
48+
GraphQLErrorCategory.DocumentError,
49+
$"Field '{name}' is not supported on a paged field within a filter. The paged field resolves to its underlying collection - use it directly (e.g. field.count()) or via items/edges/totalItems/totalCount."
50+
);
51+
}
52+
3353
// we have a schema we follow it for fields etc
3454
var schemaType = schema.GetSchemaType(context.Type, false, requestContext);
3555

@@ -44,6 +64,22 @@ internal static Expression MakePropertyCall(Expression context, ISchemaProvider?
4464
}
4565
var gqlField = schemaType.GetField(name, requestContext);
4666

67+
// A paged field's resolve is the Page/Connection wrapper object which only exists for the GraphQL
68+
// schema - it cannot execute inside a filter (or translate to SQL). Within a filter the field means
69+
// the underlying collection, so use the pre-paging resolve expression and remember it so a following
70+
// items/edges/totalItems/totalCount access can be mapped (above). See GitHub issue #378.
71+
var pagingExt = gqlField.Extensions.FirstOrDefault(e => e is OffsetPagingExtension or ConnectionPagingExtension);
72+
if (pagingExt != null && gqlField.FieldParam != null)
73+
{
74+
var original = pagingExt is OffsetPagingExtension offset ? offset.OriginalFieldExpression : ((ConnectionPagingExtension)pagingExt).OriginalFieldExpression;
75+
if (original != null)
76+
{
77+
var collection = new Util.ParameterReplacer().Replace(original, gqlField.FieldParam, context);
78+
compileContext.PagedFieldExpressions.Add(collection);
79+
return collection;
80+
}
81+
}
82+
4783
var isServiceField = gqlField.Services.Count > 0;
4884

4985
Expression? exp;

‎src/EntityGraphQL/EntityGraphQL.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<LangVersion>13.0</LangVersion>
55
<AssemblyName>EntityGraphQL</AssemblyName>
66
<PackageId>EntityGraphQL</PackageId>
7-
<PackageVersion>6.0.0-beta8</PackageVersion>
7+
<PackageVersion>6.0.0</PackageVersion>
88
<Description>A GraphQL library for .NET. Compiles queries into .NET Expressions (LinqProvider) for runtime execution against object graphs. E.g. against an ORM data model (EntityFramework or others) or just an in-memory object.</Description>
99
<Authors>Luke Murray</Authors>
1010
<PackageProjectUrl>https://github.com/lukemurray/EntityGraphQL</PackageProjectUrl>

‎src/tests/EntityGraphQL.EF.Tests/FilterExtensionTests.cs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,33 @@ public void SupportNullableDateTime()
3434
var person = Enumerable.First(people);
3535
Assert.Equal(33, person.id);
3636
}
37+
38+
[Fact]
39+
public void GitHubIssue378_FilterOnPagedChildFieldTranslatesToSql()
40+
{
41+
// filtering by a paged child field ("movies.items.count() > 0") - the filter resolves the paged
42+
// field to its underlying collection so it executes against EF (sqlite throws if untranslatable)
43+
var schema = SchemaBuilder.FromObject<TestDbContext>();
44+
schema.Type<Actor>().GetField("movies", null).UseOffsetPaging();
45+
schema.Query().ReplaceField("actors", db => db.Actors, "Get all actors").UseFilter();
46+
47+
using var factory = new TestDbContextFactory();
48+
var data = factory.CreateContext();
49+
var movie = new Movie("A movie");
50+
data.Add(new Actor("With movies") { Id = 1, Movies = [movie] });
51+
data.Add(new Actor("Without movies") { Id = 2 });
52+
data.SaveChanges();
53+
54+
var result = schema.ExecuteRequestWithContext(
55+
new QueryRequest { Query = @"{ actors(filter: ""movies.items.count() > 0"") { id name } }" },
56+
data,
57+
null,
58+
null
59+
);
60+
61+
Assert.Null(result.Errors);
62+
dynamic actors = ((IDictionary<string, object>)result.Data!)["actors"]!;
63+
Assert.Equal(1, Enumerable.Count(actors));
64+
Assert.Equal(1, Enumerable.First(actors).id);
65+
}
3766
}

‎src/tests/EntityGraphQL.Tests/QueryTests/FilteredFieldTests.cs‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ public void TestUseFilterOnFieldWithExistingArgumentsAddsFilterArgumentWithoutDe
215215
Assert.DoesNotContain("filter: String = ", sdl);
216216
}
217217

218-
[Fact(Skip = "GitHub Issue #378 - Fix not implemented yet")]
218+
[Fact]
219219
public void ReproducesGitHubIssue378_CountMethodNotFoundOnOffsetPage()
220220
{
221221
var schema = SchemaBuilder.FromObject<IncidentContext>();
@@ -269,7 +269,7 @@ public void ReproducesGitHubIssue378_CountMethodNotFoundOnOffsetPage()
269269
Assert.Empty(secondEnquiry.enquirerDaps.items);
270270
}
271271

272-
[Fact(Skip = "GitHub Issue #378 - Fix not implemented yet")]
272+
[Fact]
273273
public void ReproducesGitHubIssue378_CountMethodNotFoundOnConnectionPage()
274274
{
275275
var schema = SchemaBuilder.FromObject<IncidentContext>();
@@ -312,6 +312,58 @@ public void ReproducesGitHubIssue378_CountMethodNotFoundOnConnectionPage()
312312
Assert.Equal(3, secondEnquiry.node.id);
313313
}
314314

315+
[Fact]
316+
public void GitHubIssue378_PagedFieldUsedDirectlyInFilter()
317+
{
318+
// the paged field resolves to its underlying collection in a filter, so count()/any() work directly
319+
// (the user's first attempt in the issue) and totalItems maps to Count()
320+
var schema = SchemaBuilder.FromObject<IncidentContext>();
321+
schema.Type<Incident>().GetField("enquiries", null).UseFilter().UseOffsetPaging();
322+
schema.Type<Enquiry>().GetField("enquirerDaps", null).UseFilter().UseOffsetPaging();
323+
324+
var context = new IncidentContext().FillWithIncidentData();
325+
326+
var direct = schema.ExecuteRequestWithContext(
327+
new QueryRequest { Query = @"query { incident(id: 1) { enquiries(filter: ""enquirerDaps.count() > 0"") { items { id } } } }" },
328+
context,
329+
null,
330+
null
331+
);
332+
Assert.Null(direct.Errors);
333+
dynamic incident = ((IDictionary<string, object>)direct.Data!)["incident"];
334+
Assert.Equal(2, Enumerable.Count(incident.enquiries.items));
335+
336+
var totalItems = schema.ExecuteRequestWithContext(
337+
new QueryRequest { Query = @"query { incident(id: 1) { enquiries(filter: ""enquirerDaps.totalItems == 0"") { items { id } } } }" },
338+
context,
339+
null,
340+
null
341+
);
342+
Assert.Null(totalItems.Errors);
343+
dynamic incident2 = ((IDictionary<string, object>)totalItems.Data!)["incident"];
344+
Assert.Equal(1, Enumerable.Count(incident2.enquiries.items)); // only enquiry id=2 has none
345+
Assert.Equal(2, Enumerable.First(incident2.enquiries.items).id);
346+
}
347+
348+
[Fact]
349+
public void GitHubIssue378_UnsupportedPageFieldInFilterGivesClearError()
350+
{
351+
var schema = SchemaBuilder.FromObject<IncidentContext>();
352+
schema.Type<Incident>().GetField("enquiries", null).UseFilter().UseOffsetPaging();
353+
schema.Type<Enquiry>().GetField("enquirerDaps", null).UseFilter().UseOffsetPaging();
354+
355+
var context = new IncidentContext().FillWithIncidentData();
356+
var result = schema.ExecuteRequestWithContext(
357+
new QueryRequest { Query = @"query { incident(id: 1) { enquiries(filter: ""enquirerDaps.hasNextPage"") { items { id } } } }" },
358+
context,
359+
null,
360+
null
361+
);
362+
363+
Assert.NotNull(result.Errors);
364+
Assert.Contains("not supported on a paged field", result.Errors![0].Message);
365+
}
366+
315367
private class IncidentContext
316368
{
317369
public List<Incident> Incidents { get; set; } = [];

0 commit comments

Comments
 (0)