Aggregation and grouping¶
SpecStar can group resources by a field and reduce each group with aggregates —
counts, sums, min/max, averages — pushed down to the storage engine as a real
GROUP BY, and (since v0.11.x) ordered and paginated at the group level so a
consumer can page through the distinct groups themselves.
Use it when you want "how many chunks per document", "newest member per cluster", or "the top 20 buckets by size, one page at a time" without streaming every matching row into Python.
Experimental surface
The entry point is ResourceManager.exp_aggregate_by (the exp_ prefix
marks it experimental — the signature may still adjust before it stabilises
as aggregate_by). exp_count_groups follows the same convention.
When to use it¶
- counting or summarising rows per group (a
GROUP BYin SQL terms) - annotating each parent with a reduction over its children in another resource ("this doc's chunk count")
- paging a grouped list by an aggregate value or the group key — e.g. a review queue grouped by concept, newest first, 20 concepts per page
For row-level filtering, sorting, and pagination of individual resources, use the
Query Builder with search_resources instead —
that pages rows, this pages groups.
Self-aggregates¶
Import the aggregates from the top-level package and group with a QB field:
from specstar import QB, Count, Sum, Min, Max, Avg
rows = manager.exp_aggregate_by(
QB["source_doc_id"], # group by an indexed data field
{"chunks": Count(), "bytes": Sum(QB["size"])},
query=(QB["status"] == "ready").build(), # optional row filter (WHERE)
)
for r in rows:
print(r.key, r.chunks, r.bytes) # one GroupRow per distinct source_doc_id
| Aggregate | Reduces | Empty / all-None group |
|---|---|---|
Count() |
rows in the group | 0 |
Sum(field) |
numeric field (None-skipping) |
None |
Min(field) / Max(field) |
comparable field (None-skipping) |
None |
Avg(field) |
numeric field → float (None-skipping) |
None |
Sum/Avg raise TypeError on a non-numeric value; Min/Max use Python <
ordering, so the field's values must be mutually comparable (numbers, datetimes,
or strings). A missing / unindexed group-by value collects under key=None.
You can group by a ResourceMeta attribute too — QB.created_by(),
QB.created_time(), etc. — and aggregate over one:
rows = manager.exp_aggregate_by(
QB.created_by(),
{"n": Count(), "latest": Max(QB.created_time())},
)
The result: GroupRow¶
Each row is a GroupRow with:
.key— the group-by value (Nonewhen the value was missing)- each named aggregate as both an attribute and an item, so
row.chunksandrow["chunks"]are equivalent — a dict comprehension like{r.key: r.chunks for r in rows}just works .resource— populated only in the cross-RM parent mode below (otherwiseNone, because a group can span many rows)
Foreign aggregates: parent rows with children stats¶
To annotate each parent with a reduction over a different resource's rows,
pass a ForeignAggregate(child_rm, link_field, aggregate) — link_field is the
field on the child that holds the parent's resource_id:
from specstar import QB, Count, ForeignAggregate
rows = doc_manager.exp_aggregate_by(
QB.resource_id(), # one group == one parent row
{"chunks": ForeignAggregate(chunk_manager, QB["source_doc_id"], Count())},
)
for r in rows:
print(r.resource.data.name, r.chunks) # .resource is the parent row
Grouping by QB.resource_id() is the parent-row mode: each group is exactly
one row of this manager, so GroupRow.resource carries that row's
SearchedResource — read r.resource.data.<field> alongside the children stat.
The foreign reduction runs as one extra query scoped to just the keys the page
surfaces, not one query per parent.
Ordering and paginating the groups¶
Pass order_by, limit, and offset to page through the distinct groups
(separate from the row-level query.limit/offset, which an aggregate ignores):
page = manager.exp_aggregate_by(
QB["bucket"],
{"n": Count(), "hi": Max(QB["size"])},
order_by="-hi", # order by the 'hi' aggregate, descending
offset=0, limit=20, # first 20 groups
)
order_by— an aggregate result-name (a key of theaggregatesdict) or the sentinel"key"(the group key). It uses the same"-name"/"+name"convention asQuery.sort(): ascending by default,-for descending. A target that is neither an aggregate name nor"key"raisesValueError; a negativeoffset/limitraises.- Stable pages — the group key is always the ascending secondary sort, so
two groups with equal
order_byvalues never straddle a page boundary or reappear on the next page. - NULLs last — a
Noneorder-value and aNonegroup key always sort last, regardless of direction.
Pager total¶
exp_count_groups returns the number of distinct groups the same by + query
would produce — the total a pager needs, independent of any limit/offset:
Worked example: a paged, grouped queue¶
Group members by a cluster key, order each concept by its newest member, and return one page of concepts plus the total — then load just that page's members:
from specstar import QB, Count, Max
q = (
(QB["state"] == "active")
& (QB["kind"] << ["proposal", "question"])
).build()
page = member_manager.exp_aggregate_by(
QB["cluster_key"],
{"n": Count(), "latest": Max(QB.created_time())},
query=q,
order_by="-latest", # newest concept first
offset=offset, limit=size,
)
total = member_manager.exp_count_groups(QB["cluster_key"], query=q)
keys = [r.key for r in page] # this page's concepts
members = member_manager.search_resources((QB["cluster_key"] << keys).build())
Each page returns a fixed number of groups no matter how many rows exist — the load stays constant as the dataset grows.
Push-down and performance¶
The value reduction (Count/Sum/Min/Max/Avg) is pushed down to any meta
store that implements IMetaWithAgg (SQLite and PostgreSQL today) as a real
engine GROUP BY; other backends fall back to an in-process reduction that
returns identical results.
The group-level ORDER BY … LIMIT … OFFSET is pushed down as well — so a page
scans only the groups it returns — when both hold:
- the store advertises group paging (SQLite and PostgreSQL do), and
- the
order_bytarget is engine-orderable: the group key, or a single-column self-aggregate (Count,Sum,Min,Max, includingMin/Maxover aResourceMetadatetime column).
Ordering by Avg (internally a Sum + a Count) or by a ForeignAggregate
falls back to ordering the fully-materialised groups in Python — still correct,
still identical across backends, just not O(page) at the engine. NULLs-last is
spelled with a (expr IS NULL) prefix rather than native NULLS LAST, so
SQLite, PostgreSQL, and the in-process reference agree byte-for-byte.
Not a counter cache
A pushed-down GROUP BY is scan-fast, not O(1). For a genuinely hot,
high-fan-out count, maintain a denormalised counter on write instead.
Gotchas¶
- Group-by must be a
QBfield. PassQB["field"](indexed data) or a meta accessor likeQB.created_by()— a plain string raisesTypeError. - Index the fields you group / aggregate on. An unindexed data field is
treated as
None(and warns, or raises under theerroron_unindexed_querypolicy) — every row then collapses into thekey=Nonegroup. Add it toindexed_fields. order_by/limitpage groups, not rows. The row-levelquery.limit/query.offsetare ignored for an aggregate — an aggregate spans the whole filtered set..resourceis only set in parent-row mode (by=QB.resource_id()); for any otherbyit isNone.
See the Query Builder reference for the full
field and operator surface, and the
Python API reference for the generated
exp_aggregate_by / aggregate signatures.