Skip to content

Commit 6d12f40

Browse files
adde count(distinct) as countdistinct aggregate
added count distinct as countdistinct agg. tested to work, that is is gated if aggs are off. tested against collisions with tables and columns named countdistincts. Returns based on postgres rules name count().
1 parent 1a6ba20 commit 6d12f40

9 files changed

Lines changed: 145 additions & 2 deletions

File tree

CHANGELOG.md

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

3637
## [14.12] - 2026-05-20
3738

docs/references/api/aggregate_functions.rst

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Aggregate Functions
44
###################
55

6-
PostgREST supports the following aggregate functions: ``avg()``, ``count()``, ``max()``, ``min()``, and ``sum()``.
6+
PostgREST supports the following aggregate functions: ``avg()``, ``count()``, ``countdistinct()``, ``max()``, ``min()``, and ``sum()``.
77
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.
88

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

101+
The ``countdistinct()`` Aggregate
102+
=================================
103+
104+
``countdistinct()`` returns the number of distinct non ``NULL`` values of a column.
105+
It maps to PostgreSQL's ``COUNT(DISTINCT col)`` and must be attached to a specific column — there is no ``*`` form.
106+
107+
.. code-block:: bash
108+
109+
curl "http://localhost:3000/orders?select=customer_id.countdistinct()"
110+
111+
returns the number of distinct customers that placed an order:
112+
113+
.. code-block:: json
114+
115+
[
116+
{
117+
"count": 17
118+
}
119+
]
120+
121+
.. note::
122+
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.
123+
124+
Aliases and casts work the same way as for the other aggregates:
125+
126+
.. code-block:: bash
127+
128+
curl "http://localhost:3000/orders?select=distinct_customers:customer_id.countdistinct()::text"
129+
130+
.. code-block:: json
131+
132+
[
133+
{
134+
"distinct_customers": "17"
135+
}
136+
]
137+
101138
Casting Aggregates
102139
==================
103140

