Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add max-affected preference to prefer header #3083

Merged
merged 1 commit into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

- #2887, Add Preference `max-affected` to limit affected resources - @taimoorzaeem

## [12.0.1] - 2023-12-12

### Fixed
Expand Down
35 changes: 29 additions & 6 deletions src/PostgREST/ApiRequest/Preferences.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
, PreferResolution(..)
, PreferTransaction(..)
, PreferTimezone(..)
, PreferMaxAffected(..)
, fromHeaders
, shouldCount
, prefAppliedHeader
Expand All @@ -42,6 +43,7 @@
-- >>> deriving instance Show PreferMissing
-- >>> deriving instance Show PreferHandling
-- >>> deriving instance Show PreferTimezone
-- >>> deriving instance Show PreferMaxAffected
-- >>> deriving instance Show Preferences

-- | Preferences recognized by the application.
Expand All @@ -55,6 +57,7 @@
, preferMissing :: Maybe PreferMissing
, preferHandling :: Maybe PreferHandling
, preferTimezone :: Maybe PreferTimezone
, preferMaxAffected :: Maybe PreferMaxAffected

Check warning on line 60 in src/PostgREST/ApiRequest/Preferences.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/ApiRequest/Preferences.hs#L60

Added line #L60 was not covered by tests
, invalidPrefs :: [ByteString]
}

Expand All @@ -64,7 +67,7 @@
-- >>> let sc = S.fromList ["America/Los_Angeles"]
--
-- One header with comma-separated values can be used to set multiple preferences:
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles")]
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles, max-affected=100")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
Expand All @@ -75,12 +78,14 @@
-- , preferHandling = Nothing
-- , preferTimezone = Just
-- ( PreferTimezone "America/Los_Angeles" )
-- , preferMaxAffected = Just
-- ( PreferMaxAffected 100 )
-- , invalidPrefs = []
-- }
--
-- Multiple headers can also be used:
--
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid")]
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
Expand All @@ -90,6 +95,8 @@
-- , preferMissing = Just ApplyNulls
-- , preferHandling = Just Lenient
-- , preferTimezone = Nothing
-- , preferMaxAffected = Just
-- ( PreferMaxAffected 5999 )
-- , invalidPrefs = [ "invalid" ]
-- }
--
Expand Down Expand Up @@ -121,6 +128,7 @@
-- , preferMissing = Just ApplyDefaults
-- , preferHandling = Just Strict
-- , preferTimezone = Nothing
-- , preferMaxAffected = Nothing
-- , invalidPrefs = [ "anything" ]
-- }
--
Expand All @@ -135,7 +143,8 @@
, preferMissing = parsePrefs [ApplyDefaults, ApplyNulls]
, preferHandling = parsePrefs [Strict, Lenient]
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
, invalidPrefs = filter checkPrefs prefs
, preferMaxAffected = PreferMaxAffected <$> maxAffectedPref
, invalidPrefs = filter isUnacceptable prefs
}
where
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
Expand All @@ -151,10 +160,16 @@
prefHeaders = filter ((==) HTTP.hPrefer . fst) headers
prefs = fmap BS.strip . concatMap (BS.split ',' . snd) $ prefHeaders

timezonePref = listToMaybe $ mapMaybe (BS.stripPrefix "timezone=") prefs
listStripPrefix prefix prefList = listToMaybe $ mapMaybe (BS.stripPrefix prefix) prefList

timezonePref = listStripPrefix "timezone=" prefs
isTimezonePrefAccepted = (S.member <$> timezonePref <*> pure acceptedTzNames) == Just True

checkPrefs p = p `notElem` acceptedPrefs && not isTimezonePrefAccepted
maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack

isUnacceptable p = p `notElem` acceptedPrefs &&
(isNothing (BS.stripPrefix "timezone=" p) || not isTimezonePrefAccepted) &&
isNothing (BS.stripPrefix "max-affected=" p)

parsePrefs :: ToHeaderValue a => [a] -> Maybe a
parsePrefs vals =
Expand All @@ -164,7 +179,7 @@
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))

prefAppliedHeader :: Preferences -> Maybe HTTP.Header
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone } =
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } =
if null prefsVals
then Nothing
else Just (HTTP.hPreferenceApplied, combined)
Expand All @@ -179,6 +194,7 @@
, toHeaderValue <$> preferTransaction
, toHeaderValue <$> preferHandling
, toHeaderValue <$> preferTimezone
, if preferHandling == Just Strict then toHeaderValue <$> preferMaxAffected else Nothing
]

