Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ All notable changes to this project will be documented in this file. From versio
- Build the minimal docker image for aarch64-linux by @wolfgangwalther in #4193
- The name of an embedded table can no longer be used in filters if it has an alias by @laurenceisla in #4075
+ e.g. `?select=alias:table(*)&table.id=eq.1` is not possible anymore, use `?select=alias:table(*)&alias.id=eq.1` instead.
- Add `countdistinct()` aggregate function, mapping to PostgreSQL's `COUNT(DISTINCT col)`. Gated by `db-aggregates-enabled` like the other aggregates.

## [14.12] - 2026-05-20

Expand Down
39 changes: 38 additions & 1 deletion docs/references/api/aggregate_functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Aggregate Functions
###################

PostgREST supports the following aggregate functions: ``avg()``, ``count()``, ``max()``, ``min()``, and ``sum()``.
PostgREST supports the following aggregate functions: ``avg()``, ``count()``, ``countdistinct()``, ``max()``, ``min()``, and ``sum()``.
Please refer to the `section on aggregate functions in the PostgreSQL documentation <https://www.postgresql.org/docs/current/functions-aggregate.html>`_ for a detailed explanation of these functions.

.. note::
Expand Down Expand Up @@ -98,6 +98,43 @@ Note that there is a difference between the result of ``count()`` and ``observat
The former counts the whole row, while the latter counts the non ``NULL`` values of the ``observation`` column (both grouped by ``order_date``).
This is due to how PostgreSQL itself implements the ``count()`` function.

The ``countdistinct()`` Aggregate
=================================

``countdistinct()`` returns the number of distinct non ``NULL`` values of a column.
It maps to PostgreSQL's ``COUNT(DISTINCT col)`` and must be attached to a specific column — there is no ``*`` form.

.. code-block:: bash

curl "http://localhost:3000/orders?select=customer_id.countdistinct()"

returns the number of distinct customers that placed an order:

.. code-block:: json

[
{
"count": 17
}
]

.. note::
The default JSON key is ``"count"`` (not ``"countdistinct"``), because PostgreSQL labels the result of ``COUNT(DISTINCT col)`` as ``count`` — same as the plain ``count()`` aggregate. Provide an explicit alias (e.g. ``dc:col.countdistinct()``) if you want a different key, or to disambiguate when combining ``count()`` and ``countdistinct()`` in the same query.

Aliases and casts work the same way as for the other aggregates:

.. code-block:: bash

curl "http://localhost:3000/orders?select=distinct_customers:customer_id.countdistinct()::text"

.. code-block:: json

[
{
"distinct_customers": "17"
}
]

Casting Aggregates
==================

