Skip to content

Commit 7eda54c

Browse files
committed
perf: optimize count=exact when there's no limits, offsets or db-max-rows
1 parent 29b7d45 commit 7eda54c

File tree

6 files changed

+104
-21
lines changed

6 files changed

+104
-21
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. From versio
77
### Added
88

99
- Log error when `db-schemas` config contains schema `pg_catalog` or `information_schema` by @taimoorzaeem in #4359
10+
- Optimize requests with `Prefer: count=exact` that do not use ranges or `db-max-rows` by @laurenceisla in #3957
11+
+ The page total is the same as the resource total in these cases, so now it only counts once to build the `Content-Range`.
1012

1113
## [14.2] - 2025-12-18
1214

src/PostgREST/Query.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,16 @@ data MainQuery = MainQuery
4343

4444
mainQuery :: ActionPlan -> AppConfig -> ApiRequest -> AuthResult -> Maybe QualifiedIdentifier -> MainQuery
4545
mainQuery (NoDb _) _ _ _ _ = MainQuery mempty Nothing mempty (mempty, mempty, mempty) mempty
46-
mainQuery (Db plan) conf@AppConfig{..} apiReq@ApiRequest{iPreferences=Preferences{..}} authRes preReq =
46+
mainQuery (Db plan) conf@AppConfig{..} apiReq@ApiRequest{iTopLevelRange=range, iPreferences=Preferences{..}} authRes preReq =
4747
let genQ = MainQuery (PreQuery.txVarQuery plan conf authRes apiReq) (PreQuery.preReqQuery <$> preReq) in
4848
case plan of
4949
DbCrud _ WrappedReadPlan{..} ->
5050
let countQuery = QueryBuilder.readPlanToCountQuery wrReadPlan in
51-
genQ (Statements.mainRead wrReadPlan countQuery preferCount configDbMaxRows pMedia wrHandler) (mempty, mempty, mempty)
51+
genQ (Statements.mainRead wrReadPlan countQuery preferCount configDbMaxRows range pMedia wrHandler) (mempty, mempty, mempty)
5252
(if shouldExplainCount preferCount then Just (Statements.postExplain countQuery) else Nothing)
5353
DbCrud _ MutateReadPlan{..} ->
5454
genQ (Statements.mainWrite mrReadPlan mrMutatePlan pMedia mrHandler preferRepresentation preferResolution) (mempty, mempty, mempty) mempty
5555
DbCrud _ CallReadPlan{..} ->
56-
genQ (Statements.mainCall crProc crCallPlan crReadPlan preferCount pMedia crHandler) (mempty, mempty, mempty) mempty
56+
genQ (Statements.mainCall crProc crCallPlan crReadPlan preferCount configDbMaxRows range pMedia crHandler) (mempty, mempty, mempty) mempty
5757
MayUseDb InspectPlan{ipSchema=tSchema} ->
5858
genQ mempty (SqlFragment.accessibleTables tSchema, SqlFragment.accessibleFuncs tSchema, SqlFragment.schemaDescription tSchema) mempty

src/PostgREST/Query/SqlFragment.hs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -487,15 +487,15 @@ pgFmtGroup _ CoercibleSelectField{csAggFunction=Just _} = Nothing
487487
pgFmtGroup _ CoercibleSelectField{csAlias=Just alias, csAggFunction=Nothing} = Just $ pgFmtIdent alias
488488
pgFmtGroup qi CoercibleSelectField{csField=fld, csAlias=Nothing, csAggFunction=Nothing} = Just $ pgFmtField qi fld
489489

490-
countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SQL.Snippet)
491-
countF countQuery shouldCount =
492-
if shouldCount
493-
then (
494-
", pgrst_source_count AS (" <> countQuery <> ")"
495-
, "(SELECT pg_catalog.count(*) FROM pgrst_source_count)" )
496-
else (
497-
mempty
498-
, "null::bigint")
490+
countF :: SQL.Snippet -> SQL.Snippet -> Bool -> Maybe Integer -> NonnegRange -> (SQL.Snippet, SQL.Snippet)
491+
countF countQuery pageCountSelect shouldCount maxRows range
492+
| shouldCount = if isJust maxRows || range /= allRange
493+
then ( ", pgrst_source_count AS (" <> countQuery <> ")"
494+
, "(SELECT pg_catalog.count(*) FROM pgrst_source_count)" )
495+
-- When there are no db-max-rows and limits/offsets, the total count will be the same as the page count,
496+
-- so we use the same page count here to avoid doing a separate aggregated count.
497+
else ( mempty, pageCountSelect )
498+
| otherwise = ( mempty, "null::bigint" )
499499

