Aggregation
An aggregation computes min, max, sum, avg or count over the entities a Query matches, optionally grouped by another field. It is a projection, not a filter: an A node does not compose with & | ~ and is passed to aggregate(), not to where().
Field references
A field is addressed with the same M / P / G / C markers the filter families use, called with a bare field name instead of field=value:
from ftmq import M, P, G, C, Year
P("amountEur") # a followthemoney property
G("countries") # a property-type group
M("dataset") # a meta field: id, entity_id, canonical_id, dataset, schema
C("origin") # a context / storage column
Year() # the year of any date-typed value (derived from `dates`)
Bare strings are rejected (P("topics") is the property, G("topics") the group).
The A node
Each keyword is an aggregation function, its value the reference (or references) to aggregate; by= groups by one or more references.
from ftmq import Query, M, P, G, A, Year
A(sum=P("amountEur")) # sum of amountEur
A(sum=P("amountEur"), by=P("beneficiary")) # ... grouped by beneficiary
A(count=M("id")) # number of (distinct) entities
A(sum=[P("amountEur"), P("amount")]) # several fields
A(min=P("date"), max=P("date")) # several functions in one node
A(count=M("id"), by=[G("countries"), Year()])
count counts distinct values.
Numbers
min / max / sum / avg over a numeric property return numbers. In memory values are parsed with followthemoney's number parser; the SQL backends cast the stored value, and a value that isn't in the canonical number format (e.g. "324,687.00") reads as NULL and drops out. Store writers normalize values on write; migrate an existing store with ftmq statements cast-types.
Adding aggregations to a query
Query.aggregate() takes several A nodes, and chained calls accumulate:
q = (
Query()
.where(M(schema="Payment"))
.aggregate(
A(sum=P("amountEur"), by=P("beneficiary")),
A(avg=P("amountEur")),
)
)
q = q.aggregate(A(count=M("id"))) # chaining accumulates
Running an aggregation
Every backend returns the same result. On a store view:
from ftmq.store import get_store
view = get_store("sqlite:///followthemoney.store").default_view()
result = view.aggregations(q)
In memory, aggregations are collected while iterating; read them off the query afterwards:
The result maps function -> field -> value, with a groups sub-mapping for grouped aggregations; fields are keyed by their wire spelling:
{
"sum": {"properties.amountEur": 40589689.15},
"groups": {
"properties.beneficiary": {
"sum": {"properties.amountEur": {"<entity-id>": 3368136.15, ...}}
}
},
}
Serialization
In Query.to_dict / from_dict aggregations are a flat spec list: [{"func": "sum", "field": "properties.amountEur", "by": ["year"]}, ...] (by omitted when ungrouped).
Fields use the filter wire spelling: properties.<name>, group.<name>, context.<name>; meta fields and year are bare. filter:group.countries=de and facet=group.countries address the same dimension.
RQL carries them losslessly via sum, min, max, mean, count and the aggregate(groups..., funcs...) grouping operator, next to the filter:
q = Query().where(M(schema="Payment")).aggregate(A(count=M("id"), by=P("beneficiary")))
q.to_rql() # "and(eq(schema,Payment),aggregate(properties.beneficiary,count(id)))"
In URL params (to_params / to_string, back via from_params / from_string) each spec becomes metric:<function>=<field> and each grouped field facet=<field>:
q = Query().aggregate(A(sum=P("amountEur"), by=P("beneficiary")))
q.to_string() # "facet=properties.beneficiary&metric:sum=properties.amountEur"
Facets apply to all metrics: metrics with different groups collapse to their union on the way out, and every metric is grouped by every facet on the way in. A facet without a metric: groups an entity count: ?facet=group.countries parses as A(count=M("id"), by=G("countries")).
Ranking facet buckets
Buckets are ranked by entity count. order_facets ranks them by one of the query's grouped metrics instead (descending unless ascending=True); wire spelling facet_sort=<func>:<field>[:asc] in URL params and to_dict.
Each facet keeps its top 20 buckets, ties broken by value, on every backend. facet_size sets another size per facet: facet_size:<field>=N in URL params, {"facet_size": {field: N}} in to_dict. Neither has an RQL operator.
q = Query().aggregate(A(sum=P("amountEur"), by=P("beneficiary")))
q = q.order_facets(sum=P("amountEur")).facet_size(P("beneficiary"), 5)
q.to_string() # "facet=properties.beneficiary&facet_size:properties.beneficiary=5&facet_sort=sum%3Aproperties.amountEur&metric:sum=properties.amountEur"
The metric must be a grouped aggregation, and the facet size a facet, of the query; otherwise a QueryError is raised.
Reference
See the aggregations reference and the field references.