src/PostgREST/ApiRequest/QueryParams.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,9 @@ pFieldSelect = lexeme $ try (do
544544
pAggregation = choice
545545
[ string "sum" $> Sum
546546
, string "avg" $> Avg
547+
-- 'countdistinct' shares the 'count' prefix, so it must be tried first
548+
-- and wrapped in 'try' to backtrack when the input is plain "count".
549+
, try (string "countdistinct") $> CountDistinct
547550
, string "count" $> Count
548551
-- Using 'try' for "min" and "max" to allow backtracking.
549552
-- This is necessary because both start with the same character 'm',

src/PostgREST/ApiRequest/Types.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ type Cast = Text
149149
type Alias = Text
150150
type Hint = Text
151151

152-
data AggregateFunction = Sum | Avg | Max | Min | Count
152+
data AggregateFunction = Sum | Avg | Max | Min | Count | CountDistinct
153153
deriving (Show, Eq)
154154

155155
data EmbedParam

src/PostgREST/Plan.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ addAliases = Right . fmap addAliasToPlan
425425
-- That's why we need to use the aggregate name as an alias (e.g. COUNT(...) AS "count").
426426
-- Since PostgreSQL labels the columns with the aggregate name, it shouldn't be a problem to
427427
-- apply the aliases to all the aggregates regardless if the previous conditions are met.
428+
-- CountDistinct is special-cased to "count" because PostgreSQL labels the
429+
-- result of COUNT(DISTINCT ...) as "count", same as plain COUNT(...).
430+
fieldAliasForSpreadAgg True field@CoercibleSelectField{csAggFunction=Just CountDistinct} =
431+
field { csAlias = Just "count" }
428432
fieldAliasForSpreadAgg True field@CoercibleSelectField{csAggFunction=Just agg} =
429433
field { csAlias = Just (T.toLower $ show agg) }
430434
fieldAliasForSpreadAgg _ field = field

src/PostgREST/Query/SqlFragment.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ss
283283

284284
pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet
285285
pgFmtApplyAggregate Nothing _ snippet = snippet
286+
pgFmtApplyAggregate (Just CountDistinct) aggCast snippet =
287+
pgFmtApplyCast aggCast ("COUNT(DISTINCT " <> snippet <> ")")
286288
pgFmtApplyAggregate (Just agg) aggCast snippet =
287289
pgFmtApplyCast aggCast aggregatedSnippet
288290
where

test/spec/Feature/Query/AggregateFunctionsSpec.hs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@ allowed =
5050
it "supports count()" $
5151
get "/project_invoices?select=invoice_total.count()" `shouldRespondWith`
5252
[json|[{ "count": 8 }]|] { matchHeaders = [matchContentTypeJson] }
53+
it "supports countdistinct()" $
54+
get "/project_invoices?select=project_id.countdistinct()" `shouldRespondWith`
55+
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
56+
it "supports an alias on countdistinct()" $
57+
get "/project_invoices?select=distinct_projects:project_id.countdistinct()" `shouldRespondWith`
58+
[json|[{ "distinct_projects": 4 }]|] { matchHeaders = [matchContentTypeJson] }
59+
it "supports a cast on countdistinct()" $
60+
get "/project_invoices?select=project_id.countdistinct()::text" `shouldRespondWith`
61+
[json|[{ "count": "4" }]|] { matchHeaders = [matchContentTypeJson] }
62+
it "groups by other selected fields when combined with countdistinct()" $
63+
get "/project_invoices?select=invoice_total.countdistinct(),project_id&order=project_id.desc" `shouldRespondWith`
64+
[json|[
65+
{"count":2,"project_id":4},
66+
{"count":2,"project_id":3},
67+
{"count":2,"project_id":2},
68+
{"count":2,"project_id":1}]|]
69+
{ matchHeaders = [matchContentTypeJson] }
70+
it "combines count() and countdistinct() on the same column with aliases" $
71+
get "/project_invoices?select=tot:invoice_total.count(),dist:invoice_total.countdistinct()" `shouldRespondWith`
72+
[json|[{"tot":8,"dist":7}]|] { matchHeaders = [matchContentTypeJson] }
73+
it "combines countdistinct() with sum, max, min on the same query" $
74+
get "/project_invoices?select=dp:project_id.countdistinct(),s:invoice_total.sum(),mx:invoice_total.max(),mn:invoice_total.min()" `shouldRespondWith`
75+
[json|[{"dp":4,"s":8800,"mx":4000,"mn":100}]|] { matchHeaders = [matchContentTypeJson] }
76+
it "supports multiple countdistinct() on different columns with aliases" $
77+
get "/project_invoices?select=dp:project_id.countdistinct(),di:invoice_total.countdistinct()" `shouldRespondWith`
78+
[json|[{"dp":4,"di":7}]|] { matchHeaders = [matchContentTypeJson] }
79+
it "combines every aggregate flavour (count, countdistinct, sum, avg, max, min) in one query" $
80+
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`
81+
[json|[{"c":8,"dp":4,"di":7,"s":8800,"a":1100.0000000000000000,"mx":4000,"mn":100}]|]
82+
{ matchHeaders = [matchContentTypeJson] }
83+
it "supports multiple countdistinct() alongside other aggregates with group by" $
84+
get "/project_invoices?select=project_id,dt:invoice_total.countdistinct(),s:invoice_total.sum()&order=project_id.desc" `shouldRespondWith`
85+
[json|[
86+
{"project_id":4,"dt":2,"s":4100},
87+
{"project_id":3,"dt":2,"s":3200},
88+
{"project_id":2,"dt":2,"s":1200},
89+
{"project_id":1,"dt":2,"s":300}]|]
90+
{ matchHeaders = [matchContentTypeJson] }
5391
it "groups by any fields selected that do not have an aggregate applied" $
5492
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),project_id&order=project_id.desc" `shouldRespondWith`
5593
[json|[
@@ -96,6 +134,39 @@ allowed =
96134
{"project_id": 2, "total": 1200, "projects": {"name": "Windows 10"}},
97135
{"project_id": 3, "total": 3200, "projects": {"name": "IOS"}},
98136
{"project_id": 4, "total": 4100, "projects": {"name": "OSX"}}]|] { matchHeaders = [matchContentTypeJson] }
137+
context "regression: identifiers named 'countdistinct'" $ do
138+
it "addresses a table literally named 'countdistinct'" $
139+
get "/countdistinct?select=id&order=id" `shouldRespondWith`
140+
[json|[{"id":1},{"id":2},{"id":3},{"id":4}]|]
141+
{ matchHeaders = [matchContentTypeJson] }
142+
it "reads a column literally named 'countdistinct' as a plain field" $
143+
get "/countdistinct?select=id,countdistinct&order=id" `shouldRespondWith`
144+
[json|[
145+
{"id":1,"countdistinct":"alpha"},
146+
{"id":2,"countdistinct":"beta"},
147+
{"id":3,"countdistinct":"alpha"},
148+
{"id":4,"countdistinct":null}]|]
149+
{ matchHeaders = [matchContentTypeJson] }
150+
it "applies countdistinct() to a column literally named 'countdistinct'" $
151+
get "/countdistinct?select=countdistinct.countdistinct()" `shouldRespondWith`
152+
[json|[{"count":2}]|] { matchHeaders = [matchContentTypeJson] }
153+
it "supports an alias on a column literally named 'countdistinct'" $
154+
get "/countdistinct?select=val:countdistinct&id=eq.1" `shouldRespondWith`
155+
[json|[{"val":"alpha"}]|] { matchHeaders = [matchContentTypeJson] }
156+
it "filters by a column literally named 'countdistinct'" $
157+
get "/countdistinct?select=id&countdistinct=eq.alpha&order=id" `shouldRespondWith`
158+
[json|[{"id":1},{"id":3}]|] { matchHeaders = [matchContentTypeJson] }
159+
it "applies other aggregates on a table named 'countdistinct'" $
160+
get "/countdistinct?select=amount.sum(),amount.max(),amount.min(),amount.avg()" `shouldRespondWith`
161+
[json|[{"sum":100,"max":40,"min":10,"avg":25.0000000000000000}]|]
162+
{ matchHeaders = [matchContentTypeJson] }
163+
it "groups by a column named 'countdistinct' while aggregating amount" $
164+
get "/countdistinct?select=countdistinct,amount.sum()&order=countdistinct.asc.nullsfirst" `shouldRespondWith`
165+
[json|[
166+
{"countdistinct":null,"sum":40},
167+
{"countdistinct":"alpha","sum":40},
168+
{"countdistinct":"beta","sum":20}]|]
169+
{ matchHeaders = [matchContentTypeJson] }
99170
context "performing aggregations that involve JSON-embedded relationships" $ do
100171
it "supports sum()" $
101172
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
@@ -320,6 +391,17 @@ disallowed =
320391
{ matchStatus = 400
321392
, matchHeaders = [matchContentTypeJson] }
322393

394+
it "prevents the use of countdistinct()" $
395+
get "/project_invoices?select=project_id.countdistinct()" `shouldRespondWith`
396+
[json|{
397+
"hint":null,
398+
"details":null,
399+
"code":"PGRST123",
400+
"message":"Use of aggregate functions is not allowed"
401+
}|]
402+
{ matchStatus = 400
403+
, matchHeaders = [matchContentTypeJson] }
404+
323405
it "prevents the use of aggregates on embedded relationships" $
324406
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
325407
[json|{

test/spec/fixtures/data.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,12 @@ INSERT INTO project_invoices VALUES (6, 2000, 3);
852852
INSERT INTO project_invoices VALUES (7, 100, 4);
853853
INSERT INTO project_invoices VALUES (8, 4000, 4);
854854

855+
TRUNCATE TABLE countdistinct CASCADE;
856+
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (1, 'alpha', 10);
857+
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (2, 'beta', 20);
858+
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (3, 'alpha', 30);
859+
INSERT INTO countdistinct (id, countdistinct, amount) VALUES (4, NULL, 40);
860+
855861
TRUNCATE TABLE budget_categories CASCADE;
856862
INSERT INTO budget_categories VALUES (1, 'Beanie Babies', 'Brian Smith', 1000.31);
857863
INSERT INTO budget_categories VALUES (2, 'DVDs', 'Jane Clarkson', 2000.12);

test/spec/fixtures/schema.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3585,6 +3585,14 @@ create table project_invoices (
35853585
, project_id integer references projects(id)
35863586
);
35873587

3588+
-- Regression: a table AND a column literally named "countdistinct" must not
3589+
-- collide with the countdistinct() aggregate keyword.
3590+
create table countdistinct (
3591+
id int primary key
3592+
, countdistinct text
3593+
, amount numeric
3594+
);
3595+
35883596
create table budget_categories (
35893597
id int primary key
35903598
, category_name text

0 commit comments

Comments
 (0)