diff --git a/postgrest.cabal b/postgrest.cabal index 1b75df1524..a03317ec82 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -226,6 +226,7 @@ test-suite spec Feature.Query.ErrorSpec Feature.Query.InsertSpec Feature.Query.JsonOperatorSpec + Feature.Query.LimitOffsetSpec Feature.Query.LimitedMutationSpec Feature.Query.MultipleSchemaSpec Feature.Query.NullsStripSpec diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index 5b0c97cee1..249f7476f4 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -2,8 +2,9 @@ Module : PostgREST.Request.ApiRequest Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest. -} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RecordWildCards #-} -- TODO: This module shouldn't depend on SchemaCache module PostgREST.ApiRequest ( ApiRequest(..) @@ -35,8 +36,7 @@ import Data.Either.Combinators (mapBoth) import Control.Arrow ((***)) import Data.Aeson.Types (emptyArray, emptyObject) import Data.List (lookup) -import Data.Ranged.Ranges (emptyRange, rangeIntersection, - rangeIsEmpty) +import Data.Ranged.Ranges (rangeIsEmpty) import Network.HTTP.Types.Header (RequestHeaders, hCookie) import Network.HTTP.Types.URI (parseSimpleQuery) import Network.Wai (Request (..)) @@ -50,8 +50,6 @@ import PostgREST.Config (AppConfig (..), OpenAPIMode (..)) import PostgREST.MediaType (MediaType (..)) import PostgREST.RangeQuery (NonnegRange, allRange, - convertToLimitZeroRange, - hasLimitZero, rangeRequested) import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache.Identifiers (FieldName, @@ -111,8 +109,7 @@ data Action -} data ApiRequest = ApiRequest { iAction :: Action -- ^ Action on the resource - , iRange :: HM.HashMap Text NonnegRange -- ^ Requested range of rows within response - , iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level + , iRange :: NonnegRange -- ^ Requested range of rows from the selected resource , iPayload :: Maybe Payload -- ^ Data sent by client and used for mutation actions , iPreferences :: Preferences.Preferences -- ^ Prefer header values , iQueryParams :: QueryParams.QueryParams @@ -134,12 +131,11 @@ userApiRequest conf req reqBody sCache = do (schema, negotiatedByProfile) <- getSchema conf hdrs method act <- getAction resource schema method qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req - (topLevelRange, ranges) <- getRanges method qPrms hdrs + hRange <- getRange method qPrms hdrs (payload, columns) <- getPayload reqBody contentMediaType qPrms act return $ ApiRequest { iAction = act - , iRange = ranges - , iTopLevelRange = topLevelRange + , iRange = hRange , iPayload = payload , iPreferences = Preferences.fromHeaders (configDbTxAllowOverride conf) (dbTimezones sCache) hdrs , iQueryParams = qPrms @@ -217,24 +213,17 @@ getSchema AppConfig{configDbSchemas} hdrs method = do acceptProfile = T.decodeUtf8 <$> lookupHeader "Accept-Profile" lookupHeader = flip lookup hdrs -getRanges :: ByteString -> QueryParams -> RequestHeaders -> Either ApiRequestError (NonnegRange, HM.HashMap Text NonnegRange) -getRanges method QueryParams{qsOrder,qsRanges} hdrs - | isInvalidRange = Left $ InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit) - | method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError - | method == "PUT" && topLevelRange /= allRange = Left PutLimitNotAllowedError - | otherwise = Right (topLevelRange, ranges) +getRange :: ByteString -> QueryParams -> RequestHeaders -> Either ApiRequestError NonnegRange +getRange method QueryParams{..} hdrs + | rangeIsEmpty headerRange = Left $ InvalidRange LowerGTUpper -- A Range is empty unless its upper boundary is GT its lower boundary + | method `elem` ["PATCH","DELETE"] && not (null qsLimit) && null qsOrder = Left LimitNoOrderError + | method == "PUT" && offsetLimitPresent = Left PutLimitNotAllowedError + | otherwise = Right headerRange where -- According to the RFC (https://www.rfc-editor.org/rfc/rfc9110.html#name-range), -- the Range header must be ignored for all methods other than GET headerRange = if method == "GET" then rangeRequested hdrs else allRange - limitRange = fromMaybe allRange (HM.lookup "limit" qsRanges) - headerAndLimitRange = rangeIntersection headerRange limitRange - -- Bypass all the ranges and send only the limit zero range (0 <= x <= -1) if - -- limit=0 is present in the query params (not allowed for the Range header) - ranges = HM.insert "limit" (convertToLimitZeroRange limitRange headerAndLimitRange) qsRanges - -- The only emptyRange allowed is the limit zero range - isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange) - topLevelRange = fromMaybe allRange $ HM.lookup "limit" ranges -- if no limit is specified, get all the request rows + offsetLimitPresent = not (null qsOffset && null qsLimit) getPayload :: RequestBody -> MediaType -> QueryParams.QueryParams -> Action -> Either ApiRequestError (Maybe Payload, S.Set FieldName) getPayload reqBody contentMediaType QueryParams{qsColumns} action = do diff --git a/src/PostgREST/ApiRequest/Preferences.hs b/src/PostgREST/ApiRequest/Preferences.hs index b869c4a9f2..d1cb8c9c07 100644 --- a/src/PostgREST/ApiRequest/Preferences.hs +++ b/src/PostgREST/ApiRequest/Preferences.hs @@ -178,8 +178,8 @@ fromHeaders allowTxDbOverride acceptedTzNames headers = prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref)) -prefAppliedHeader :: Preferences -> Maybe HTTP.Header -prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } = +prefAppliedHeader :: Bool -> Preferences -> Maybe HTTP.Header +prefAppliedHeader rangeHdPresent Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } = if null prefsVals then Nothing else Just (HTTP.hPreferenceApplied, combined) @@ -190,7 +190,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferPar , toHeaderValue <$> preferMissing , toHeaderValue <$> preferRepresentation , toHeaderValue <$> preferParameters - , toHeaderValue <$> preferCount + , toHeaderValue <$> (if rangeHdPresent then preferCount else Nothing) , toHeaderValue <$> preferTransaction , toHeaderValue <$> preferHandling , toHeaderValue <$> preferTimezone @@ -254,7 +254,7 @@ instance ToHeaderValue PreferCount where shouldCount :: Maybe PreferCount -> Bool shouldCount prefCount = - prefCount == Just ExactCount || prefCount == Just EstimatedCount + prefCount `elem` [Just ExactCount, Just PlannedCount, Just EstimatedCount] -- | Whether to commit or roll back transactions. data PreferTransaction diff --git a/src/PostgREST/ApiRequest/QueryParams.hs b/src/PostgREST/ApiRequest/QueryParams.hs index f9150e04e7..3c83d554a1 100644 --- a/src/PostgREST/ApiRequest/QueryParams.hs +++ b/src/PostgREST/ApiRequest/QueryParams.hs @@ -9,11 +9,10 @@ module PostgREST.ApiRequest.QueryParams ( parse , QueryParams(..) - , pRequestRange + , pTreePath ) where import qualified Data.ByteString.Char8 as BS -import qualified Data.HashMap.Strict as HM import qualified Data.List as L import qualified Data.Set as S import qualified Data.Text as T @@ -22,42 +21,42 @@ import qualified Network.HTTP.Base as HTTP import qualified Network.HTTP.Types.URI as HTTP import qualified Text.ParserCombinators.Parsec as P -import Control.Arrow ((***)) -import Data.Either.Combinators (mapLeft) -import Data.List (init, last) -import Data.Ranged.Boundaries (Boundary (..)) -import Data.Ranged.Ranges (Range (..)) -import Data.Tree (Tree (..)) -import Text.Parsec.Error (errorMessages, - showErrorMessages) -import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser, - anyChar, between, char, choice, - digit, eof, errorPos, letter, - lookAhead, many1, noneOf, - notFollowedBy, oneOf, - optionMaybe, sepBy, sepBy1, - string, try, ()) - -import PostgREST.RangeQuery (NonnegRange, allRange, - rangeGeq, rangeLimit, - rangeOffset, restrictRange) +import Control.Arrow ((***)) +import Data.Either.Combinators (mapLeft) +import Data.List (init, last) +import Data.Tree (Tree (..)) +import PostgREST.ApiRequest.Types (AggregateFunction (..), + EmbedParam (..), EmbedPath, + Field, Filter (..), + FtsOperator (..), Hint, + JoinType (..), + JsonOperand (..), + JsonOperation (..), + JsonPath, ListVal, + LogicOperator (..), + LogicTree (..), OpExpr (..), + OpQuantifier (..), + Operation (..), + OrderDirection (..), + OrderNulls (..), + OrderTerm (..), + QPError (..), + QuantOperator (..), + SelectItem (..), + SimpleOperator (..), + SingleVal, TrileanVal (..)) import PostgREST.SchemaCache.Identifiers (FieldName) - -import PostgREST.ApiRequest.Types (AggregateFunction (..), - EmbedParam (..), EmbedPath, Field, - Filter (..), FtsOperator (..), - Hint, JoinType (..), - JsonOperand (..), - JsonOperation (..), JsonPath, - ListVal, LogicOperator (..), - LogicTree (..), OpExpr (..), - OpQuantifier (..), Operation (..), - OrderDirection (..), - OrderNulls (..), OrderTerm (..), - QPError (..), QuantOperator (..), - SelectItem (..), - SimpleOperator (..), SingleVal, - TrileanVal (..)) +import Text.Parsec.Error (errorMessages, + showErrorMessages) +import Text.ParserCombinators.Parsec (GenParser, ParseError, + Parser, anyChar, between, + char, choice, digit, eof, + errorPos, letter, lookAhead, + many1, noneOf, + notFollowedBy, oneOf, + optionMaybe, sepBy, sepBy1, + string, try, ()) +import Text.Read (read) import Protolude hiding (Sum, try) @@ -67,8 +66,10 @@ data QueryParams = -- ^ Canonical representation of the query params, sorted alphabetically , qsParams :: [(Text, Text)] -- ^ Parameters for RPC calls - , qsRanges :: HM.HashMap Text (Range Integer) - -- ^ Ranges derived from &limit and &offset params + , qsOffset :: [(EmbedPath, Integer)] + -- ^ &offset parameter + , qsLimit :: [(EmbedPath, Integer)] + -- ^ &limit parameter , qsOrder :: [(EmbedPath, [OrderTerm])] -- ^ &order parameters for each level , qsLogic :: [(EmbedPath, LogicTree)] @@ -115,6 +116,8 @@ parse :: Bool -> ByteString -> Either QPError QueryParams parse isRpcRead qs = do rOrd <- pRequestOrder `traverse` order rLogic <- pRequestLogicTree `traverse` logic + rOffset <- pRequestOffset `traverse` offset + rLimit <- pRequestLimit `traverse` limit rCols <- pRequestColumns columns rSel <- pRequestSelect select (rFlts, params) <- L.partition hasOp <$> pRequestFilter isRpcRead `traverse` filters @@ -125,7 +128,7 @@ parse isRpcRead qs = do params' = mapMaybe (\case {(_, Filter (fld, _) (NoOpExpr v)) -> Just (fld,v); _ -> Nothing}) params rFltsRoot' = snd <$> rFltsRoot - return $ QueryParams canonical params' ranges rOrd rLogic rCols rSel rFlts rFltsRoot' rFltsNotRoot rFltsFields rOnConflict + return $ QueryParams canonical params' rOffset rLimit rOrd rLogic rCols rSel rFlts rFltsRoot' rFltsNotRoot rFltsFields rOnConflict where hasRootFilter, hasOp :: (EmbedPath, Filter) -> Bool hasRootFilter ([], _) = True @@ -138,9 +141,8 @@ parse isRpcRead qs = do onConflict = lookupParam "on_conflict" columns = lookupParam "columns" order = filter (endingIn ["order"] . fst) nonemptyParams - limits = filter (endingIn ["limit"] . fst) nonemptyParams - -- Replace .offset ending with .limit to be able to match those params later in a map - offsets = first (replaceLast "limit") <$> filter (endingIn ["offset"] . fst) nonemptyParams + offset = filter (endingIn ["offset"] . fst) nonemptyParams + limit = filter (endingIn ["limit"] . fst) nonemptyParams lookupParam :: Text -> Maybe Text lookupParam needle = toS <$> join (L.lookup needle qParams) nonemptyParams = mapMaybe (\(k, v) -> (k,) <$> v) qParams @@ -155,7 +157,7 @@ parse isRpcRead qs = do . map (join (***) BS.unpack . second (fromMaybe mempty)) $ qString - endingIn:: [Text] -> Text -> Bool + endingIn :: [Text] -> Text -> Bool endingIn xx key = lastWord `elem` xx where lastWord = L.last $ T.split (== '.') key @@ -164,21 +166,6 @@ parse isRpcRead qs = do reserved = ["select", "columns", "on_conflict"] reservedEmbeddable = ["order", "limit", "offset", "and", "or"] - replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) <> [x] - - ranges :: HM.HashMap Text (Range Integer) - ranges = HM.unionWith f limitParams offsetParams - where - f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1) - where - l = fromMaybe 0 $ rangeLimit rl - o = rangeOffset ro - - limitParams = - HM.fromList [(k, restrictRange (readMaybe v) allRange) | (k,v) <- limits] - - offsetParams = - HM.fromList [(k, maybe allRange rangeGeq (readMaybe v)) | (k,v) <- offsets] simpleOperator :: Parser SimpleOperator simpleOperator = @@ -243,11 +230,19 @@ pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord' path = fst <$> treePath ord' = P.parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v -pRequestRange :: (Text, NonnegRange) -> Either QPError (EmbedPath, NonnegRange) -pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v +pRequestOffset :: (Text, Text) -> Either QPError (EmbedPath, Integer) +pRequestOffset (k,v) = mapError $ (,) <$> path <*> int where treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k path = fst <$> treePath + int = P.parse pInt ("failed to parse offset parameter (" <> toS v <> ")") $ toS v + +pRequestLimit :: (Text, Text) -> Either QPError (EmbedPath, Integer) +pRequestLimit (k,v) = mapError $ (,) <$> path <*> int + where + treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k + path = fst <$> treePath + int = P.parse pInt ("failed to parse limit parameter (" <> toS v <> ")") $ toS v pRequestLogicTree :: (Text, Text) -> Either QPError (EmbedPath, LogicTree) pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree @@ -842,6 +837,18 @@ pLogicPath = do notOp = "not." <> op return (filter (/= "not") (init path), if "not" `elem` path then notOp else op) +pInt :: Parser Integer +pInt = pPosInt <|> pNegInt + where + pPosInt :: Parser Integer + pPosInt = many1 digit <&> read + + pNegInt :: Parser Integer + pNegInt = do + _ <- char '-' + n <- many1 digit + return ((-1) * read n) + pColumns :: Parser [FieldName] pColumns = pFieldName `sepBy1` lexeme (char ',') diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index e4fb6dc323..6194d8dfc5 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -108,8 +108,7 @@ data RaiseError | NoDetail deriving Show data RangeError - = NegativeLimit - | LowerGTUpper + = LowerGTUpper | OutOfBounds Text Text deriving Show diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 9c7d6d3a6f..29f1761855 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -84,7 +84,7 @@ instance PgrstError ApiRequestError where status UnacceptableFilter{} = HTTP.status400 status UnacceptableSchema{} = HTTP.status406 status UnsupportedMethod{} = HTTP.status405 - status LimitNoOrderError = HTTP.status400 + status LimitNoOrderError{} = HTTP.status400 status ColumnNotFound{} = HTTP.status400 status GucHeadersError = HTTP.status500 status GucStatusError = HTTP.status500 @@ -118,7 +118,6 @@ instance JSON.ToJSON ApiRequestError where ApiRequestErrorCode03 "Requested range not satisfiable" (Just $ case rangeError of - NegativeLimit -> "Limit should be greater than or equal to zero." LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header." OutOfBounds lower total -> JSON.String $ "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows.") Nothing @@ -141,7 +140,10 @@ instance JSON.ToJSON ApiRequestError where (Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter.") toJSON LimitNoOrderError = toJsonPgrstError - ApiRequestErrorCode09 "A 'limit' was applied without an explicit 'order'" Nothing (Just "Apply an 'order' using unique column(s)") + ApiRequestErrorCode09 + "A 'limit' was applied without an explicit 'order'" + Nothing + (Just "Apply an 'order' using unique column(s)") toJSON (OffLimitsChangesError n maxs) = toJsonPgrstError ApiRequestErrorCode10 @@ -475,13 +477,15 @@ pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError '0':'9':_ -> HTTP.status500 -- triggered action exception '0':'L':_ -> HTTP.status403 -- invalid grantor '0':'P':_ -> HTTP.status403 -- invalid role specification - "23503" -> HTTP.status409 -- foreign_key_violation - "23505" -> HTTP.status409 -- unique_violation - "25006" -> HTTP.status405 -- read_only_sql_transaction "21000" -> -- cardinality_violation if BS.isSuffixOf "requires a WHERE clause" m then HTTP.status400 -- special case for pg-safeupdate, which we consider as client error else HTTP.status500 -- generic function or view server error, e.g. "more than one row returned by a subquery used as an expression" + "2201W" -> HTTP.status400 -- invalid/negative limit param + "2201X" -> HTTP.status400 -- invalid/negative offset param + "23503" -> HTTP.status409 -- foreign_key_violation + "23505" -> HTTP.status409 -- unique_violation + "25006" -> HTTP.status405 -- read_only_sql_transaction '2':'5':_ -> HTTP.status500 -- invalid tx state '2':'8':_ -> HTTP.status403 -- invalid auth specification '2':'D':_ -> HTTP.status500 -- invalid tx termination diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index 4718b05ed0..e67eb3bf88 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -46,7 +46,6 @@ import PostgREST.Error (Error (..)) import PostgREST.MediaType (MediaType (..)) import PostgREST.Query.SqlFragment (sourceCTEName) import PostgREST.RangeQuery (NonnegRange, allRange, - convertToLimitZeroRange, restrictRange) import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache.Identifiers (FieldName, @@ -342,7 +341,9 @@ readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregate expandStars ctx =<< addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<< addLogicTrees ctx apiRequest =<< - addRanges apiRequest =<< + addOffset apiRequest =<< + addLimit apiRequest =<< + addRange apiRequest =<< addOrders ctx apiRequest =<< addFilters ctx apiRequest (initReadRequest ctx $ QueryParams.qsSelect $ iQueryParams apiRequest) @@ -352,7 +353,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} = foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} [] where rootDepth = 0 - defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False [] rootDepth + defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] Nothing Nothing allRange mempty Nothing [] Nothing mempty Nothing Nothing False [] rootDepth treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree treeEntry depth (Node si fldForest) (Node q rForest) = let nxtDepth = succ depth in @@ -455,7 +456,7 @@ treeRestrictRange _ (ActDb (ActRelationMut _ _)) request = Right request treeRestrictRange maxRows _ request = pure $ nodeRestrictRange maxRows <$> request where nodeRestrictRange :: Maybe Integer -> ReadPlan -> ReadPlan - nodeRestrictRange m q@ReadPlan{range_=r} = q{range_= convertToLimitZeroRange r (restrictRange m r) } + nodeRestrictRange m q@ReadPlan{range_=r} = q{range_= restrictRange m r } -- add relationships to the nodes of the tree by traversing the forest while keeping track of the parentNode(https://stackoverflow.com/questions/22721064/get-the-parent-of-a-node-in-data-tree-haskell#comment34627048_22721064) -- also adds aliasing @@ -794,7 +795,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do -- rootLabel = ReadPlan { -- select = [], -- there will be fields at this stage but we just omit them for brevity -- from = QualifiedIdentifier {qiSchema = "test", qiName = "projects"}, --- fromAlias = Just "projects_1", where_ = [], order = [], range_ = fullRange, +-- fromAlias = Just "projects_1", where_ = [], order = [], +-- offset = Nothing, limit = Nothing, range_ = fullRange, -- relName = "projects", -- relToParent = Nothing, -- relJoinConds = [], @@ -823,7 +825,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do -- } -- ) -- ], --- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing, +-- order = [], offset = Nothing, limit = Nothing, range_ = fullRange, +-- relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing, -- relJoinType = Nothing, relIsSpread = False, depth = 0, -- relSelect = [] -- }, @@ -861,26 +864,38 @@ addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do flt@(CoercibleStmnt _) -> Right flt -addRanges :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree -addRanges ApiRequest{..} rReq = +addOffset :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree +addOffset ApiRequest{..} rReq = + foldr addOffsetToNode (Right rReq) qsOffset + where + QueryParams.QueryParams{..} = iQueryParams + addOffsetToNode :: (EmbedPath, Integer) -> Either ApiRequestError ReadPlanTree ->Either ApiRequestError ReadPlanTree + addOffsetToNode = updateNode (\o (Node q f) -> Node q{offset = Just o} f) + +addLimit :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree +addLimit ApiRequest{..} rReq = + foldr addLimitToNode (Right rReq) qsLimit + where + QueryParams.QueryParams{..} = iQueryParams + addLimitToNode :: (EmbedPath, Integer) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree + addLimitToNode = updateNode (\l (Node q f) -> Node q{limit = Just l} f) + +addRange :: ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree +addRange ApiRequest{..} rReq = case iAction of ActDb (ActRelationMut _ _) -> Right rReq - _ -> foldr addRangeToNode (Right rReq) =<< ranges + _ -> foldr addRangeToNode (Right rReq) [([], iRange)] where - ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)] - ranges = first QueryParamError $ QueryParams.pRequestRange `traverse` HM.toList iRange - addRangeToNode :: (EmbedPath, NonnegRange) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree addRangeToNode = updateNode (\r (Node q f) -> Node q{range_=r} f) addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree addLogicTrees ctx ApiRequest{..} rReq = foldr addLogicTreeToNode (Right rReq) qsLogic - where - QueryParams.QueryParams{..} = iQueryParams - - addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree - addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f) + where + QueryParams.QueryParams{..} = iQueryParams + addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either ApiRequestError ReadPlanTree -> Either ApiRequestError ReadPlanTree + addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f) resolveLogicTree :: ResolverContext -> LogicTree -> CoercibleLogicTree resolveLogicTree ctx (Stmnt flt) = CoercibleStmnt $ resolveFilter ctx flt @@ -913,12 +928,12 @@ updateNode f (targetNodeName:remainingPath, a) (Right (Node rootNode forest)) = findNode = find (\(Node ReadPlan{relName, relAlias} _) -> relName == targetNodeName || relAlias == Just targetNodeName) forest mutatePlan :: Mutation -> QualifiedIdentifier -> ApiRequest -> SchemaCache -> ReadPlanTree -> Either Error MutatePlan -mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{dbTables, dbRepresentations} readReq = mapLeft ApiRequestError $ +mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{dbTables, dbRepresentations} readReq@(Node ReadPlan{offset,limit} _) = mapLeft ApiRequestError $ case mutation of MutationCreate -> mapRight (\typedColumns -> Insert qi typedColumns body ((,) <$> preferResolution <*> Just confCols) [] returnings pkCols applyDefaults) typedColumnsOrError MutationUpdate -> - mapRight (\typedColumns -> Update qi typedColumns body combinedLogic iTopLevelRange rootOrder returnings applyDefaults) typedColumnsOrError + mapRight (\typedColumns -> Update qi typedColumns body combinedLogic offset limit rootOrder returnings applyDefaults) typedColumnsOrError MutationSingleUpsert -> if null qsLogic && qsFilterFields == S.fromList pkCols && @@ -929,7 +944,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{ then mapRight (\typedColumns -> Insert qi typedColumns body (Just (MergeDuplicates, pkCols)) combinedLogic returnings mempty False) typedColumnsOrError else Left InvalidFilters - MutationDelete -> Right $ Delete qi combinedLogic iTopLevelRange rootOrder returnings + MutationDelete -> Right $ Delete qi combinedLogic offset limit rootOrder returnings where ctx = ResolverContext dbTables dbRepresentations qi "json" confCols = fromMaybe pkCols qsOnConflict diff --git a/src/PostgREST/Plan/MutatePlan.hs b/src/PostgREST/Plan/MutatePlan.hs index 2e1b2e9cdc..b69357430b 100644 --- a/src/PostgREST/Plan/MutatePlan.hs +++ b/src/PostgREST/Plan/MutatePlan.hs @@ -1,5 +1,7 @@ +{-# LANGUAGE LambdaCase #-} module PostgREST.Plan.MutatePlan ( MutatePlan(..) + , mPlanLimit ) where @@ -9,7 +11,6 @@ import PostgREST.ApiRequest.Preferences (PreferResolution) import PostgREST.Plan.Types (CoercibleField, CoercibleLogicTree, CoercibleOrderTerm) -import PostgREST.RangeQuery (NonnegRange) import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier) @@ -32,7 +33,8 @@ data MutatePlan , updCols :: [CoercibleField] , updBody :: Maybe LBS.ByteString , where_ :: [CoercibleLogicTree] - , mutRange :: NonnegRange + , offset_ :: Maybe Integer + , limit_ :: Maybe Integer , mutOrder :: [CoercibleOrderTerm] , returning :: [FieldName] , applyDefs :: Bool @@ -40,7 +42,14 @@ data MutatePlan | Delete { in_ :: QualifiedIdentifier , where_ :: [CoercibleLogicTree] - , mutRange :: NonnegRange + , offset_ :: Maybe Integer + , limit_ :: Maybe Integer , mutOrder :: [CoercibleOrderTerm] , returning :: [FieldName] } + +mPlanLimit :: MutatePlan -> Maybe Integer +mPlanLimit = \case + Insert{} -> Nothing + Update{limit_=limit} -> limit + Delete{limit_=limit} -> limit diff --git a/src/PostgREST/Plan/ReadPlan.hs b/src/PostgREST/Plan/ReadPlan.hs index 854cf1ffa7..7b723abb47 100644 --- a/src/PostgREST/Plan/ReadPlan.hs +++ b/src/PostgREST/Plan/ReadPlan.hs @@ -34,6 +34,8 @@ data ReadPlan = ReadPlan , fromAlias :: Maybe Alias , where_ :: [CoercibleLogicTree] , order :: [CoercibleOrderTerm] + , offset :: Maybe Integer + , limit :: Maybe Integer , range_ :: NonnegRange , relName :: NodeName , relToParent :: Maybe Relationship diff --git a/src/PostgREST/Query.hs b/src/PostgREST/Query.hs index 17c5854708..986041b4d3 100644 --- a/src/PostgREST/Query.hs +++ b/src/PostgREST/Query.hs @@ -25,7 +25,6 @@ import qualified PostgREST.AppState as AppState import qualified PostgREST.Error as Error import qualified PostgREST.Query.QueryBuilder as QueryBuilder import qualified PostgREST.Query.Statements as Statements -import qualified PostgREST.RangeQuery as RangeQuery import qualified PostgREST.SchemaCache as SchemaCache import PostgREST.ApiRequest (ApiRequest (..), @@ -49,7 +48,7 @@ import PostgREST.Plan (ActionPlan (..), DbActionPlan (..), InfoPlan (..), InspectPlan (..)) -import PostgREST.Plan.MutatePlan (MutatePlan (..)) +import PostgREST.Plan.MutatePlan (MutatePlan (..), mPlanLimit) import PostgREST.Plan.ReadPlan (ReadPlanTree) import PostgREST.Query.SqlFragment (escapeIdentList, fromQi, intercalateSnippet, @@ -135,11 +134,11 @@ actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationCreate, ..}) conf api optionalRollback conf apiReq pure $ DbCrudResult plan resultSet -actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationUpdate, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}, ..} _ _ = do +actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationUpdate, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}} _ _ = do resultSet <- writeQuery mrReadPlan mrMutatePlan mrMedia mrHandler apiReq conf failNotSingular mrMedia resultSet failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet - failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet + failsChangesOffLimits (mPlanLimit mrMutatePlan) resultSet optionalRollback conf apiReq pure $ DbCrudResult plan resultSet @@ -149,11 +148,11 @@ actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationSingleUpsert, ..}) co optionalRollback conf apiReq pure $ DbCrudResult plan resultSet -actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationDelete, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}, ..} _ _ = do +actionQuery (DbCrud plan@MutateReadPlan{mrMutation=MutationDelete, ..}) conf apiReq@ApiRequest{iPreferences=Preferences{..}} _ _ = do resultSet <- writeQuery mrReadPlan mrMutatePlan mrMedia mrHandler apiReq conf failNotSingular mrMedia resultSet failExceedsMaxAffectedPref (preferMaxAffected,preferHandling) resultSet - failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet + failsChangesOffLimits (mPlanLimit mrMutatePlan) resultSet optionalRollback conf apiReq pure $ DbCrudResult plan resultSet diff --git a/src/PostgREST/Query/QueryBuilder.hs b/src/PostgREST/Query/QueryBuilder.hs index 96701be252..abdfe718b4 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -40,13 +40,12 @@ import PostgREST.Plan.MutatePlan import PostgREST.Plan.ReadPlan import PostgREST.Plan.Types import PostgREST.Query.SqlFragment -import PostgREST.RangeQuery (allRange) import Protolude readPlanToQuery :: ReadPlanTree -> SQL.Snippet -readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect} forest) = - "SELECT " <> +readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order,offset,limit,range_=readRange, relToParent, relJoinConds, relSelect} forest) = + "WITH pgrst_select_body AS ( SELECT " <> intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ joinsSelects) <> " " <> fromFrag <> " " <> intercalateSnippet " " joins <> " " <> @@ -55,8 +54,12 @@ readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicFor else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <> groupF qi select relSelect <> " " <> orderF qi order <> " " <> - limitOffsetF readRange + offsetF <> " " <> limitF <> " ) " <> + "SELECT * FROM pgrst_select_body " <> + rangeHeaderF readRange where + limitF = maybe mempty (\x -> "LIMIT " <> intToSqlSnip x) limit + offsetF = maybe mempty (\x -> "OFFSET " <> intToSqlSnip x) offset fromFrag = fromF relToParent mainQi fromAlias qi = getQualifiedIdentifier relToParent mainQi fromAlias -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage @@ -135,14 +138,14 @@ mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings mergeDups = case onConflict of {Just (MergeDuplicates,_) -> True; _ -> False;} -- An update without a limit is always filtered with a WHERE -mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults) +mutatePlanToQuery (Update mainQi uCols body logicForest offset limit ordts returnings applyDefaults) | null uCols = -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax -- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select= -- the select has to be based on "returnings" to make computed overloaded functions not throw "SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false" - | range == allRange = + | isNothing offset && isNothing limit = "UPDATE " <> mainTbl <> " SET " <> nonRangeCols <> " " <> fromJsonBodyF body uCols False False applyDefaults <> whereLogic <> " " <> @@ -155,7 +158,7 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings a "SELECT " <> rangeIdF <> " FROM " <> mainTbl <> whereLogic <> " " <> orderF mainQi ordts <> " " <> - limitOffsetF range <> + offsetF <> " " <> limitF <> " " <> ") " <> "UPDATE " <> mainTbl <> " SET " <> rangeCols <> "FROM pgrst_affected_rows " <> @@ -163,6 +166,8 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings a returningF mainQi returnings where + limitF = maybe mempty (\x -> "LIMIT " <> intToSqlSnip x) limit + offsetF = maybe mempty (\x -> "OFFSET " <> intToSqlSnip x) offset whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest) mainTbl = fromQi mainQi emptyBodyReturnedColumns = if null returnings then "NULL" else intercalateSnippet ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings) @@ -170,8 +175,8 @@ mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings a rangeCols = intercalateSnippet ", " ((\col -> pgFmtIdent (cfName col) <> " = (SELECT " <> pgFmtIdent (cfName col) <> " FROM pgrst_update_body) ") <$> uCols) (whereRangeIdF, rangeIdF) = mutRangeF mainQi (cfName . coField <$> ordts) -mutatePlanToQuery (Delete mainQi logicForest range ordts returnings) - | range == allRange = +mutatePlanToQuery (Delete mainQi logicForest offset limit ordts returnings) + | isNothing offset && isNothing limit = "DELETE FROM " <> fromQi mainQi <> " " <> whereLogic <> " " <> returningF mainQi returnings @@ -182,14 +187,16 @@ mutatePlanToQuery (Delete mainQi logicForest range ordts returnings) "SELECT " <> rangeIdF <> " FROM " <> fromQi mainQi <> whereLogic <> " " <> orderF mainQi ordts <> " " <> - limitOffsetF range <> - ") " <> + offsetF <> " " <> limitF <> + " ) " <> "DELETE FROM " <> fromQi mainQi <> " " <> "USING pgrst_affected_rows " <> "WHERE " <> whereRangeIdF <> " " <> returningF mainQi returnings where + limitF = maybe mempty (\x -> "LIMIT " <> intToSqlSnip x) limit + offsetF = maybe mempty (\x -> "OFFSET " <> intToSqlSnip x) offset whereLogic = if null logicForest then mempty else " WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest) (whereRangeIdF, rangeIdF) = mutRangeF mainQi (cfName . coField <$> ordts) diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index 39b869d5d9..0b0fc57293 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -11,7 +11,7 @@ module PostgREST.Query.SqlFragment , countF , groupF , fromQi - , limitOffsetF + , rangeHeaderF , locationF , mutRangeF , orderF @@ -33,6 +33,7 @@ module PostgREST.Query.SqlFragment , sourceCTE , sourceCTEName , unknownEncoder + , intToSqlSnip , intercalateSnippet , explainF , setConfigWithConstantName @@ -479,12 +480,12 @@ returningF qi returnings = then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified else "RETURNING " <> intercalateSnippet ", " (pgFmtColumn qi <$> returnings) -limitOffsetF :: NonnegRange -> SQL.Snippet -limitOffsetF range = +rangeHeaderF :: NonnegRange -> SQL.Snippet +rangeHeaderF range = if range == allRange then mempty else "LIMIT " <> limit <> " OFFSET " <> offset where - limit = maybe "ALL" (\l -> unknownEncoder (BS.pack $ show l)) $ rangeLimit range - offset = unknownEncoder (BS.pack . show $ rangeOffset range) + limit = maybe "ALL" intToSqlSnip $ rangeLimit range + offset = intToSqlSnip $ rangeOffset range responseHeadersF :: SQL.Snippet responseHeadersF = currentSettingF "response.headers" @@ -520,6 +521,9 @@ unknownEncoder = SQL.encoderAndParam (HE.nonNullable HE.unknown) unknownLiteral :: Text -> SQL.Snippet unknownLiteral = unknownEncoder . encodeUtf8 +intToSqlSnip :: Integer -> SQL.Snippet +intToSqlSnip x = unknownEncoder (BS.pack $ show x) + intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet intercalateSnippet _ [] = mempty intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets diff --git a/src/PostgREST/RangeQuery.hs b/src/PostgREST/RangeQuery.hs index a27a1af5cd..b1165d374a 100644 --- a/src/PostgREST/RangeQuery.hs +++ b/src/PostgREST/RangeQuery.hs @@ -15,7 +15,6 @@ module PostgREST.RangeQuery ( , convertToLimitZeroRange , NonnegRange , rangeStatusHeader -, contentRangeH ) where import qualified Data.ByteString.Char8 as BS @@ -29,6 +28,9 @@ import Data.Ranged.Ranges import Network.HTTP.Types.Header import Network.HTTP.Types.Status +import PostgREST.ApiRequest.Preferences (PreferCount (..), + shouldCount) + import Protolude type NonnegRange = Range Integer @@ -93,29 +95,33 @@ convertToLimitZeroRange :: Range Integer -> Range Integer -> Range Integer convertToLimitZeroRange range fallbackRange = if hasLimitZero range then limitZeroRange else fallbackRange -rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header) -rangeStatusHeader topLevelRange queryTotal tableTotal = - let lower = rangeOffset topLevelRange - upper = lower + toInteger queryTotal - 1 - contentRange = contentRangeH lower upper (toInteger <$> tableTotal) - status = rangeStatus lower upper (toInteger <$> tableTotal) - in (status, contentRange) - where - rangeStatus :: Integer -> Integer -> Maybe Integer -> Status - rangeStatus _ _ Nothing = status200 - rangeStatus lower upper (Just total) - | lower > total = status416 -- 416 Range Not Satisfiable - | (1 + upper - lower) < total = status206 -- 206 Partial Content - | otherwise = status200 -- 200 OK - -contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header -contentRangeH lower upper total = - ("Content-Range", toUtf8 headerValue) +rangeStatusHeader :: Maybe PreferCount -> NonnegRange -> Int64 -> Maybe Int64 -> (Status, Maybe Header) +rangeStatusHeader prefCount topLevelRange queryTotal tableTotal + = (status, Just contentRange) where - headerValue = rangeString <> "/" <> totalString :: Text - rangeString - | totalNotZero && fromInRange = show lower <> "-" <> show upper - | otherwise = "*" - totalString = maybe "*" show total - totalNotZero = Just 0 /= total - fromInRange = lower <= upper + lower = rangeOffset topLevelRange + upper = lower + toInteger queryTotal - 1 + tblTotal = if shouldCount prefCount + then toInteger <$> tableTotal + else Nothing + contentRange = contentRangeH lower upper tblTotal + status = rangeStatus lower upper tblTotal + + rangeStatus :: Integer -> Integer -> Maybe Integer -> Status + rangeStatus _ _ Nothing = status200 + rangeStatus low up (Just total) + | low > total = status416 -- 416 Range Not Satisfiable + | (1 + up - low) < total = status206 -- 206 Partial Content + | otherwise = status200 -- 200 OK + + contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header + contentRangeH low up tot = + ("Content-Range", toUtf8 headerValue) + where + headerValue = rangeString <> "/" <> totalString :: Text + rangeString + | totalNotZero && fromInRange = show low <> "-" <> show up + | otherwise = "*" + totalString = maybe "*" show tot + totalNotZero = Just 0 /= tot + fromInRange = low <= up diff --git a/src/PostgREST/Response.hs b/src/PostgREST/Response.hs index d50f825ba2..d92257ec60 100644 --- a/src/PostgREST/Response.hs +++ b/src/PostgREST/Response.hs @@ -30,8 +30,7 @@ import PostgREST.ApiRequest (ApiRequest (..), import PostgREST.ApiRequest.Preferences (PreferRepresentation (..), PreferResolution (..), Preferences (..), - prefAppliedHeader, - shouldCount) + prefAppliedHeader) import PostgREST.ApiRequest.QueryParams (QueryParams (..)) import PostgREST.Config (AppConfig (..)) import PostgREST.MediaType (MediaType (..)) @@ -68,23 +67,18 @@ actionResponse (DbCrudResult WrappedReadPlan{wrMedia, wrHdrsOnly=headersOnly, cr case resultSet of RSStandard{..} -> do let - (status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal - prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing [] + (status, contentRangeH) = RangeQuery.rangeStatusHeader preferCount iRange rsQueryTotal rsTableTotal + prefHeader = maybeToList . prefAppliedHeader (iRange /= RangeQuery.allRange) $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing [] headers = - [ contentRange - , ( "Content-Location" - , "/" - <> toUtf8 (qiName identifier) - <> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams - ) - ] - ++ contentTypeHeaders wrMedia ctxApiRequest - ++ prefHeader + catMaybes [ contentRangeH + , Just ( "Content-Location", "/" <> toUtf8 (qiName identifier) + <> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams) + ] ++ contentTypeHeaders wrMedia ctxApiRequest ++ prefHeader (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers let bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $ - ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal) + ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iRange) (maybe "0" show rsTableTotal) | headersOnly = mempty | otherwise = LBS.fromStrict rsBody @@ -97,9 +91,10 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationCreate, mrMutateP RSStandard{..} -> do let pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;} - prefHeader = prefAppliedHeader $ + prefHeader = prefAppliedHeader (iRange /= RangeQuery.allRange) $ Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution) preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone Nothing [] + (status,contentRangeH) = RangeQuery.rangeStatusHeader preferCount iRange rsQueryTotal rsTableTotal headers = catMaybes [ if null rsLocation then @@ -111,88 +106,98 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationCreate, mrMutateP <> toUtf8 qiName <> HTTP.renderSimpleQuery True rsLocation ) - , Just . RangeQuery.contentRangeH 1 0 $ - if shouldCount preferCount then Just rsQueryTotal else Nothing - , prefHeader ] + , contentRangeH , prefHeader ] let isInsertIfGTZero i = if i <= 0 && preferResolution == Just MergeDuplicates then HTTP.status200 else HTTP.status201 - status = maybe HTTP.status200 isInsertIfGTZero rsInserted + status' = maybe HTTP.status200 isInsertIfGTZero rsInserted (headers', bod) = case preferRepresentation of Just Full -> (headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody) - Just None -> (headers, mempty) - Just HeadersOnly -> (headers, mempty) - Nothing -> (headers, mempty) + _ -> (headers, mempty) - (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers' + (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status' headers' - Right $ PgrstResponse ovStatus ovHeaders bod + let bod' | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $ + ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iRange) (maybe "0" show rsTableTotal) + | otherwise = bod + + Right $ PgrstResponse ovStatus ovHeaders bod' RSPlan plan -> Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan -actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationUpdate, mrMedia} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = case resultSet of +actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationUpdate, mrMedia} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} _ _ _ _ _ = case resultSet of RSStandard{..} -> do let - 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 preferMaxAffected [] - headers = catMaybes [contentRangeHeader, prefHeader] + prefHeader = prefAppliedHeader (iRange /= RangeQuery.allRange) $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone preferMaxAffected [] + (status, cRangeHeader) = RangeQuery.rangeStatusHeader preferCount iRange rsQueryTotal rsTableTotal + headers = catMaybes [cRangeHeader, prefHeader] - let (status, headers', body) = + let (status', headers', body) = case preferRepresentation of Just Full -> (HTTP.status200, headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody) Just None -> (HTTP.status204, headers, mempty) _ -> (HTTP.status204, headers, mempty) - (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers' + (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status' headers' - Right $ PgrstResponse ovStatus ovHeaders body + let bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $ + ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iRange) (maybe "0" show rsTableTotal) + | otherwise = body + + Right $ PgrstResponse ovStatus ovHeaders bod RSPlan plan -> Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan -actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationSingleUpsert, mrMedia} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = case resultSet of +actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationSingleUpsert, mrMedia} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} _ _ _ _ _ = case resultSet of RSStandard {..} -> do let - prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing [] + prefHeader = maybeToList . prefAppliedHeader (iRange /= RangeQuery.allRange) $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing [] cTHeader = contentTypeHeaders mrMedia ctxApiRequest + (status, cRangeHeader) = RangeQuery.rangeStatusHeader preferCount iRange rsQueryTotal rsTableTotal + allHeaders = cTHeader ++ prefHeader ++ maybeToList cRangeHeader + let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200 upsertStatus = isInsertIfGTZero $ fromJust rsInserted - (status, headers, body) = + (status', headers, body) = case preferRepresentation of - Just Full -> (upsertStatus, cTHeader ++ prefHeader, LBS.fromStrict rsBody) - Just None -> (HTTP.status204, prefHeader, mempty) - _ -> (HTTP.status204, prefHeader, mempty) - (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers + Just Full -> (upsertStatus, allHeaders, LBS.fromStrict rsBody) + _ -> (HTTP.status204, prefHeader, mempty) + (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status' headers - Right $ PgrstResponse ovStatus ovHeaders body + let bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $ + ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iRange) (maybe "0" show rsTableTotal) + | otherwise = body + + Right $ PgrstResponse ovStatus ovHeaders bod RSPlan plan -> Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan -actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, mrMedia} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = case resultSet of +actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, mrMedia} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} _ _ _ _ _ = case resultSet of RSStandard {..} -> do let - 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 preferMaxAffected [] - headers = contentRangeHeader : prefHeader + prefHeader = maybeToList . prefAppliedHeader (iRange /= RangeQuery.allRange) $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected [] + (status, cRangeHeader) = RangeQuery.rangeStatusHeader preferCount iRange rsQueryTotal rsTableTotal + headers = prefHeader ++ maybeToList cRangeHeader - let (status, headers', body) = + let (status', headers', body) = case preferRepresentation of Just Full -> (HTTP.status200, headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody) Just None -> (HTTP.status204, headers, mempty) _ -> (HTTP.status204, headers, mempty) - (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers' + (ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status' headers' - Right $ PgrstResponse ovStatus ovHeaders body + let bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $ + ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iRange) (maybe "0" show rsTableTotal) + | otherwise = body + + Right $ PgrstResponse ovStatus ovHeaders bod RSPlan plan -> Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan @@ -200,14 +205,14 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, mrMedia} actionResponse (DbCallResult CallReadPlan{crMedia, crInvMthd=invMethod, crProc=proc} resultSet) ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} _ _ _ _ _ = case resultSet of RSStandard {..} -> do let - (status, contentRange) = - RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal + (status, cRangeHeader) = + RangeQuery.rangeStatusHeader preferCount iRange rsQueryTotal rsTableTotal rsOrErrBody = if status == HTTP.status416 then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange - $ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal) + $ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iRange) (maybe "0" show rsTableTotal) else LBS.fromStrict rsBody - prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected [] - headers = contentRange : prefHeader + prefHeader = maybeToList . prefAppliedHeader (iRange /= RangeQuery.allRange) $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected [] + headers = prefHeader ++ maybeToList cRangeHeader let (status', headers', body) = if Routine.funcReturnsVoid proc then diff --git a/test/io/test_io.py b/test/io/test_io.py index b9130472e1..0bc92d5c2d 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -437,7 +437,7 @@ def test_max_rows_reload(defaultenv): with run(env=env) as postgrest: response = postgrest.session.head("/projects") assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-4/*" + assert response.text == "" # change max-rows config on the db postgrest.session.post("/rpc/change_max_rows_config", data={"val": 1}) @@ -447,9 +447,9 @@ def test_max_rows_reload(defaultenv): sleep_until_postgrest_config_reload() - response = postgrest.session.head("/projects") + response = postgrest.session.get("/projects") assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-0/*" + assert response.text == "" # reset max-rows config on the db response = postgrest.session.post("/rpc/reset_max_rows_config") @@ -467,9 +467,9 @@ def test_max_rows_notify_reload(defaultenv): } with run(env=env) as postgrest: - response = postgrest.session.head("/projects") + response = postgrest.session.get("/projects") assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-4/*" + assert response.text == "" # change max-rows config on the db and reload with notify postgrest.session.post( @@ -478,9 +478,9 @@ def test_max_rows_notify_reload(defaultenv): sleep_until_postgrest_config_reload() - response = postgrest.session.head("/projects") + response = postgrest.session.get("/projects") assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-0/*" + assert response.text == "" # reset max-rows config on the db response = postgrest.session.post("/rpc/reset_max_rows_config") diff --git a/test/spec/Feature/Query/LimitOffsetSpec.hs b/test/spec/Feature/Query/LimitOffsetSpec.hs new file mode 100644 index 0000000000..72522378fa --- /dev/null +++ b/test/spec/Feature/Query/LimitOffsetSpec.hs @@ -0,0 +1,150 @@ +module Feature.Query.LimitOffsetSpec where + +import Network.Wai (Application) + +import Network.HTTP.Types +import Test.Hspec +import Test.Hspec.Wai +import Test.Hspec.Wai.JSON + +import Protolude hiding (get) +import SpecHelper + +spec :: SpecWith ((), Application) +spec = do + describe "GET /rpc/getitemrange" $ do + context "without range headers" $ do + context "with response under server size limit" $ + it "returns whole range with status 200" $ + get "/rpc/getitemrange?min=0&max=15" `shouldRespondWith` 200 + + context "offset exceeding total rows" $ do + it "returns nothing when offset greater than 0 for when result is empty" $ + request methodGet "/rpc/getitemrange?offset=1&min=2&max=2" + [] mempty + `shouldRespondWith` + [json|[]|] + { matchStatus = 200 } + + it "returns nothing when offset exceeds total rows" $ + request methodGet "/rpc/getitemrange?offset=100&min=0&max=15" + [] mempty + `shouldRespondWith` + [json|[]|] + { matchStatus = 200 } + + describe "GET /items" $ do + context "without range headers" $ do + context "with response under server size limit" $ + it "returns whole range with status 200" $ + get "/items" `shouldRespondWith` 200 + + context "count with an empty body" $ do + it "returns empty body with Content-Range */0" $ + request methodGet "/items?id=eq.0" + [("Prefer", "count=exact")] "" + `shouldRespondWith` + [json|[]|] + { matchStatus = 200 } + + context "when I don't want the count" $ do + it "does not return Content-Range" $ + request methodGet "/menagerie" + [] "" + `shouldRespondWith` + [json|[]|] + { matchStatus = 200 + , matchHeaders = [ matchHeaderAbsent "Content-Range" ] } + + it "does not return Content-Range" $ + request methodGet "/items?order=id" + [] "" + `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] + { matchStatus = 200 + , matchHeaders = [ matchHeaderAbsent "Content-Range" ] } + + it "does not return Content-Range even using other filters" $ + request methodGet "/items?id=eq.1&order=id" + [] "" + `shouldRespondWith` [json| [{"id":1}] |] + { matchStatus = 200 + , matchHeaders = [ matchHeaderAbsent "Content-Range"] } + + context "with limit/offset parameters" $ do + it "no parameters return everything" $ + get "/items?select=id&order=id.asc" + `shouldRespondWith` + [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|] + { matchStatus = 200 } + it "top level limit with parameter" $ + get "/items?select=id&order=id.asc&limit=3" + `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|] + { matchStatus = 200 + , matchHeaders = [ matchHeaderAbsent "Content-Range" ] } + it "headers override get parameters" $ + request methodGet "/items?select=id&order=id.asc&limit=3" + (rangeHdrs $ ByteRangeFromTo 0 1) "" + `shouldRespondWith` [json|[{"id":1},{"id":2}]|] + { matchStatus = 200 + , matchHeaders = ["Content-Range" <:> "0-1/*"] + } + + it "limit works on all levels" $ + get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1" + `shouldRespondWith` + [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|] + { matchStatus = 200 } + + it "limit and offset works on first level" $ do + get "/items?select=id&order=id.asc&limit=3&offset=2" + `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|] + { matchStatus = 200 } + request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" + [] + mempty + `shouldRespondWith` + "" + { matchStatus = 200 + , matchHeaders = [ matchContentTypeJson ] + } + + context "succeeds if offset equals 0 as a no-op" $ do + it "no items" $ do + get "/items?offset=0&id=eq.0" + `shouldRespondWith` + [json|[]|] + { matchStatus = 200 + , matchHeaders = [ matchHeaderAbsent "Content-Range"] } + + request methodGet "/items?offset=0&id=eq.0" + [] "" + `shouldRespondWith` + [json|[]|] + { matchStatus = 200 } + + it "one or more items" $ + get "/items?select=id&offset=0&order=id" + `shouldRespondWith` + [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|] + { matchStatus = 200 + , matchHeaders = [ matchHeaderAbsent "Content-Range" ] } + + it "fails with pg error if offset is negative" $ + get "/items?select=id&offset=-4&order=id" + `shouldRespondWith` + [json|{"code":"2201X","details":null,"hint":null,"message":"OFFSET must not be negative"} |] + { matchStatus = 400 + , matchHeaders = [matchContentTypeJson] } + + it "succeeds and returns an empty array if limit equals 0" $ + get "/items?select=id&limit=0" + `shouldRespondWith` [json|[]|] + { matchStatus = 200 } + + it "fails if limit is negative" $ + get "/items?select=id&limit=-1" + `shouldRespondWith` + [json|{"code":"2201W","details":null,"hint":null,"message":"LIMIT must not be negative"} |] + { matchStatus = 400 + , matchHeaders = [matchContentTypeJson] + } diff --git a/test/spec/Feature/Query/RangeSpec.hs b/test/spec/Feature/Query/RangeSpec.hs index 5eb2e17740..6c14fb89f5 100644 --- a/test/spec/Feature/Query/RangeSpec.hs +++ b/test/spec/Feature/Query/RangeSpec.hs @@ -14,51 +14,6 @@ import SpecHelper spec :: SpecWith ((), Application) spec = do describe "GET /rpc/getitemrange" $ do - context "without range headers" $ do - context "with response under server size limit" $ - it "returns whole range with status 200" $ - get "/rpc/getitemrange?min=0&max=15" `shouldRespondWith` 200 - - context "when I don't want the count" $ do - it "returns range Content-Range with */* for empty range" $ - get "/rpc/getitemrange?min=2&max=2" - `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]} - - it "returns range Content-Range with range/*" $ - get "/rpc/getitemrange?order=id&min=0&max=15" - `shouldRespondWith` - [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] - { matchHeaders = ["Content-Range" <:> "0-14/*"] } - - context "of invalid range" $ do - it "refuses a range with nonzero start when there are no items" $ - request methodGet "/rpc/getitemrange?offset=1&min=2&max=2" - [("Prefer", "count=exact")] mempty - `shouldRespondWith` - [json| { - "message":"Requested range not satisfiable", - "code":"PGRST103", - "details":"An offset of 1 was requested, but there are only 0 rows.", - "hint":null - }|] - { matchStatus = 416 - , matchHeaders = ["Content-Range" <:> "*/0"] - } - - it "refuses a range requesting start past last item" $ - request methodGet "/rpc/getitemrange?offset=100&min=0&max=15" - [("Prefer", "count=exact")] mempty - `shouldRespondWith` - [json| { - "message":"Requested range not satisfiable", - "code":"PGRST103", - "details":"An offset of 100 was requested, but there are only 15 rows.", - "hint":null - }|] - { matchStatus = 416 - , matchHeaders = ["Content-Range" <:> "*/15"] - } - context "with range headers" $ do context "of acceptable range" $ do it "succeeds with partial content" $ do @@ -140,261 +95,6 @@ spec = do } describe "GET /items" $ do - context "without range headers" $ do - context "with response under server size limit" $ - it "returns whole range with status 200" $ - get "/items" `shouldRespondWith` 200 - - context "count with an empty body" $ do - it "returns empty body with Content-Range */0" $ - request methodGet "/items?id=eq.0" - [("Prefer", "count=exact")] "" - `shouldRespondWith` - [json|[]|] - { matchHeaders = ["Content-Range" <:> "*/0"] } - - context "when I don't want the count" $ do - it "returns range Content-Range with /*" $ - request methodGet "/menagerie" - [("Prefer", "count=none")] "" - `shouldRespondWith` - [json|[]|] - { matchHeaders = ["Content-Range" <:> "*/*"] } - - it "returns range Content-Range with range/*" $ - request methodGet "/items?order=id" - [("Prefer", "count=none")] "" - `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] - { matchHeaders = ["Content-Range" <:> "0-14/*"] } - - it "returns range Content-Range with range/* even using other filters" $ - request methodGet "/items?id=eq.1&order=id" - [("Prefer", "count=none")] "" - `shouldRespondWith` [json| [{"id":1}] |] - { matchHeaders = ["Content-Range" <:> "0-0/*"] } - - context "with limit/offset parameters" $ do - it "no parameters return everything" $ - get "/items?select=id&order=id.asc" - `shouldRespondWith` - [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-14/*"] - } - it "top level limit with parameter" $ - get "/items?select=id&order=id.asc&limit=3" - `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-2/*"] - } - it "headers override get parameters" $ - request methodGet "/items?select=id&order=id.asc&limit=3" - (rangeHdrs $ ByteRangeFromTo 0 1) "" - `shouldRespondWith` [json|[{"id":1},{"id":2}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/*"] - } - - it "limit works on all levels" $ - get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1" - `shouldRespondWith` - [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-0/*"] - } - - it "limit and offset works on first level" $ do - get "/items?select=id&order=id.asc&limit=3&offset=2" - `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "2-4/*"] - } - request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" - [] - mempty - `shouldRespondWith` - "" - { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "2-4/*" ] - } - - context "succeeds if offset equals 0 as a no-op" $ do - it "no items" $ do - get "/items?offset=0&id=eq.0" - `shouldRespondWith` - [json|[]|] - { matchHeaders = ["Content-Range" <:> "*/*"] } - - request methodGet "/items?offset=0&id=eq.0" - [("Prefer", "count=exact")] "" - `shouldRespondWith` - [json|[]|] - { matchHeaders = ["Content-Range" <:> "*/0"] } - - it "one or more items" $ - get "/items?select=id&offset=0&order=id" - `shouldRespondWith` - [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|] - { matchHeaders = ["Content-Range" <:> "0-14/*"] } - - it "succeeds if offset is negative as a no-op" $ - get "/items?select=id&offset=-4&order=id" - `shouldRespondWith` - [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|] - { matchHeaders = ["Content-Range" <:> "0-14/*"] } - - it "succeeds and returns an empty array if limit equals 0" $ - get "/items?select=id&limit=0" - `shouldRespondWith` [json|[]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "*/*"] - } - - it "fails if limit is negative" $ - get "/items?select=id&limit=-1" - `shouldRespondWith` - [json| { - "message":"Requested range not satisfiable", - "code":"PGRST103", - "details":"Limit should be greater than or equal to zero.", - "hint":null - }|] - { matchStatus = 416 - , matchHeaders = [matchContentTypeJson] - } - - context "of invalid range" $ do - it "refuses a range with nonzero start when there are no items" $ - request methodGet "/menagerie?offset=1" - [("Prefer", "count=exact")] "" - `shouldRespondWith` - [json| { - "message":"Requested range not satisfiable", - "code":"PGRST103", - "details":"An offset of 1 was requested, but there are only 0 rows.", - "hint":null - }|] - { matchStatus = 416 - , matchHeaders = ["Content-Range" <:> "*/0"] - } - - it "refuses a range requesting start past last item" $ - request methodGet "/items?offset=100" - [("Prefer", "count=exact")] "" - `shouldRespondWith` - [json| { - "message":"Requested range not satisfiable", - "code":"PGRST103", - "details":"An offset of 100 was requested, but there are only 15 rows.", - "hint":null - }|] - { matchStatus = 416 - , matchHeaders = ["Content-Range" <:> "*/15"] - } - - context "when count=planned" $ do - it "obtains a filtered range" $ do - request methodGet "/items?select=id&id=gt.8" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|] - { matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "0-6/8"] - } - - request methodGet "/child_entities?select=id&id=gt.3" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - [json|[{"id":4}, {"id":5}, {"id":6}]|] - { matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "0-2/4"] - } - - request methodGet "/getallprojects_view?select=id&id=lt.3" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - [json|[{"id":1}, {"id":2}]|] - { matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "0-1/673"] - } - - it "obtains the full range" $ do - request methodHead "/items" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-14/15" ] - } - - request methodHead "/child_entities" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-5/6" ] - } - - request methodHead "/getallprojects_view" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-4/2019" ] - } - - it "ignores limit/offset on the planned count" $ do - request methodHead "/items?limit=2&offset=3" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "3-4/15" ] - } - - request methodHead "/child_entities?limit=2" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/6" ] - } - - request methodHead "/getallprojects_view?limit=2" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2019" ] - } - - it "works with two levels" $ - request methodHead "/child_entities?select=*,entities(*)" - [("Prefer", "count=planned")] - "" - `shouldRespondWith` - "" - { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-5/6" ] - } - context "with range headers" $ do context "of acceptable range" $ do it "succeeds with partial content" $ do @@ -474,3 +174,32 @@ spec = do { matchStatus = 416 , matchHeaders = ["Content-Range" <:> "*/15"] } + + context "when count=planned" $ do + it "obtains a filtered range" $ do + request methodGet "/items?select=id&id=gt.8" + (("Prefer", "count=planned") : rangeHdrs (ByteRangeFromTo 0 6)) + "" + `shouldRespondWith` + [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|] + { matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-6/8"] + } + + request methodGet "/child_entities?select=id&id=gt.3" + (("Prefer", "count=planned") : rangeHdrs (ByteRangeFromTo 0 2)) + "" + `shouldRespondWith` + [json|[{"id":4}, {"id":5}, {"id":6}]|] + { matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-2/4"] + } + + request methodGet "/getallprojects_view?select=id&id=lt.3" + (("Prefer", "count=planned") : rangeHdrs (ByteRangeFromTo 0 1)) + "" + `shouldRespondWith` + [json|[{"id":1}, {"id":2}]|] + { matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-1/673"] + } diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 526bd45dcc..e15c94a6e9 100644 --- a/test/spec/Main.hs +++ b/test/spec/Main.hs @@ -48,6 +48,7 @@ import qualified Feature.Query.ErrorSpec import qualified Feature.Query.InsertSpec import qualified Feature.Query.JsonOperatorSpec import qualified Feature.Query.LimitedMutationSpec +import qualified Feature.Query.LimitOffsetSpec import qualified Feature.Query.MultipleSchemaSpec import qualified Feature.Query.NullsStripSpec import qualified Feature.Query.PgSafeUpdateSpec @@ -149,6 +150,7 @@ main = do , ("Feature.Query.EmbedDisambiguationSpec" , Feature.Query.EmbedDisambiguationSpec.spec) , ("Feature.Query.EmbedInnerJoinSpec" , Feature.Query.EmbedInnerJoinSpec.spec) , ("Feature.Query.InsertSpec" , Feature.Query.InsertSpec.spec actualPgVersion) + , ("Feature.Query.LimitOffsetSpec" , Feature.Query.LimitOffsetSpec.spec) , ("Feature.Query.JsonOperatorSpec" , Feature.Query.JsonOperatorSpec.spec) , ("Feature.Query.NullsStripSpec" , Feature.Query.NullsStripSpec.spec) , ("Feature.Query.PgErrorCodeMappingSpec" , Feature.Query.ErrorSpec.pgErrorCodeMapping)