Aggregation Operators Reference¶
Status: Stable
Overview¶
FraiseQL v1 performs runtime auto-aggregation against PostgreSQL. When a GraphQL
query selects aggregate fields on a view-backed type, FraiseQL derives the matching
GROUP BY and aggregate SQL automatically and runs it against your v_/tv_ view.
There is no build step, no compiler, and no schema artifact — everything happens at
app startup and request time. FraiseQL v1 targets PostgreSQL only.
Auto-aggregation is implemented by _derive_auto_aggregation (with
_parse_aggregation_expr) in src/fraiseql/db.py. The aggregate expressions you
declare per type are parsed and validated against an allowlist, then composed into a
parameterized SELECT ... GROUP BY statement against the registered view.
This document covers two related things:
- The aggregate functions FraiseQL knows how to derive automatically at runtime.
- The standard PostgreSQL aggregate, grouping, and bucketing constructs you write directly in your view SQL when you need behavior beyond auto-aggregation.
Supported aggregate functions¶
The following PostgreSQL aggregates are available for analytics over view-backed types:
| Function | SQL | Returns | Notes |
|---|---|---|---|
COUNT |
COUNT(...) |
integer | Row counts; COUNT(DISTINCT col) for distinct counts. |
SUM |
SUM(...) |
numeric | Totals over a measure column. |
AVG |
AVG(...) |
double precision | Mean of a measure column. |
MIN |
MIN(...) |
same as input | Smallest value in the group. |
MAX |
MAX(...) |
same as input | Largest value in the group. |
STDDEV |
STDDEV(...) |
double precision | Sample standard deviation. |
VARIANCE |
VARIANCE(...) |
double precision | Sample variance. |
COUNT, SUM, AVG, MIN, and MAX are wired into runtime auto-aggregation and can
be derived from the field selection (see below). STDDEV and VARIANCE are standard
PostgreSQL aggregates: compute them in your view SQL (or a dedicated analytics view) and
expose the result as an ordinary field.
SUM and AVG operate on numeric inputs. When a measure is read from a JSONB data
column, FraiseQL casts the extracted text to numeric before applying the aggregate;
when the measure is a native numeric column (see native_measures), it aggregates the
column directly and avoids the cast.
How runtime auto-aggregation works¶
Auto-aggregation kicks in when a query selects only dimensions and measures on a
view-backed type — that is, when no identity field (such as id) is requested. FraiseQL
then:
- Reads the field selection from the GraphQL query.
- Looks up the type's aggregation metadata (registered via
register_type_for_view). - Splits the selected fields into
GROUP BYdimensions and aggregate measures. - Builds a parameterized
SELECT <dimensions>, <aggregates> FROM <view> GROUP BY <dimensions>and executes it.
Aggregate expressions are declared as strings like SUM(cost) or AVG(volume) and are
validated against the allowlist before any SQL is composed, so the function name can
never be injected.
Registering aggregation metadata¶
Attach aggregation metadata to a view-backed type with register_type_for_view:
from fraiseql.db import register_type_for_view
register_type_for_view(
view_name="v_sales_summary",
type_class=SalesSummary,
aggregation={
"measures": {
"measures.revenue": "SUM",
"measures.quantity": "SUM",
},
"dimensions": "dimensions",
"native_dimensions": ["period_date", "category_id"],
"native_measures": {"measures.quantity": "quantity"},
},
column_mapping={
"dimensions.dateInfo.date": "period_date",
"dimensions.productCategory.id": "category_id",
},
)
Mapping key spelling¶
Mapping keys are matched against database field paths, and each segment is
normalised to snake_case at registration — so the two spellings below declare the
same thing and both work:
column_mapping={"dimensions.dateInfo.date": "period_date"} # GraphQL spelling
column_mapping={"dimensions.date_info.date": "period_date"} # database spelling
This matters because the example above used to read
{"dimensions.category.id": "category_id"} — every segment a single word, so the
question never came up. A key like dimensions.dateInfo.date written against an engine
that matches the raw GraphQL name would previously have matched nothing here, silently:
the value is a real column and the key is a valid path, so no validation caught it and
the mapping simply never fired. That is issue #467.
A key that names a column which does not exist on the view now raises at registration
under validate_fk_strict=True, and warns otherwise.
Metadata keys:
measures— maps a JSONB measure path to the aggregate function to apply ("SUM","AVG", etc.).dimensions— the JSONB key (default"data"substructure) holding grouping attributes.native_dimensions— SQL columns that should be grouped viat."col"instead of JSONB extraction. Native columns let PostgreSQL use btree indexes and keepORDER BYcorrect for dimension columns.native_measures— maps JSONB measure paths to flat SQL column names soSUM/AVGrun on native numeric columns and skip the::numericcast.native_dimension_mapping— superseded by the top-levelcolumn_mapping=below. Still read, still merged, still works;column_mapping=wins where both declare the same path. Prefer the top-level parameter in new code.
The top-level column_mapping= parameter on register_type_for_view is a peer of
fk_relationships: it maps a deep JSONB path to a flat SQL column, and it is applied
unconditionally to GROUP BY, WHERE and ORDER BY alike. Before 1.24.0 the
mapping reached GROUP BY only, so a query could group on the flat column while
filtering and sorting on the JSONB snapshot — three expressions for one logical field.
Use native_dimensions whenever your view exposes a real SQL column for a grouping key:
it is the difference between a sequential scan over extracted JSONB text and an
index-backed GROUP BY.
Migrating an existing native_dimension_mapping (1.24.0)¶
Nothing to change: the key is still read and still merged. What changes is that the
mapping you already declared now also applies to WHERE and ORDER BY, so two things
become visible:
- Filtering results can change. Where the flat column and the frozen JSONB snapshot disagree — a column updated after the snapshot was written, say — the filter now follows the column. That is the correction, and it is the reason 1.24.0 is a minor release rather than a patch.
- NULLs move in a sort. A jsonb
nullis the lowest jsonb value and sorts first; a SQLNULLsorts last inASC. Any row with a missing or null mapped dimension moves from one end of the result to the other. The non-null values keep their order for the shapes this library produces — ISO dates sort lexicographically in chronological order by construction, and the generator uses->rather than->>so a JSON number keeps its type. Values do reorder where the snapshot is untyped: a number written as a string, or an ISO timestamp whose rows do not share one UTC offset.
If a mapping key you declared never took effect because of the casing rule above, it will start working — check the key names before upgrading if you are unsure.
Aggregation in view SQL¶
For anything beyond derived COUNT/SUM/AVG/MIN/MAX — including STDDEV,
VARIANCE, conditional aggregates, time bucketing, and the array/JSON/string aggregates
below — write standard PostgreSQL in your v_/tv_ view. FraiseQL reads the resulting
rows like any other view.
GROUP BY¶
SELECT
data->>'category' AS category,
SUM((data->>'revenue')::numeric) AS revenue_sum,
AVG((data->>'revenue')::numeric) AS revenue_avg,
COUNT(*) AS order_count
FROM tv_sales
GROUP BY data->>'category';
HAVING¶
Filter groups after aggregation with HAVING:
SELECT
data->>'category' AS category,
SUM((data->>'revenue')::numeric) AS revenue_sum
FROM tv_sales
GROUP BY data->>'category'
HAVING SUM((data->>'revenue')::numeric) > 10000;
Conditional aggregates with FILTER¶
FILTER (WHERE ...) computes an aggregate over a subset of rows in the same pass — the
idiomatic PostgreSQL way to do conditional sums and counts:
SELECT
data->>'region' AS region,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE (data->>'status') = 'shipped') AS shipped_orders,
SUM((data->>'revenue')::numeric)
FILTER (WHERE (data->>'on_sale')::boolean) AS sale_revenue
FROM tv_sales
GROUP BY data->>'region';
Time bucketing with DATE_TRUNC¶
Bucket a timestamp into fixed periods with DATE_TRUNC. Supported buckets include
second, minute, hour, day, week, month, quarter, and year:
SELECT
DATE_TRUNC('month', (data->>'occurred_at')::timestamptz) AS bucket_month,
SUM((data->>'revenue')::numeric) AS revenue_sum
FROM tv_sales
GROUP BY DATE_TRUNC('month', (data->>'occurred_at')::timestamptz)
ORDER BY bucket_month;
Expose the bucket as a regular column and group on it. For dimension columns that you
group on, prefer a native SQL column (and list it in native_dimensions) so the
GROUP BY and ORDER BY can use an index.
Array, JSON, and string aggregates¶
PostgreSQL provides several aggregates that collapse a group into a single composite value. Use them directly in your view SQL; expose the result as a field on the view-backed type.
ARRAY_AGG¶
Collect group values into an array:
SELECT
data->>'category' AS category,
ARRAY_AGG(data->>'product_name') AS product_names
FROM tv_sales
GROUP BY data->>'category';
JSON_AGG / JSONB_AGG¶
Collect rows into a JSON array — useful for building nested read models inside a tv_
projection view:
SELECT
data->>'customer_id' AS customer_id,
JSONB_AGG(jsonb_build_object(
'product', data->>'product_name',
'revenue', (data->>'revenue')::numeric
)) AS orders
FROM tv_sales
GROUP BY data->>'customer_id';
STRING_AGG¶
Concatenate group values with a delimiter, optionally ordered:
SELECT
data->>'customer_id' AS customer_id,
STRING_AGG(data->>'product_name', ', ' ORDER BY (data->>'revenue')::numeric DESC)
AS products
FROM tv_sales
GROUP BY data->>'customer_id';
BOOL_AND / BOOL_OR¶
Boolean aggregates — "all true" and "any true" across a group:
SELECT
data->>'category' AS category,
BOOL_AND((data->>'in_stock')::boolean) AS all_in_stock,
BOOL_OR((data->>'on_sale')::boolean) AS any_on_sale
FROM tv_sales
GROUP BY data->>'category';
Choosing measure and dimension columns¶
When modeling a view for aggregation:
- Measures are numeric values you aggregate (
revenue,quantity). Store them as native numeric columns where possible and reference them throughnative_measuresto avoid per-row JSONB casts. - Dimensions are the attributes you group by (
category,region, aDATE_TRUNCbucket). Surface them as native SQL columns and list them innative_dimensionsso PostgreSQL can use btree indexes forGROUP BYandORDER BY. - Never expose internal keys (
pk_*,fk_*) as dimensions; group on the publicid(UUID),identifier, or a derived dimension column instead.
For the full naming conventions, see the schema conventions reference linked below.
Related references¶
- Aggregation Model — how
auto-aggregation derives
GROUP BYand aggregate SQL at runtime. - Fact / Dimension Pattern — data modeling for analytics views in PostgreSQL.
- Window Functions —
ROW_NUMBER,RANK,LAG,LEAD, andOVER (PARTITION BY ...)patterns for view SQL. - Schema Conventions —
tb_/v_/tv_/fn_prefixes, the trinity identifier pattern, and thedataJSONB convention.