Add "for" param for the vnd.pgrst.plan media type

This commit is contained in:
steve-chavez
2022-08-01 17:41:23 -05:00
committed by Steve Chavez
parent 84e03a16dd
commit fd7c23f7a1
6 changed files with 112 additions and 42 deletions
+3 -2
View File
@@ -38,8 +38,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2378, Support http OPTIONS method on RPC and root path - @steve-chavez - #2378, Support http OPTIONS method on RPC and root path - @steve-chavez
- #2354, Allow getting the EXPLAIN plan of a request by using the `Accept: application/vnd.pgrst.plan` header - @steve-chavez - #2354, Allow getting the EXPLAIN plan of a request by using the `Accept: application/vnd.pgrst.plan` header - @steve-chavez
+ Only allowed if the `db-plan-enabled` config is set to true + Only allowed if the `db-plan-enabled` config is set to true
+ Limited to generating the plan of a json representation(`application/json`) but can be extended later to allow other representations. + Can generate the plan for different media types using the `for` parameter: `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.object"`
+ The plan can be obtained in text(`Accept: application/vnd.pgrst.plan+text`) and json(`Accept: application/vnd.pgrst.plan+json` or `Accept: application/vnd.pgrst.plan`) format. + Different options for the plan can be used with the `options` parameter: `Accept: application/vnd.pgrst.plan; options=analyze|verbose|settings|buffers|wal`
+ The plan can be obtained in text or json by using different media type suffixes: `Accept: application/vnd.pgrst.plan+text` and `Accept: application/vnd.pgrst.plan+json`.
### Fixed ### Fixed
+10 -3
View File
@@ -73,7 +73,8 @@ import PostgREST.Error (Error)
import PostgREST.GucHeader (GucHeader, import PostgREST.GucHeader (GucHeader,
addHeadersIfNotIncluded, addHeadersIfNotIncluded,
unwrapGucHeader) unwrapGucHeader)
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MTPlanAttrs (..),
MediaType (..))
import PostgREST.Query.Statements (ResultSet (..)) import PostgREST.Query.Statements (ResultSet (..))
import PostgREST.Request.ApiRequest (Action (..), import PostgREST.Request.ApiRequest (Action (..),
ApiRequest (..), ApiRequest (..),
@@ -647,11 +648,17 @@ binaryField RequestContext{..} readReq
if length fldNames == 1 && fieldName /= Just "*" then if length fldNames == 1 && fieldName /= Just "*" then
return fieldName return fieldName
else else
throwError $ Error.BinaryFieldError (iAcceptMediaType ctxApiRequest) throwError $ Error.BinaryFieldError mediaType
| otherwise = | otherwise =
return Nothing return Nothing
where where
isRawMediaType = iAcceptMediaType ctxApiRequest `elem` configRawMediaTypes ctxConfig `union` [MTOctetStream, MTTextPlain, MTTextXML] mediaType = iAcceptMediaType ctxApiRequest
isRawMediaType = mediaType `elem` configRawMediaTypes ctxConfig `union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan mediaType
isRawPlan mt = case mt of
MTPlan (MTPlanAttrs (Just MTOctetStream) _ _) -> True
MTPlan (MTPlanAttrs (Just MTTextPlain) _ _) -> True
MTPlan (MTPlanAttrs (Just MTTextXML) _ _) -> True
_ -> False
profileHeader :: ApiRequest -> Maybe HTTP.Header profileHeader :: ApiRequest -> Maybe HTTP.Header
profileHeader ApiRequest{..} = profileHeader ApiRequest{..} =
+19 -6
View File
@@ -8,10 +8,12 @@ module PostgREST.MediaType
, toContentType , toContentType
, toMime , toMime
, decodeMediaType , decodeMediaType
, getMediaType
) where ) where
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w) import qualified Data.ByteString.Internal as BS (c2w)
import Data.Maybe (fromJust)
import Network.HTTP.Types.Header (Header, hContentType) import Network.HTTP.Types.Header (Header, hContentType)
@@ -33,7 +35,7 @@ data MediaType
| MTPlan MTPlanAttrs | MTPlan MTPlanAttrs
deriving Eq deriving Eq
data MTPlanAttrs = MTPlanAttrs MTPlanFormat [MTPlanOption] data MTPlanAttrs = MTPlanAttrs (Maybe MediaType) MTPlanFormat [MTPlanOption]
instance Eq MTPlanAttrs where instance Eq MTPlanAttrs where
MTPlanAttrs {} == MTPlanAttrs {} = True -- we don't care about the attributes when comparing two MTPlan media types MTPlanAttrs {} == MTPlanAttrs {} = True -- we don't care about the attributes when comparing two MTPlan media types
@@ -65,8 +67,10 @@ toMime MTUrlEncoded = "application/x-www-form-urlencoded"
toMime MTOctetStream = "application/octet-stream" toMime MTOctetStream = "application/octet-stream"
toMime MTAny = "*/*" toMime MTAny = "*/*"
toMime (MTOther ct) = ct toMime (MTOther ct) = ct
toMime (MTPlan (MTPlanAttrs fmt opts)) = "application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <> toMime (MTPlan (MTPlanAttrs mt fmt opts)) =
if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts) "application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
(if isNothing mt then mempty else "; for=\"" <> toMime (fromJust mt) <> "\"") <>
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
toMimePlanOption :: MTPlanOption -> ByteString toMimePlanOption :: MTPlanOption -> ByteString
toMimePlanOption PlanAnalyze = "analyze" toMimePlanOption PlanAnalyze = "analyze"
@@ -101,11 +105,20 @@ decodeMediaType mt =
_ -> MTAny _ -> MTAny
where where
getPlan fmt rest = getPlan fmt rest =
let opts = BS.split (BS.c2w '|') $ fromMaybe mempty (BS.stripPrefix "options=" =<< find (BS.isPrefixOf "options=") rest) let
inOpts str = str `elem` opts in opts = BS.split (BS.c2w '|') $ fromMaybe mempty (BS.stripPrefix "options=" =<< find (BS.isPrefixOf "options=") rest)
MTPlan $ MTPlanAttrs fmt $ inOpts str = str `elem` opts
mtFor = decodeMediaType . dropAround (== BS.c2w '"') <$> (BS.stripPrefix "for=" =<< find (BS.isPrefixOf "for=") rest)
dropAround p = BS.dropWhile p . BS.dropWhileEnd p in
MTPlan $ MTPlanAttrs mtFor fmt $
[PlanAnalyze | inOpts "analyze" ] ++ [PlanAnalyze | inOpts "analyze" ] ++
[PlanVerbose | inOpts "verbose" ] ++ [PlanVerbose | inOpts "verbose" ] ++
[PlanSettings | inOpts "settings"] ++ [PlanSettings | inOpts "settings"] ++
[PlanBuffers | inOpts "buffers" ] ++ [PlanBuffers | inOpts "buffers" ] ++
[PlanWAL | inOpts "wal" ] [PlanWAL | inOpts "wal" ]
getMediaType :: MediaType -> MediaType
getMediaType mt = case mt of
MTPlan (MTPlanAttrs (Just mType) _ _) -> mType
MTPlan (MTPlanAttrs Nothing _ _) -> MTApplicationJSON
other -> other
+23 -22
View File
@@ -35,7 +35,8 @@ import PostgREST.GucHeader (GucHeader)
import PostgREST.DbStructure.Identifiers (FieldName) import PostgREST.DbStructure.Identifiers (FieldName)
import PostgREST.MediaType (MTPlanAttrs (..), import PostgREST.MediaType (MTPlanAttrs (..),
MTPlanFormat (..), MTPlanFormat (..),
MediaType (..)) MediaType (..),
getMediaType)
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.Request.Preferences import PostgREST.Request.Preferences
@@ -63,8 +64,8 @@ data ResultSet
prepareWrite :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> prepareWrite :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType ->
PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet
prepareWrite selectQuery mutateQuery isInsert mediaType rep pKeys = prepareWrite selectQuery mutateQuery isInsert mt rep pKeys =
SQL.dynamicallyParameterized (mtSnippet mediaType snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
"WITH " <> SQL.sql sourceCTEName <> " AS (" <> mutateQuery <> ") " <> "WITH " <> SQL.sql sourceCTEName <> " AS (" <> mutateQuery <> ") " <>
@@ -90,9 +91,9 @@ prepareWrite selectQuery mutateQuery isInsert mediaType rep pKeys =
bodyF bodyF
| rep /= Full = "''" | rep /= Full = "''"
| mediaType == MTTextCSV = asCsvF | getMediaType mt == MTTextCSV = asCsvF
| mediaType == MTGeoJSON = asGeoJsonF | getMediaType mt == MTGeoJSON = asGeoJsonF
| mediaType == MTSingularJSON = asJsonSingleF False | getMediaType mt == MTSingularJSON = asJsonSingleF False
| otherwise = asJsonF False | otherwise = asJsonF False
selectF selectF
@@ -101,13 +102,13 @@ prepareWrite selectQuery mutateQuery isInsert mediaType rep pKeys =
| otherwise = selectQuery | otherwise = selectQuery
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mediaType of decodeIt = case mt of
MTPlan{} -> planRow MTPlan{} -> planRow
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow False) _ -> fromMaybe (RSStandard Nothing 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow False)
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> Maybe FieldName -> Bool -> SQL.Statement () ResultSet prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> Maybe FieldName -> Bool -> SQL.Statement () ResultSet
prepareRead selectQuery countQuery countTotal mediaType binaryField = prepareRead selectQuery countQuery countTotal mt binaryField =
SQL.dynamicallyParameterized (mtSnippet mediaType snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
"WITH " <> "WITH " <>
@@ -124,23 +125,23 @@ prepareRead selectQuery countQuery countTotal mediaType binaryField =
(countCTEF, countResultF) = countF countQuery countTotal (countCTEF, countResultF) = countF countQuery countTotal
bodyF bodyF
| mediaType == MTTextCSV = asCsvF | getMediaType mt == MTTextCSV = asCsvF
| mediaType == MTSingularJSON = asJsonSingleF False | getMediaType mt == MTSingularJSON = asJsonSingleF False
| mediaType == MTGeoJSON = asGeoJsonF | getMediaType mt == MTGeoJSON = asGeoJsonF
| isJust binaryField && mediaType == MTTextXML = asXmlF $ fromJust binaryField | isJust binaryField && getMediaType mt == MTTextXML = asXmlF $ fromJust binaryField
| isJust binaryField = asBinaryF $ fromJust binaryField | isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF False | otherwise = asJsonF False
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mediaType of decodeIt = case mt of
MTPlan{} -> planRow MTPlan{} -> planRow
_ -> HD.singleRow $ standardRow True _ -> HD.singleRow $ standardRow True
prepareCall :: Bool -> Bool -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool -> prepareCall :: Bool -> Bool -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
MediaType -> Bool -> Maybe FieldName -> Bool -> MediaType -> Bool -> Maybe FieldName -> Bool ->
SQL.Statement () ResultSet SQL.Statement () ResultSet
prepareCall returnsScalar returnsSingle callProcQuery selectQuery countQuery countTotal mediaType multObjects binaryField = prepareCall returnsScalar returnsSingle callProcQuery selectQuery countQuery countTotal mt multObjects binaryField =
SQL.dynamicallyParameterized (mtSnippet mediaType snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
"WITH " <> SQL.sql sourceCTEName <> " AS (" <> callProcQuery <> ") " <> "WITH " <> SQL.sql sourceCTEName <> " AS (" <> callProcQuery <> ") " <>
@@ -157,16 +158,16 @@ prepareCall returnsScalar returnsSingle callProcQuery selectQuery countQuery cou
(countCTEF, countResultF) = countF countQuery countTotal (countCTEF, countResultF) = countF countQuery countTotal
bodyF bodyF
| mediaType == MTSingularJSON = asJsonSingleF returnsScalar | getMediaType mt == MTSingularJSON = asJsonSingleF returnsScalar
| mediaType == MTTextCSV = asCsvF | getMediaType mt == MTTextCSV = asCsvF
| mediaType == MTGeoJSON = asGeoJsonF | getMediaType mt == MTGeoJSON = asGeoJsonF
| isJust binaryField && mediaType == MTTextXML = asXmlF $ fromJust binaryField | isJust binaryField && getMediaType mt == MTTextXML = asXmlF $ fromJust binaryField
| isJust binaryField = asBinaryF $ fromJust binaryField | isJust binaryField = asBinaryF $ fromJust binaryField
| returnsSingle && not multObjects = asJsonSingleF returnsScalar | returnsSingle && not multObjects = asJsonSingleF returnsScalar
| otherwise = asJsonF returnsScalar | otherwise = asJsonF returnsScalar
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mediaType of decodeIt = case mt of
MTPlan{} -> planRow MTPlan{} -> planRow
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow True) _ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty (Right []) (Right Nothing)) <$> HD.rowMaybe (standardRow True)
@@ -194,7 +195,7 @@ standardRow noLocation =
mtSnippet :: MediaType -> SQL.Snippet -> SQL.Snippet mtSnippet :: MediaType -> SQL.Snippet -> SQL.Snippet
mtSnippet mediaType snippet = case mediaType of mtSnippet mediaType snippet = case mediaType of
MTPlan (MTPlanAttrs fmt opts) -> explainF fmt opts snippet MTPlan (MTPlanAttrs _ fmt opts) -> explainF fmt opts snippet
_ -> snippet _ -> snippet
-- | We use rowList because when doing EXPLAIN (FORMAT TEXT), the result comes as many rows. FORMAT JSON comes as one. -- | We use rowList because when doing EXPLAIN (FORMAT TEXT), the result comes as many rows. FORMAT JSON comes as one.
+1 -1
View File
@@ -429,7 +429,7 @@ requestMediaTypes conf action path =
++ [MTOpenAPI | pathIsRootSpec path] ++ [MTOpenAPI | pathIsRootSpec path]
defaultMediaTypes = defaultMediaTypes =
[MTApplicationJSON, MTSingularJSON, MTGeoJSON, MTTextCSV] ++ [MTApplicationJSON, MTSingularJSON, MTGeoJSON, MTTextCSV] ++
[MTPlan $ MTPlanAttrs PlanJSON mempty | configDbPlanEnabled conf] [MTPlan $ MTPlanAttrs Nothing PlanJSON mempty | configDbPlanEnabled conf]
rawMediaTypes = configRawMediaTypes conf `union` [MTOctetStream, MTTextPlain, MTTextXML] rawMediaTypes = configRawMediaTypes conf `union` [MTOctetStream, MTTextPlain, MTTextXML]
{-| {-|
+48
View File
@@ -125,6 +125,32 @@ spec actualPgVersion = do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; options=verbose; charset=utf-8") resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; options=verbose; charset=utf-8")
cols `shouldBe` Just [aesonQQ| ["projects.id", "projects.name", "projects.client_id"] |] cols `shouldBe` Just [aesonQQ| ["projects.id", "projects.name", "projects.client_id"] |]
it "outputs the plan for application/json " $ do
r <- request methodGet "/projects" (acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/json\"; options=verbose") ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; options=verbose; charset=utf-8")
aggCol `shouldBe`
if actualPgVersion >= pgVersion120
then Just [aesonQQ| "(COALESCE(json_agg(ROW(projects.id, projects.name, projects.client_id)), '[]'::json))::character varying" |]
else Just [aesonQQ| "(COALESCE(json_agg(ROW(pgrst_source.id, pgrst_source.name, pgrst_source.client_id)), '[]'::json))::character varying" |]
it "outputs the plan for application/vnd.pgrst.object " $ do
r <- request methodGet "/projects_view" (acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object\"; options=verbose") ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object+json\"; options=verbose; charset=utf-8")
aggCol `shouldBe`
if actualPgVersion >= pgVersion120
then Just [aesonQQ| "COALESCE(((json_agg(ROW(projects.id, projects.name, projects.client_id)) -> 0))::text, 'null'::text)" |]
else Just [aesonQQ| "COALESCE(((json_agg(ROW(pgrst_source.id, pgrst_source.name, pgrst_source.client_id)) -> 0))::text, 'null'::text)" |]
describe "writes plans" $ do describe "writes plans" $ do
it "outputs the total cost for an insert" $ do it "outputs the total cost for an insert" $ do
r <- request methodPost "/projects" r <- request methodPost "/projects"
@@ -188,6 +214,17 @@ spec actualPgVersion = do
then Just [aesonQQ|1.3|] then Just [aesonQQ|1.3|]
else Just [aesonQQ|1.35|] else Just [aesonQQ|1.35|]
it "outputs the plan for application/vnd.pgrst.object" $ do
r <- request methodDelete "/projects?id=eq.6"
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object\"; options=verbose")] ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 3
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object+json\"; options=verbose; charset=utf-8")
aggCol `shouldBe` Just [aesonQQ| "COALESCE(((json_agg(ROW(projects.id, projects.name, projects.client_id)) -> 0))::text, 'null'::text)" |]
describe "function plan" $ do describe "function plan" $ do
it "outputs the total cost for a function call" $ do it "outputs the total cost for a function call" $ do
r <- request methodGet "/rpc/getallprojects?id=in.(1,2,3)" r <- request methodGet "/rpc/getallprojects?id=in.(1,2,3)"
@@ -202,6 +239,17 @@ spec actualPgVersion = do
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" } resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` Just [aesonQQ|68.57|] totalCost `shouldBe` Just [aesonQQ|68.57|]
it "outputs the plan for text/xml" $ do
r <- request methodGet "/rpc/return_scalar_xml"
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose") ""
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
resHeaders = simpleHeaders r
liftIO $ do
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose; charset=utf-8")
aggCol `shouldBe` Just [aesonQQ| "COALESCE(xmlagg(return_scalar_xml.pgrst_scalar), ''::xml)" |]
describe "text format" $ describe "text format" $
it "outputs the total cost for a function call" $ do it "outputs the total cost for a function call" $ do
r <- request methodGet "/projects?id=in.(1,2,3)" r <- request methodGet "/projects?id=in.(1,2,3)"