Aggregate
The UseAggregate() field extension exposes aggregate data — count plus min/max/sum/average over the fields of a collection — computed over the whole collection the field resolves to (not just a page). It builds the aggregates as part of the same query expression so they translate to a single SQL query under Entity Framework.
schema.ReplaceField("people",
ctx => ctx.People,
"Return a list of people")
.UseAggregate();
If you are using SchemaBuilder.FromObject you can use the UseAggregateAttribute on your collection properties.
public class DemoContext : DbContext
{
[UseAggregate]
public DbSet<Person> People { get; set; }
}
This extension can only be used on a field whose Resolve expression is a collection (IEnumerable/IQueryable).
The aggregate shape
The aggregate data is function-first (the same convention as Hasura). A generated {Element}Aggregate type exposes:
count— the number of itemsmin/max— over every comparable field (numbers, dates, strings)sum/average— over numeric fields only (short,int,long,float,double,decimaland their nullable forms;shortis widened tointfor these two)
For a Person with id: Int, height: Float and birthday: Date:
type PersonAggregate {
count: Int!
min: PersonMinAggregate
max: PersonMaxAggregate
sum: PersonSumAggregate
average: PersonAverageAggregate
}
type PersonMinAggregate { id: Int, height: Float, birthday: Date }
type PersonMaxAggregate { id: Int, height: Float, birthday: Date }
type PersonSumAggregate { id: Int, height: Float }
type PersonAverageAggregate { id: Float, height: Float }
average always returns a floating point type. A function that has no applicable fields (e.g. sum when the element has no numeric fields) is not generated.
You query it like:
{
peopleAggregate {
count
min { height }
max { height }
average { height }
}
}
Choosing the fields
By default every aggregatable scalar field is exposed. Pass a field selection to restrict it to specific fields, either an anonymous object of members or a single member:
schema.ReplaceField("people", ctx => ctx.People, "People")
.UseAggregate((Person p) => new { p.Height, p.Age });
schema.ReplaceField("people", ctx => ctx.People, "People")
.UseAggregate((Person p) => p.Height);
The generated {Element}Aggregate type is built once per element type and reused — the first UseAggregate configured for an element type determines its exposed fields; a later UseAggregate on another field of the same element type reuses that type and its selection is ignored. Apply a consistent selection when aggregating the same type from multiple fields.
Placement
Where the aggregate data is exposed is controlled by AggregatePlacement. The default is Auto.
Auto (default)
Picks the placement that works for the field's shape:
- the field is paged →
PagingWrapper - otherwise →
OwnWrapper
OwnWrapper is the default for unpaged fields because it handles every field uniformly (filtering, custom arguments, service-backed resolvers). SiblingField is never selected automatically — choose it explicitly when you want the additive behavior (see below).
SiblingField
Adds a new field named {field}Aggregate next to the original collection field. The original field is unchanged.
schema.ReplaceField("people", ctx => ctx.People, "People")
.UseAggregate(AggregatePlacement.SiblingField);
{
people { id name }
peopleAggregate { count max { height } }
}
Because it's additive (it doesn't change the original field's shape), SiblingField is the safe way to add aggregates to an existing field without a breaking change — clients keep querying people { ... } and gain peopleAggregate { ... }.
The sibling carries the source field's arguments, so it can be filtered/parameterised the same way (e.g. peopleAggregate(filter: "...", minId: 5)). Note these are supplied to the two fields independently, so it's up to the caller to keep them in sync; PagingWrapper/OwnWrapper avoid that by exposing one field. SiblingField cannot be used on a service-backed collection resolver (a separate field would invoke the service twice) — use OwnWrapper/PagingWrapper there.
schema.ReplaceField("people", ctx => ctx.People, "People")
.UseFilter()
.UseAggregate(AggregatePlacement.SiblingField);
{
people(filter: "id > 1") { id }
peopleAggregate(filter: "id > 1") { count }
}
PagingWrapper
Attaches an aggregate field onto the paging wrapper (OffsetPage/Connection) the field already returns. Chain UseAggregate() after the paging extension. The aggregate is computed over the full filtered collection, not just the current page — and because it is one field the filter/sort arguments cannot drift between the items and the aggregate.
schema.ReplaceField("people", ctx => ctx.People.OrderBy(p => p.Id), "People")
.UseFilter()
.UseOffsetPaging()
.UseAggregate(AggregatePlacement.PagingWrapper);
{
people(filter: "id > 1", take: 10) {
items { id name }
totalItems
aggregate { count max { height } }
}
}
OwnWrapper
Replaces an unpaged field's return type with a generated {Element}WithAggregate wrapper exposing items and aggregate under one field with shared arguments. Any UseFilter() on the field applies to both.
schema.ReplaceField("people", ctx => ctx.People, "People")
.UseFilter()
.UseAggregate(AggregatePlacement.OwnWrapper);
{
people(filter: "id > 1") {
items { id name }
aggregate { count min { height } }
}
}
Custom arguments and services
All placements carry the field's own resolver arguments. For example a field defined with a minId argument:
schema.Query()
.AddField("people", new { minId = 0 }, (ctx, args) => ctx.People.Where(p => p.Id >= args.minId), "People")
.UseAggregate(); // Auto -> OwnWrapper
{
people(minId: 5) {
items { id }
aggregate { count } # aggregate also only counts id >= 5
}
}
With OwnWrapper/PagingWrapper the argument is on the single field, so items and aggregate always share the same value. With SiblingField the two fields take the argument independently (people(minId: 5) and peopleAggregate(minId: 5)), so the caller keeps them in sync.
Service-backed fields
A collection field whose resolver uses a service (.Resolve<TService>((ctx, svc) => svc.Get())) is materialized once; the aggregate is then computed over that single result (the service is not re-invoked per aggregate value). This is supported with OwnWrapper and PagingWrapper, both of which keep the original field. Auto selects OwnWrapper for an unpaged service field:
schema.Query()
.AddField("people", "People from a service")
.Resolve<PeopleService>((ctx, svc) => svc.Get())
.UseAggregate(); // Auto -> OwnWrapper
With paging, the aggregate runs over the same set as the page — including an interleaved service in the collection resolver:
schema.Query()
.ReplaceField("people", "People")
.Resolve<PeopleService>((ctx, svc) => ctx.People.Where(p => svc.Keep(p.Id)).OrderBy(p => p.Id))
.UseOffsetPaging()
.UseAggregate(AggregatePlacement.PagingWrapper);
SiblingField is a separate field and would invoke the service a second time, so it cannot be used with a service-backed collection resolver and throws at schema-build time. (A paged field that merely has service-backed fields in its element selection works with every mode — only the collection resolver itself needs the wrapper modes.)
Aggregating a service-backed field
A field on the element type whose value is resolved by a service can also be aggregated:
schema.Type<Person>().AddField("score", "computed score").Resolve<ScoreService>((p, svc) => svc.Score(p.Id));
schema.Query().ReplaceField("people", ctx => ctx.People, "People").UseAggregate();
{
people { aggregate { sum { score } max { score } average { score } } }
}
The per-element values the service needs are projected and materialized in the first (DB) pass, then the reduction runs in memory with the service in the second pass — so this works under EF even though the service itself isn't translatable to SQL.
If the field uses a bulk resolver (ResolveBulk), aggregation keeps the bulk optimization: the keys are materialized in the first pass and all values are fetched in a single bulk call in the second pass, then reduced — rather than one service call per element. (Applies to sync, no-argument bulk resolvers; async or argument-taking bulk resolvers fall back to the per-element resolver.)
Ordering with other extensions
When combining with filter, sort and paging, the order matters: Filter -> Sort -> Paging -> Aggregate. UseAggregate() must come last so it can attach to the paging wrapper (for Auto/PagingWrapper) and so its aggregate respects the filter.