diff --git a/CHANGELOG.md b/CHANGELOG.md index 2261f7d16..4b2c2d36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - #889, Allow more than two conditions in a single and/or - @steve-chavez +- #883, Binary output support for RPC - @steve-chavez ### Fixed diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 7ebff9960..b9dfc5e01 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -232,19 +232,25 @@ app dbStructure conf apiRequest = return $ responseLBS status200 [allOrigins, acceptH] "" (ActionInvoke, TargetProc qi, Just (PayloadJSON payload)) -> - case readSqlParts of + let proc = M.lookup (qiName qi) allProcs + returnsScalar = case proc of + Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True + _ -> False + rpcBinaryField = if returnsScalar + then Right Nothing + else binaryField contentType =<< fldNames + partsField = (,) <$> readSqlParts <*> rpcBinaryField in + case partsField of Left errorResponse -> return errorResponse - Right (q, cq) -> do + Right ((q, cq), bField) -> do let p = V.head payload singular = contentType == CTSingularJSON paramsAsSingleObject = iPreferSingleObjectParameter apiRequest - proc = M.lookup (qiName qi) allProcs - returnsScalar = case proc of - Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True - _ -> False row <- H.query () $ - callProc qi p returnsScalar q cq topLevelRange shouldCount singular - paramsAsSingleObject (contentType == CTTextCSV) + callProc qi p returnsScalar q cq topLevelRange shouldCount + singular paramsAsSingleObject + (contentType == CTTextCSV) + (contentType == CTOctetStream) bField let (tableTotal, queryTotal, body) = fromMaybe (Just 0, 0, "[]") row (status, contentRange) = rangeHeader queryTotal tableTotal @@ -311,7 +317,7 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] - ActionInvoke -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] + ActionInvoke -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] ActionInspect -> [CTOpenAPI, CTApplicationJSON] ActionInfo -> [CTTextCSV] serves sProduces cAccepts = diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index d82dbe37f..e35775990 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -145,8 +145,8 @@ createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys type ProcResults = (Maybe Int64, Int64, ByteString) callProc :: QualifiedIdentifier -> JSON.Object -> Bool -> SqlQuery -> SqlQuery -> NonnegRange -> - Bool -> Bool -> Bool -> Bool -> H.Query () (Maybe ProcResults) -callProc qi params returnsScalar selectQuery countQuery _ countTotal isSingle paramsAsJson asCsv = + Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> H.Query () (Maybe ProcResults) +callProc qi params returnsScalar selectQuery countQuery _ countTotal isSingle paramsAsJson asCsv asBinary binaryField = unicodeStatement sql HE.unit decodeProc True where sql = @@ -155,7 +155,7 @@ callProc qi params returnsScalar selectQuery countQuery _ countTotal isSingle pa SELECT {countResultF} AS total_result_set, 1 AS page_total, - (row_to_json(_postgrest_t)->{_procName})::character varying as body + {scalarBodyF} as body FROM ({selectQuery}) _postgrest_t;|] else [qc| WITH {sourceCTEName} AS ({_callSql}) @@ -165,22 +165,24 @@ callProc qi params returnsScalar selectQuery countQuery _ countTotal isSingle pa {bodyF} as body FROM ({selectQuery}) _postgrest_t;|] - countResultF = if countTotal then "("<>countQuery<>")" else "null::bigint" :: Text + countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text _args = if paramsAsJson then insertableValueWithType "json" $ JSON.Object params else intercalate "," $ map _assignment (HM.toList params) - _procName = pgFmtLit $ qiName qi + _procName = qiName qi _assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v _callSql = [qc|select * from {fromQi qi}({_args}) |] :: Text - _countExpr = if countTotal - then [qc|(select pg_catalog.count(*) from {sourceCTEName})|] - else "null::bigint" :: Text decodeProc = HD.maybeRow procRow procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8 <*> HD.value HD.bytea + scalarBodyF + | asBinary = asBinaryF _procName + | otherwise = "(row_to_json(_postgrest_t)->" <> pgFmtLit _procName <> ")::character varying" + bodyF | isSingle = asJsonSingleF | asCsv = asCsvF + | isJust binaryField = asBinaryF $ fromJust binaryField | otherwise = asJsonF pgFmtIdent :: SqlFragment -> SqlFragment diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 1c90101d1..06c35b950 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -693,27 +693,50 @@ spec = do { matchHeaders = [matchContentTypeJson] } describe "binary output" $ do - it "can query if a single column is selected" $ - request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" - { matchStatus = 200 - , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] - } + context "on GET" $ do + it "can query if a single column is selected" $ + request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] + } - it "fails if a single column is not selected" $ do - request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` 406 - request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` 406 - request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` 406 + it "fails if a single column is not selected" $ do + request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` 406 + request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` 406 + request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` 406 + + it "concatenates results if more than one row is returned" $ + request methodGet "/images_base64?select=img&name=in.A.png,B.png" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] + } + + context "on RPC" $ do + context "Proc that returns scalar" $ + it "can query without selecting column" $ + request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] + } + + context "Proc that returns rows" $ do + it "can query if a single column is selected" $ + request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] + } + + it "fails if a single column is not selected" $ + request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` 406 - it "concatenates results if more than one row is returned" $ - request methodGet "/images_base64?select=img&name=in.A.png,B.png" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=" - { matchStatus = 200 - , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] - } describe "HTTP request env vars" $ do it "custom header is set" $ request methodPost "/rpc/get_guc_value" diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index ec9539b80..c396c4475 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1115,6 +1115,7 @@ create table images ( ); create view images_base64 as ( + -- encoding in base64 puts a '\n' after every 76 character due to legacy reasons, this is isn't necessary here so it's removed select name, replace(encode(img, 'base64'), E'\n', '') as img from images ); @@ -1161,6 +1162,14 @@ $$ language sql; create function test.ret_void() returns void as '' language sql; +create function test.ret_base64_bin() returns text as $$ + select i.img from test.images_base64 i where i.name = 'A.png'; +$$ language sql; + +create function test.ret_rows_with_base64_bin() returns setof test.images_base64 as $$ + select i.name, i.img from test.images_base64 i; +$$ language sql; + create function test.single_article(id integer) returns test.articles as $$ select a.* from test.articles a where a.id = $1; $$ language sql;