-- |
Expand Down Expand Up @@ -278,3 +294,10 @@

instance ToHeaderValue PreferTimezone where
toHeaderValue (PreferTimezone tz) = "timezone=" <> tz

-- |
-- Limit Affected Resources
newtype PreferMaxAffected = PreferMaxAffected Int64

instance ToHeaderValue PreferMaxAffected where
toHeaderValue (PreferMaxAffected n) = "max-affected=" <> show n
1 change: 1 addition & 0 deletions src/PostgREST/ApiRequest/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ data ApiRequestError
| PutMatchingPkError
| SingularityError Integer
| PGRSTParseError
| MaxAffectedViolationError Integer
deriving Show

data QPError = QPError Text Text
Expand Down
67 changes: 38 additions & 29 deletions src/PostgREST/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -62,35 +62,36 @@
responseLBS (status err) (baseHeader : headers err) $ errorPayload err

instance PgrstError ApiRequestError where
status AggregatesNotAllowed{} = HTTP.status400
status AmbiguousRelBetween{} = HTTP.status300
status AmbiguousRpc{} = HTTP.status300
status MediaTypeError{} = HTTP.status415
status InvalidBody{} = HTTP.status400
status InvalidFilters = HTTP.status405
status InvalidPreferences{} = HTTP.status400
status InvalidRpcMethod{} = HTTP.status405
status InvalidRange{} = HTTP.status416
status NotFound = HTTP.status404

status NoRelBetween{} = HTTP.status400
status NoRpc{} = HTTP.status404
status NotEmbedded{} = HTTP.status400
status PutLimitNotAllowedError = HTTP.status400
status QueryParamError{} = HTTP.status400
status RelatedOrderNotToOne{} = HTTP.status400
status SpreadNotToOne{} = HTTP.status400
status UnacceptableFilter{} = HTTP.status400
status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400
status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500
status OffLimitsChangesError{} = HTTP.status400
status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406
status PGRSTParseError = HTTP.status500
status AggregatesNotAllowed{} = HTTP.status400
status AmbiguousRelBetween{} = HTTP.status300
status AmbiguousRpc{} = HTTP.status300
status MediaTypeError{} = HTTP.status415
status InvalidBody{} = HTTP.status400
status InvalidFilters = HTTP.status405
status InvalidPreferences{} = HTTP.status400
status InvalidRpcMethod{} = HTTP.status405
status InvalidRange{} = HTTP.status416
status NotFound = HTTP.status404

status NoRelBetween{} = HTTP.status400
status NoRpc{} = HTTP.status404
status NotEmbedded{} = HTTP.status400
status PutLimitNotAllowedError = HTTP.status400
status QueryParamError{} = HTTP.status400
status RelatedOrderNotToOne{} = HTTP.status400
status SpreadNotToOne{} = HTTP.status400
status UnacceptableFilter{} = HTTP.status400

Check warning on line 83 in src/PostgREST/Error.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/Error.hs#L83

Added line #L83 was not covered by tests
status UnacceptableSchema{} = HTTP.status406
status UnsupportedMethod{} = HTTP.status405
status LimitNoOrderError = HTTP.status400
status ColumnNotFound{} = HTTP.status400
status GucHeadersError = HTTP.status500
status GucStatusError = HTTP.status500
status OffLimitsChangesError{} = HTTP.status400
status PutMatchingPkError = HTTP.status400
status SingularityError{} = HTTP.status406
status PGRSTParseError = HTTP.status500
status MaxAffectedViolationError{} = HTTP.status400

headers SingularityError{} = [MediaType.toContentType $ MTVndSingularJSON False]
headers _ = mempty
Expand Down Expand Up @@ -199,6 +200,12 @@
toJSON AggregatesNotAllowed = toJsonPgrstError
ApiRequestErrorCode23 "Use of aggregate functions is not allowed" Nothing Nothing

toJSON (MaxAffectedViolationError n) = toJsonPgrstError
ApiRequestErrorCode24
"Query result exceeds max-affected preference constraint"
(Just $ JSON.String $ T.unwords ["The query affects", show n, "rows"])
Nothing