Expand Down
3 changes: 3 additions & 0 deletions src/PostgREST/ApiRequest/QueryParams.hs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,9 @@ pFieldSelect = lexeme $ try (do
pAggregation = choice
[ string "sum" $> Sum
, string "avg" $> Avg
-- 'countdistinct' shares the 'count' prefix, so it must be tried first
-- and wrapped in 'try' to backtrack when the input is plain "count".
, try (string "countdistinct") $> CountDistinct
, string "count" $> Count
-- Using 'try' for "min" and "max" to allow backtracking.
-- This is necessary because both start with the same character 'm',
Expand Down
2 changes: 1 addition & 1 deletion src/PostgREST/ApiRequest/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ type Cast = Text
type Alias = Text
type Hint = Text

data AggregateFunction = Sum | Avg | Max | Min | Count
data AggregateFunction = Sum | Avg | Max | Min | Count | CountDistinct
deriving (Show, Eq)

data EmbedParam
Expand Down
4 changes: 4 additions & 0 deletions src/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ addAliases = Right . fmap addAliasToPlan
-- That's why we need to use the aggregate name as an alias (e.g. COUNT(...) AS "count").
-- Since PostgreSQL labels the columns with the aggregate name, it shouldn't be a problem to
-- apply the aliases to all the aggregates regardless if the previous conditions are met.
-- CountDistinct is special-cased to "count" because PostgreSQL labels the
-- result of COUNT(DISTINCT ...) as "count", same as plain COUNT(...).
fieldAliasForSpreadAgg True field@CoercibleSelectField{csAggFunction=Just CountDistinct} =
field { csAlias = Just "count" }
fieldAliasForSpreadAgg True field@CoercibleSelectField{csAggFunction=Just agg} =
field { csAlias = Just (T.toLower $ show agg) }
fieldAliasForSpreadAgg _ field = field
Expand Down
2 changes: 2 additions & 0 deletions src/PostgREST/Query/SqlFragment.hs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ss

pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet
pgFmtApplyAggregate Nothing _ snippet = snippet
pgFmtApplyAggregate (Just CountDistinct) aggCast snippet =
pgFmtApplyCast aggCast ("COUNT(DISTINCT " <> snippet <> ")")
pgFmtApplyAggregate (Just agg) aggCast snippet =
pgFmtApplyCast aggCast aggregatedSnippet
where
Expand Down
86 changes: 86 additions & 0 deletions test/spec/Feature/Query/AggregateFunctionsSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ allowed =
it "supports count()" $
get "/project_invoices?select=invoice_total.count()" `shouldRespondWith`
[json|[{ "count": 8 }]|] { matchHeaders = [matchContentTypeJson] }
it "supports countdistinct()" $
get "/project_invoices?select=project_id.countdistinct()" `shouldRespondWith`
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "supports an alias on countdistinct()" $
get "/project_invoices?select=distinct_projects:project_id.countdistinct()" `shouldRespondWith`
[json|[{ "distinct_projects": 4 }]|] { matchHeaders = [matchContentTypeJson] }
it "supports a cast on countdistinct()" $
get "/project_invoices?select=project_id.countdistinct()::text" `shouldRespondWith`
[json|[{ "count": "4" }]|] { matchHeaders = [matchContentTypeJson] }
it "groups by other selected fields when combined with countdistinct()" $
get "/project_invoices?select=invoice_total.countdistinct(),project_id&order=project_id.desc" `shouldRespondWith`
[json|[
{"count":2,"project_id":4},
{"count":2,"project_id":3},
{"count":2,"project_id":2},
{"count":2,"project_id":1}]|]
{ matchHeaders = [matchContentTypeJson] }
it "combines count() and countdistinct() on the same column with aliases" $
get "/project_invoices?select=tot:invoice_total.count(),dist:invoice_total.countdistinct()" `shouldRespondWith`
[json|[{"tot":8,"dist":7}]|] { matchHeaders = [matchContentTypeJson] }
it "combines countdistinct() with sum, max, min on the same query" $
get "/project_invoices?select=dp:project_id.countdistinct(),s:invoice_total.sum(),mx:invoice_total.max(),mn:invoice_total.min()" `shouldRespondWith`
[json|[{"dp":4,"s":8800,"mx":4000,"mn":100}]|] { matchHeaders = [matchContentTypeJson] }
it "supports multiple countdistinct() on different columns with aliases" $
get "/project_invoices?select=dp:project_id.countdistinct(),di:invoice_total.countdistinct()" `shouldRespondWith`
[json|[{"dp":4,"di":7}]|] { matchHeaders = [matchContentTypeJson] }
it "combines every aggregate flavour (count, countdistinct, sum, avg, max, min) in one query" $
get "/project_invoices?select=c:count(),dp:project_id.countdistinct(),di:invoice_total.countdistinct(),s:invoice_total.sum(),a:invoice_total.avg(),mx:invoice_total.max(),mn:invoice_total.min()" `shouldRespondWith`
[json|[{"c":8,"dp":4,"di":7,"s":8800,"a":1100.0000000000000000,"mx":4000,"mn":100}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports multiple countdistinct() alongside other aggregates with group by" $
get "/project_invoices?select=project_id,dt:invoice_total.countdistinct(),s:invoice_total.sum()&order=project_id.desc" `shouldRespondWith`
[json|[
{"project_id":4,"dt":2,"s":4100},
{"project_id":3,"dt":2,"s":3200},
{"project_id":2,"dt":2,"s":1200},
{"project_id":1,"dt":2,"s":300}]|]
{ matchHeaders = [matchContentTypeJson] }
it "groups by any fields selected that do not have an aggregate applied" $
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),project_id&order=project_id.desc" `shouldRespondWith`
[json|[
Expand Down Expand Up @@ -96,6 +134,39 @@ allowed =
{"project_id": 2, "total": 1200, "projects": {"name": "Windows 10"}},
{"project_id": 3, "total": 3200, "projects": {"name": "IOS"}},
{"project_id": 4, "total": 4100, "projects": {"name": "OSX"}}]|] { matchHeaders = [matchContentTypeJson] }
context "regression: identifiers named 'countdistinct'" $ do
it "addresses a table literally named 'countdistinct'" $
get "/countdistinct?select=id&order=id" `shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4}]|]
{ matchHeaders = [matchContentTypeJson] }
it "reads a column literally named 'countdistinct' as a plain field" $
get "/countdistinct?select=id,countdistinct&order=id" `shouldRespondWith`
[json|[
{"id":1,"countdistinct":"alpha"},
{"id":2,"countdistinct":"beta"},
{"id":3,"countdistinct":"alpha"},
{"id":4,"countdistinct":null}]|]
{ matchHeaders = [matchContentTypeJson] }
it "applies countdistinct() to a column literally named 'countdistinct'" $
get "/countdistinct?select=countdistinct.countdistinct()" `shouldRespondWith`
[json|[{"count":2}]|] { matchHeaders = [matchContentTypeJson] }
it "supports an alias on a column literally named 'countdistinct'" $
get "/countdistinct?select=val:countdistinct&id=eq.1" `shouldRespondWith`
[json|[{"val":"alpha"}]|] { matchHeaders = [matchContentTypeJson] }
it "filters by a column literally named 'countdistinct'" $
get "/countdistinct?select=id&countdistinct=eq.alpha&order=id" `shouldRespondWith`
[json|[{"id":1},{"id":3}]|] { matchHeaders = [matchContentTypeJson] }
it "applies other aggregates on a table named 'countdistinct'" $
get "/countdistinct?select=amount.sum(),amount.max(),amount.min(),amount.avg()" `shouldRespondWith`
[json|[{"sum":100,"max":40,"min":10,"avg":25.0000000000000000}]|]
{ matchHeaders = [matchContentTypeJson] }
it "groups by a column named 'countdistinct' while aggregating amount" $
get "/countdistinct?select=countdistinct,amount.sum()&order=countdistinct.asc.nullsfirst" `shouldRespondWith`
[json|[
{"countdistinct":null,"sum":40},
{"countdistinct":"alpha","sum":40},
{"countdistinct":"beta","sum":20}]|]
{ matchHeaders = [matchContentTypeJson] }
context "performing aggregations that involve JSON-embedded relationships" $ do
it "supports sum()" $
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
Expand Down Expand Up @@ -153,6 +224,10 @@ allowed =
get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith`
[json|[{"total_budget": 9501.06}]|]
{ matchHeaders = [matchContentTypeJson] }
it "auto-aliases countdistinct() to 'count' inside a to-one spread" $ do
get "/budget_expenses?select=...budget_categories(budget_amount.countdistinct())" `shouldRespondWith`
[json|[{"count": 4}]|]
{ matchHeaders = [matchContentTypeJson] }
it "supports aggregates from a spread relationships grouped by spreaded fields from other relationships" $ do
get "/processes?select=...process_costs(cost.sum()),...process_categories(name)&order=process_categories(name)" `shouldRespondWith`
[json|[
Expand Down Expand Up @@ -320,6 +395,17 @@ disallowed =
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }

it "prevents the use of countdistinct()" $
get "/project_invoices?select=project_id.countdistinct()" `shouldRespondWith`
[json|{
"hint":null,
"details":null,
"code":"PGRST123",
"message":"Use of aggregate functions is not allowed"
}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }

it "prevents the use of aggregates on embedded relationships" $
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
[json|{
Expand Down
6 changes: 6 additions & 0 deletions test/spec/fixtures/data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,12 @@ INSERT INTO project_invoices VALUES (6, 2000, 3);
INSERT INTO project_invoices VALUES (7, 100, 4);
INSERT INTO project_invoices VALUES (8, 4000, 4);

TRUNCATE TABLE countdistinct CASCADE;
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (1, 'alpha', 10);
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (2, 'beta', 20);
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (3, 'alpha', 30);
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (4, NULL, 40);

TRUNCATE TABLE budget_categories CASCADE;
INSERT INTO budget_categories VALUES (1, 'Beanie Babies', 'Brian Smith', 1000.31);
INSERT INTO budget_categories VALUES (2, 'DVDs', 'Jane Clarkson', 2000.12);
Expand Down
8 changes: 8 additions & 0 deletions test/spec/fixtures/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3585,6 +3585,14 @@ create table project_invoices (
, project_id integer references projects(id)
);

-- Regression: a table AND a column literally named "countdistinct" must not
-- collide with the countdistinct() aggregate keyword.
create table countdistinct (
id int primary key
, countdistinct text
, amount numeric
);

create table budget_categories (
id int primary key
, category_name text
Expand Down
Loading