Aggregation
Aggregations ship in the paid habarico-enterprise gem — install it and
require "habarico_enterprise", see Installation. Without it
the endpoint and the aggregate_* / dashboard_enabled options below do not exist.
An opt-in GET /{resource}/aggregate endpoint returning count / sum / avg / min / max
over a whitelisted set of fields, optionally grouped into buckets.
It reuses the resource's own filters, so a chart and the list beneath it can be driven by one filter set. The feature is off by default and every field has to be whitelisted — that whitelist is what keeps a caller from aggregating over a column you never meant to expose.
Declaration
aggregate_enabled true
aggregate_max_buckets 100 # default: 50
aggregate_fields [
Habarico::AggregateFieldDefinition.new(
name: :price,
label: 'Price',
aggregations: %i[sum avg min max],
group_by: %i[exact width],
),
Habarico::AggregateFieldDefinition.new(
name: :created_at,
group_by: %i[exact trunc:second trunc:minute trunc:hour trunc:day
trunc:week trunc:month trunc:quarter trunc:year],
),
]
Options
| Attribute | Type | Default | Description |
|---|---|---|---|
aggregate_enabled | Boolean | false | Enables GET /{resource}/aggregate and GET /{resource}/aggregate/schema; both answer 404 while it is off |
aggregate_max_buckets | Integer | 50 | Most group_by buckets one request may return — going over raises a 422 |
aggregate_fields | Array<Habarico::AggregateFieldDefinition> | [] | Per field: the aggregation functions and the group_by bucket types it allows |
can_read_aggregations | Boolean / callable | unset | Who may reach both endpoints; unset falls back to can_read — see Permissions |
dashboard_enabled | Boolean | false | Marks the resource as a data source for a dashboard widget; on its own it does nothing without aggregate_enabled / views |
aggregate_enabled and dashboard_enabled are also emitted on the global resource schema
(GET /schema), so the frontend can tell which resources support aggregation without probing each
one.
aggregate_fields is checked against the model's real column types when the routes are drawn —
sum / avg / width need a numeric column, trunc:* a date or datetime one. A misconfigured
field raises Eluvia::Errors::StandardError and the app refuses to boot, instead of failing later
with a raw SQL error on the first request.
Permissions
Aggregating is a different level of access than reading rows: a resource can be readable while its
totals are not, and a summary can be worth sharing with someone who may not see the records behind
it. Both GET /{resource}/aggregate and GET /{resource}/aggregate/schema therefore resolve:
can_read_aggregations, when declared on the resource's controller — a boolean or a callable receiving theHabarico::Context, the same contract ascan_read;- the resource's
can_readotherwise.
The schema endpoint is gated with the data: it describes the whitelist, the labels, the enum
choices and the resource's filter schema, so it is no less sensitive than the numbers it leads to.
A request without the permission gets 403 on the data endpoint and 404 on the schema one, and
aggregate_enabled is reported as false for that user in the global resource schema — the
frontend then offers no aggregation widget at all rather than one that fails when opened.
class Apps::Admin::OrdersController < ApplicationController
include Habarico::Controller
model Order
aggregate_enabled true
# Everyone with read access sees orders; only finance may aggregate over them.
can_read_aggregations ->(context) { context.current_user.finance? }
end
The request
GET /{resource}/aggregate?function=sum&field=price&group_by=created_at:trunc:month
| Param | Required | Description |
|---|---|---|
function | yes | count, sum, avg, min or max |
field | for everything but count | The field to aggregate; it must whitelist that function |
group_by | no, repeatable | One or more grouping keys, see below |
A group_by item takes one of three forms:
<field>or<field>:exact— grouping by the exact value. The bare form is what the frontend sends, so both are accepted.<field>:trunc:<granularity>— date/datetime truncation;<granularity>issecond,minute,hour,day,week,month,quarteroryear.<field>:width:<n>— numeric fixed-width bucketing, e.g.price:width:1000bands values into 1000-wide buckets.
The field has to whitelist the bucket type it is asked for.
Pass several groupings as repeated bare parameters — group_by=a&group_by=b. They are read from
the raw query string, not from Rails' params, because Rails' parser keeps only the last value of
a repeated key and would silently drop every grouping but one.
The resource's filters, default_filter, policy_scope and scopes apply exactly as they do on
index.
The response
{
"results": [
{ "group": { "created_at": "2026-01-01T00:00:00Z" }, "value": 1234.5 },
{ "group": { "created_at": "2026-02-01T00:00:00Z" }, "value": 987.0 }
]
}
Without group_by there is a single entry and no group key:
{ "results": [{ "value": 1234.5 }] }.
GET /{resource}/aggregate/schema returns the whitelist itself —
{ "fields": [...], "filters": [...] } — reusing the resource's own filters for the filters
section.
Both bucketing forms are PostgreSQL-only: trunc:* compiles to a raw DATE_TRUNC expression
and width to CAST(… AS integer), which rounds to the nearest bucket rather than flooring.
See also
- Views — where an aggregated resource is presented
- useAggregate — the frontend hook calling this endpoint
- Dashboard data sources — which widgets need it