toJSON (NoRelBetween parent child embedHint schema allRels) = toJsonPgrstError
SchemaCacheErrorCode00
("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache")
Expand Down Expand Up @@ -606,6 +613,7 @@
| ApiRequestErrorCode21
| ApiRequestErrorCode22
| ApiRequestErrorCode23
| ApiRequestErrorCode24
-- Schema Cache errors
| SchemaCacheErrorCode00
| SchemaCacheErrorCode01
Expand Down Expand Up @@ -653,6 +661,7 @@
ApiRequestErrorCode21 -> "121"
ApiRequestErrorCode22 -> "122"
ApiRequestErrorCode23 -> "123"
ApiRequestErrorCode24 -> "124"

SchemaCacheErrorCode00 -> "200"
SchemaCacheErrorCode01 -> "201"
Expand Down
16 changes: 14 additions & 2 deletions src/PostgREST/Query.hs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..),
PreferHandling (..),
PreferMaxAffected (..),
PreferTimezone (..),
PreferTransaction (..),
Preferences (..),
Expand Down Expand Up @@ -114,9 +116,10 @@
pure resultSet

updateQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
updateQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{..} conf = do
updateQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} conf = do
resultSet <- writeQuery mrPlan apiReq conf
failNotSingular mrMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq
pure resultSet
Expand All @@ -141,9 +144,10 @@
throwError $ Error.ApiRequestError ApiRequestTypes.PutMatchingPkError

deleteQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
deleteQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{..} conf = do
deleteQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} conf = do
resultSet <- writeQuery mrPlan apiReq conf
failNotSingular mrMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
optionalRollback conf apiReq
pure resultSet
Expand All @@ -165,6 +169,7 @@

optionalRollback conf apiReq
failNotSingular crMedia resultSet
failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet
pure resultSet

openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, RoutineMap, Maybe Text))
Expand Down Expand Up @@ -213,6 +218,13 @@
lift SQL.condemn
throwError $ Error.ApiRequestError . ApiRequestTypes.SingularityError $ toInteger queryTotal

failExceedsMaxAffectedPref :: (Maybe PreferMaxAffected, Maybe PreferHandling) -> ResultSet -> DbHandler ()
failExceedsMaxAffectedPref (Nothing,_) _ = pure ()
failExceedsMaxAffectedPref _ RSPlan{} = pure ()

Check warning on line 223 in src/PostgREST/Query.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/Query.hs#L223

Added line #L223 was not covered by tests
failExceedsMaxAffectedPref (Just (PreferMaxAffected n), handling) RSStandard{rsQueryTotal=queryTotal} = when ((queryTotal > n) && (handling == Just Strict)) $ do
lift SQL.condemn
throwError $ Error.ApiRequestError . ApiRequestTypes.MaxAffectedViolationError $ toInteger queryTotal

failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler ()
failsChangesOffLimits _ RSPlan{} = pure ()
failsChangesOffLimits Nothing _ = pure ()
Expand Down
12 changes: 6 additions & 6 deletions src/PostgREST/Response.hs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ readResponse WrappedReadPlan{wrMedia} headersOnly identifier ctxApiRequest@ApiRe
RSStandard{..} -> do
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing []
headers =
[ contentRange
, ( "Content-Location"
Expand Down Expand Up @@ -105,7 +105,7 @@ createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan, mrMedia} ctx
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
prefHeader = prefAppliedHeader $
Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution)
preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone Nothing []
headers =
catMaybes
[ if null rsLocation then
Expand Down Expand Up @@ -146,7 +146,7 @@ updateResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Pre
contentRangeHeader =
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount preferCount then Just rsQueryTotal else Nothing
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone preferMaxAffected []
headers = catMaybes [contentRangeHeader, prefHeader]

let (status, headers', body) =
Expand All @@ -166,7 +166,7 @@ singleUpsertResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Erro
singleUpsertResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard {..} -> do
let
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing []
cTHeader = contentTypeHeaders mrMedia ctxApiRequest

let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200
Expand All @@ -190,7 +190,7 @@ deleteResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Pre
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount preferCount then Just rsQueryTotal else Nothing
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected []
headers = contentRangeHeader : prefHeader

let (status, headers', body) =
Expand Down Expand Up @@ -243,7 +243,7 @@ invokeResponse CallReadPlan{crMedia} invMethod proc ctxApiRequest@ApiRequest{iPr
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected []
headers = contentRange : prefHeader

let (status', headers', body) =
Expand Down
Loading
Loading