Add aggregate functions (#2925)
The aggregate functions SUM(), MAX(), MIN(), AVG(), and COUNT() are now supported.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = "pre_config_role"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = "anonymous"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = true
|
||||
db-anon-role = "root"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
PGRST_APP_SETTINGS_test2: test
|
||||
PGRST_APP_SETTINGS_test: test
|
||||
PGRST_DB_AGGREGATES_ENABLED: true
|
||||
PGRST_DB_ANON_ROLE: root
|
||||
PGRST_DB_CHANNEL: postgrest
|
||||
PGRST_DB_CHANNEL_ENABLED: false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = true
|
||||
db-anon-role = "root"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
|
||||
@@ -6,6 +6,7 @@ ALTER ROLE db_config_authenticator SET pgrst.openapi_server_proxy_uri = 'https:/
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'false';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_aggregates_enabled = 'false';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_anon_role = 'anonymous';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_pre_config = 'postgrest.preconf';
|
||||
@@ -53,6 +54,7 @@ ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org';
|
||||
ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'true';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_aggregates_enabled = 'false';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, other_tenant2';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_root_spec = 'other_root';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_plan_enabled = 'true';
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
module Feature.Query.AggregateFunctionsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
allowed :: SpecWith ((), Application)
|
||||
allowed =
|
||||
describe "aggregate functions" $ do
|
||||
context "performing a count without specifying a field" $ do
|
||||
it "returns the count of all rows when no other fields are selected" $
|
||||
get "/entities?select=count()" `shouldRespondWith`
|
||||
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to specify an alias for the count" $
|
||||
get "/entities?select=cnt:count()" `shouldRespondWith`
|
||||
[json|[{ "cnt": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to cast the result of the count" $
|
||||
get "/entities?select=count()::text" `shouldRespondWith`
|
||||
[json|[{ "count": "4" }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "returns the count grouped by all provided fields when other fields are selected" $
|
||||
get "/projects?select=c:count(),client_id&order=client_id.desc" `shouldRespondWith`
|
||||
[json|[{ "c": 1, "client_id": null }, { "c": 2, "client_id": 2 }, { "c": 2, "client_id": 1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "performing a count by using it as a column (backwards compat)" $ do
|
||||
it "returns the count of all rows when no other fields are selected" $
|
||||
get "/entities?select=count" `shouldRespondWith`
|
||||
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "returns the embedded count of another resource" $
|
||||
get "/clients?select=name,projects(count)'" `shouldRespondWith`
|
||||
[json|[{"name":"Microsoft","projects":[{"count": 2}]}, {"name":"Apple","projects":[{"count": 2}]}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "performing an aggregation on one or more fields" $ do
|
||||
it "supports sum()" $
|
||||
get "/project_invoices?select=invoice_total.sum()" `shouldRespondWith`
|
||||
[json|[{"sum":8800}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports avg()" $
|
||||
get "/project_invoices?select=invoice_total.avg()" `shouldRespondWith`
|
||||
[json|[{"avg":1100.0000000000000000}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports min()" $
|
||||
get "/project_invoices?select=invoice_total.min()" `shouldRespondWith`
|
||||
[json|[{ "min": 100 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports max()" $
|
||||
get "/project_invoices?select=invoice_total.max()" `shouldRespondWith`
|
||||
[json|[{ "max": 4000 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports count()" $
|
||||
get "/project_invoices?select=invoice_total.count()" `shouldRespondWith`
|
||||
[json|[{ "count": 8 }]|] { 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|[
|
||||
{"sum":4100,"max":4000,"min":100,"project_id":4},
|
||||
{"sum":3200,"max":2000,"min":1200,"project_id":3},
|
||||
{"sum":1200,"max":700,"min":500,"project_id":2},
|
||||
{"sum":300,"max":200,"min":100,"project_id":1} ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports the use of aliases on fields that will be used in the group by" $
|
||||
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),pid:project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"sum":4100,"max":4000,"min":100,"pid":4},
|
||||
{"sum":3200,"max":2000,"min":1200,"pid":3},
|
||||
{"sum":1200,"max":700,"min":500,"pid":2},
|
||||
{"sum":300,"max":200,"min":100,"pid":1}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to specify an alias for the aggregate" $
|
||||
get "/project_invoices?select=total_charged:invoice_total.sum(),project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"total_charged":4100,"project_id":4},
|
||||
{"total_charged":3200,"project_id":3},
|
||||
{"total_charged":1200,"project_id":2},
|
||||
{"total_charged":300,"project_id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to cast the result of the aggregate" $
|
||||
get "/project_invoices?select=total_charged:invoice_total.sum()::text,project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"total_charged":"4100","project_id":4},
|
||||
{"total_charged":"3200","project_id":3},
|
||||
{"total_charged":"1200","project_id":2},
|
||||
{"total_charged":"300","project_id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to cast the input argument of the aggregate" $
|
||||
get "/trash_details?select=jsonb_col->>key::integer.sum()" `shouldRespondWith`
|
||||
[json|[{"sum": 24}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows the combination of an alias, a before cast, and an after cast" $
|
||||
get "/trash_details?select=s:jsonb_col->>key::integer.sum()::text" `shouldRespondWith`
|
||||
[json|[{"s": "24"}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports use of aggregates on RPC functions that return table values" $
|
||||
get "/rpc/getallprojects?select=id.max()" `shouldRespondWith`
|
||||
[json|[{"max": 5}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows the use of an JSON-embedded relationship column as part of the group by" $
|
||||
get "/project_invoices?select=project_id,total:invoice_total.sum(),projects(name)&order=project_id" `shouldRespondWith`
|
||||
[json|[
|
||||
{"project_id": 1, "total": 300, "projects": {"name": "Windows 7"}},
|
||||
{"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 "performing aggregations that involve JSON-embedded relationships" $ do
|
||||
it "supports sum()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7","project_invoices":[{"sum": 300}]},
|
||||
{"name":"Windows 10","project_invoices":[{"sum": 1200}]},
|
||||
{"name":"IOS","project_invoices":[{"sum": 3200}]},
|
||||
{"name":"OSX","project_invoices":[{"sum": 4100}]},
|
||||
{"name":"Orphan","project_invoices":[{"sum": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports max()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.max())" `shouldRespondWith`
|
||||
[json|[{"name":"Windows 7","project_invoices":[{"max": 200}]},
|
||||
{"name":"Windows 10","project_invoices":[{"max": 700}]},
|
||||
{"name":"IOS","project_invoices":[{"max": 2000}]},
|
||||
{"name":"OSX","project_invoices":[{"max": 4000}]},
|
||||
{"name":"Orphan","project_invoices":[{"max": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports avg()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.avg())" `shouldRespondWith`
|
||||
[json|[{"name":"Windows 7","project_invoices":[{"avg": 150.0000000000000000}]},
|
||||
{"name":"Windows 10","project_invoices":[{"avg": 600.0000000000000000}]},
|
||||
{"name":"IOS","project_invoices":[{"avg": 1600.0000000000000000}]},
|
||||
{"name":"OSX","project_invoices":[{"avg": 2050.0000000000000000}]},
|
||||
{"name":"Orphan","project_invoices":[{"avg": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports min()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.min())" `shouldRespondWith`
|
||||
[json|[{"name":"Windows 7","project_invoices":[{"min": 100}]},
|
||||
{"name":"Windows 10","project_invoices":[{"min": 500}]},
|
||||
{"name":"IOS","project_invoices":[{"min": 1200}]},
|
||||
{"name":"OSX","project_invoices":[{"min": 100}]},
|
||||
{"name":"Orphan","project_invoices":[{"min": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports all at once" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.max(),invoice_total.min(),invoice_total.avg(),invoice_total.sum(),invoice_total.count())" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7","project_invoices":[{"avg": 150.0000000000000000, "max": 200, "min": 100, "sum": 300, "count": 2}]},
|
||||
{"name":"Windows 10","project_invoices":[{"avg": 600.0000000000000000, "max": 700, "min": 500, "sum": 1200, "count": 2}]},
|
||||
{"name":"IOS","project_invoices":[{"avg": 1600.0000000000000000, "max": 2000, "min": 1200, "sum": 3200, "count": 2}]},
|
||||
{"name":"OSX","project_invoices":[{"avg": 2050.0000000000000000, "max": 4000, "min": 100, "sum": 4100, "count": 2}]},
|
||||
{"name":"Orphan","project_invoices":[{"avg": null, "max": null, "min": null, "sum": null, "count": 0}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "performing aggregations on spreaded fields from an embedded resource" $ do
|
||||
it "supports the use of aggregates on spreaded fields" $ do
|
||||
get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42},
|
||||
{"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41},
|
||||
{"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do
|
||||
get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith`
|
||||
[json|[{"total_budget": 9501.06}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
disallowed :: SpecWith ((), Application)
|
||||
disallowed =
|
||||
describe "attempting to use an aggregate when aggregate functions are disallowed" $ do
|
||||
it "prevents the use of aggregates" $
|
||||
get "/project_invoices?select=invoice_total.sum()" `shouldRespondWith`
|
||||
[json|{
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"PGRST123",
|
||||
"message":"Use of aggregate functions is not allowed"
|
||||
}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -348,12 +348,12 @@ spec actualPgVersion = do
|
||||
r1 <- request methodGet "/users?select=*,tasks!inner(*)&tasks.id=eq.1"
|
||||
[planHdr] ""
|
||||
|
||||
liftIO $ planCost r1 `shouldSatisfy` (< 20876.14)
|
||||
liftIO $ planCost r1 `shouldSatisfy` (< 20888.83)
|
||||
|
||||
r2 <- request methodGet "/users?select=*,tasks(*)&tasks.id=eq.1&tasks=not.is.null"
|
||||
[planHdr] ""
|
||||
|
||||
liftIO $ planCost r2 `shouldSatisfy` (< 20876.14)
|
||||
liftIO $ planCost r2 `shouldSatisfy` (< 20888.83)
|
||||
|
||||
describe "function call costs" $ do
|
||||
it "should not exceed cost when calling setof composite proc" $ do
|
||||
|
||||
@@ -34,6 +34,7 @@ import qualified Feature.OpenApi.ProxySpec
|
||||
import qualified Feature.OpenApi.RootSpec
|
||||
import qualified Feature.OpenApi.SecurityOpenApiSpec
|
||||
import qualified Feature.OptionsSpec
|
||||
import qualified Feature.Query.AggregateFunctionsSpec
|
||||
import qualified Feature.Query.AndOrParamsSpec
|
||||
import qualified Feature.Query.ComputedRelsSpec
|
||||
import qualified Feature.Query.CustomMediaSpec
|
||||
@@ -109,6 +110,7 @@ main = do
|
||||
pgSafeUpdateApp = app testPgSafeUpdateEnabledCfg
|
||||
obsApp = app testObservabilityCfg
|
||||
serverTiming = app testCfgServerTiming
|
||||
aggregatesEnabled = app testCfgAggregatesEnabled
|
||||
|
||||
extraSearchPathApp = appDbs testCfgExtraSearchPath
|
||||
unicodeApp = appDbs testUnicodeCfg
|
||||
@@ -242,6 +244,12 @@ main = do
|
||||
parallel $ before serverTiming $
|
||||
describe "Feature.Query.ServerTimingSpec.spec" Feature.Query.ServerTimingSpec.spec
|
||||
|
||||
parallel $ before aggregatesEnabled $
|
||||
describe "Feature.Query.AggregateFunctionsSpec" Feature.Query.AggregateFunctionsSpec.allowed
|
||||
|
||||
parallel $ before withApp $
|
||||
describe "Feature.Query.AggregateFunctionsDisallowedSpec." Feature.Query.AggregateFunctionsSpec.disallowed
|
||||
|
||||
-- Note: the rollback tests can not run in parallel, because they test persistance and
|
||||
-- this results in race conditions
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ baseCfg :: AppConfig
|
||||
baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
||||
AppConfig {
|
||||
configAppSettings = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]
|
||||
, configDbAggregates = False
|
||||
, configDbAnonRole = Just "postgrest_test_anonymous"
|
||||
, configDbChannel = mempty
|
||||
, configDbChannelEnabled = True
|
||||
@@ -235,6 +236,9 @@ testObservabilityCfg = baseCfg { configServerTraceHeader = Just $ mk "X-Request-
|
||||
testCfgServerTiming :: AppConfig
|
||||
testCfgServerTiming = baseCfg { configDbPlanEnabled = True }
|
||||
|
||||
testCfgAggregatesEnabled :: AppConfig
|
||||
testCfgAggregatesEnabled = baseCfg { configDbAggregates = True }
|
||||
|
||||
analyzeTable :: Text -> IO ()
|
||||
analyzeTable tableName =
|
||||
void $ readProcess "psql" ["-U", "postgres", "--set", "ON_ERROR_STOP=1", "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []
|
||||
|
||||
Vendored
+24
@@ -866,3 +866,27 @@ TRUNCATE TABLE timestamps CASCADE;
|
||||
INSERT INTO timestamps VALUES ('2023-10-18 12:37:59.611000+0000');
|
||||
INSERT INTO timestamps VALUES ('2023-10-18 14:37:59.611000+0000');
|
||||
INSERT INTO timestamps VALUES ('2023-10-18 16:37:59.611000+0000');
|
||||
|
||||
TRUNCATE TABLE project_invoices CASCADE;
|
||||
INSERT INTO project_invoices VALUES (1, 100, 1);
|
||||
INSERT INTO project_invoices VALUES (2, 200, 1);
|
||||
INSERT INTO project_invoices VALUES (3, 500, 2);
|
||||
INSERT INTO project_invoices VALUES (4, 700, 2);
|
||||
INSERT INTO project_invoices VALUES (5, 1200, 3);
|
||||
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 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);
|
||||
INSERT INTO budget_categories VALUES (3, 'Pizza', 'Brian Smith', 1000.11);
|
||||
INSERT INTO budget_categories VALUES (4, 'Opera Tickets', 'Jane Clarkson', 7000.41);
|
||||
INSERT INTO budget_categories VALUES (5, 'Nuclear Fusion Research', 'Sally Hughes', 500.23);
|
||||
INSERT INTO budget_categories VALUES (6, 'T-5hirts', 'Dana de Groot', 500.33);
|
||||
|
||||
TRUNCATE TABLE budget_expenses CASCADE;
|
||||
INSERT INTO budget_expenses VALUES (1, 200.26, 1);
|
||||
INSERT INTO budget_expenses VALUES (2, 400.26, 3);
|
||||
INSERT INTO budget_expenses VALUES (3, 100.22, 4);
|
||||
INSERT INTO budget_expenses VALUES (5, 900.27, 5);
|
||||
|
||||
Vendored
+19
@@ -3631,3 +3631,22 @@ create table empty_string as select 1 as id, ''::text as string;
|
||||
create table timestamps (
|
||||
t timestamp with time zone
|
||||
);
|
||||
|
||||
create table project_invoices (
|
||||
id int primary key
|
||||
, invoice_total numeric
|
||||
, project_id integer references projects(id)
|
||||
);
|
||||
|
||||
create table budget_categories (
|
||||
id int primary key
|
||||
, category_name text
|
||||
, budget_owner text
|
||||
, budget_amount numeric
|
||||
);
|
||||
|
||||
create table budget_expenses (
|
||||
id int primary key
|
||||
, expense_amount numeric
|
||||
, budget_category_id integer references budget_categories(id)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user