500500
pageCountSelectF :: Maybe Routine -> SQL.Snippet
501501
pageCountSelectF rout =

src/PostgREST/Query/Statements.hs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import PostgREST.Plan.MutatePlan as MTPlan
2020
import PostgREST.Plan.ReadPlan
2121
import PostgREST.Query.QueryBuilder
2222
import PostgREST.Query.SqlFragment
23+
import PostgREST.RangeQuery (NonnegRange)
2324
import PostgREST.SchemaCache.Routine (MediaHandler (..), Routine)
2425

2526
import Protolude
@@ -63,50 +64,52 @@ mainWrite rPlan mtplan mt handler rep resolution = mtSnippet mt snippet
6364
_ -> (False,False, mempty);
6465

6566
mainRead :: ReadPlanTree -> SQL.Snippet -> Maybe PreferCount -> Maybe Integer ->
66-
MediaType -> MediaHandler -> SQL.Snippet
67-
mainRead rPlan countQuery pCount maxRows mt handler = mtSnippet mt snippet
67+
NonnegRange -> MediaType -> MediaHandler -> SQL.Snippet
68+
mainRead rPlan countQuery pCount maxRows range mt handler = mtSnippet mt snippet
6869
where
6970
snippet =
7071
"WITH " <> sourceCTE <> " AS ( " <> selectQuery <> " ) " <>
7172
countCTEF <> " " <>
7273
"SELECT " <>
7374
countResultF <> " AS total_result_set, " <>
74-
pageCountSelectF Nothing <> " AS page_total, " <>
75+
pageCountSelect <> " AS page_total, " <>
7576
handlerF Nothing handler <> " AS body, " <>
7677
responseHeadersF <> " AS response_headers, " <>
7778
responseStatusF <> " AS response_status, " <>
7879
"''" <> " AS response_inserted " <>
7980
"FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t"
8081

81-
(countCTEF, countResultF) = countF countQ $ shouldCount pCount
82+
(countCTEF, countResultF) = countF countQ pageCountSelect (shouldCount pCount) maxRows range
8283
selectQuery = readPlanToQuery rPlan
84+
pageCountSelect = pageCountSelectF Nothing
8385
countQ =
8486
if pCount == Just EstimatedCount then
8587
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
8688
limitedQuery countQuery ((+ 1) <$> maxRows)
8789
else
8890
countQuery
8991

90-
mainCall :: Routine -> CallPlan -> ReadPlanTree -> Maybe PreferCount ->
91-
MediaType -> MediaHandler -> SQL.Snippet
92-
mainCall rout cPlan rPlan pCount mt handler = mtSnippet mt snippet
92+
mainCall :: Routine -> CallPlan -> ReadPlanTree -> Maybe PreferCount -> Maybe Integer ->
93+
NonnegRange-> MediaType -> MediaHandler -> SQL.Snippet
94+
mainCall rout cPlan rPlan pCount maxRows range mt handler = mtSnippet mt snippet
9395
where
9496
snippet =
9597
"WITH " <> sourceCTE <> " AS (" <> callProcQuery <> ") " <>
9698
countCTEF <>
9799
"SELECT " <>
98100
countResultF <> " AS total_result_set, " <>
99-
pageCountSelectF (Just rout) <> " AS page_total, " <>
101+
pageCountSelect <> " AS page_total, " <>
100102
handlerF (Just rout) handler <> " AS body, " <>
101103
responseHeadersF <> " AS response_headers, " <>
102104
responseStatusF <> " AS response_status, " <>
103105
"''" <> " AS response_inserted " <>
104106
"FROM (" <> selectQuery <> ") _postgrest_t"
105107

106-
(countCTEF, countResultF) = countF countQuery $ shouldCount pCount
108+
(countCTEF, countResultF) = countF countQuery pageCountSelect (shouldCount pCount) maxRows range
107109
selectQuery = readPlanToQuery rPlan
108110
callProcQuery = callPlanToQuery cPlan
109111
countQuery = readPlanToCountQuery rPlan
112+
pageCountSelect = pageCountSelectF (Just rout)
110113

