feat: sql handlers for custom media types

* test text/html and drop HtmlRawOutputSpec.hs
* all tests passing, removed all pendingWith
* make functions compatible with pg <= 12
* move custom media types tests to own spec
* anyelement aggregate
* apply aggregates without a final function
* overriding works
* overriding anyelement with particular agg
* cannot override vendored media types
* plan spec works with custom aggregate
* renamed media types to make clear which ones are overridable
* correct content negotiation with same weight
* text/tab-separated-values media type
* text/csv with BOM plus content-disposition header
This commit is contained in:
steve-chavez
2023-10-26 09:04:02 -05:00
committed by Steve Chavez
parent 4a90e9fbd9
commit 82b38341bf
28 changed files with 755 additions and 384 deletions
+8 -8
View File
@@ -14,11 +14,19 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2943, Add `handling=strict/lenient` for Prefer header - @taimoorzaeem - #2943, Add `handling=strict/lenient` for Prefer header - @taimoorzaeem
- #2983, Add more data to `Server-Timing` header - @develop7 - #2983, Add more data to `Server-Timing` header - @develop7
- #2441, Add config `server-cors-allowed-origins` to specify CORS origins - @taimoorzaeem - #2441, Add config `server-cors-allowed-origins` to specify CORS origins - @taimoorzaeem
- #2825, SQL handlers for custom media types - @steve-chavez
- Solves #1548, #2699, #2763, #2170, #1462, #1102, #1374, #2901
### Fixed ### Fixed
- #3015, Fix unnecessary count() on RPC returning single - @steve-chavez - #3015, Fix unnecessary count() on RPC returning single - @steve-chavez
### Changed
- Removed [raw-media-types config](https://postgrest.org/en/v11.1/references/configuration.html#raw-media-types) - @steve-chavez
- Removed `application/octet-stream`, `text/plain`, `text/xml` [builtin support for scalar results](https://postgrest.org/en/v11.1/references/api/resource_representation.html#scalar-function-response-format) - @steve-chavez
- Removed default `application/openapi+json` media type for [db-root-spec](https://postgrest.org/en/v11.1/references/configuration.html#db-root-spec) - @steve-chavez
## [11.2.2] - 2023-10-25 ## [11.2.2] - 2023-10-25
### Fixed ### Fixed
@@ -86,14 +94,6 @@ This project adheres to [Semantic Versioning](http://semver.org/).
+ The `/table?select=*,other!fk(*)` must be used to disambiguate + The `/table?select=*,other!fk(*)` must be used to disambiguate
+ The server aids in choosing the `!fk` by sending a `hint` on the error whenever an ambiguous request happens. + The server aids in choosing the `!fk` by sending a `hint` on the error whenever an ambiguous request happens.
### Changed
- #1462, #1548, Removed [raw-media-types config](https://postgrest.org/en/v11.1/references/configuration.html#raw-media-types) - @steve-chavez
+ Can be replaced with custom media types
- #1462, #1548, Removed `application/octet-stream`, `text/plain`, `text/xml` [builtin support for scalar results](https://postgrest.org/en/v11.1/references/api/resource_representation.html#scalar-function-response-format) - @steve-chavez
+ Can be replaced with custom media types
- #1462, #1548, Removed default `application/openapi+json` media type for [db-root-spec](https://postgrest.org/en/v11.1/references/configuration.html#db-root-spec) - @steve-chavez
## [11.1.0] - 2023-06-07 ## [11.1.0] - 2023-06-07
### Added ### Added
+1 -1
View File
@@ -204,11 +204,11 @@ test-suite spec
Feature.OptionsSpec Feature.OptionsSpec
Feature.Query.AndOrParamsSpec Feature.Query.AndOrParamsSpec
Feature.Query.ComputedRelsSpec Feature.Query.ComputedRelsSpec
Feature.Query.CustomMediaSpec
Feature.Query.DeleteSpec Feature.Query.DeleteSpec
Feature.Query.EmbedDisambiguationSpec Feature.Query.EmbedDisambiguationSpec
Feature.Query.EmbedInnerJoinSpec Feature.Query.EmbedInnerJoinSpec
Feature.Query.ErrorSpec Feature.Query.ErrorSpec
Feature.Query.HtmlRawOutputSpec
Feature.Query.InsertSpec Feature.Query.InsertSpec
Feature.Query.JsonOperatorSpec Feature.Query.JsonOperatorSpec
Feature.Query.MultipleSchemaSpec Feature.Query.MultipleSchemaSpec
+1 -1
View File
@@ -220,7 +220,7 @@ handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@A
return $ pgrstResponse metrics pgrst return $ pgrstResponse metrics pgrst
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do (ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
(planTime', iPlan) <- withTiming $ liftEither $ Plan.inspectPlan conf apiReq (planTime', iPlan) <- withTiming $ liftEither $ Plan.inspectPlan apiReq
(rsTime', oaiResult) <- withTiming $ runQuery roleIsoLvl (Plan.ipTxmode iPlan) $ Query.openApiQuery sCache pgVer conf tSchema (rsTime', oaiResult) <- withTiming $ runQuery roleIsoLvl (Plan.ipTxmode iPlan) $ Query.openApiQuery sCache pgVer conf tSchema
(renderTime', pgrst) <- withTiming $ liftEither $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile (renderTime', pgrst) <- withTiming $ liftEither $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
let metrics = Map.fromList [(SMPlan, planTime'), (SMQuery, rsTime'), (SMRender, renderTime'), jwtTime] let metrics = Map.fromList [(SMPlan, planTime'), (SMQuery, rsTime'), (SMRender, renderTime'), jwtTime]
+1 -1
View File
@@ -91,7 +91,7 @@ instance PgrstError ApiRequestError where
status SingularityError{} = HTTP.status406 status SingularityError{} = HTTP.status406
status PGRSTParseError = HTTP.status500 status PGRSTParseError = HTTP.status500
headers SingularityError{} = [MediaType.toContentType $ MTSingularJSON False] headers SingularityError{} = [MediaType.toContentType $ MTVndSingularJSON False]
headers _ = mempty headers _ = mempty
toJsonPgrstError :: ErrorCode -> Text -> Maybe JSON.Value -> Maybe JSON.Value -> JSON.Value toJsonPgrstError :: ErrorCode -> Text -> Maybe JSON.Value -> Maybe JSON.Value -> JSON.Value
+32 -42
View File
@@ -1,9 +1,10 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.MediaType module PostgREST.MediaType
( MediaType(..) ( MediaType(..)
, MTPlanOption (..) , MTVndPlanOption (..)
, MTPlanFormat (..) , MTVndPlanFormat (..)
, toContentType , toContentType
, toMime , toMime
, decodeMediaType , decodeMediaType
@@ -19,8 +20,6 @@ import Protolude
-- | Enumeration of currently supported media types -- | Enumeration of currently supported media types
data MediaType data MediaType
= MTApplicationJSON = MTApplicationJSON
| MTArrayJSONStrip
| MTSingularJSON Bool
| MTGeoJSON | MTGeoJSON
| MTTextCSV | MTTextCSV
| MTTextPlain | MTTextPlain
@@ -30,32 +29,23 @@ data MediaType
| MTOctetStream | MTOctetStream
| MTAny | MTAny
| MTOther ByteString | MTOther ByteString
-- TODO MTPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes. -- vendored media types
| MTPlan MediaType MTPlanFormat [MTPlanOption] | MTVndArrayJSONStrip
deriving Show | MTVndSingularJSON Bool
instance Eq MediaType where -- TODO MTVndPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
MTApplicationJSON == MTApplicationJSON = True | MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]
MTArrayJSONStrip == MTArrayJSONStrip = True deriving (Eq, Show, Generic)
MTSingularJSON x == MTSingularJSON y = x == y instance Hashable MediaType
MTGeoJSON == MTGeoJSON = True
MTTextCSV == MTTextCSV = True
MTTextPlain == MTTextPlain = True
MTTextXML == MTTextXML = True
MTOpenAPI == MTOpenAPI = True
MTUrlEncoded == MTUrlEncoded = True
MTOctetStream == MTOctetStream = True
MTAny == MTAny = True
MTOther x == MTOther y = x == y
MTPlan{} == MTPlan{} = True
_ == _ = False
data MTPlanOption data MTVndPlanOption
= PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL = PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL
deriving (Eq, Show) deriving (Eq, Show, Generic)
instance Hashable MTVndPlanOption
data MTPlanFormat data MTVndPlanFormat
= PlanJSON | PlanText = PlanJSON | PlanText
deriving (Eq, Show) deriving (Eq, Show, Generic)
instance Hashable MTVndPlanFormat
-- | Convert MediaType to a Content-Type HTTP Header -- | Convert MediaType to a Content-Type HTTP Header
toContentType :: MediaType -> Header toContentType :: MediaType -> Header
@@ -69,31 +59,31 @@ toContentType ct = (hContentType, toMime ct <> charset)
-- | Convert from MediaType to a ByteString representing the mime type -- | Convert from MediaType to a ByteString representing the mime type
toMime :: MediaType -> ByteString toMime :: MediaType -> ByteString
toMime MTApplicationJSON = "application/json" toMime MTApplicationJSON = "application/json"
toMime MTArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped" toMime MTVndArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
toMime MTGeoJSON = "application/geo+json" toMime MTGeoJSON = "application/geo+json"
toMime MTTextCSV = "text/csv" toMime MTTextCSV = "text/csv"
toMime MTTextPlain = "text/plain" toMime MTTextPlain = "text/plain"
toMime MTTextXML = "text/xml" toMime MTTextXML = "text/xml"
toMime MTOpenAPI = "application/openapi+json" toMime MTOpenAPI = "application/openapi+json"
toMime (MTSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped" toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
toMime (MTSingularJSON False) = "application/vnd.pgrst.object+json" toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"
toMime MTUrlEncoded = "application/x-www-form-urlencoded" 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 mt fmt opts) = toMime (MTVndPlan mt fmt opts) =
"application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <> "application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
("; for=\"" <> toMime mt <> "\"") <> ("; for=\"" <> toMime mt <> "\"") <>
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts)) (if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
toMimePlanOption :: MTPlanOption -> ByteString toMimePlanOption :: MTVndPlanOption -> ByteString
toMimePlanOption PlanAnalyze = "analyze" toMimePlanOption PlanAnalyze = "analyze"
toMimePlanOption PlanVerbose = "verbose" toMimePlanOption PlanVerbose = "verbose"
toMimePlanOption PlanSettings = "settings" toMimePlanOption PlanSettings = "settings"
toMimePlanOption PlanBuffers = "buffers" toMimePlanOption PlanBuffers = "buffers"
toMimePlanOption PlanWAL = "wal" toMimePlanOption PlanWAL = "wal"
toMimePlanFormat :: MTPlanFormat -> ByteString toMimePlanFormat :: MTVndPlanFormat -> ByteString
toMimePlanFormat PlanJSON = "json" toMimePlanFormat PlanJSON = "json"
toMimePlanFormat PlanText = "text" toMimePlanFormat PlanText = "text"
@@ -103,25 +93,25 @@ toMimePlanFormat PlanText = "text"
-- MTApplicationJSON -- MTApplicationJSON
-- --
-- >>> decodeMediaType "application/vnd.pgrst.plan;" -- >>> decodeMediaType "application/vnd.pgrst.plan;"
-- MTPlan MTApplicationJSON PlanText [] -- MTVndPlan MTApplicationJSON PlanText []
-- --
-- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\"" -- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\""
-- MTPlan MTApplicationJSON PlanText [] -- MTVndPlan MTApplicationJSON PlanText []
-- --
-- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\"" -- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
-- MTPlan MTTextCSV PlanJSON [] -- MTVndPlan MTTextCSV PlanJSON []
-- --
-- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped" -- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped"
-- MTArrayJSONStrip -- MTVndArrayJSONStrip
-- --
-- >>> decodeMediaType "application/vnd.pgrst.array+json" -- >>> decodeMediaType "application/vnd.pgrst.array+json"
-- MTApplicationJSON -- MTApplicationJSON
-- --
-- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped" -- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped"
-- MTSingularJSON True -- MTVndSingularJSON True
-- --
-- >>> decodeMediaType "application/vnd.pgrst.object+json" -- >>> decodeMediaType "application/vnd.pgrst.object+json"
-- MTSingularJSON False -- MTVndSingularJSON False
decodeMediaType :: BS.ByteString -> MediaType decodeMediaType :: BS.ByteString -> MediaType
decodeMediaType mt = decodeMediaType mt =
@@ -145,11 +135,11 @@ decodeMediaType mt =
other:_ -> MTOther other other:_ -> MTOther other
_ -> MTAny _ -> MTAny
where where
checkArrayNullStrip ["nulls=stripped"] = MTArrayJSONStrip checkArrayNullStrip ["nulls=stripped"] = MTVndArrayJSONStrip
checkArrayNullStrip _ = MTApplicationJSON checkArrayNullStrip _ = MTApplicationJSON
checkSingularNullStrip ["nulls=stripped"] = MTSingularJSON True checkSingularNullStrip ["nulls=stripped"] = MTVndSingularJSON True
checkSingularNullStrip _ = MTSingularJSON False checkSingularNullStrip _ = MTVndSingularJSON False
getPlan fmt rest = getPlan fmt rest =
let let
@@ -161,7 +151,7 @@ decodeMediaType mt =
strippedFor <- BS.stripPrefix "for=" foundFor strippedFor <- BS.stripPrefix "for=" foundFor
pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor
in in
MTPlan mtFor fmt $ MTVndPlan mtFor fmt $
[PlanAnalyze | inOpts "analyze" ] ++ [PlanAnalyze | inOpts "analyze" ] ++
[PlanVerbose | inOpts "verbose" ] ++ [PlanVerbose | inOpts "verbose" ] ++
[PlanSettings | inOpts "settings"] ++ [PlanSettings | inOpts "settings"] ++
+50 -61
View File
@@ -44,8 +44,7 @@ import PostgREST.ApiRequest (Action (..),
Payload (..)) Payload (..))
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.Error (Error (..)) import PostgREST.Error (Error (..))
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MediaType (..))
MediaType (..))
import PostgREST.Query.SqlFragment (sourceCTEName) import PostgREST.Query.SqlFragment (sourceCTEName)
import PostgREST.RangeQuery (NonnegRange, allRange, import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange, convertToLimitZeroRange,
@@ -53,6 +52,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
RelIdentifier (..),
Schema) Schema)
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
@@ -61,7 +61,7 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
relIsToOne) relIsToOne)
import PostgREST.SchemaCache.Representations (DataRepresentation (..), import PostgREST.SchemaCache.Representations (DataRepresentation (..),
RepresentationsMap) RepresentationsMap)
import PostgREST.SchemaCache.Routine (ResultAggregate (..), import PostgREST.SchemaCache.Routine (MediaHandler (..),
Routine (..), Routine (..),
RoutineMap, RoutineMap,
RoutineParam (..), RoutineParam (..),
@@ -93,16 +93,18 @@ import Protolude hiding (from)
data WrappedReadPlan = WrappedReadPlan { data WrappedReadPlan = WrappedReadPlan {
wrReadPlan :: ReadPlanTree wrReadPlan :: ReadPlanTree
, wrTxMode :: SQL.Mode , wrTxMode :: SQL.Mode
, wrResAgg :: ResultAggregate , wrHandler :: MediaHandler
, wrMedia :: MediaType , wrMedia :: MediaType
, wrIdent :: QualifiedIdentifier
} }
data MutateReadPlan = MutateReadPlan { data MutateReadPlan = MutateReadPlan {
mrReadPlan :: ReadPlanTree mrReadPlan :: ReadPlanTree
, mrMutatePlan :: MutatePlan , mrMutatePlan :: MutatePlan
, mrTxMode :: SQL.Mode , mrTxMode :: SQL.Mode
, mrResAgg :: ResultAggregate , mrHandler :: MediaHandler
, mrMedia :: MediaType , mrMedia :: MediaType
, mrIdent :: QualifiedIdentifier
} }
data CallReadPlan = CallReadPlan { data CallReadPlan = CallReadPlan {
@@ -110,8 +112,9 @@ data CallReadPlan = CallReadPlan {
, crCallPlan :: CallPlan , crCallPlan :: CallPlan
, crTxMode :: SQL.Mode , crTxMode :: SQL.Mode
, crProc :: Routine , crProc :: Routine
, crResAgg :: ResultAggregate , crHandler :: MediaHandler
, crMedia :: MediaType , crMedia :: MediaType
, crIdent :: QualifiedIdentifier
} }
data InspectPlan = InspectPlan { data InspectPlan = InspectPlan {
@@ -122,17 +125,17 @@ data InspectPlan = InspectPlan {
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} = do wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} = do
rPlan <- readPlan identifier conf sCache apiRequest rPlan <- readPlan identifier conf sCache apiRequest
mediaType <- mapLeft ApiRequestError $ negotiateContent conf iAction iAcceptMediaType (hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right () if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
return $ WrappedReadPlan rPlan SQL.Read (mediaToAggregate mediaType apiRequest) mediaType return $ WrappedReadPlan rPlan SQL.Read hdler mediaType identifier
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
rPlan <- readPlan identifier conf sCache apiRequest rPlan <- readPlan identifier conf sCache apiRequest
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
mediaType <- mapLeft ApiRequestError $ negotiateContent conf iAction iAcceptMediaType
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right () if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
return $ MutateReadPlan rPlan mPlan SQL.Write (mediaToAggregate mediaType apiRequest) mediaType (hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
return $ MutateReadPlan rPlan mPlan SQL.Write hdler mediaType identifier
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan
callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} invMethod = do callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} invMethod = do
@@ -156,15 +159,19 @@ callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferenc
(InvPost, Routine.Immutable) -> SQL.Read (InvPost, Routine.Immutable) -> SQL.Read
(InvPost, Routine.Volatile) -> SQL.Write (InvPost, Routine.Volatile) -> SQL.Write
cPlan = callPlan proc apiRequest paramKeys args rPlan cPlan = callPlan proc apiRequest paramKeys args rPlan
mediaType <- mapLeft ApiRequestError $ negotiateContent conf iAction iAcceptMediaType (hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest relIdentifier iAcceptMediaType (dbMediaHandlers sCache)
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right () if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
return $ CallReadPlan rPlan cPlan txMode proc (mediaToAggregate mediaType apiRequest) mediaType return $ CallReadPlan rPlan cPlan txMode proc hdler mediaType relIdentifier
where where
qsParams' = QueryParams.qsParams iQueryParams qsParams' = QueryParams.qsParams iQueryParams
inspectPlan :: AppConfig -> ApiRequest -> Either Error InspectPlan inspectPlan :: ApiRequest -> Either Error InspectPlan
inspectPlan conf apiRequest = do inspectPlan apiRequest = do
mediaType <- mapLeft ApiRequestError $ negotiateContent conf (iAction apiRequest) (iAcceptMediaType apiRequest) let producedMTs = [MTOpenAPI, MTApplicationJSON, MTAny]
accepts = iAcceptMediaType apiRequest
mediaType <- if not . null $ L.intersect accepts producedMTs
then Right MTOpenAPI
else Left . ApiRequestError . MediaTypeError $ MediaType.toMime <$> accepts
return $ InspectPlan mediaType SQL.Read return $ InspectPlan mediaType SQL.Read
{-| {-|
@@ -824,52 +831,34 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree] addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree]
addFilterToLogicForest flt lf = CoercibleStmnt flt : lf addFilterToLogicForest flt lf = CoercibleStmnt flt : lf
mediaToAggregate :: MediaType -> ApiRequest -> ResultAggregate
mediaToAggregate mt apiReq@ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} =
if noAgg then NoAgg
else case mt of
MTApplicationJSON -> BuiltinAggJson
MTSingularJSON strip -> BuiltinAggSingleJson strip
MTArrayJSONStrip -> BuiltinAggArrayJsonStrip
MTGeoJSON -> BuiltinAggGeoJson
MTTextCSV -> BuiltinAggCsv
MTAny -> BuiltinAggJson
MTOpenAPI -> BuiltinAggJson
MTUrlEncoded -> NoAgg -- TODO: unreachable since a previous step (producedMediaTypes) whitelists the media types that can become aggregates.
-- Doing `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.plan"` doesn't make sense, so we just empty the body.
-- TODO: fail instead to be more strict
MTPlan (MTPlan{}) _ _ -> NoAgg
MTPlan media _ _ -> mediaToAggregate media apiReq
_ -> NoAgg
where
noAgg = case act of
ActionMutate _ -> rep == Just HeadersOnly || rep == Just None || isNothing rep
ActionRead _isHead -> _isHead -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
ActionInvoke invMethod -> invMethod == InvHead
_ -> False
-- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types. -- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
negotiateContent :: AppConfig -> Action -> [MediaType] -> Either ApiRequestError MediaType negotiateContent :: AppConfig -> ApiRequest -> QualifiedIdentifier -> [MediaType] ->
negotiateContent conf action accepts = HM.HashMap (RelIdentifier, MediaType) MediaHandler -> Either ApiRequestError (MediaHandler, MediaType)
case firstAcceptedPick of negotiateContent conf ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} identifier accepts produces =
Just MTAny -> Right MTApplicationJSON -- by default(for */*) we respond with json mtAnyToJSON $ case (act, firstAcceptedPick) of
Just mt -> Right mt (_, Nothing) -> Left . MediaTypeError $ map MediaType.toMime accepts
Nothing -> Left . MediaTypeError $ map MediaType.toMime accepts (ActionMutate _, Just (x, mt)) -> Right (if rep == Just Full then x else NoAgg, mt)
-- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
-- TODO: despite no aggregate, these are responding with a Content-Type, which is not correct.
(ActionRead True, Just (_, mt)) -> Right (NoAgg, mt)
(ActionInvoke InvHead, Just (_, mt)) -> Right (NoAgg, mt)
(_, Just (x, mt)) -> Right (x, mt)
where where
-- TODO initial */* is not overridable
-- initial handlers in the schema cache have a */* to BuiltinAggJson but they don't preserve the media type (application/json)
-- for now we just convert the resultant */* to application/json here
mtAnyToJSON = mapRight (\(x, y) -> (x, if y == MTAny then MTApplicationJSON else y))
-- if there are multiple accepted media types, pick the first -- if there are multiple accepted media types, pick the first
firstAcceptedPick = listToMaybe $ L.intersect accepts $ producedMediaTypes conf action firstAcceptedPick = listToMaybe $ mapMaybe searchMT accepts
lookupIdent mt = -- first search for an aggregate that applies to the particular relation, then for one that applies to anyelement
producedMediaTypes :: AppConfig -> Action -> [MediaType] HM.lookup (RelId identifier, mt) produces <|> HM.lookup (RelAnyElement, mt) produces
producedMediaTypes conf action = searchMT mt = case mt of
case action of -- all the vendored media types have special handling as they have media type parameters, they cannot be overridden
ActionRead _ -> defaultMediaTypes m@(MTVndSingularJSON strip) -> Just (BuiltinAggSingleJson strip, m)
ActionInvoke _ -> defaultMediaTypes m@MTVndArrayJSONStrip -> Just (BuiltinAggArrayJsonStrip, m)
ActionInfo -> defaultMediaTypes m@(MTVndPlan (MTVndSingularJSON strip) _ _) -> mtPlanToNothing $ Just (BuiltinAggSingleJson strip, m)
ActionMutate _ -> defaultMediaTypes m@(MTVndPlan MTVndArrayJSONStrip _ _) -> mtPlanToNothing $ Just (BuiltinAggArrayJsonStrip, m)
ActionInspect _ -> inspectMediaTypes -- all the other media types can be overridden
where m@(MTVndPlan mType _ _) -> mtPlanToNothing $ (,) <$> lookupIdent mType <*> pure m
inspectMediaTypes = [MTOpenAPI, MTApplicationJSON, MTArrayJSONStrip, MTAny] x -> (,) <$> lookupIdent x <*> pure x
defaultMediaTypes = mtPlanToNothing x = if configDbPlanEnabled conf then x else Nothing -- don't find anything if the plan media type is not allowed
[MTApplicationJSON, MTArrayJSONStrip, MTSingularJSON True, MTSingularJSON False, MTGeoJSON, MTTextCSV] ++
[MTPlan MTApplicationJSON PlanText mempty | configDbPlanEnabled conf] ++ [MTAny]
+8 -5
View File
@@ -71,6 +71,7 @@ readQuery WrappedReadPlan{..} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=
resultSet <- resultSet <-
lift . SQL.statement mempty $ lift . SQL.statement mempty $
Statements.prepareRead Statements.prepareRead
wrIdent
(QueryBuilder.readPlanToQuery wrReadPlan) (QueryBuilder.readPlanToQuery wrReadPlan)
(if preferCount == Just EstimatedCount then (if preferCount == Just EstimatedCount then
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
@@ -80,7 +81,7 @@ readQuery WrappedReadPlan{..} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=
) )
(shouldCount preferCount) (shouldCount preferCount)
wrMedia wrMedia
wrResAgg wrHandler
configDbPreparedStatements configDbPreparedStatements
failNotSingular wrMedia resultSet failNotSingular wrMedia resultSet
optionalRollback conf apiReq optionalRollback conf apiReq
@@ -155,13 +156,14 @@ invokeQuery rout CallReadPlan{..} apiReq@ApiRequest{iPreferences=Preferences{..}
resultSet <- resultSet <-
lift . SQL.statement mempty $ lift . SQL.statement mempty $
Statements.prepareCall Statements.prepareCall
crIdent
rout rout
(QueryBuilder.callPlanToQuery crCallPlan pgVer) (QueryBuilder.callPlanToQuery crCallPlan pgVer)
(QueryBuilder.readPlanToQuery crReadPlan) (QueryBuilder.readPlanToQuery crReadPlan)
(QueryBuilder.readPlanToCountQuery crReadPlan) (QueryBuilder.readPlanToCountQuery crReadPlan)
(shouldCount preferCount) (shouldCount preferCount)
crMedia crMedia
crResAgg crHandler
configDbPreparedStatements configDbPreparedStatements
optionalRollback conf apiReq optionalRollback conf apiReq
@@ -186,17 +188,18 @@ openApiQuery sCache pgVer AppConfig{..} tSchema =
pure Nothing pure Nothing
writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg, mrMedia} ApiRequest{iPreferences=Preferences{..}} conf = writeQuery MutateReadPlan{..} ApiRequest{iPreferences=Preferences{..}} conf =
let let
(isInsert, pkCols) = case mrMutatePlan of {Insert{insPkCols} -> (True, insPkCols); _ -> (False, mempty);} (isInsert, pkCols) = case mrMutatePlan of {Insert{insPkCols} -> (True, insPkCols); _ -> (False, mempty);}
in in
lift . SQL.statement mempty $ lift . SQL.statement mempty $
Statements.prepareWrite Statements.prepareWrite
mrIdent
(QueryBuilder.readPlanToQuery mrReadPlan) (QueryBuilder.readPlanToQuery mrReadPlan)
(QueryBuilder.mutatePlanToQuery mrMutatePlan) (QueryBuilder.mutatePlanToQuery mrMutatePlan)
isInsert isInsert
mrMedia mrMedia
mrResAgg mrHandler
preferRepresentation preferRepresentation
pkCols pkCols
(configDbPreparedStatements conf) (configDbPreparedStatements conf)
@@ -207,7 +210,7 @@ writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg, mrMedia} ApiReques
failNotSingular :: MediaType -> ResultSet -> DbHandler () failNotSingular :: MediaType -> ResultSet -> DbHandler ()
failNotSingular _ RSPlan{} = pure () failNotSingular _ RSPlan{} = pure ()
failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} = failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} =
when (elem mediaType [MTSingularJSON True,MTSingularJSON False] && queryTotal /= 1) $ do when (elem mediaType [MTVndSingularJSON True, MTVndSingularJSON False] && queryTotal /= 1) $ do
lift SQL.condemn lift SQL.condemn
throwError $ Error.ApiRequestError . ApiRequestTypes.SingularityError $ toInteger queryTotal throwError $ Error.ApiRequestError . ApiRequestTypes.SingularityError $ toInteger queryTotal
+17 -11
View File
@@ -7,7 +7,7 @@ Description : Helper functions for PostgREST.QueryBuilder.
-} -}
module PostgREST.Query.SqlFragment module PostgREST.Query.SqlFragment
( noLocationF ( noLocationF
, aggF , handlerF
, countF , countF
, fromQi , fromQi
, limitOffsetF , limitOffsetF
@@ -65,8 +65,8 @@ import PostgREST.ApiRequest.Types (Alias, Cast,
QuantOperator (..), QuantOperator (..),
SimpleOperator (..), SimpleOperator (..),
TrileanVal (..)) TrileanVal (..))
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MTVndPlanFormat (..),
MTPlanOption (..)) MTVndPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..)) import PostgREST.Plan.ReadPlan (JoinCondition (..))
import PostgREST.Plan.Types (CoercibleField (..), import PostgREST.Plan.Types (CoercibleField (..),
CoercibleFilter (..), CoercibleFilter (..),
@@ -77,7 +77,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
rangeLimit, rangeOffset) rangeLimit, rangeOffset)
import PostgREST.SchemaCache.Identifiers (FieldName, import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..)) QualifiedIdentifier (..))
import PostgREST.SchemaCache.Routine (ResultAggregate (..), import PostgREST.SchemaCache.Routine (MediaHandler (..),
Routine (..), Routine (..),
funcReturnsScalar, funcReturnsScalar,
funcReturnsSetOfScalar, funcReturnsSetOfScalar,
@@ -212,6 +212,11 @@ asJsonF rout strip
asGeoJsonF :: SQL.Snippet asGeoJsonF :: SQL.Snippet
asGeoJsonF = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_postgrest_t)::json), '[]'))" asGeoJsonF = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_postgrest_t)::json), '[]'))"
customFuncF :: Maybe Routine -> QualifiedIdentifier -> QualifiedIdentifier -> SQL.Snippet
customFuncF rout funcQi target
| (funcReturnsScalar <$> rout) == Just True = fromQi funcQi <> "(_postgrest_t.pgrst_scalar)"
| otherwise = fromQi funcQi <> "(_postgrest_t::" <> fromQi target <> ")"
locationF :: [Text] -> SQL.Snippet locationF :: [Text] -> SQL.Snippet
locationF pKeys = [qc|( locationF pKeys = [qc|(
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1) WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
@@ -455,13 +460,13 @@ intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet
intercalateSnippet _ [] = mempty intercalateSnippet _ [] = mempty
intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets
explainF :: MTPlanFormat -> [MTPlanOption] -> SQL.Snippet -> SQL.Snippet explainF :: MTVndPlanFormat -> [MTVndPlanOption] -> SQL.Snippet -> SQL.Snippet
explainF fmt opts snip = explainF fmt opts snip =
"EXPLAIN (" <> "EXPLAIN (" <>
SQL.sql (BS.intercalate ", " (fmtPlanFmt fmt : (fmtPlanOpt <$> opts))) <> SQL.sql (BS.intercalate ", " (fmtPlanFmt fmt : (fmtPlanOpt <$> opts))) <>
") " <> snip ") " <> snip
where where
fmtPlanOpt :: MTPlanOption -> BS.ByteString fmtPlanOpt :: MTVndPlanOption -> BS.ByteString
fmtPlanOpt PlanAnalyze = "ANALYZE" fmtPlanOpt PlanAnalyze = "ANALYZE"
fmtPlanOpt PlanVerbose = "VERBOSE" fmtPlanOpt PlanVerbose = "VERBOSE"
fmtPlanOpt PlanSettings = "SETTINGS" fmtPlanOpt PlanSettings = "SETTINGS"
@@ -486,11 +491,12 @@ setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal k
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)] arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
aggF :: Maybe Routine -> ResultAggregate -> SQL.Snippet handlerF :: Maybe Routine -> QualifiedIdentifier -> MediaHandler -> SQL.Snippet
aggF rout = \case handlerF rout target = \case
BuiltinAggJson -> asJsonF rout False
BuiltinAggArrayJsonStrip -> asJsonF rout True BuiltinAggArrayJsonStrip -> asJsonF rout True
BuiltinAggSingleJson strip -> asJsonSingleF rout strip BuiltinAggSingleJson strip -> asJsonSingleF rout strip
BuiltinAggGeoJson -> asGeoJsonF BuiltinOvAggJson -> asJsonF rout False
BuiltinAggCsv -> asCsvF BuiltinOvAggGeoJson -> asGeoJsonF
BuiltinOvAggCsv -> asCsvF
CustomFunc funcQi -> customFuncF rout funcQi target
NoAgg -> "''::text" NoAgg -> "''::text"
+23 -22
View File
@@ -25,11 +25,12 @@ import qualified Hasql.Statement as SQL
import Control.Lens ((^?)) import Control.Lens ((^?))
import PostgREST.ApiRequest.Preferences import PostgREST.ApiRequest.Preferences
import PostgREST.MediaType (MTPlanFormat (..), import PostgREST.MediaType (MTVndPlanFormat (..),
MediaType (..)) MediaType (..))
import PostgREST.Query.SqlFragment import PostgREST.Query.SqlFragment
import PostgREST.SchemaCache.Routine (ResultAggregate (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier)
Routine, funcReturnsSingle) import PostgREST.SchemaCache.Routine (MediaHandler (..), Routine,
funcReturnsSingle)
import Protolude import Protolude
@@ -53,9 +54,9 @@ data ResultSet
| RSPlan BS.ByteString -- ^ the plan of the query | RSPlan BS.ByteString -- ^ the plan of the query
prepareWrite :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate -> prepareWrite :: QualifiedIdentifier -> SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> MediaHandler ->
Maybe PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet Maybe PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet
prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys = prepareWrite qi selectQuery mutateQuery isInsert mt handler rep pKeys =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
@@ -64,7 +65,7 @@ prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys =
"'' AS total_result_set, " <> "'' AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <> "pg_catalog.count(_postgrest_t) AS page_total, " <>
locF <> " AS header, " <> locF <> " AS header, " <>
aggF Nothing rAgg <> " AS body, " <> handlerF Nothing qi handler <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <> responseStatusF <> " AS response_status " <>
"FROM (" <> selectF <> ") _postgrest_t" "FROM (" <> selectF <> ") _postgrest_t"
@@ -80,16 +81,16 @@ prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys =
selectF selectF
-- prevent using any of the column names in ?select= when no response is returned from the CTE -- prevent using any of the column names in ?select= when no response is returned from the CTE
| rAgg == NoAgg = "SELECT * FROM " <> sourceCTE | handler == NoAgg = "SELECT * FROM " <> sourceCTE
| otherwise = selectQuery | otherwise = selectQuery
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTVndPlan{} -> planRow
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False) _ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False)
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate -> Bool -> SQL.Statement () ResultSet prepareRead :: QualifiedIdentifier -> SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> MediaHandler -> Bool -> SQL.Statement () ResultSet
prepareRead selectQuery countQuery countTotal mt rAgg = prepareRead qi selectQuery countQuery countTotal mt handler =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
@@ -98,7 +99,7 @@ prepareRead selectQuery countQuery countTotal mt rAgg =
"SELECT " <> "SELECT " <>
countResultF <> " AS total_result_set, " <> countResultF <> " AS total_result_set, " <>
"pg_catalog.count(_postgrest_t) AS page_total, " <> "pg_catalog.count(_postgrest_t) AS page_total, " <>
aggF Nothing rAgg <> " AS body, " <> handlerF Nothing qi handler <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <> responseStatusF <> " AS response_status " <>
"FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t" "FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t"
@@ -107,13 +108,13 @@ prepareRead selectQuery countQuery countTotal mt rAgg =
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTVndPlan{} -> planRow
_ -> HD.singleRow $ standardRow True _ -> HD.singleRow $ standardRow True
prepareCall :: Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool -> prepareCall :: QualifiedIdentifier -> Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
MediaType -> ResultAggregate -> Bool -> MediaType -> MediaHandler -> Bool ->
SQL.Statement () ResultSet SQL.Statement () ResultSet
prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg = prepareCall qi rout callProcQuery selectQuery countQuery countTotal mt handler =
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
where where
snippet = snippet =
@@ -124,7 +125,7 @@ prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg =
(if funcReturnsSingle rout (if funcReturnsSingle rout
then "1" then "1"
else "pg_catalog.count(_postgrest_t)") <> " AS page_total, " <> else "pg_catalog.count(_postgrest_t)") <> " AS page_total, " <>
aggF (Just rout) rAgg <> " AS body, " <> handlerF (Just rout) qi handler <> " AS body, " <>
responseHeadersF <> " AS response_headers, " <> responseHeadersF <> " AS response_headers, " <>
responseStatusF <> " AS response_status " <> responseStatusF <> " AS response_status " <>
"FROM (" <> selectQuery <> ") _postgrest_t" "FROM (" <> selectQuery <> ") _postgrest_t"
@@ -133,7 +134,7 @@ prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg =
decodeIt :: HD.Result ResultSet decodeIt :: HD.Result ResultSet
decodeIt = case mt of decodeIt = case mt of
MTPlan{} -> planRow MTVndPlan{} -> planRow
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow True) _ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow True)
preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64) preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
@@ -160,8 +161,8 @@ 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 _ fmt opts -> explainF fmt opts snippet MTVndPlan _ 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.
planRow :: HD.Result ResultSet planRow :: HD.Result ResultSet
+3 -3
View File
@@ -350,7 +350,7 @@ makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
& summary .~ pSum & summary .~ pSum
& description .~ mfilter (/="") pDesc & description .~ mfilter (/="") pDesc
& tags .~ Set.fromList ["(rpc) " <> pdName pd] & tags .~ Set.fromList ["(rpc) " <> pdName pd]
& produces ?~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False] & produces ?~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False]
& at 200 ?~ "OK" & at 200 ?~ "OK"
getOp = procOp getOp = procOp
& parameters .~ makeProcGetParams (pdParams pd) & parameters .~ makeProcGetParams (pdParams pd)
@@ -406,8 +406,8 @@ postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecu
& definitions .~ fromList (makeTableDef rels <$> ti) & definitions .~ fromList (makeTableDef rels <$> ti)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs ti)
& paths .~ makePathItems pds ti & paths .~ makePathItems pds ti
& produces .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV] & produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& consumes .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV] & consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef & securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
& security .~ [SecurityRequirement (fromList [(securityDefName, [])]) | allowSecurityDef] & security .~ [SecurityRequirement (fromList [(securityDefName, [])]) | allowSecurityDef]
where where
+96 -2
View File
@@ -28,7 +28,9 @@ module PostgREST.SchemaCache
import Control.Monad.Extra (whenJust) import Control.Monad.Extra (whenJust)
import Data.Aeson ((.=))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.Aeson.Types as JSON
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.HashMap.Strict.InsOrd as HMI import qualified Data.HashMap.Strict.InsOrd as HMI
import qualified Data.Set as S import qualified Data.Set as S
@@ -48,7 +50,8 @@ import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
pgVersion120) pgVersion120)
import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName, import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName,
QualifiedIdentifier (..), QualifiedIdentifier (..),
Schema) RelIdentifier (..),
Schema, isAnyElement)
import PostgREST.SchemaCache.Relationship (Cardinality (..), import PostgREST.SchemaCache.Relationship (Cardinality (..),
Junction (..), Junction (..),
Relationship (..), Relationship (..),
@@ -56,6 +59,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
import PostgREST.SchemaCache.Representations (DataRepresentation (..), import PostgREST.SchemaCache.Representations (DataRepresentation (..),
RepresentationsMap) RepresentationsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), import PostgREST.SchemaCache.Routine (FuncVolatility (..),
MediaHandler (..),
MediaHandlerMap,
PgType (..), PgType (..),
RetType (..), RetType (..),
Routine (..), Routine (..),
@@ -64,6 +69,8 @@ import PostgREST.SchemaCache.Routine (FuncVolatility (..),
import PostgREST.SchemaCache.Table (Column (..), ColumnMap, import PostgREST.SchemaCache.Table (Column (..), ColumnMap,
Table (..), TablesMap) Table (..), TablesMap)
import qualified PostgREST.MediaType as MediaType
import Protolude import Protolude
@@ -72,8 +79,16 @@ data SchemaCache = SchemaCache
, dbRelationships :: RelationshipsMap , dbRelationships :: RelationshipsMap
, dbRoutines :: RoutineMap , dbRoutines :: RoutineMap
, dbRepresentations :: RepresentationsMap , dbRepresentations :: RepresentationsMap
, dbMediaHandlers :: MediaHandlerMap
} }
deriving (Generic, JSON.ToJSON) instance JSON.ToJSON SchemaCache where
toJSON (SchemaCache tabs rels routs reps _) = JSON.object [
"dbTables" .= JSON.toJSON tabs
, "dbRelationships" .= JSON.toJSON rels
, "dbRoutines" .= JSON.toJSON routs
, "dbRepresentations" .= JSON.toJSON reps
, "dbMediaHandlers" .= JSON.emptyArray
]
-- | A view foreign key or primary key dependency detected on its source table -- | A view foreign key or primary key dependency detected on its source table
-- Each column of the key could be referenced multiple times in the view, e.g. -- Each column of the key could be referenced multiple times in the view, e.g.
@@ -113,6 +128,7 @@ data KeyDep
-- | A SQL query that can be executed independently -- | A SQL query that can be executed independently
type SqlQuery = ByteString type SqlQuery = ByteString
querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache
querySchemaCache AppConfig{..} = do querySchemaCache AppConfig{..} = do
SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
@@ -123,6 +139,7 @@ querySchemaCache AppConfig{..} = do
funcs <- SQL.statement schemas $ allFunctions pgVer prepared funcs <- SQL.statement schemas $ allFunctions pgVer prepared
cRels <- SQL.statement mempty $ allComputedRels prepared cRels <- SQL.statement mempty $ allComputedRels prepared
reps <- SQL.statement schemas $ dataRepresentations prepared reps <- SQL.statement schemas $ dataRepresentations prepared
mHdlers <- SQL.statement schemas $ mediaHandlers pgVer prepared
_ <- _ <-
let sleepCall = SQL.Statement "select pg_sleep($1)" (param HE.int4) HD.noResult prepared in let sleepCall = SQL.Statement "select pg_sleep($1)" (param HE.int4) HD.noResult prepared in
whenJust configInternalSCSleep (`SQL.statement` sleepCall) -- only used for testing whenJust configInternalSCSleep (`SQL.statement` sleepCall) -- only used for testing
@@ -135,6 +152,7 @@ querySchemaCache AppConfig{..} = do
, dbRelationships = getOverrideRelationshipsMap rels cRels , dbRelationships = getOverrideRelationshipsMap rels cRels
, dbRoutines = funcs , dbRoutines = funcs
, dbRepresentations = reps , dbRepresentations = reps
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
} }
where where
schemas = toList configDbSchemas schemas = toList configDbSchemas
@@ -169,6 +187,7 @@ removeInternal schemas dbStruct =
HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct) HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct)
, dbRoutines = dbRoutines dbStruct -- procs are only obtained from the exposed schemas, no need to filter them. , dbRoutines = dbRoutines dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API , dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
, dbMediaHandlers = dbMediaHandlers dbStruct
} }
where where
hasInternalJunction ComputedRelationship{} = False hasInternalJunction ComputedRelationship{} = False
@@ -1084,6 +1103,81 @@ allViewsKeyDependencies =
having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1) having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1)
|] |]
initialMediaHandlers :: MediaHandlerMap
initialMediaHandlers =
HM.insert (RelAnyElement, MediaType.MTAny ) BuiltinOvAggJson $
HM.insert (RelAnyElement, MediaType.MTApplicationJSON) BuiltinOvAggJson $
HM.insert (RelAnyElement, MediaType.MTTextCSV ) BuiltinOvAggCsv $
HM.insert (RelAnyElement, MediaType.MTGeoJSON ) BuiltinOvAggGeoJson
HM.empty
mediaHandlers :: PgVersion -> Bool -> SQL.Statement [Schema] MediaHandlerMap
mediaHandlers pgVer =
SQL.Statement sql (arrayParam HE.text) decodeMediaHandlers
where
sql = [q|
with
all_relations as (
select reltype
from pg_class
where relkind in ('v','r','m','f','p')
union
select oid
from pg_type
where typname = 'anyelement'
),
media_types as (
SELECT
t.oid,
lower(t.typname) as typname,
b.oid as base_oid,
b.typname AS basetypname,
t.typnamespace
FROM pg_type t
JOIN pg_type b ON t.typbasetype = b.oid
WHERE
t.typbasetype <> 0 and
t.typname ~* '^[A-Za-z0-9.-]+/[A-Za-z0-9.\+-]+$'
)
select
proc_schema.nspname as handler_schema,
proc.proname as handler_name,
arg_schema.nspname::text as target_schema,
arg_name.typname::text as target_name,
media_types.typname as media_type
from media_types
join pg_proc proc on proc.prorettype = media_types.oid
join pg_namespace proc_schema on proc_schema.oid = proc.pronamespace
join pg_aggregate agg on agg.aggfnoid = proc.oid
join pg_type arg_name on arg_name.oid = proc.proargtypes[0]
join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace
where
proc_schema.nspname = ANY($1) and
proc.pronargs = 1 and
arg_name.oid in (select reltype from all_relations)
union
select
typ_sch.nspname as handler_schema,
mtype.typname as handler_name,
pro_sch.nspname as target_schema,
proname as target_name,
mtype.typname as media_type
from pg_proc proc
join pg_namespace pro_sch on pro_sch.oid = proc.pronamespace
join media_types mtype on proc.prorettype = mtype.oid
join pg_namespace typ_sch on typ_sch.oid = mtype.typnamespace
where NOT proretset
|] <> (if pgVer >= pgVersion110 then " AND prokind = 'f'" else " AND NOT (proisagg OR proiswindow)")
decodeMediaHandlers :: HD.Result MediaHandlerMap
decodeMediaHandlers =
HM.fromList . fmap (\(x, y, z) -> ((if isAnyElement y then RelAnyElement else RelId y, z), CustomFunc x) ) <$> HD.rowList caggRow
where
caggRow = (,,)
<$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
param :: HE.Value a -> HE.Params a param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable param = HE.param . HE.nonNullable
+8
View File
@@ -3,6 +3,8 @@
module PostgREST.SchemaCache.Identifiers module PostgREST.SchemaCache.Identifiers
( QualifiedIdentifier(..) ( QualifiedIdentifier(..)
, RelIdentifier(..)
, isAnyElement
, Schema , Schema
, TableName , TableName
, FieldName , FieldName
@@ -17,6 +19,9 @@ import qualified Data.Text as T
import Protolude import Protolude
data RelIdentifier = RelId QualifiedIdentifier | RelAnyElement
deriving (Eq, Ord, Generic, JSON.ToJSON, JSON.ToJSONKey)
instance Hashable RelIdentifier
-- | Represents a pg identifier with a prepended schema name "schema.table". -- | Represents a pg identifier with a prepended schema name "schema.table".
-- When qiSchema is "", the schema is defined by the pg search_path. -- When qiSchema is "", the schema is defined by the pg search_path.
@@ -28,6 +33,9 @@ data QualifiedIdentifier = QualifiedIdentifier
instance Hashable QualifiedIdentifier instance Hashable QualifiedIdentifier
isAnyElement :: QualifiedIdentifier -> Bool
isAnyElement y = QualifiedIdentifier "pg_catalog" "anyelement" == y
dumpQi :: QualifiedIdentifier -> Text dumpQi :: QualifiedIdentifier -> Text
dumpQi (QualifiedIdentifier s i) = dumpQi (QualifiedIdentifier s i) =
(if T.null s then mempty else s <> ".") <> i (if T.null s then mempty else s <> ".") <> i
+18 -7
View File
@@ -15,16 +15,20 @@ module PostgREST.SchemaCache.Routine
, funcTableName , funcTableName
, funcReturnsCompositeAlias , funcReturnsCompositeAlias
, funcReturnsSingle , funcReturnsSingle
, ResultAggregate(..) , MediaHandlerMap
, MediaHandler(..)
) where ) where
import Data.Aeson ((.=)) import Data.Aeson ((.=))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Hasql.Transaction.Sessions as SQL import qualified Hasql.Transaction.Sessions as SQL
import qualified PostgREST.MediaType as MediaType
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Schema, TableName) RelIdentifier (..), Schema,
TableName)
import Protolude import Protolude
@@ -88,12 +92,17 @@ instance Ord Routine where
-- | It uses a HashMap for a faster lookup. -- | It uses a HashMap for a faster lookup.
type RoutineMap = HM.HashMap QualifiedIdentifier [Routine] type RoutineMap = HM.HashMap QualifiedIdentifier [Routine]
data ResultAggregate -- | A media handler can be an aggregate over a composite type or a function over a scalar
= BuiltinAggJson data MediaHandler
| BuiltinAggSingleJson Bool -- non overridable builtins
= BuiltinAggSingleJson Bool
| BuiltinAggArrayJsonStrip | BuiltinAggArrayJsonStrip
| BuiltinAggGeoJson -- these builtins are overridable
| BuiltinAggCsv | BuiltinOvAggJson
| BuiltinOvAggGeoJson
| BuiltinOvAggCsv
-- custom
| CustomFunc QualifiedIdentifier
| NoAgg | NoAgg
deriving (Eq, Show) deriving (Eq, Show)
@@ -133,3 +142,5 @@ funcTableName proc = case pdReturnType proc of
SetOf (Composite qi _) -> Just $ qiName qi SetOf (Composite qi _) -> Just $ qiName qi
Single (Composite qi _) -> Just $ qiName qi Single (Composite qi _) -> Just $ qiName qi
_ -> Nothing _ -> Nothing
type MediaHandlerMap = HM.HashMap (RelIdentifier, MediaType.MediaType) MediaHandler
+1 -1
View File
@@ -102,7 +102,7 @@ postJsonArrayTest(){
echo "Running memory usage tests.." echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "23M" jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "24M"
jsonKeyTest "1M" "POST" "/leak?columns=blob" "16M" jsonKeyTest "1M" "POST" "/leak?columns=blob" "16M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "16M" jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "16M"
+9 -1
View File
@@ -13,7 +13,6 @@ spec :: SpecWith ((), Application)
spec = spec =
describe "root spec function" $ do describe "root spec function" $ do
it "accepts application/openapi+json" $ do it "accepts application/openapi+json" $ do
pendingWith "TBD"
request methodGet "/" request methodGet "/"
[("Accept","application/openapi+json")] "" `shouldRespondWith` [("Accept","application/openapi+json")] "" `shouldRespondWith`
[json|{ [json|{
@@ -21,3 +20,12 @@ spec =
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"} "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
}|] }|]
{ matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] } { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
it "accepts application/json" $ do
request methodGet "/"
[("Accept","application/json")] "" `shouldRespondWith`
[json|{
"swagger": "2.0",
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
}|]
{ matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] }
+224
View File
@@ -0,0 +1,224 @@
module Feature.Query.CustomMediaSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc (str)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith ((), Application)
spec = describe "custom media types" $ do
context "for tables with aggregate" $ do
it "can query if there's an aggregate defined for the table" $ do
r <- request methodGet "/lines" (acceptHdrs "application/vnd.twkb") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "lines.twkb"
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
it "can query by id if there's an aggregate defined for the table" $ do
r <- request methodGet "/lines?id=eq.1" (acceptHdrs "application/vnd.twkb") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "1.twkb"
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
it "will fail if there's no aggregate defined for the table" $ do
request methodGet "/lines" (acceptHdrs "text/plain") ""
`shouldRespondWith`
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/plain"} |]
{ matchStatus = 415
, matchHeaders = [matchContentTypeJson]
}
it "can get raw xml output with Accept: text/xml if there's an aggregate defined" $ do
request methodGet "/xmltest" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<myxml>foo</myxml>bar<foobar><baz/></foobar>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
-- TODO SOH (start of heading) is being added to results
context "for tables with anyelement aggregate" $ do
it "will use the application/vnd.geo2+json media type for any table" $
request methodGet "/lines" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH{\"type\": \"FeatureCollection\", \"hello\": \"world\"}"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
it "will use the more specific application/vnd.geo2 handler for this table" $ do
request methodGet "/shop_bles" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH\"anyelement overridden\""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
request methodGet "/rpc/get_shop_bles" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH\"anyelement overridden\""
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
context "Proc that returns scalar" $ do
it "can get raw output with Accept: text/html" $ do
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html"]
}
it "can get raw output with Accept: text/plain" $ do
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Welcome to PostgREST"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
request methodGet "/rpc/return_scalar_xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<my-xml-tag/>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
request methodGet "/rpc/welcome.xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<html>\n <head>\n <title>PostgREST</title>\n </head>\n <body>\n <h1>Welcome to PostgREST</h1>\n </body>\n</html>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "should fail with function returning text and Accept: text/xml" $ do
request methodGet "/rpc/welcome" (acceptHdrs "text/xml") ""
`shouldRespondWith`
[json|
{"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/xml"}
|]
{ matchStatus = 415
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
context "Proc that returns scalar based on a table" $ do
it "can get an image with Accept: image/png" $ do
r <- request methodGet "/rpc/ret_image" (acceptHdrs "image/png") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "A.png"
simpleHeaders r `shouldContain` [("Content-Type", "image/png")]
context "Proc that returns set of scalars and Accept: text/plain" $
it "will err because only scalars work with media type domains" $ do
request methodGet "/rpc/welcome_twice"
(acceptHdrs "text/plain")
""
`shouldRespondWith`
[json|{"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/plain"}|]
{ matchStatus = 415
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
context "Proc that returns rows and accepts custom media type" $ do
it "works if it has an aggregate defined" $ do
r <- request methodGet "/rpc/get_lines" [("Accept", "application/vnd.twkb")] ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "lines.twkb"
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
it "fails if doesn't have an aggregate defined" $ do
request methodGet "/rpc/get_lines"
(acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: application/octet-stream"} |]
{ matchStatus = 415 }
-- TODO SOH (start of heading) is being added to results
it "works if there's an anyelement aggregate defined" $ do
request methodGet "/rpc/get_lines" (acceptHdrs "application/vnd.geo2+json") ""
`shouldRespondWith`
"\SOH{\"type\": \"FeatureCollection\", \"hello\": \"world\"}"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
}
context "overriding" $ do
it "will override the application/json handler for a single table" $
request methodGet "/ov_json" (acceptHdrs "application/json") ""
`shouldRespondWith`
[json| {"overridden": "true"} |]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
-- TODO SOH (start of heading) is being added to results
it "will override the application/geo+json handler for a single table" $
request methodGet "/lines?id=eq.1" (acceptHdrs "application/geo+json") ""
`shouldRespondWith`
"\SOH{\"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:4326\"}}, \"type\": \"FeatureCollection\", \"features\": [{\"type\": \"Feature\", \"geometry\": {\"type\": \"LineString\", \"coordinates\": [[1, 1], [5, 5]]}, \"properties\": {\"id\": 1, \"name\": \"line-1\"}}]}"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/geo+json; charset=utf-8"]
}
it "will not override vendored media types like application/vnd.pgrst.object" $
request methodGet "/projects?id=eq.1" (acceptHdrs "application/vnd.pgrst.object") ""
`shouldRespondWith`
[json|{"id":1,"name":"Windows 7","client_id":1}|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
context "matches requested media type correctly" $ do
-- https://github.com/PostgREST/postgrest/issues/1462
it "will match image/png according to q values" $ do
r1 <- request methodGet "/rpc/ret_image" (acceptHdrs "image/png, */*") ""
liftIO $ do
simpleBody r1 `shouldBe` readFixtureFile "A.png"
simpleHeaders r1 `shouldContain` [("Content-Type", "image/png")]
r2 <- request methodGet "/rpc/ret_image" (acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/png,*/*;q=0.8") ""
liftIO $ do
simpleBody r2 `shouldBe` readFixtureFile "A.png"
simpleHeaders r2 `shouldContain` [("Content-Type", "image/png")]
-- https://github.com/PostgREST/postgrest/issues/2170
it "will match json in presence of text/plain" $ do
r <- request methodGet "/projects?id=eq.1" (acceptHdrs "text/plain, application/json") ""
liftIO $ do
simpleStatus r `shouldBe` status200
simpleHeaders r `shouldContain` [("Content-Type", "application/json; charset=utf-8")]
-- https://github.com/PostgREST/postgrest/issues/1102
it "will match a custom text/tab-separated-values" $ do
request methodGet "/projects?id=in.(1,2)" (acceptHdrs "text/tab-separated-values") ""
`shouldRespondWith`
"id\tname\tclient_id\n1\tWindows 7\t1\n2\tWindows 10\t1\n"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/tab-separated-values"]
}
-- https://github.com/PostgREST/postgrest/issues/1371#issuecomment-519248984
it "will match a custom text/csv with BOM" $ do
r <- request methodGet "/lines" (acceptHdrs "text/csv") ""
liftIO $ do
simpleBody r `shouldBe` readFixtureFile "lines.csv"
simpleHeaders r `shouldContain` [("Content-Type", "text/csv; charset=utf-8")]
simpleHeaders r `shouldContain` [("Content-Disposition", "attachment; filename=\"lines.csv\"")]
@@ -1,31 +0,0 @@
module Feature.Query.HtmlRawOutputSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Text.Heredoc
import Protolude hiding (get)
import SpecHelper (acceptHdrs)
spec :: SpecWith ((), Application)
spec = describe "When raw-media-types is set to \"text/html\"" $
it "can get raw output with Accept: text/html" $ do
pendingWith "TBD"
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
`shouldRespondWith`
[str|
|<html>
| <head>
| <title>PostgREST</title>
| </head>
| <body>
| <h1>Welcome to PostgREST</h1>
| </body>
|</html>
|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/html"]
}
+28 -12
View File
@@ -275,18 +275,6 @@ spec actualPgVersion = do
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" } resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
totalCost `shouldBe` 68.56 totalCost `shouldBe` 68.56
it "outputs the plan for text/xml" $ do
pendingWith "TBD"
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" $ do describe "text format" $ do
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)"
@@ -442,6 +430,34 @@ spec actualPgVersion = do
liftIO $ do liftIO $ do
resBody `shouldSatisfy` (\t -> T.isInfixOf "Index" (decodeUtf8 $ LBS.toStrict t)) resBody `shouldSatisfy` (\t -> T.isInfixOf "Index" (decodeUtf8 $ LBS.toStrict t))
describe "custom media types" $ do
it "outputs the plan for a scalar function 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| "return_scalar_xml.pgrst_scalar" |]
it "outputs the plan for an aggregate application/vnd.twkb" $ do
r <- request methodGet "/lines"
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/vnd.twkb\"; 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.twkb\"; options=verbose; charset=utf-8")
aggCol `shouldBe`
(
if actualPgVersion >= pgVersion120
then Just [aesonQQ| "twkb_agg(ROW(lines.id, lines.name, lines.geom)::lines)" |]
else Just [aesonQQ| "twkb_agg(ROW(pgrst_source.id, pgrst_source.name, pgrst_source.geom)::lines)" |]
)
disabledSpec :: SpecWith ((), Application) disabledSpec :: SpecWith ((), Application)
disabledSpec = disabledSpec =
it "doesn't work if db-plan-enabled=false(the default)" $ do it "doesn't work if db-plan-enabled=false(the default)" $ do
-55
View File
@@ -1042,61 +1042,6 @@ spec actualPgVersion = do
[json|[{"a$num$":100}]|] [json|[{"a$num$":100}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
context "binary output" $ do
it "can query if a single column is selected" $ do
pendingWith "TBD"
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"]
}
it "can get raw output with Accept: text/plain" $ do
pendingWith "TBD"
request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Windows 7"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
pendingWith "TBD"
request methodGet "/xmltest?select=xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<myxml>foo</myxml>bar<foobar><baz/></foobar>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "fails if a single column is not selected" $ do
pendingWith "TBD"
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
request methodGet "/images?select=*&name=eq.A.png"
(acceptHdrs "application/octet-stream")
""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
request methodGet "/images?name=eq.A.png"
(acceptHdrs "application/octet-stream")
""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
it "concatenates results if more than one row is returned" $ do
pendingWith "TBD"
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"]
}
describe "values with quotes in IN and NOT IN" $ do describe "values with quotes in IN and NOT IN" $ do
it "succeeds when only quoted values are present" $ do it "succeeds when only quoted values are present" $ do
get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith` get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
+4 -92
View File
@@ -1,12 +1,11 @@
module Feature.Query.RpcSpec where module Feature.Query.RpcSpec where
import qualified Data.ByteString.Lazy as BL (empty, readFile) import qualified Data.ByteString.Lazy as BL (empty)
import Network.Wai (Application) import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import Network.HTTP.Types import Network.HTTP.Types
import System.IO.Unsafe (unsafePerformIO)
import Test.Hspec hiding (pendingWith) import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
@@ -1077,87 +1076,6 @@ spec actualPgVersion =
, matchHeaders = [] , matchHeaders = []
} }
context "binary output" $ do
context "Proc that returns scalar" $ do
it "can query without selecting column" $ do
pendingWith "TBD"
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"]
}
it "can get raw output with Accept: text/plain" $ do
pendingWith "TBD"
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
`shouldRespondWith` "Welcome to PostgREST"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
pendingWith "TBD"
request methodGet "/rpc/return_scalar_xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<my-xml-tag/>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "can get raw xml output with Accept: text/xml" $ do
pendingWith "TBD"
request methodGet "/rpc/welcome.xml" (acceptHdrs "text/xml") ""
`shouldRespondWith`
"<html>\n <head>\n <title>PostgREST</title>\n </head>\n <body>\n <h1>Welcome to PostgREST</h1>\n </body>\n</html>"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
}
it "should fail with function returning text and Accept: text/xml" $ do
pendingWith "TBD"
request methodGet "/rpc/welcome" (acceptHdrs "text/xml") ""
`shouldRespondWith`
[json|
{
"hint":"No function matches the given name and argument types. You might need to add explicit type casts.",
"details":null,
"code":"42883",
"message":"function xmlagg(text) does not exist"
}
|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
context "Proc that returns set of scalars" $
it "can query without selecting column" $ do
pendingWith "TBD"
request methodGet "/rpc/welcome_twice"
(acceptHdrs "text/plain")
""
`shouldRespondWith`
"Welcome to PostgRESTWelcome to PostgREST"
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
}
context "Proc that returns rows" $ do
it "can query if a single column is selected" $ do
pendingWith "TBD"
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"]
}
it "fails if a single column is not selected" $ do
pendingWith "TBD"
request methodPost "/rpc/ret_rows_with_base64_bin"
(acceptHdrs "application/octet-stream") ""
`shouldRespondWith`
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
{ matchStatus = 406 }
context "only for GET rpc" $ do context "only for GET rpc" $ do
it "should fail on mutating procs" $ do it "should fail on mutating procs" $ do
get "/rpc/callcounter" `shouldRespondWith` 405 get "/rpc/callcounter" `shouldRespondWith` 405
@@ -1327,7 +1245,6 @@ spec actualPgVersion =
[json|{"A": 1, "B": 2, "C": 3}|] [json|{"A": 1, "B": 2, "C": 3}|]
it "can insert text directly" $ do it "can insert text directly" $ do
pendingWith "TBD"
request methodPost "/rpc/unnamed_text_param" request methodPost "/rpc/unnamed_text_param"
[("Content-Type", "text/plain"), ("Accept", "text/plain")] [("Content-Type", "text/plain"), ("Accept", "text/plain")]
[str|unnamed text arg|] [str|unnamed text arg|]
@@ -1335,7 +1252,6 @@ spec actualPgVersion =
[str|unnamed text arg|] [str|unnamed text arg|]
it "can insert xml directly" $ do it "can insert xml directly" $ do
pendingWith "TBD"
request methodPost "/rpc/unnamed_xml_param" request methodPost "/rpc/unnamed_xml_param"
[("Content-Type", "text/xml"), ("Accept", "text/xml")] [("Content-Type", "text/xml"), ("Accept", "text/xml")]
[str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|] [str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|]
@@ -1343,8 +1259,7 @@ spec actualPgVersion =
[str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|] [str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|]
it "can insert bytea directly" $ do it "can insert bytea directly" $ do
pendingWith "TBD" let file = readFixtureFile "image.png"
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"
r <- request methodPost "/rpc/unnamed_bytea_param" r <- request methodPost "/rpc/unnamed_bytea_param"
[("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")] [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
file file
@@ -1397,10 +1312,9 @@ spec actualPgVersion =
} }
it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $ it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" in
request methodPost "/rpc/unnamed_int_param" request methodPost "/rpc/unnamed_int_param"
[("Content-Type", "application/octet-stream")] [("Content-Type", "application/octet-stream")]
file (readFixtureFile "image.png")
`shouldRespondWith` `shouldRespondWith`
[json|{ [json|{
"hint": null, "hint": null,
@@ -1429,7 +1343,6 @@ spec actualPgVersion =
} }
it "should be able to fallback to the single unnamed parameter function when other overloaded functions are not found" $ do it "should be able to fallback to the single unnamed parameter function when other overloaded functions are not found" $ do
pendingWith "TBD"
request methodPost "/rpc/overloaded_unnamed_param" request methodPost "/rpc/overloaded_unnamed_param"
[("Content-Type", "application/json")] [("Content-Type", "application/json")]
[json|{"A": 1, "B": 2, "C": 3}|] [json|{"A": 1, "B": 2, "C": 3}|]
@@ -1440,7 +1353,7 @@ spec actualPgVersion =
[str|unnamed text arg|] [str|unnamed text arg|]
`shouldRespondWith` `shouldRespondWith`
[str|unnamed text arg|] [str|unnamed text arg|]
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" let file = readFixtureFile "image.png"
r <- request methodPost "/rpc/overloaded_unnamed_param" r <- request methodPost "/rpc/overloaded_unnamed_param"
[("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")] [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
file file
@@ -1488,7 +1401,6 @@ spec actualPgVersion =
} }
it "should fail on /rpc/unnamed_xml_param when posting invalid xml" $ do it "should fail on /rpc/unnamed_xml_param when posting invalid xml" $ do
pendingWith "TBD"
request methodPost "/rpc/unnamed_xml_param" request methodPost "/rpc/unnamed_xml_param"
[("Content-Type", "text/xml"), ("Accept", "text/xml")] [("Content-Type", "text/xml"), ("Accept", "text/xml")]
[str|<|] [str|<|]
+2 -2
View File
@@ -37,11 +37,11 @@ import qualified Feature.OpenApi.SecurityOpenApiSpec
import qualified Feature.OptionsSpec import qualified Feature.OptionsSpec
import qualified Feature.Query.AndOrParamsSpec import qualified Feature.Query.AndOrParamsSpec
import qualified Feature.Query.ComputedRelsSpec import qualified Feature.Query.ComputedRelsSpec
import qualified Feature.Query.CustomMediaSpec
import qualified Feature.Query.DeleteSpec import qualified Feature.Query.DeleteSpec
import qualified Feature.Query.EmbedDisambiguationSpec import qualified Feature.Query.EmbedDisambiguationSpec
import qualified Feature.Query.EmbedInnerJoinSpec import qualified Feature.Query.EmbedInnerJoinSpec
import qualified Feature.Query.ErrorSpec import qualified Feature.Query.ErrorSpec
import qualified Feature.Query.HtmlRawOutputSpec
import qualified Feature.Query.InsertSpec import qualified Feature.Query.InsertSpec
import qualified Feature.Query.JsonOperatorSpec import qualified Feature.Query.JsonOperatorSpec
import qualified Feature.Query.MultipleSchemaSpec import qualified Feature.Query.MultipleSchemaSpec
@@ -129,6 +129,7 @@ main = do
, ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec actualPgVersion) , ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec actualPgVersion)
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
, ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.CustomMediaSpec" , Feature.Query.CustomMediaSpec.spec)
, ("Feature.Query.DeleteSpec" , Feature.Query.DeleteSpec.spec) , ("Feature.Query.DeleteSpec" , Feature.Query.DeleteSpec.spec)
, ("Feature.Query.EmbedDisambiguationSpec" , Feature.Query.EmbedDisambiguationSpec.spec) , ("Feature.Query.EmbedDisambiguationSpec" , Feature.Query.EmbedDisambiguationSpec.spec)
, ("Feature.Query.EmbedInnerJoinSpec" , Feature.Query.EmbedInnerJoinSpec.spec) , ("Feature.Query.EmbedInnerJoinSpec" , Feature.Query.EmbedInnerJoinSpec.spec)
@@ -149,7 +150,6 @@ main = do
, ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec) , ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec)
, ("Feature.Query.RelatedQueriesSpec" , Feature.Query.RelatedQueriesSpec.spec) , ("Feature.Query.RelatedQueriesSpec" , Feature.Query.RelatedQueriesSpec.spec)
, ("Feature.Query.SpreadQueriesSpec" , Feature.Query.SpreadQueriesSpec.spec) , ("Feature.Query.SpreadQueriesSpec" , Feature.Query.SpreadQueriesSpec.spec)
, ("Feature.Query.HtmlRawOutputSpec" , Feature.Query.HtmlRawOutputSpec.spec)
, ("Feature.NoSuperuserSpec" , Feature.NoSuperuserSpec.spec) , ("Feature.NoSuperuserSpec" , Feature.NoSuperuserSpec.spec)
] ]
+4
View File
@@ -15,6 +15,7 @@ import Data.CaseInsensitive (CI (..), mk, original)
import Data.List (lookup) import Data.List (lookup)
import Data.List.NonEmpty (fromList) import Data.List.NonEmpty (fromList)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import System.IO.Unsafe (unsafePerformIO)
import System.Process (readProcess) import System.Process (readProcess)
import Text.Regex.TDFA ((=~)) import Text.Regex.TDFA ((=~))
@@ -340,3 +341,6 @@ getInsertDataForTiobePlsTable rows =
JSON.encode $ fromList $ [TiobePlsRow {name' = nm, rank = rk} | (nm,rk) <- nameRankList] JSON.encode $ fromList $ [TiobePlsRow {name' = nm, rank = rk} | (nm,rk) <- nameRankList]
where where
nameRankList = [("Lang " <> show i, i) | i <- [20..(rows+20)] ] :: [(Text, Int)] nameRankList = [("Lang " <> show i, i) | i <- [20..(rows+20)] ] :: [(Text, Int)]
readFixtureFile :: FilePath -> BL.ByteString
readFixtureFile file = unsafePerformIO $ BL.readFile $ "test/spec/fixtures/" <> file
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

+3
View File
@@ -858,3 +858,6 @@ TRUNCATE TABLE table_a CASCADE;
INSERT INTO table_a(id, name) VALUES (1, 'Not null 1'), (2, null), (3, 'Not null 2'); INSERT INTO table_a(id, name) VALUES (1, 'Not null 1'), (2, null), (3, 'Not null 2');
TRUNCATE TABLE table_b CASCADE; TRUNCATE TABLE table_b CASCADE;
INSERT INTO table_b(table_a_id, name) VALUES (1, 'Test 1'), (2, 'Test 2'), (null, 'Test 3'); INSERT INTO table_b(table_a_id, name) VALUES (1, 'Test 1'), (2, 'Test 2'), (null, 'Test 3');
TRUNCATE TABLE lines CASCADE;
insert into lines values (1, 'line-1', 'LINESTRING(1 1,5 5)'::extensions.geometry), (2, 'line-2', 'LINESTRING(2 2,6 6)'::extensions.geometry);
+3
View File
@@ -0,0 +1,3 @@
id,name,geom
1,line-1,0102000020E610000002000000000000000000F03F000000000000F03F00000000000014400000000000001440
2,line-2,0102000020E6100000020000000000000000000040000000000000004000000000000018400000000000001840
1 id name geom
2 1 line-1 0102000020E610000002000000000000000000F03F000000000000F03F00000000000014400000000000001440
3 2 line-2 0102000020E6100000020000000000000000000040000000000000004000000000000018400000000000001840
BIN
View File
Binary file not shown.
+211 -26
View File
@@ -62,6 +62,7 @@ CREATE TYPE enum_menagerie_type AS ENUM (
'bar' 'bar'
); );
create type bit as enum ('one', 'two');
SET search_path = postgrest, pg_catalog; SET search_path = postgrest, pg_catalog;
@@ -119,6 +120,20 @@ SET default_tablespace = '';
SET default_with_oids = false; SET default_with_oids = false;
create domain "text/plain" as text;
create domain "text/html" as text;
create domain "text/xml" as pg_catalog.xml;
create domain "application/octet-stream" as bytea;
create domain "image/png" as bytea;
create domain "application/vnd.twkb" as bytea;
create domain "application/openapi+json" as json;
create domain "application/geo+json" as jsonb;
create domain "application/vnd.geo2+json" as jsonb;
create domain "application/json" as json;
create domain "application/vnd.pgrst.object" as json;
create domain "text/tab-separated-values" as text;
create domain "text/csv" as text;
CREATE TABLE items ( CREATE TABLE items (
id bigserial primary key id bigserial primary key
); );
@@ -1160,12 +1175,8 @@ create or replace function test.ret_null() returns int as $$
select null::int; select null::int;
$$ language sql; $$ language sql;
create function test.ret_base64_bin() returns text as $$ create function test.ret_image() returns "image/png" as $$
select i.img from test.images_base64 i where i.name = 'A.png'; select i.img::"image/png" from test.images 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; $$ language sql;
create function test.single_article(id integer) returns test.articles as $$ create function test.single_article(id integer) returns test.articles as $$
@@ -1896,7 +1907,7 @@ returns integer as $$
select a + b; select a + b;
$$ language sql; $$ language sql;
create or replace function root() returns json as $_$ create or replace function root() returns "application/openapi+json" as $_$
declare declare
openapi json = $$ openapi json = $$
{ {
@@ -1912,17 +1923,17 @@ begin
end end
$_$ language plpgsql; $_$ language plpgsql;
create or replace function welcome() returns text as $$ create or replace function welcome() returns "text/plain" as $$
select 'Welcome to PostgREST'::text; select 'Welcome to PostgREST'::"text/plain";
$$ language sql; $$ language sql;
create or replace function welcome_twice() returns setof text as $$ create or replace function welcome_twice() returns setof "text/plain" as $$
select 'Welcome to PostgREST' select 'Welcome to PostgREST'
union all union all
select 'Welcome to PostgREST'; select 'Welcome to PostgREST';
$$ language sql; $$ language sql;
create or replace function "welcome.html"() returns text as $_$ create or replace function "welcome.html"() returns "text/html" as $_$
select $$ select $$
<html> <html>
<head> <head>
@@ -1932,7 +1943,7 @@ select $$
<h1>Welcome to PostgREST</h1> <h1>Welcome to PostgREST</h1>
</body> </body>
</html> </html>
$$::text; $$::"text/html";
$_$ language sql; $_$ language sql;
create view getallprojects_view as create view getallprojects_view as
@@ -2370,16 +2381,16 @@ create or replace function test.unnamed_json_param(json) returns json as $$
select $1; select $1;
$$ language sql; $$ language sql;
create or replace function test.unnamed_text_param(text) returns text as $$ create or replace function test.unnamed_text_param(text) returns "text/plain" as $$
select $1; select $1::"text/plain";
$$ language sql; $$ language sql;
create or replace function test.unnamed_xml_param(pg_catalog.xml) returns pg_catalog.xml as $$ create or replace function test.unnamed_xml_param(pg_catalog.xml) returns "text/xml" as $$
select $1; select $1::"text/xml";
$$ language sql; $$ language sql;
create or replace function test.unnamed_bytea_param(bytea) returns bytea as $$ create or replace function test.unnamed_bytea_param(bytea) returns "application/octet-stream" as $$
select $1::bytea; select $1::"application/octet-stream";
$$ language sql; $$ language sql;
create or replace function test.unnamed_int_param(int) returns int as $$ create or replace function test.unnamed_int_param(int) returns int as $$
@@ -2390,12 +2401,12 @@ create or replace function test.overloaded_unnamed_param(json) returns json as $
select $1; select $1;
$$ language sql; $$ language sql;
create or replace function test.overloaded_unnamed_param(bytea) returns bytea as $$ create or replace function test.overloaded_unnamed_param(bytea) returns "application/octet-stream" as $$
select $1; select $1::"application/octet-stream";
$$ language sql; $$ language sql;
create or replace function test.overloaded_unnamed_param(text) returns text as $$ create or replace function test.overloaded_unnamed_param(text) returns "text/plain" as $$
select $1; select $1::"text/plain";
$$ language sql; $$ language sql;
create or replace function test.overloaded_unnamed_param() returns int as $$ create or replace function test.overloaded_unnamed_param() returns int as $$
@@ -2631,12 +2642,12 @@ create table plate_plan_step (
REFERENCES well(well_id) REFERENCES well(well_id)
); );
CREATE FUNCTION test.return_scalar_xml() RETURNS pg_catalog.xml CREATE FUNCTION test.return_scalar_xml() RETURNS "text/xml"
LANGUAGE sql AS $$ LANGUAGE sql AS $$
SELECT '<my-xml-tag/>'::pg_catalog.xml SELECT '<my-xml-tag/>'::"text/xml"
$$; $$;
CREATE OR REPLACE FUNCTION "welcome.xml"() RETURNS pg_catalog.xml CREATE OR REPLACE FUNCTION "welcome.xml"() RETURNS "text/xml"
LANGUAGE sql AS $_$ LANGUAGE sql AS $_$
select $$ select $$
<html> <html>
@@ -2646,7 +2657,7 @@ select $$
<body> <body>
<h1>Welcome to PostgREST</h1> <h1>Welcome to PostgREST</h1>
</body> </body>
</html>$$::pg_catalog.xml; </html>$$::"text/xml";
$_$; $_$;
CREATE TABLE test.xmltest ( CREATE TABLE test.xmltest (
@@ -2654,6 +2665,23 @@ CREATE TABLE test.xmltest (
xml pg_catalog.xml NOT NULL xml pg_catalog.xml NOT NULL
); );
create or replace function test.xml_handler_transition (state "text/xml", next test.xmltest)
returns "text/xml" as $$
select xmlconcat2(state, next.xml)::"text/xml";
$$ language sql;
create or replace function test.xml_handler_final (data "text/xml")
returns "text/xml" as $$
select data;
$$ language sql;
drop aggregate if exists test.text_xml_agg(test.xmltest);
create aggregate test.text_xml_agg (test.xmltest) (
stype = "text/xml"
, sfunc = test.xml_handler_transition
, finalfunc = test.xml_handler_final
);
CREATE TABLE oid_test( CREATE TABLE oid_test(
id int, id int,
oid_col oid, oid_col oid,
@@ -3471,3 +3499,160 @@ stable
as $$ begin as $$ begin
return query select items2.id from items2 where items2.id=search2.id; return query select items2.id from items2 where items2.id=search2.id;
end$$; end$$;
create table test.lines (
id int primary key
, name text
, geom extensions.geometry(LINESTRING, 4326)
);
create or replace function test.get_lines ()
returns setof test.lines as $$
select * from lines;
$$ language sql;
create or replace function test.get_shop_bles ()
returns setof test.shop_bles as $$
select * from shop_bles;
$$ language sql;
-- it can work without a final function too if the stype is already the media type
create or replace function test.twkb_handler_transition (state "application/vnd.twkb", next test.lines)
returns "application/vnd.twkb" as $$
select (state || extensions.st_astwkb(next.geom)) :: "application/vnd.twkb";
$$ language sql;
drop aggregate if exists test.twkb_agg(test.lines);
create aggregate test.twkb_agg (test.lines) (
initcond = ''
, stype = "application/vnd.twkb"
, sfunc = test.twkb_handler_transition
);
create or replace function test.geo2json_trans (state "application/vnd.geo2+json", next anyelement)
returns "application/vnd.geo2+json" as $$
select (state || extensions.ST_AsGeoJSON(next)::jsonb)::"application/vnd.geo2+json";
$$ language sql;
create or replace function test.geo2json_final (data "application/vnd.geo2+json")
returns "application/vnd.geo2+json" as $$
select (jsonb_build_object('type', 'FeatureCollection', 'hello', 'world'))::"application/vnd.geo2+json";
$$ language sql;
drop aggregate if exists test.geo2json_agg(anyelement);
create aggregate test.geo2json_agg(anyelement) (
initcond = '[]'
, stype = "application/vnd.geo2+json"
, sfunc = geo2json_trans
, finalfunc = geo2json_final
);
create or replace function test.geo2json_trans (state "application/vnd.geo2+json", next test.shop_bles)
returns "application/vnd.geo2+json" as $$
select '"anyelement overridden"'::"application/vnd.geo2+json";
$$ language sql;
drop aggregate if exists test.geo2json_agg(test.shop_bles);
create aggregate test.geo2json_agg(test.shop_bles) (
initcond = '[]'
, stype = "application/vnd.geo2+json"
, sfunc = geo2json_trans
);
create table ov_json ();
-- override application/json
create or replace function test.ov_json_trans (state "application/json", next ov_json)
returns "application/json" as $$
select null;
$$ language sql;
drop aggregate if exists test.ov_json_agg(ov_json);
create aggregate test.ov_json_agg(ov_json) (
initcond = '{"overridden": "true"}'
, stype = "application/json"
, sfunc = ov_json_trans
);
-- override application/geo+json
create or replace function test.lines_geojson_trans (state jsonb, next test.lines)
returns "application/geo+json" as $$
select (state || extensions.ST_AsGeoJSON(next)::jsonb)::"application/geo+json";
$$ language sql;
create or replace function test.lines_geojson_final (data jsonb)
returns "application/geo+json" as $$
select jsonb_build_object(
'type', 'FeatureCollection',
'crs', json_build_object(
'type', 'name',
'properties', json_build_object(
'name', 'EPSG:4326'
)
),
'features', data
)::"application/geo+json";
$$ language sql;
drop aggregate if exists test.lines_geojson_agg(test.lines);
create aggregate test.lines_geojson_agg (test.lines) (
initcond = '[]'
, stype = "application/geo+json"
, sfunc = lines_geojson_trans
, finalfunc = lines_geojson_final
);
-- override application/vnd.pgrst.object
create or replace function test.pgrst_obj_json_trans (state "application/vnd.pgrst.object", next anyelement)
returns "application/vnd.pgrst.object" as $$
select null;
$$ language sql;
drop aggregate if exists test.pgrst_obj_agg(anyelement);
create aggregate test.pgrst_obj_agg(anyelement) (
initcond = '{"overridden": "true"}'
, stype = "application/vnd.pgrst.object"
, sfunc = pgrst_obj_json_trans
);
-- create a "text/tab-separated-values" media type
create or replace function test.tsv_trans (state text, next test.projects)
returns "text/tab-separated-values" as $$
select (state || next.id::text || E'\t' || next.name || E'\t' || coalesce(next.client_id::text, '') || E'\n')::"text/tab-separated-values";
$$ language sql;
create or replace function test.tsv_final (data "text/tab-separated-values")
returns "text/tab-separated-values" as $$
select set_config('response.headers', '[{"Cache-Control": "public"}, {"Cache-Control": "max-age=259200"}]', true);
select (E'id\tname\tclient_id\n' || data)::"text/tab-separated-values";
$$ language sql;
drop aggregate if exists test.tsv_agg(test.projects);
create aggregate test.tsv_agg (test.projects) (
initcond = ''
, stype = "text/tab-separated-values"
, sfunc = tsv_trans
, finalfunc = tsv_final
);
-- override CSV with BOM plus attachment
create or replace function test.bom_csv_trans (state text, next test.lines)
returns "text/csv" as $$
select (state || next.id::text || ',' || next.name || ',' || next.geom::text || E'\n')::"text/csv";
$$ language sql;
create or replace function test.bom_csv_final (data "text/csv")
returns "text/csv" as $$
select set_config('response.headers', '[{"Content-Disposition": "attachment; filename=\"lines.csv\""}]', true);
-- EFBBBF is the BOM in UTF8 https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8
select (convert_from (decode (E'EFBBBF', 'hex'),'UTF8') || (E'id,name,geom\n' || data))::"text/csv";
$$ language sql;
drop aggregate if exists test.bom_csv_agg(test.lines);
create aggregate test.bom_csv_agg (test.lines) (
initcond = ''
, stype = "text/csv"
, sfunc = bom_csv_trans
, finalfunc = bom_csv_final
);