Skip to main content
Premium

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​

AttributeTypeDefaultDescription
aggregate_enabledBooleanfalseEnables GET /{resource}/aggregate and GET /{resource}/aggregate/schema; both answer 404 while it is off
aggregate_max_bucketsInteger50Most group_by buckets one request may return — going over raises a 422
aggregate_fieldsArray<Habarico::AggregateFieldDefinition>[]Per field: the aggregation functions and the group_by bucket types it allows
can_read_aggregationsBoolean / callableunsetWho may reach both endpoints; unset falls back to can_read — see Permissions
dashboard_enabledBooleanfalseMarks 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.

caution

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 the Habarico::Context, the same contract as can_read;
  • the resource's can_read otherwise.

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
ParamRequiredDescription
functionyescount, sum, avg, min or max
fieldfor everything but countThe field to aggregate; it must whitelist that function
group_byno, repeatableOne 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> is second, minute, hour, day, week, month, quarter or year.
  • <field>:width:<n> — numeric fixed-width bucketing, e.g. price:width:1000 bands values into 1000-wide buckets.

The field has to whitelist the bucket type it is asked for.

caution

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.

caution

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​