111114
-- This occurs after the main query runs, that's why it's prefixed with "post"
112115
postExplain :: SQL.Snippet -> SQL.Snippet

test/spec/Feature/Query/PlanSpec.hs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,80 @@ spec actualPgVersion = do
455455
nextValSnip `shouldBe`
456456
Just [aesonQQ| ["jsonb_agg((jsonb_build_object('id', nextval('\"Surr_Gen_Default_Upsert_id_seq\"'::regclass)) || elem.value))"] |]
457457

458+
describe "count preference plan costs" $ do
459+
context "tables with count=exact" $ do
460+
it "shows only 1 count aggregate when no limits/max-rows are set" $ do
461+
_ <- request methodPost "/tiobe_pls"
462+
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
463+
(getInsertDataForTiobePlsTable 1000)
464+
465+
r <- request methodGet "/tiobe_pls"
466+
(("Prefer", "count=exact") : acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze;") ""
467+
468+
let resBody = simpleBody r
469+
resHeaders = simpleHeaders r
470+
totalCost = planCost r
471+
aggregateQty = subtract 1 $ length $ T.splitOn "Aggregate" (decodeUtf8 $ LBS.toStrict resBody)
472+
473+
liftIO $ do
474+
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze; charset=utf-8")
475+
totalCost `shouldSatisfy` (< 33.0)
476+
aggregateQty `shouldBe` 1
477+
478+
it "shows relevant count aggregates when limits are set" $ do
479+
_ <- request methodPost "/tiobe_pls"
480+
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
481+
(getInsertDataForTiobePlsTable 1000)
482+
483+
r <- request methodGet "/tiobe_pls?limit=1000"
484+
(("Prefer", "count=exact") : acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze;") ""
485+
486+
let resBody = simpleBody r
487+
resHeaders = simpleHeaders r
488+
totalCost = planCost r
489+
aggregateQty = subtract 1 $ length $ T.splitOn "Aggregate" (decodeUtf8 $ LBS.toStrict resBody)
490+
491+
liftIO $ do
492+
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze; charset=utf-8")
493+
totalCost `shouldSatisfy` (> 49.0)
494+
aggregateQty `shouldSatisfy` (> 1)
495+
496+
context "functions with count=exact" $ do
497+
it "shows only 1 count aggregate when no limits/max-rows are set" $ do
498+
_ <- request methodPost "/tiobe_pls"
499+
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json"), ("Prefer", "return=representation")]
500+
(getInsertDataForTiobePlsTable 1000)
501+
502+
r <- request methodGet "/rpc/get_tiobe_pls"
503+
(("Prefer", "count=exact") : acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze;") ""
504+
505+
let resBody = simpleBody r
506+
resHeaders = simpleHeaders r
507+
totalCost = planCost r
508+
aggregateQty = subtract 1 $ length $ T.splitOn "Aggregate" (decodeUtf8 $ LBS.toStrict resBody)
509+
510+
liftIO $ do
511+
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze; charset=utf-8")
512+
totalCost `shouldSatisfy` (< 38.0)
513+
aggregateQty `shouldBe` 1
514+
515+
it "shows relevant count aggregates when limits are set" $ do
516+
_ <- request methodPost "/tiobe_pls"
517+
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
518+
(getInsertDataForTiobePlsTable 1000)
519+
520+
r <- request methodGet "/rpc/get_tiobe_pls?limit=1000"
521+
(("Prefer", "count=exact") : acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze;") ""
522+
523+
let resBody = simpleBody r
524+
resHeaders = simpleHeaders r
525+
totalCost = planCost r
526+
aggregateQty = subtract 1 $ length $ T.splitOn "Aggregate" (decodeUtf8 $ LBS.toStrict resBody)
527+
528+
liftIO $ do
529+
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; options=analyze; charset=utf-8")
530+
totalCost `shouldSatisfy` (> 67.0)
531+
aggregateQty `shouldSatisfy` (> 1)
458532

459533
disabledSpec :: SpecWith ((), Application)
460534
disabledSpec =

test/spec/fixtures/schema.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3851,3 +3851,7 @@ $$ language sql;
38513851

38523852
create function do_nothing() returns void as $_$
38533853
$_$ language sql;
3854+
3855+
create or replace function test.get_tiobe_pls() returns setof test.tiobe_pls as $$
3856+
select * from test.tiobe_pls;
3857+
$$ language sql;

0 commit comments

Comments
 (0)