diff --git a/CHANGELOG.md b/CHANGELOG.md index 575c86f68d..11c20123a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/references/api/aggregate_functions.rst b/docs/references/api/aggregate_functions.rst index 433725d87f..07b786438f 100644 --- a/docs/references/api/aggregate_functions.rst +++ b/docs/references/api/aggregate_functions.rst @@ -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 `_ for a detailed explanation of these functions. .. note:: @@ -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 ================== diff --git a/src/PostgREST/ApiRequest/QueryParams.hs b/src/PostgREST/ApiRequest/QueryParams.hs index b8f21fd181..3521b566c4 100644 --- a/src/PostgREST/ApiRequest/QueryParams.hs +++ b/src/PostgREST/ApiRequest/QueryParams.hs @@ -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', diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index bf3ddd9b03..cdfa8d3698 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -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 diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index bb7a0d4a17..7367ba317e 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -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 diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index 75cb298213..a54441a311 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -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 diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index e0d67bb424..26bf6aa60c 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -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|[ @@ -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` @@ -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|[ @@ -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|{ diff --git a/test/spec/fixtures/data.sql b/test/spec/fixtures/data.sql index a1187d1722..236cc2cf78 100644 --- a/test/spec/fixtures/data.sql +++ b/test/spec/fixtures/data.sql @@ -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); diff --git a/test/spec/fixtures/schema.sql b/test/spec/fixtures/schema.sql index 9a7937e385..a95022b50c 100644 --- a/test/spec/fixtures/schema.sql +++ b/test/spec/fixtures/schema.sql @@ -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