From d4c6abbaec670e2ad74715b02da33dbe8b00c096 Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Sun, 22 Aug 2021 17:52:54 -0500 Subject: [PATCH] feat: RPC POST for function w/single unnamed param For POST on RPC, allows: * passing a json object without using `Prefer: params=single-object` The function must be defined with a single unnamed json param and `Content-Type: application/json` must be specified. * uploading binary to a function The function must be defined with a single unnamed bytea param and `Content-Type: application/octet-stream` must be specified. * uploading raw text to a function The function must be defined with a single unnamed text param and `Content-Type: text/plain` must be specified. BREAKING CHANGE If there's a function "my_func" having a single unnamed json param and other overloaded pairs(with any number of params), PostgREST won't be able to resolve a POST request to "my_func". For solving this, you can name the unnamed json param. my_func(json) -> my_func(prm json) --- CHANGELOG.md | 9 ++- src/PostgREST/Error.hs | 16 +++-- src/PostgREST/Query/QueryBuilder.hs | 21 +++--- src/PostgREST/Query/SqlFragment.hs | 17 +++-- src/PostgREST/Request/ApiRequest.hs | 63 ++++++++++------- src/PostgREST/Request/DbRequestBuilder.hs | 19 ++--- src/PostgREST/Request/Types.hs | 10 ++- test/C.png | Bin 0 -> 324 bytes test/Feature/RpcSpec.hs | 80 +++++++++++++++++++++- test/QueryCost.hs | 22 +++--- test/fixtures/schema.sql | 28 +++++++- 11 files changed, 209 insertions(+), 76 deletions(-) create mode 100644 test/C.png diff --git a/CHANGELOG.md b/CHANGELOG.md index c678872a5..c71118e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,17 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #1783, Include partitioned tables into the schema cache. Allows embedding, UPSERT, INSERT with Location response, OPTIONS request and OpenAPI support for partitioned tables - @laurenceisla - #1878, Add Retry-After hint header when in recovery mode - @gautam1168 - + - #1735, Allow calling function with single unnamed param through RPC POST. - @steve-chavez + + Enables calling a function with a single json parameter without using `Prefer: params=single-object` + + Enables uploading bytea to a function with `Content-Type: application/octet-stream` + + Enables uploading raw text to a function with `Content-Type: text/plain` + ### Fixed - #1871, Fix OpenAPI missing default values for String types and identify Array types as "array" instead of "string" - @laurenceisla +### Changed + + - #1927, Overloaded Functions: If there's a function "my_func" having a single unnamed json param and other overloaded pairs(with any number of params), PostgREST won't be able to resolve a POST request to "my_func". For solving this, you can name the unnamed json param `my_func(json) -> my_func(prm json)`. ## [8.0.0] - 2021-07-25 diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 689945505..b11d2954a 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -60,7 +60,7 @@ data ApiRequestError | NoRelBetween Text Text | AmbiguousRelBetween Text Text [Relationship] | AmbiguousRpc [ProcDescription] - | NoRpc Text Text [Text] Bool + | NoRpc Text Text [Text] Bool ContentType Bool | InvalidFilters | UnacceptableSchema [Text] | ContentTypeError [ByteString] @@ -99,14 +99,18 @@ instance JSON.ToJSON ApiRequestError where "message" .= ("More than one relationship was found for " <> parent <> " and " <> child :: Text), "details" .= (compressedRel <$> rels) ] toJSON (AmbiguousRpc procs) = JSON.object [ - "hint" .= ("Overloaded functions with the same parameter name but different types are not supported" :: Text), + "hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text), "message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs])] - toJSON (NoRpc schema procName payloadKeys hasPreferSingleObject) = JSON.object [ + toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost) = + let prms = "(" <> T.intercalate ", " argumentKeys <> ")" in JSON.object [ "hint" .= ("If a new function was created in the database with this name and parameters, try reloading the schema cache." :: Text), "message" .= ("Could not find the " <> schema <> "." <> procName <> - (if hasPreferSingleObject - then " function with a single json or jsonb parameter" - else "(" <> T.intercalate ", " payloadKeys <> ")" <> " function") <> + (case (hasPreferSingleObject, isInvPost, contentType) of + (True, _, _) -> " function with a single json or jsonb parameter" + (_, True, CTTextPlain) -> " function with a single unnamed text parameter" + (_, True, CTOctetStream) -> " function with a single unnamed bytea parameter" + (_, True, CTApplicationJSON) -> prms <> " function or the " <> schema <> "." <> procName <>" function with a single unnamed json or jsonb parameter" + _ -> prms <> " function") <> " in the schema cache")] toJSON UnsupportedVerb = JSON.object [ "message" .= ("Unsupported HTTP verb" :: Text)] diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 9eb30cf60..5542b5d76 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -116,26 +116,27 @@ mutateRequestToQuery (Delete mainQi logicForest returnings) = H.sql (returningF mainQi returnings) requestToCallProcQuery :: CallRequest -> H.Snippet -requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall singleParam returnings) = +requestToCallProcQuery (FunctionCall qi params args returnsScalar multipleCall returnings) = prmsCTE <> argsBody where - (prmsCTE, argFrag) - | null params = (mempty, mempty) - | singleParam = ("WITH pgrst_args AS (SELECT NULL)", jsonPlaceHolder args) - | otherwise = ( + (prmsCTE, argFrag) = case params of + OnePosParam prm -> ("WITH pgrst_args AS (SELECT NULL)", singleParameter args (encodeUtf8 $ ppType prm)) + KeyParams [] -> (mempty, mempty) + KeyParams prms -> ( "WITH " <> normalizedBody args <> ", " <> H.sql ( BS.unwords [ "pgrst_args AS (", - "SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtParams (const mempty) (\a -> " " <> encodeUtf8 (ppType a)) <> ")", + "SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtParams prms (const mempty) (\a -> " " <> encodeUtf8 (ppType a)) <> ")", ")"]) , H.sql $ if multipleCall - then fmtParams varadicPrefix (\a -> " := pgrst_args." <> pgFmtIdent (ppName a)) - else fmtParams varadicPrefix (\a -> " := (SELECT " <> pgFmtIdent (ppName a) <> " FROM pgrst_args LIMIT 1)") + then fmtParams prms varadicPrefix (\a -> " := pgrst_args." <> pgFmtIdent (ppName a)) + else fmtParams prms varadicPrefix (\a -> " := (SELECT " <> pgFmtIdent (ppName a) <> " FROM pgrst_args LIMIT 1)") ) - fmtParams :: (ProcParam -> SqlFragment) -> (ProcParam -> SqlFragment) -> SqlFragment - fmtParams prmFragPre prmFragSuf = BS.intercalate ", " ((\a -> prmFragPre a <> pgFmtIdent (ppName a) <> prmFragSuf a) <$> params) + fmtParams :: [ProcParam] -> (ProcParam -> SqlFragment) -> (ProcParam -> SqlFragment) -> SqlFragment + fmtParams prms prmFragPre prmFragSuf = BS.intercalate ", " + ((\a -> prmFragPre a <> pgFmtIdent (ppName a) <> prmFragSuf a) <$> prms) varadicPrefix :: ProcParam -> SqlFragment varadicPrefix a = if ppVar a then "VARIADIC " else mempty diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index e7eac1c3a..7ecb283ba 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -16,7 +16,6 @@ module PostgREST.Query.SqlFragment , countF , fromQi , ftsOperators - , jsonPlaceHolder , limitOffsetF , locationF , normalizedBody @@ -31,6 +30,7 @@ module PostgREST.Query.SqlFragment , responseStatusF , returningF , selectBody + , singleParameter , sourceCTEName , unknownEncoder , intercalateSnippet @@ -105,9 +105,10 @@ ftsOperators = HM.fromList [ -- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads -- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays -- We do this in SQL to avoid processing the JSON in application code +-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body normalizedBody :: Maybe BL.ByteString -> H.Snippet normalizedBody body = - "pgrst_payload AS (SELECT " <> jsonPlaceHolder body <> " AS json_data), " <> + "pgrst_payload AS (SELECT " <> jsonPlaceHolder <> " AS json_data), " <> H.sql (BS.unwords [ "pgrst_body AS (", "SELECT", @@ -116,12 +117,14 @@ normalizedBody body = "ELSE json_build_array(json_data)", "END AS val", "FROM pgrst_payload)"]) + where + jsonPlaceHolder = H.encoderAndParam (HE.nullable HE.unknown) (toS <$> body) <> "::json" --- | Equivalent to "$1::json" --- | TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body -jsonPlaceHolder :: Maybe BL.ByteString -> H.Snippet -jsonPlaceHolder body = - H.encoderAndParam (HE.nullable HE.unknown) (toS <$> body) <> "::json" +singleParameter :: Maybe BL.ByteString -> ByteString -> H.Snippet +singleParameter body typ = + if typ == "bytea" + then H.encoderAndParam (HE.nullable HE.bytea) (toS <$> body) -- needed because bytea fails with HE.unknown(pg tries to utf8 encode) + else H.encoderAndParam (HE.nullable HE.unknown) (toS <$> body) <> "::" <> H.sql typ selectBody :: SqlFragment selectBody = "(SELECT val FROM pgrst_body)" diff --git a/src/PostgREST/Request/ApiRequest.hs b/src/PostgREST/Request/ApiRequest.hs index 0f4bac427..062728567 100644 --- a/src/PostgREST/Request/ApiRequest.hs +++ b/src/PostgREST/Request/ApiRequest.hs @@ -13,7 +13,7 @@ module PostgREST.Request.ApiRequest , ContentType(..) , Action(..) , Target(..) - , PayloadJSON(..) + , Payload(..) , userApiRequest ) where @@ -74,18 +74,19 @@ import Protolude.Conv (toS) type RequestBody = BL.ByteString -data PayloadJSON +data Payload = ProcessedJSON -- ^ Cached attributes of a JSON payload - { pjRaw :: BL.ByteString + { payRaw :: BL.ByteString -- ^ This is the raw ByteString that comes from the request body. We -- cache this instead of an Aeson Value because it was detected that for -- large payloads the encoding had high memory usage, see -- https://github.com/PostgREST/postgrest/pull/1005 for more details - , pjKeys :: S.Set Text + , payKeys :: S.Set Text -- ^ Keys of the object or if it's an array these keys are guaranteed to -- be the same across all its objects } - | RawJSON { pjRaw :: BL.ByteString } + | RawJSON { payRaw :: BL.ByteString } + | RawPay { payRaw :: BL.ByteString } data InvokeMethod = InvHead | InvGet | InvPost deriving Eq -- | Types of things a user wants to do to tables/views/procs @@ -124,7 +125,7 @@ toRpcParamValue proc (k, v) | prmIsVariadic k = (k, Variadic [v]) prmIsVariadic prm = isJust $ find (\ProcParam{ppName, ppVar} -> ppName == prm && ppVar) $ pdParams proc -- | Convert rpc params `/rpc/func?a=val1&b=val2` to json `{"a": "val1", "b": "val2"} -jsonRpcParams :: ProcDescription -> [(Text, Text)] -> PayloadJSON +jsonRpcParams :: ProcDescription -> [(Text, Text)] -> Payload jsonRpcParams proc prms = if not $ pdHasVariadic proc then -- if proc has no variadic param, save steps and directly convert to json ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> prms) (S.fromList $ fst <$> prms) @@ -136,7 +137,7 @@ jsonRpcParams proc prms = mergeParams (Variadic a) (Variadic b) = Variadic $ b ++ a mergeParams v _ = v -- repeated params for non-variadic parameters are not merged -targetToJsonRpcParams :: Maybe Target -> [(Text, Text)] -> Maybe PayloadJSON +targetToJsonRpcParams :: Maybe Target -> [(Text, Text)] -> Maybe Payload targetToJsonRpcParams target params = case target of Just TargetProc{tProc} -> Just $ jsonRpcParams tProc params @@ -154,7 +155,7 @@ data ApiRequest = ApiRequest { , iRange :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response , iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level , iTarget :: Target -- ^ The target, be it calling a proc or accessing a table - , iPayload :: Maybe PayloadJSON -- ^ Data sent by client and used for mutation actions + , iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions , iPreferRepresentation :: PreferRepresentation -- ^ If client wants created items echoed back , iPreferParameters :: Maybe PreferParameters -- ^ How to pass parameters to a stored procedure , iPreferCount :: Maybe PreferCount -- ^ Whether the client wants a result count @@ -253,7 +254,7 @@ userApiRequest conf@AppConfig{..} dbStructure req reqBody isTargetingDefaultSpec = case path of PathInfo{pIsDefaultSpec=True} -> True _ -> False - contentType = ContentType.decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" + contentType = maybe CTApplicationJSON ContentType.decodeContentType $ lookupHeader "content-type" columns | action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams) | otherwise = Nothing @@ -264,9 +265,9 @@ userApiRequest conf@AppConfig{..} dbStructure req reqBody (_, ActionInvoke InvHead) -> S.fromList $ fst <$> rpcQParams (CTUrlEncoded, _) -> S.fromList $ map (toS . fst) $ parseSimpleQuery $ toS reqBody _ -> case (relevantPayload, fromRight Nothing parsedColumns) of - (Just ProcessedJSON{pjKeys}, _) -> pjKeys - (Just RawJSON{}, Just cls) -> cls - _ -> S.empty + (Just ProcessedJSON{payKeys}, _) -> payKeys + (Just RawJSON{}, Just cls) -> cls + _ -> S.empty payload = case contentType of CTApplicationJSON -> if isJust columns @@ -282,7 +283,9 @@ userApiRequest conf@AppConfig{..} dbStructure req reqBody let paramsMap = M.fromList $ (toS *** JSON.String . toS) <$> parseSimpleQuery (toS reqBody) in Right $ ProcessedJSON (JSON.encode paramsMap) $ S.fromList (M.keys paramsMap) ct -> - Left $ toS $ "Content-Type not acceptable: " <> ContentType.toMime ct + if isTargetingProc && ct `elem` [CTTextPlain, CTOctetStream] + then Right $ RawPay reqBody + else Left $ toS $ "Content-Type not acceptable: " <> ContentType.toMime ct topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows action = case method of @@ -321,7 +324,9 @@ userApiRequest conf@AppConfig{..} dbStructure req reqBody schema = fromMaybe defaultSchema profile target = let - callFindProc procSch procNam = findProc (QualifiedIdentifier procSch procNam) payloadColumns (hasPrefer (show SingleObject)) $ dbProcs dbStructure + callFindProc procSch procNam = findProc + (QualifiedIdentifier procSch procNam) payloadColumns (hasPrefer (show SingleObject)) (dbProcs dbStructure) + contentType (action == ActionInvoke InvPost) in case path of PathInfo{pSchema, pName, pHasRpc, pIsRootSpec, pIsDefaultSpec} @@ -426,7 +431,7 @@ csvToJson (_, vals) = else JSON.String $ toS str ) -payloadAttributes :: RequestBody -> JSON.Value -> Maybe PayloadJSON +payloadAttributes :: RequestBody -> JSON.Value -> Maybe Payload payloadAttributes raw json = -- Test that Array contains only Objects having the same keys case json of @@ -482,26 +487,34 @@ rawContentTypes AppConfig{..} = Search a pg proc by matching name and arguments keys to parameters. Since a function can be overloaded, the name is not enough to find it. An overloaded function can have a different volatility or even a different return type. -} -findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Either ApiRequestError ProcDescription -findProc qi argumentsKeys paramsAsSingleObject allProcs = +findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> ContentType -> Bool -> Either ApiRequestError ProcDescription +findProc qi argumentsKeys paramsAsSingleObject allProcs contentType isInvPost = case matchProc of - [] -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject + [] -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList argumentsKeys) paramsAsSingleObject contentType isInvPost [proc] -> Right proc procs -> Left $ AmbiguousRpc (toList procs) where matchProc = filter matchesParams $ M.lookupDefault mempty qi allProcs -- first find the proc by name matchesParams proc = let params = pdParams proc in - -- here we don't match by argument key(there isn't one) but by the single parameter type - if paramsAsSingleObject then - case params of - [prm] -> ppType prm `elem` ["json", "jsonb"] - _ -> False + -- exceptional case for Prefer: params=single-object + if paramsAsSingleObject + then length params == 1 && (ppType <$> headMay params) `elem` [Just "json", Just "jsonb"] + -- If the function has no parameters, the arguments keys must be empty as well + else if null params + then null argumentsKeys + -- If the function is called with post and has a single unnamed parameter + -- it can be called depending on content type and the parameter type + else if isInvPost && length params == 1 && (ppName <$> headMay params) == Just mempty + then case headMay params of + Just prm | contentType == CTApplicationJSON -> ppType prm `elem` ["json", "jsonb"] + | contentType == CTTextPlain -> ppType prm == "text" + | contentType == CTOctetStream -> ppType prm == "bytea" + | otherwise -> False + Nothing -> False -- A function has optional and required parameters. Optional parameters have a default value and -- don't require arguments for the function to be executed, required parameters must have an argument present. else case L.partition ppReq params of - -- If the function has no parameters, the arguments keys must be empty as well - ([], []) -> null argumentsKeys -- If the function only has required parameters, the arguments keys must match those parameters (reqParams, []) -> argumentsKeys == S.fromList (ppName <$> reqParams) -- If the function only has optional parameters, the arguments keys can match none or any of them(a subset) diff --git a/src/PostgREST/Request/DbRequestBuilder.hs b/src/PostgREST/Request/DbRequestBuilder.hs index 6f43b9fcc..4d71bbb49 100644 --- a/src/PostgREST/Request/DbRequestBuilder.hs +++ b/src/PostgREST/Request/DbRequestBuilder.hs @@ -48,7 +48,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange) import PostgREST.Request.ApiRequest (Action (..), ApiRequest (..), - PayloadJSON (..)) + Payload (..)) import PostgREST.Request.Parsers import PostgREST.Request.Preferences @@ -344,24 +344,25 @@ mutateRequest schema tName apiRequest pkCols readReq = mapLeft ApiRequestError $ -- update/delete filters can be only on the root table (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest) onlyRoot = filter (not . ( "." `isInfixOf` ) . fst) - body = pjRaw <$> iPayload apiRequest + body = payRaw <$> iPayload apiRequest -- the body is assumed to be json at this stage(ApiRequest validates) callRequest :: ProcDescription -> ApiRequest -> ReadRequest -> CallRequest callRequest proc apiReq readReq = FunctionCall { funCQi = QualifiedIdentifier (pdSchema proc) (pdName proc) -, funCParams = specifiedParams -, funCArgs = pjRaw <$> iPayload apiReq +, funCParams = callParams +, funCArgs = payRaw <$> iPayload apiReq , funCScalar = procReturnsScalar proc , funCMultipleCall = iPreferParameters apiReq == Just MultipleObjects -, funCSingleParam = paramsAsSingleObject , funCReturning = returningCols readReq [] } where paramsAsSingleObject = iPreferParameters apiReq == Just SingleObject - specifiedParams = - if paramsAsSingleObject - then pdParams proc - else filter (\x -> ppName x `S.member` iColumns apiReq) $ pdParams proc + callParams = case pdParams proc of + [prm] | paramsAsSingleObject -> OnePosParam prm + | ppName prm == mempty -> OnePosParam prm + | otherwise -> KeyParams $ specifiedParams [prm] + prms -> KeyParams $ specifiedParams prms + specifiedParams params = filter (\x -> ppName x `S.member` iColumns apiReq) params returningCols :: ReadRequest -> [FieldName] -> [FieldName] returningCols rr@(Node _ forest) pkCols diff --git a/src/PostgREST/Request/Types.hs b/src/PostgREST/Request/Types.hs index ea4735b98..a0f800d61 100644 --- a/src/PostgREST/Request/Types.hs +++ b/src/PostgREST/Request/Types.hs @@ -7,6 +7,7 @@ module PostgREST.Request.Types , Field , Filter(..) , CallQuery(..) + , CallParams(..) , CallRequest , JoinCondition(..) , JsonOperand(..) @@ -40,7 +41,7 @@ import qualified GHC.Show (show) import PostgREST.DbStructure.Identifiers (FieldName, QualifiedIdentifier) -import PostgREST.DbStructure.Proc (ProcParam) +import PostgREST.DbStructure.Proc (ProcParam (..)) import PostgREST.DbStructure.Relationship (Relationship) import PostgREST.RangeQuery (NonnegRange) import PostgREST.Request.Preferences (PreferResolution) @@ -127,14 +128,17 @@ data MutateQuery data CallQuery = FunctionCall { funCQi :: QualifiedIdentifier - , funCParams :: [ProcParam] + , funCParams :: CallParams , funCArgs :: Maybe BL.ByteString , funCScalar :: Bool , funCMultipleCall :: Bool - , funCSingleParam :: Bool , funCReturning :: [FieldName] } +data CallParams + = KeyParams [ProcParam] -- ^ Call with key params: func(a := val1, b:= val2) + | OnePosParam ProcParam -- ^ Call with positional params(only one supported): func(val) + -- | The select value in `/tbl?select=alias:field::cast` type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe EmbedHint) diff --git a/test/C.png b/test/C.png new file mode 100644 index 0000000000000000000000000000000000000000..46dabaa96a5785dbbea8055dc468b39ba1331514 GIT binary patch literal 324 zcmeAS@N?(olHy`uVBq!ia0y~yU=U?sU=ZS9V_;y|A~aQxfq{Xg*vT`5gM)*kh9jke zfq~(-r;B5VMeo}wd%X`Ah`8pzUgmiq^v#8c!m^9Le}uc5SX&#spFh%KEfg?!cMK3` z-)*6NZkI-cfY-vhfVnOUPn|#LG3)W8TSqdMJ(~I6`op{C>5KuVQZ9>qTWWIQ@4-b} zeX6&&J=yX3R%Xo~*RFZ~VIeV}L)Y#Ysdv^F*gTe~DWM4fEQpo) literal 0 HcmV?d00001 diff --git a/test/Feature/RpcSpec.hs b/test/Feature/RpcSpec.hs index 2f6fbef00..9c9f71bd4 100644 --- a/test/Feature/RpcSpec.hs +++ b/test/Feature/RpcSpec.hs @@ -1,11 +1,12 @@ module Feature.RpcSpec where -import qualified Data.ByteString.Lazy as BL (empty) +import qualified Data.ByteString.Lazy as BL (empty, readFile) import Network.Wai (Application) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import Network.HTTP.Types +import System.IO.Unsafe (unsafePerformIO) import Test.Hspec hiding (pendingWith) import Test.Hspec.Wai import Test.Hspec.Wai.JSON @@ -145,8 +146,8 @@ spec actualPgVersion = it "should fail with 300 Multiple Choices without explicit type casts" $ get "/rpc/overloaded_same_args?arg=value" `shouldRespondWith` [json| { - "hint":"Overloaded functions with the same parameter name but different types are not supported", - "message":"Could not choose the best candidate function between: test.overloaded_same_args(arg => integer), test.overloaded_same_args(arg => xml), test.overloaded_same_args(arg => text, num => integer)" } |] + "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved", + "message":"Could not choose the best candidate function between: test.overloaded_same_args(arg => integer), test.overloaded_same_args(arg => xml), test.overloaded_same_args(arg => text, num => integer)"}|] { matchStatus = 300 , matchHeaders = [matchContentTypeJson] } @@ -1059,3 +1060,76 @@ spec actualPgVersion = { matchStatus = 500 , matchHeaders = [ matchContentTypeJson ] } + + context "single unnamed param" $ do + it "can insert json directly" $ + post "/rpc/unnamed_json_param" + [json|{"A": 1, "B": 2, "C": 3}|] + `shouldRespondWith` + [json|{"A": 1, "B": 2, "C": 3}|] + + it "can insert text directly" $ + request methodPost "/rpc/unnamed_text_param" + [("Content-Type", "text/plain"), ("Accept", "text/plain")] + [str|unnamed text arg|] + `shouldRespondWith` + [str|unnamed text arg|] + + it "can insert bytea directly" $ do + let file = unsafePerformIO $ BL.readFile "test/C.png" + r <- request methodPost "/rpc/unnamed_bytea_param" + [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")] + file + liftIO $ do + let respBody = simpleBody r + respBody `shouldBe` file + + it "will err when no function with single unnamed json parameter exists and application/json is specified" $ + request methodPost "/rpc/unnamed_int_param" [("Content-Type", "application/json")] + [json|{"x": 1, "y": 2}|] + `shouldRespondWith` + [json|{ + "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", + "message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache" + }|] + { matchStatus = 404 + , matchHeaders = [ matchContentTypeJson ] + } + + it "will err when no function with single unnamed text parameter exists and text/plain is specified" $ + request methodPost "/rpc/unnamed_int_param" + [("Content-Type", "text/plain")] + [str|a simple text|] + `shouldRespondWith` + [json|{ + "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", + "message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache" + }|] + { matchStatus = 404 + , matchHeaders = [ matchContentTypeJson ] + } + + it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $ + let file = unsafePerformIO $ BL.readFile "test/C.png" in + request methodPost "/rpc/unnamed_int_param" + [("Content-Type", "application/octet-stream")] + file + `shouldRespondWith` + [json|{ + "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.", + "message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache" + }|] + { matchStatus = 404 + , matchHeaders = [ matchContentTypeJson ] + } + + it "will not be able to resolve when a single unnamed json parameter exists and other overloaded functions exist" $ + request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")] + [json|{"x": 1, "y": 2}|] + `shouldRespondWith` + [json| { + "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved", + "message":"Could not choose the best candidate function between: test.overloaded_unnamed_param( => json), test.overloaded_unnamed_param(x => integer, y => integer)"}|] + { matchStatus = 300 + , matchHeaders = [matchContentTypeJson] + } diff --git a/test/QueryCost.hs b/test/QueryCost.hs index 451fa48e2..4e719d2f9 100644 --- a/test/QueryCost.hs +++ b/test/QueryCost.hs @@ -15,7 +15,7 @@ import Protolude hiding (get, toS) import Protolude.Conv (toS) import PostgREST.Query.QueryBuilder (requestToCallProcQuery) -import PostgREST.Request.Types (CallQuery (..)) +import PostgREST.Request.Types import PostgREST.DbStructure.Identifiers import PostgREST.DbStructure.Proc @@ -33,30 +33,32 @@ main = do context "call proc query" $ do it "should not exceed cost when calling setof composite proc" $ do cost <- exec pool $ - requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below") [ProcParam "id" "int" True False] - (Just [str| {"id": 3} |]) False False False []) + requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below") + (KeyParams [ProcParam "id" "int" True False]) + (Just [str| {"id": 3} |]) False False []) liftIO $ cost `shouldSatisfy` (< Just 40) it "should not exceed cost when calling setof composite proc with empty params" $ do cost <- exec pool $ - requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") [] Nothing False False False []) + requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False []) liftIO $ cost `shouldSatisfy` (< Just 30) it "should not exceed cost when calling scalar proc" $ do cost <- exec pool $ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them") - [ProcParam "a" "int" True False, ProcParam "b" "int" True False] - (Just [str| {"a": 3, "b": 4} |]) True False False []) + (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False]) + (Just [str| {"a": 3, "b": 4} |]) True False []) liftIO $ cost `shouldSatisfy` (< Just 10) context "params=multiple-objects" $ do it "should not exceed cost when calling setof composite proc" $ do cost <- exec pool $ - requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below") [ProcParam "id" "int" True False] - (Just [str| [{"id": 1}, {"id": 4}] |]) False True False []) + requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below") + (KeyParams [ProcParam "id" "int" True False]) + (Just [str| [{"id": 1}, {"id": 4}] |]) False True []) liftIO $ do -- lower bound needed for now to make sure that cost is not Nothing cost `shouldSatisfy` (> Just 2000) @@ -65,8 +67,8 @@ main = do it "should not exceed cost when calling scalar proc" $ do cost <- exec pool $ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them") - [ProcParam "a" "int" True False, ProcParam "b" "int" True False] - (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False False []) + (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False]) + (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False []) liftIO $ cost `shouldSatisfy` (< Just 10) diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 58b664cf5..648cc0aea 100644 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1050,7 +1050,7 @@ create function test.ret_point_overloaded(x int, y int) returns test.point_2d as select row(x, y)::test.point_2d; $$ language sql; -create function test.ret_point_overloaded(json) returns json as $$ +create function test.ret_point_overloaded(x json) returns json as $$ select $1; $$ language sql; @@ -2251,4 +2251,28 @@ A test for partitioned tables$$; foreign key (id_a, name_a) references test.partitioned_a (id, name) ); end if; -end$do$; \ No newline at end of file +end$do$; + +create or replace function test.unnamed_json_param(json) returns json as $$ + select $1; +$$ language sql; + +create or replace function test.unnamed_text_param(text) returns text as $$ + select $1; +$$ language sql ; + +create or replace function test.unnamed_bytea_param(bytea) returns bytea as $$ + select $1::bytea; +$$ language sql ; + +create or replace function test.unnamed_int_param(int) returns int as $$ + select $1; +$$ language sql; + +create or replace function test.overloaded_unnamed_param(json) returns int as $$ + select $1; +$$ language sql; + +create or replace function test.overloaded_unnamed_param(x int, y int) returns int as $$ + select x + y; +$$ language sql;