From 6d12f40b6deb77ffcf3c773beb0ff73b00ba216a Mon Sep 17 00:00:00 2001 From: datavaultbuilder <64465479+datavaultbuilder@users.noreply.github.com> Date: Mon, 25 May 2026 17:09:54 +0200 Subject: [PATCH 1/3] 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(). --- CHANGELOG.md | 1 + docs/references/api/aggregate_functions.rst | 39 ++++++++- src/PostgREST/ApiRequest/QueryParams.hs | 3 + src/PostgREST/ApiRequest/Types.hs | 2 +- src/PostgREST/Plan.hs | 4 + src/PostgREST/Query/SqlFragment.hs | 2 + .../Feature/Query/AggregateFunctionsSpec.hs | 82 +++++++++++++++++++ test/spec/fixtures/data.sql | 6 ++ test/spec/fixtures/schema.sql | 8 ++ 9 files changed, 145 insertions(+), 2 deletions(-) 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..fd5aa17305 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` @@ -320,6 +391,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 From 24f7d4dddc313cd6ba6f6e312d52e41e62e49439 Mon Sep 17 00:00:00 2001 From: datavaultbuilder <64465479+datavaultbuilder@users.noreply.github.com> Date: Mon, 25 May 2026 17:09:54 +0200 Subject: [PATCH 2/3] feat: add count(distinct) as countdistinct aggregate Added count(distinct) as --- CHANGELOG.md | 1 + docs/references/api/aggregate_functions.rst | 39 ++++++++- src/PostgREST/ApiRequest/QueryParams.hs | 3 + src/PostgREST/ApiRequest/Types.hs | 2 +- src/PostgREST/Plan.hs | 4 + src/PostgREST/Query/SqlFragment.hs | 2 + .../Feature/Query/AggregateFunctionsSpec.hs | 82 +++++++++++++++++++ test/spec/fixtures/data.sql | 6 ++ test/spec/fixtures/schema.sql | 8 ++ 9 files changed, 145 insertions(+), 2 deletions(-) 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..fd5aa17305 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` @@ -320,6 +391,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 From 523e52efffaaa82383ccde3bc382490aa7a0a0dc Mon Sep 17 00:00:00 2001 From: datavaultbuilder <64465479+datavaultbuilder@users.noreply.github.com> Date: Wed, 27 May 2026 19:33:25 +0200 Subject: [PATCH 3/3] feat: add count(distinct) as countdistinct aggregate Added count(distinct) as countdistinct aggregate. Tested to work, and that it is gated when aggregates are off. Tested against collisions with tables and columns named countdistinct. Returns type based on Postgres rules for count(). Extended test coverage to paths including spread path. --- test/spec/Feature/Query/AggregateFunctionsSpec.hs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/spec/Feature/Query/AggregateFunctionsSpec.hs b/test/spec/Feature/Query/AggregateFunctionsSpec.hs index fd5aa17305..26bf6aa60c 100644 --- a/test/spec/Feature/Query/AggregateFunctionsSpec.hs +++ b/test/spec/Feature/Query/AggregateFunctionsSpec.hs @@ -224,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|[