From 224bdf5d1f70d571cd9c95550a94f328077d9e57 Mon Sep 17 00:00:00 2001 From: Taimoor Zaeem Date: Mon, 10 Jun 2024 11:33:31 +0500 Subject: [PATCH] fix: mixing offset and limit with Range header --- postgrest.cabal | 1 + src/PostgREST/ApiRequest.hs | 39 +-- src/PostgREST/ApiRequest/Preferences.hs | 8 +- src/PostgREST/ApiRequest/QueryParams.hs | 129 +++---- src/PostgREST/ApiRequest/Types.hs | 3 +- src/PostgREST/Error.hs | 16 +- src/PostgREST/Plan.hs | 55 +-- src/PostgREST/Plan/MutatePlan.hs | 15 +- src/PostgREST/Plan/ReadPlan.hs | 2 + src/PostgREST/Query.hs | 11 +- src/PostgREST/Query/QueryBuilder.hs | 29 +- src/PostgREST/Query/SqlFragment.hs | 14 +- src/PostgREST/RangeQuery.hs | 59 ++-- src/PostgREST/Response.hs | 115 +++--- test/io/test_io.py | 14 +- test/spec/Feature/Query/ComputedRelsSpec.hs | 20 +- test/spec/Feature/Query/DeleteSpec.hs | 30 +- test/spec/Feature/Query/EmbedInnerJoinSpec.hs | 106 +++--- test/spec/Feature/Query/InsertSpec.hs | 35 +- test/spec/Feature/Query/LimitOffsetSpec.hs | 150 ++++++++ test/spec/Feature/Query/PlanSpec.hs | 16 +- test/spec/Feature/Query/QueryLimitedSpec.hs | 36 +- test/spec/Feature/Query/QuerySpec.hs | 49 +-- test/spec/Feature/Query/RangeSpec.hs | 329 ++---------------- test/spec/Feature/Query/RelatedQueriesSpec.hs | 45 ++- test/spec/Feature/Query/RpcSpec.hs | 82 ++--- test/spec/Feature/Query/SingularSpec.hs | 9 +- test/spec/Feature/Query/UpdateSpec.hs | 54 +-- test/spec/Main.hs | 2 + 29 files changed, 629 insertions(+), 844 deletions(-) create mode 100644 test/spec/Feature/Query/LimitOffsetSpec.hs diff --git a/postgrest.cabal b/postgrest.cabal index 3787aea13c..cdf508ccdb 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -227,6 +227,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 db52d36332..2e5468752b 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -47,7 +47,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, @@ -343,7 +342,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) @@ -353,7 +354,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 @@ -456,7 +457,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 @@ -791,7 +792,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 = [], @@ -820,7 +822,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 = [] -- }, @@ -858,26 +861,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 @@ -910,12 +925,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 && @@ -926,7 +941,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 ab6ddf8cd5..8b193ae9aa 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 0d51c55484..39a129febe 100644 --- a/src/PostgREST/Query/QueryBuilder.hs +++ b/src/PostgREST/Query/QueryBuilder.hs @@ -37,13 +37,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 <> " " <> @@ -52,8 +51,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 @@ -132,14 +135,14 @@ mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ mergeDups = case onConflct 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 <> " " <> @@ -152,7 +155,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 " <> @@ -160,6 +163,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) @@ -167,8 +172,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 @@ -179,14 +184,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 bc6e483661..073d0f2bb1 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..4fdf17c02e 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,34 @@ 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 + | topLevelRange == allRange = (status200, Nothing) + | otherwise = (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 32919544ad..0d241c13a6 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -362,7 +362,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}) @@ -372,9 +372,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") @@ -392,9 +392,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( @@ -403,9 +403,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/ComputedRelsSpec.hs b/test/spec/Feature/Query/ComputedRelsSpec.hs index b2a9256abb..ae26bf59db 100644 --- a/test/spec/Feature/Query/ComputedRelsSpec.hs +++ b/test/spec/Feature/Query/ComputedRelsSpec.hs @@ -40,34 +40,28 @@ spec = describe "computed relationships" $ do {"name":"Hironobu Sakaguchi","videogames":[{"name":"Final Fantasy I"}, {"name":"Final Fantasy II"}]} ]|] { matchHeaders = [matchContentTypeJson] } - it "works with !inner and count=exact" $ do + it "works with !inner" $ do request methodGet "/designers?select=name,videogames:computed_videogames!inner(name)&videogames.name=eq.Civilization%20I" - [("Prefer", "count=exact")] "" + [] "" `shouldRespondWith` [json|[{"name":"Sid Meier","videogames":[{"name":"Civilization I"}]}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-0/1"] - } + { matchStatus = 200 } request methodGet "/videogames?select=name,designer:computed_designers!inner(name)&designer.name=like.*Hironobu*" - [("Prefer", "count=exact")] "" + [] "" `shouldRespondWith` [json|[ {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}}, {"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}} ]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/2"] - } + { matchStatus = 200 } request methodGet "/videogames?select=name,designer:computed_designers_noset!inner(name)&designer.name=like.*Hironobu*" - [("Prefer", "count=exact")] "" + [] "" `shouldRespondWith` [json|[ {"name":"Final Fantasy I","designer":{"name":"Hironobu Sakaguchi"}}, {"name":"Final Fantasy II","designer":{"name":"Hironobu Sakaguchi"}} ]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/2"] - } + { matchStatus = 200 } it "works with rpc" $ do get "/rpc/getallvideogames?select=name,designer:computed_designers(name)" diff --git a/test/spec/Feature/Query/DeleteSpec.hs b/test/spec/Feature/Query/DeleteSpec.hs index 7e3c83d10e..c61fa75c5c 100644 --- a/test/spec/Feature/Query/DeleteSpec.hs +++ b/test/spec/Feature/Query/DeleteSpec.hs @@ -21,16 +21,14 @@ spec = `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } - it "returns the deleted item and count if requested" $ - request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] "" + it "returns the deleted item if requested" $ + request methodDelete "/items?id=eq.2" [("Prefer", "return=representation")] "" `shouldRespondWith` [json|[{"id":2}]|] { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "*/1" - , "Preference-Applied" <:> "return=representation, count=exact"] + , matchHeaders = ["Preference-Applied" <:> "return=representation"] } it "ignores ?select= when return not set or return=minimal" $ do @@ -40,8 +38,7 @@ spec = `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } request methodDelete "/items?id=eq.3&select=id" [("Prefer", "return=minimal")] @@ -50,16 +47,13 @@ spec = "" { matchStatus = 204 , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=minimal"] } it "returns the deleted item and shapes the response" $ request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] "" `shouldRespondWith` [json|[{"id":2,"name":"Two"}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "*/*"] - } + { matchStatus = 200 } it "can rename and cast the selected columns" $ request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] "" @@ -68,9 +62,7 @@ spec = it "can embed (parent) entities" $ request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] "" `shouldRespondWith` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "*/*"] - } + { matchStatus = 200 } it "embeds an O2O relationship after delete" $ do request methodDelete "/students?id=eq.1&select=name,students_info(address)" @@ -103,9 +95,7 @@ spec = request methodDelete "/items?id=eq.101" [("Prefer", "return=representation")] "" `shouldRespondWith` "[]" - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "*/*"] - } + { matchStatus = 200 } context "totally unknown route" $ it "fails with 404" $ @@ -120,9 +110,7 @@ spec = request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")] [json| { "password": "passxyz" } |] `shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "*/*"] - } + { matchStatus = 200 } it "suceeds deleting the row with no explicit select when using return=minimal" $ request methodDelete "/app_users?id=eq.2" diff --git a/test/spec/Feature/Query/EmbedInnerJoinSpec.hs b/test/spec/Feature/Query/EmbedInnerJoinSpec.hs index 580bf5c374..dda61770e0 100644 --- a/test/spec/Feature/Query/EmbedInnerJoinSpec.hs +++ b/test/spec/Feature/Query/EmbedInnerJoinSpec.hs @@ -26,12 +26,10 @@ spec = {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}, {"id":5,"clients":null}]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty + request methodHead "/projects?select=id,clients!inner(id)" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-3/4" ] - } + , matchHeaders = [ matchContentTypeJson ] } it "filters source tables when the embedded table is filtered" $ do get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith` @@ -47,11 +45,10 @@ spec = get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith` [json|[]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty + request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "filters source tables when a two levels below embedded table is filtered" $ do @@ -69,11 +66,10 @@ spec = {"id":7,"projects":{"id":4,"clients":{"id":2}}}, {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty + request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-3/4" ] + , matchHeaders = [ matchContentTypeJson ] } it "only affects the source table rows if his direct embedding is an inner join" $ do @@ -88,22 +84,20 @@ spec = {"id":7,"projects":{"id":4,"clients":{"id":2}}}, {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty + request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-7/8" ] + , matchHeaders = [ matchContentTypeJson ] } it "works with views" $ do get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith` [json| [{"title":"1984","authors":{"name":"George Orwell"}}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty + request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } context "one-to-many relationships" $ do @@ -120,11 +114,10 @@ spec = {"id":3,"child_entities":[]}, {"id":4,"child_entities":[]}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty + request methodHead "/entities?select=id,child_entities!inner(id)" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "filters source tables when the embedded table is filtered" $ do @@ -137,11 +130,10 @@ spec = get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith` [json|[]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty + request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } it "filters source tables when a two levels below embedded table is filtered" $ do @@ -165,11 +157,10 @@ spec = } ]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty + request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } it "only affects the source table rows if his direct embedding is an inner join" $ do @@ -191,22 +182,20 @@ spec = } ]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty + request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "works with views" $ do get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith` [json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1,"first_publisher_id":1}]}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty + request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } context "many-to-many relationships" $ do @@ -222,11 +211,10 @@ spec = {"id":2,"suppliers":[{"id":1}, {"id":3}]}, {"id":3,"suppliers":[]}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty + request methodHead "/products?select=id,suppliers!inner(id)" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "filters source tables when the embedded table is filtered" $ do @@ -239,11 +227,10 @@ spec = get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith` [json| [] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty + request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } it "filters source tables when a two levels below embedded table is filtered" $ do @@ -255,11 +242,10 @@ spec = `shouldRespondWith` [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty + request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } it "only affects the source table rows if his direct embedding is an inner join" $ do @@ -268,11 +254,10 @@ spec = {"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]}, {"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty + request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "works with views" $ do @@ -282,11 +267,10 @@ spec = get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith` [json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty + request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , matchHeaders = [ matchContentTypeJson ] } it "works with m2o and m2m relationships combined" $ do @@ -297,22 +281,20 @@ spec = {"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]}, {"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty + request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-3/4" ] + , matchHeaders = [ matchContentTypeJson ] } it "works with rpc" $ do get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith` [json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty + request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "works when using hints" $ do @@ -322,11 +304,10 @@ spec = get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith` [json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty + request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "works with many one-to-many relationships" $ do @@ -348,11 +329,10 @@ spec = {"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]} ]|] { matchHeaders = [matchContentTypeJson] } - request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty + request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-2/3" ] + , matchHeaders = [ matchContentTypeJson ] } it "works alongside another embedding" $ do @@ -364,15 +344,13 @@ spec = {"id":8,"authors":{"name":"Kurt Vonnegut"},"publishers":{"name":"Delacorte"}}, {"id":9,"authors":{"name":"Ken Kesey"},"publishers":{"name":"Viking Press & Signet Books"}}] |] { matchHeaders = [matchContentTypeJson] } - request methodHead "/books?select=id,authors(name),publishers!inner(name)&id=gte.7" [("Prefer", "count=exact")] mempty + request methodHead "/books?select=id,authors(name),publishers!inner(name)&id=gte.7" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-2/3" ] + , matchHeaders = [ matchContentTypeJson ] } - request methodHead "/books?select=id,publishers!inner(name),authors(name)&id=gte.7" [("Prefer", "count=exact")] mempty + request methodHead "/books?select=id,publishers!inner(name),authors(name)&id=gte.7" [] mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-2/3" ] + , matchHeaders = [ matchContentTypeJson ] } diff --git a/test/spec/Feature/Query/InsertSpec.hs b/test/spec/Feature/Query/InsertSpec.hs index 4bf15d8985..c2b8b32545 100644 --- a/test/spec/Feature/Query/InsertSpec.hs +++ b/test/spec/Feature/Query/InsertSpec.hs @@ -96,13 +96,12 @@ spec actualPgVersion = do context "requesting full representation" $ do it "includes related data after insert" $ request methodPost "/projects?select=id,name,clients(id,name)" - [("Prefer", "return=representation"), ("Prefer", "count=exact")] + [("Prefer", "return=representation")] [json|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [json|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|] { matchStatus = 201 , matchHeaders = [ matchContentTypeJson , matchHeaderAbsent hLocation - , "Content-Range" <:> "*/1" - , "Preference-Applied" <:> "return=representation, count=exact"] + , "Preference-Applied" <:> "return=representation"] } it "can rename and cast the selected columns" $ @@ -113,7 +112,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchContentTypeJson , matchHeaderAbsent hLocation - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=representation"] } @@ -124,7 +122,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchContentTypeJson , matchHeaderAbsent hLocation - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=representation"] } @@ -138,7 +135,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/projects?id=eq.11" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -152,7 +148,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -664,8 +659,7 @@ spec actualPgVersion = do "id,name,client_id\n8,Xenix,1\n9,Windows NT,1" `shouldRespondWith` "id\n8\n9" { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"] } context "with wrong number of columns" $ @@ -768,7 +762,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/with_multiple_pks?pk1=eq.1&pk2=eq.2" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -781,7 +774,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -793,7 +785,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -812,7 +803,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/datarep_todos?id=eq.5" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -822,8 +812,7 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{"id":5, "label_color": "#001100"}] |] { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "parses values in POST body and formats values in return=representation" $ @@ -832,8 +821,7 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{"id":5,"name": "party", "label_color": "#001100", "due_at":"2018-01-03T11:00:00Z", "icon_image": "3q2+7w==", "created_at":-15, "budget": "-100000000000000.13"}] |] { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } context "with ?columns parameter" $ do @@ -843,8 +831,7 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{"id":5, "name":null, "label_color": "#001100", "due_at": "2018-01-01T00:00:00Z"}] |] { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "fails without parsing anything if at least one specified column doesn't exist" $ @@ -867,7 +854,6 @@ spec actualPgVersion = do { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType , "Location" <:> "/datarep_todos_computed?id=eq.5" - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=headers-only"] } @@ -877,8 +863,7 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{"id":5, "label_color": "#001100"}] |] { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "parses values in POST body and formats values in return=representation" $ @@ -887,8 +872,7 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{"id":5,"name": "party", "label_color": "#001100", "due_at":"2018-01-03T11:00:00Z", "dark_color":"#000880"}] |] { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } context "on updatable views with ?columns parameter" $ do @@ -898,8 +882,7 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{"id":5, "name":null, "label_color": "#001100", "due_at": "2018-01-01T00:00:00Z"}] |] { matchStatus = 201 - , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", - "Content-Range" <:> "*/*"] + , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] } it "fails without parsing anything if at least one specified column doesn't exist" $ 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/PlanSpec.hs b/test/spec/Feature/Query/PlanSpec.hs index d9e78d7709..292b4f5b2b 100644 --- a/test/spec/Feature/Query/PlanSpec.hs +++ b/test/spec/Feature/Query/PlanSpec.hs @@ -259,7 +259,7 @@ spec actualPgVersion = do liftIO $ do resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/vnd.pgrst.object+json\"; options=verbose; charset=utf-8") - aggCol `shouldBe` Just [aesonQQ| "COALESCE((json_agg(ROW(projects.id, projects.name, projects.client_id)) -> 0), 'null'::json)" |] + aggCol `shouldBe` Just [aesonQQ| "COALESCE((json_agg(_postgrest_t.*) -> 0), 'null'::json)" |] describe "function plan" $ do it "outputs the total cost for a function call" $ do @@ -273,7 +273,7 @@ spec actualPgVersion = do liftIO $ do resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8") resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" } - totalCost `shouldBe` 68.56 + totalCost `shouldBe` 68.86 describe "text format" $ do it "outputs the total cost for a function call" $ do @@ -307,7 +307,7 @@ spec actualPgVersion = do r <- request methodGet "/clients?select=*,projects(*)&id=eq.1" [planHdr] "" - liftIO $ planCost r `shouldSatisfy` (< 33.3) + liftIO $ planCost r `shouldSatisfy` (< 34.3) it "a many to one doesn't surpass a threshold" $ do r <- request methodGet "/projects?select=*,clients(*)&id=eq.1" @@ -326,12 +326,12 @@ spec actualPgVersion = do r1 <- request methodGet "/clients?select=*,projects!inner(*)&id=eq.1" [planHdr] "" - liftIO $ planCost r1 `shouldSatisfy` (< 33.3) + liftIO $ planCost r1 `shouldSatisfy` (< 33.4) r2 <- request methodGet "/clients?select=*,projects(*)&projects=not.is.null&id=eq.1" [planHdr] "" - liftIO $ planCost r2 `shouldSatisfy` (< 33.3) + liftIO $ planCost r2 `shouldSatisfy` (< 33.4) it "on an m2o, an !inner has a similar cost to not.null" $ do r1 <- request methodGet "/projects?select=*,clients!inner(*)&id=eq.1" @@ -360,13 +360,13 @@ spec actualPgVersion = do r <- request methodGet "/rpc/get_projects_below?id=3" [planHdr] "" - liftIO $ planCost r `shouldSatisfy` (< 45.4) + liftIO $ planCost r `shouldSatisfy` (< 55.29) it "should not exceed cost when calling setof composite proc with empty params" $ do r <- request methodGet "/rpc/getallprojects" [planHdr] "" - liftIO $ planCost r `shouldSatisfy` (< 71.0) + liftIO $ planCost r `shouldSatisfy` (< 91.14) it "should not exceed cost when calling scalar proc" $ do r <- request methodGet "/rpc/add_them?a=3&b=4" @@ -440,7 +440,7 @@ spec actualPgVersion = do 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" |] + aggCol `shouldBe` Just [aesonQQ| "_postgrest_t.pgrst_scalar" |] it "outputs the plan for an aggregate application/vnd.twkb" $ do r <- request methodGet "/lines" diff --git a/test/spec/Feature/Query/QueryLimitedSpec.hs b/test/spec/Feature/Query/QueryLimitedSpec.hs index 251c319814..3ffdb2c3a4 100644 --- a/test/spec/Feature/Query/QueryLimitedSpec.hs +++ b/test/spec/Feature/Query/QueryLimitedSpec.hs @@ -17,7 +17,7 @@ spec = get "/items?order=id" `shouldRespondWith` [json| [{"id":1},{"id":2}] |] - { matchHeaders = ["Content-Range" <:> "0-1/*"] } + { matchStatus = 200 } it "respects additional client limiting" $ do request methodGet "/items" @@ -31,51 +31,41 @@ spec = get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc" `shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|] - { matchHeaders = ["Content-Range" <:> "0-1/*"] } + { matchStatus = 200 } it "succeeds in getting parent embeds despite the limit, see #647" $ get "/tasks?select=id,project:projects(id)&id=gt.5" `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|] - { matchHeaders = ["Content-Range" <:> "0-1/*"] } + { matchStatus = 200 } it "can offset the parent embed, being consistent with the other embed types" $ get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1" `shouldRespondWith` [json|[{"id":6,"project":null}, {"id":7,"project":null}]|] - { matchHeaders = ["Content-Range" <:> "0-1/*"] } + { matchStatus = 200 } context "count=estimated" $ do it "uses the query planner guess when query rows > maxRows" $ - request methodHead "/getallprojects_view" - [("Prefer", "count=estimated")] + request methodGet "/getallprojects_view" + (("Prefer", "count=estimated") : rangeHdrs (ByteRangeFromTo 0 1)) "" `shouldRespondWith` - "" + [json|[{"id":1,"name":"Windows 7","client_id":1},{"id":2,"name":"Windows 10","client_id":1}]|] { matchStatus = 206 , matchHeaders = [ matchContentTypeJson , "Content-Range" <:> "0-1/2019" ] } it "gives exact count when query rows <= maxRows" $ - request methodHead "/getallprojects_view?id=lt.3" - [("Prefer", "count=estimated")] - "" - `shouldRespondWith` - "" - { matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] - } - - it "only uses the query planner guess if it's indeed greater than the exact count" $ - request methodHead "/get_projects_above_view" - [("Prefer", "count=estimated")] + request methodGet "/getallprojects_view?id=lt.3" + (("Prefer", "count=estimated") : rangeHdrs (ByteRangeFromTo 0 1)) "" `shouldRespondWith` - "" - { matchStatus = 206 + [json|[{"id":1,"name":"Windows 7","client_id":1},{"id":2,"name":"Windows 10","client_id":1}]|] + { matchStatus = 200 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/3" ] + , "Content-Range" <:> "0-1/2" ] } context "max-rows=2 on mutations" $ do @@ -120,4 +110,4 @@ spec = get "/items?limit=0" `shouldRespondWith` [json| [] |] - { matchHeaders = ["Content-Range" <:> "*/*"] } + { matchStatus = 200 } diff --git a/test/spec/Feature/Query/QuerySpec.hs b/test/spec/Feature/Query/QuerySpec.hs index 3b1da167b6..d8704fc602 100644 --- a/test/spec/Feature/Query/QuerySpec.hs +++ b/test/spec/Feature/Query/QuerySpec.hs @@ -32,12 +32,12 @@ spec actualPgVersion = do it "matches with equality" $ get "/items?id=eq.5" `shouldRespondWith` [json| [{"id":5}] |] - { matchHeaders = ["Content-Range" <:> "0-0/*"] } + { matchStatus = 200 } it "matches with equality using not operator" $ get "/items?id=not.eq.5&order=id" `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] - { matchHeaders = ["Content-Range" <:> "0-13/*"] } + { matchStatus = 200 } it "matches with more than one condition using not operator" $ get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]" @@ -45,20 +45,20 @@ spec actualPgVersion = do it "matches with inequality using not operator" $ do get "/items?id=not.lt.14&order=id.asc" `shouldRespondWith` [json| [{"id":14},{"id":15}] |] - { matchHeaders = ["Content-Range" <:> "0-1/*"] } + { matchStatus = 200 } get "/items?id=not.gt.2&order=id.asc" `shouldRespondWith` [json| [{"id":1},{"id":2}] |] - { matchHeaders = ["Content-Range" <:> "0-1/*"] } + { matchStatus = 200 } it "matches items IN" $ get "/items?id=in.(1,3,5)" `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |] - { matchHeaders = ["Content-Range" <:> "0-2/*"] } + { matchStatus = 200 } it "matches items NOT IN using not operator" $ get "/items?id=not.in.(2,4,6,7,8,9,10,11,12,13,14,15)" `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |] - { matchHeaders = ["Content-Range" <:> "0-2/*"] } + { matchStatus = 200 } it "matches nulls using not operator" $ get "/no_pk?a=not.is.null" `shouldRespondWith` @@ -754,10 +754,7 @@ spec actualPgVersion = do it "can embed a view that has group by" $ get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith` - [json| - [{"number_of_projects":1,"client":null}, - {"number_of_projects":2,"client":{"name":"Microsoft"}}, - {"number_of_projects":2,"client":{"name":"Apple"}}] |] + [json|[{"number_of_projects":1,"client":null},{"number_of_projects":2,"client":{"name": "Apple"}},{"number_of_projects":2,"client":{"name": "Microsoft"}}] |] { matchHeaders = [matchContentTypeJson] } it "can embed a view that has a subselect containing a select in a where" $ @@ -840,17 +837,13 @@ spec actualPgVersion = do it "by a column asc" $ get "/items?id=lte.2&order=id.asc" `shouldRespondWith` [json| [{"id":1},{"id":2}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/*"] - } + { matchStatus = 200 } it "by a column desc" $ get "/items?id=lte.2&order=id.desc" `shouldRespondWith` [json| [{"id":2},{"id":1}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/*"] - } + { matchStatus = 200 } it "by a column with nulls first" $ get "/no_pk?order=a.nullsfirst" @@ -858,36 +851,28 @@ spec actualPgVersion = do {"a":"1","b":"0"}, {"a":"2","b":"0"} ] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-2/*"] - } + { matchStatus = 200 } it "by a column asc with nulls last" $ get "/no_pk?order=a.asc.nullslast" `shouldRespondWith` [json| [{"a":"1","b":"0"}, {"a":"2","b":"0"}, {"a":null,"b":null}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-2/*"] - } + { matchStatus = 200 } it "by a column desc with nulls first" $ get "/no_pk?order=a.desc.nullsfirst" `shouldRespondWith` [json| [{"a":null,"b":null}, {"a":"2","b":"0"}, {"a":"1","b":"0"}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-2/*"] - } + { matchStatus = 200 } it "by a column desc with nulls last" $ get "/no_pk?order=a.desc.nullslast" `shouldRespondWith` [json| [{"a":"2","b":"0"}, {"a":"1","b":"0"}, {"a":null,"b":null}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-2/*"] - } + { matchStatus = 200 } it "by two columns with nulls and direction specified" $ get "/projects?select=client_id,id,name&order=client_id.desc.nullslast,id.desc" @@ -898,16 +883,12 @@ spec actualPgVersion = do {"client_id":1,"id":1,"name":"Windows 7"}, {"client_id":null,"id":5,"name":"Orphan"}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-4/*"] - } + { matchStatus = 200 } it "by a column with no direction or nulls specified" $ get "/items?id=lte.2&order=id" `shouldRespondWith` [json| [{"id":1},{"id":2}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/*"] - } + { matchStatus = 200 } it "without other constraints" $ get "/items?order=id.asc" `shouldRespondWith` 200 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/Feature/Query/RelatedQueriesSpec.hs b/test/spec/Feature/Query/RelatedQueriesSpec.hs index de0dd8f2d0..743f86457b 100644 --- a/test/spec/Feature/Query/RelatedQueriesSpec.hs +++ b/test/spec/Feature/Query/RelatedQueriesSpec.hs @@ -279,7 +279,7 @@ spec = describe "related queries" $ do it "works with count=exact" $ do request methodGet "/projects?select=name,clients(name)&clients=not.is.null" - [("Prefer", "count=exact")] "" + (rangeHdrsWithCount (ByteRangeFromTo 0 3)) "" `shouldRespondWith` [json|[ {"name":"Windows 7", "clients":{"name":"Microsoft"}}, @@ -289,18 +289,20 @@ spec = describe "related queries" $ do ]|] { matchStatus = 200 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-3/4" ] + , "Content-Range" <:> "0-3/4" + , "Preference-Applied" <:> "count=exact" ] } request methodGet "/projects?select=name,clients()&clients=is.null" - [("Prefer", "count=exact")] "" + (rangeHdrsWithCount (ByteRangeFromTo 0 0)) "" `shouldRespondWith` [json|[{"name":"Orphan"}]|] { matchStatus = 200 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , "Content-Range" <:> "0-0/1" + , "Preference-Applied" <:> "count=exact" ] } request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)" - [("Prefer", "count=exact")] "" + (rangeHdrsWithCount (ByteRangeFromTo 0 1)) "" `shouldRespondWith` [json|[ {"id":1,"name":"Walmart"}, @@ -308,12 +310,13 @@ spec = describe "related queries" $ do ]|] { matchStatus = 200 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , "Content-Range" <:> "0-1/2" + , "Preference-Applied" <:> "count=exact" ] } it "works with count=planned" $ do request methodGet "/projects?select=name,clients(name)&clients=not.is.null" - [("Prefer", "count=planned")] "" + (("Prefer", "count=planned") : rangeHdrs (ByteRangeFromTo 0 3)) "" `shouldRespondWith` [json|[ {"name":"Windows 7", "clients":{"name":"Microsoft"}}, @@ -323,18 +326,20 @@ spec = describe "related queries" $ do ]|] { matchStatus = 206 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-3/1200" ] + , "Content-Range" <:> "0-3/1200" + , "Preference-Applied" <:> "count=planned" ] } request methodGet "/projects?select=name,clients()&clients=is.null" - [("Prefer", "count=planned")] "" + (("Prefer", "count=planned") : rangeHdrs (ByteRangeFromTo 0 0)) "" `shouldRespondWith` [json|[{"name":"Orphan"}]|] { matchStatus = 200 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , "Content-Range" <:> "0-0/1" + , "Preference-Applied" <:> "count=planned" ] } request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)" - [("Prefer", "count=planned")] "" + (("Prefer", "count=planned") : rangeHdrs (ByteRangeFromTo 0 1)) "" `shouldRespondWith` [json|[ {"id":1,"name":"Walmart"}, @@ -342,12 +347,13 @@ spec = describe "related queries" $ do ]|] { matchStatus = 206 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/952" ] + , "Content-Range" <:> "0-1/952" + , "Preference-Applied" <:> "count=planned" ] } it "works with count=estimated" $ do request methodGet "/projects?select=name,clients(name)&clients=not.is.null" - [("Prefer", "count=estimated")] "" + (("Prefer", "count=estimated") : rangeHdrs (ByteRangeFromTo 0 3)) "" `shouldRespondWith` [json|[ {"name":"Windows 7", "clients":{"name":"Microsoft"}}, @@ -357,18 +363,20 @@ spec = describe "related queries" $ do ]|] { matchStatus = 206 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-3/1200" ] + , "Content-Range" <:> "0-3/1200" + , "Preference-Applied" <:> "count=estimated" ] } request methodGet "/projects?select=name,clients()&clients=is.null" - [("Prefer", "count=estimated")] "" + (("Prefer", "count=estimated") : rangeHdrs (ByteRangeFromTo 0 0)) "" `shouldRespondWith` [json|[{"name":"Orphan"}]|] { matchStatus = 200 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-0/1" ] + , "Content-Range" <:> "0-0/1" + , "Preference-Applied" <:> "count=estimated" ] } request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)" - [("Prefer", "count=estimated")] "" + (("Prefer", "count=estimated") : rangeHdrs (ByteRangeFromTo 0 1)) "" `shouldRespondWith` [json|[ {"id":1,"name":"Walmart"}, @@ -376,5 +384,6 @@ spec = describe "related queries" $ do ]|] { matchStatus = 206 , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/952" ] + , "Content-Range" <:> "0-1/952" + , "Preference-Applied" <:> "count=estimated" ] } diff --git a/test/spec/Feature/Query/RpcSpec.hs b/test/spec/Feature/Query/RpcSpec.hs index 0e4af838e2..383f4b0314 100644 --- a/test/spec/Feature/Query/RpcSpec.hs +++ b/test/spec/Feature/Query/RpcSpec.hs @@ -34,20 +34,15 @@ spec actualPgVersion = it "using limit and offset" $ do post "/rpc/getitemrange?limit=1&offset=1" [json| { "min": 2, "max": 4 } |] `shouldRespondWith` [json| [{"id":4}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "1-1/*"] - } + { matchStatus = 200 } get "/rpc/getitemrange?min=2&max=4&limit=1&offset=1" `shouldRespondWith` [json| [{"id":4}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "1-1/*"] - } + { matchStatus = 200 } request methodHead "/rpc/getitemrange?min=2&max=4&limit=1&offset=1" mempty mempty `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "1-1/*" ] + , matchHeaders = [ matchContentTypeJson ] } context "includes total count if requested" $ do @@ -61,56 +56,38 @@ spec actualPgVersion = it "using limit and offset" $ do request methodPost "/rpc/getitemrange?limit=1&offset=1" - [("Prefer", "count=exact")] + [] [json| { "min": 2, "max": 4 } |] `shouldRespondWith` [json| [{"id":4}] |] - { matchStatus = 206 -- it now knows the response is partial - , matchHeaders = ["Content-Range" <:> "1-1/2"] - } + { matchStatus = 200 } request methodGet "/rpc/getitemrange?min=2&max=4&limit=1&offset=1" - [("Prefer", "count=exact")] mempty + [] mempty `shouldRespondWith` [json| [{"id":4}] |] - { matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "1-1/2"] + { matchStatus = 200 } request methodHead "/rpc/getitemrange?min=2&max=4&limit=1&offset=1" - [("Prefer", "count=exact")] mempty + [] mempty `shouldRespondWith` "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "1-1/2" ] + { matchStatus = 200 + , matchHeaders = [ matchContentTypeJson ] } - it "includes exact count if requested" $ do - request methodHead "/rpc/getallprojects" - [("Prefer", "count=exact")] "" - `shouldRespondWith` "" - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-4/5"] - } - request methodHead "/rpc/getallprojects?select=*,clients!inner(*)&clients.id=eq.1" - [("Prefer", "count=exact")] "" - `shouldRespondWith` "" - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/2"] - } - it "includes exact count of 1 for functions that return a single scalar, domain or composite" $ do request methodGet "/rpc/add_them?a=3&b=4" - [("Prefer", "count=exact")] "" + (rangeHdrsWithCount (ByteRangeFromTo 0 0)) "" `shouldRespondWith` "7" { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-0/1"] } request methodGet "/rpc/ret_domain?val=8" - [("Prefer", "count=exact")] "" + (rangeHdrsWithCount (ByteRangeFromTo 0 0)) "" `shouldRespondWith` "8" { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-0/1"] } request methodGet "/rpc/ret_point_2d" - [("Prefer", "count=exact")] "" + (rangeHdrsWithCount (ByteRangeFromTo 0 0)) "" `shouldRespondWith` [json|{"x": 10, "y": 5}|] { matchStatus = 200 @@ -153,16 +130,13 @@ spec actualPgVersion = (rangeHdrsWithCount (ByteRangeFromTo 1 1)) [json| { "min": 2, "max": 4 } |] `shouldRespondWith` [json| [{"id": 3}, {"id": 4}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-1/2"] - } + { matchStatus = 200 } request methodHead "/rpc/getitemrange?min=2&max=4" (rangeHdrsWithCount (ByteRangeFromTo 1 1)) "" `shouldRespondWith` "" { matchStatus = 200 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "0-1/2" ] + , matchHeaders = [ matchContentTypeJson ] } it "with limit and offset" $ do @@ -170,16 +144,13 @@ spec actualPgVersion = (rangeHdrsWithCount (ByteRangeFromTo 1 1)) [json| { "min": 2, "max": 5 } |] `shouldRespondWith` [json| [{"id": 4}, {"id": 5}] |] - { matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "1-2/3"] - } + { matchStatus = 200 } request methodHead "/rpc/getitemrange?min=2&max=5&limit=2&offset=1" (rangeHdrsWithCount (ByteRangeFromTo 1 1)) "" `shouldRespondWith` "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "1-2/3" ] + { matchStatus = 200 + , matchHeaders = [ matchContentTypeJson ] } it "does not throw an invalid range error" $ do @@ -187,16 +158,13 @@ spec actualPgVersion = (rangeHdrsWithCount (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 5 } |] `shouldRespondWith` [json| [{"id": 4}, {"id": 5}] |] - { matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "1-2/3"] - } + { matchStatus = 200 } request methodHead "/rpc/getitemrange?min=2&max=5&limit=2&offset=1" (rangeHdrsWithCount (ByteRangeFromTo 0 0)) "" `shouldRespondWith` "" - { matchStatus = 206 - , matchHeaders = [ matchContentTypeJson - , "Content-Range" <:> "1-2/3" ] + { matchStatus = 200 + , matchHeaders = [ matchContentTypeJson ] } context "unknown function" $ do @@ -317,15 +285,13 @@ spec actualPgVersion = it "can limit proc results" $ do post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1" [json| {} |] `shouldRespondWith` [json|[{"id":3},{"id":4}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "1-2/*"] } + { matchStatus = 200 } get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1" `shouldRespondWith` [json|[{"id":3},{"id":4}]|] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "1-2/*"] } + { matchStatus = 200 } it "select works on the first level" $ do - post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith` + post "/rpc/getproject?select=id,name" [json| {"id": 1} |] `shouldRespondWith` [json|[{"id":1,"name":"Windows 7"}]|] get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith` [json|[{"id":1,"name":"Windows 7"}]|] diff --git a/test/spec/Feature/Query/SingularSpec.hs b/test/spec/Feature/Query/SingularSpec.hs index 1f856166e4..2a19e472d2 100644 --- a/test/spec/Feature/Query/SingularSpec.hs +++ b/test/spec/Feature/Query/SingularSpec.hs @@ -132,7 +132,6 @@ spec = "" { matchStatus = 201 , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" , "Preference-Applied" <:> "return=minimal"] } @@ -228,9 +227,7 @@ spec = get "/items?id=gt.0&id=lt.6&order=id" `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-4/*"] - } + { matchStatus = 200 } it "raises an error when attempting to delete multiple entities with return=rep" $ do request methodDelete "/items?id=gt.5&id=lt.11" @@ -244,9 +241,7 @@ spec = -- the rows should still exist get "/items?id=gt.5&id=lt.11" `shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |] - { matchStatus = 200 - , matchHeaders = ["Content-Range" <:> "0-4/*"] - } + { matchStatus = 200 } it "raises an error when deleting zero entities" $ request methodDelete "/items?id=lt.0" diff --git a/test/spec/Feature/Query/UpdateSpec.hs b/test/spec/Feature/Query/UpdateSpec.hs index 68c09ebdb7..1021826248 100644 --- a/test/spec/Feature/Query/UpdateSpec.hs +++ b/test/spec/Feature/Query/UpdateSpec.hs @@ -66,8 +66,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "0-0/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } it "returns empty array when no rows updated and return=rep" $ @@ -89,8 +88,7 @@ spec actualPgVersion = do [("Prefer", "return=representation")] [json| { "id":2 } |] `shouldRespondWith` [json|[{"id":2}]|] { matchStatus = 200, - matchHeaders = ["Content-Range" <:> "0-0/*" - , "Preference-Applied" <:> "return=representation"] + matchHeaders = ["Preference-Applied" <:> "return=representation"] } it "can update multiple items" $ do @@ -105,7 +103,6 @@ spec actualPgVersion = do "" { matchStatus = 204 , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "0-1/*" , "Preference-Applied" <:> "tx=commit" ] } @@ -140,7 +137,6 @@ spec actualPgVersion = do `shouldRespondWith` [json| [{ id: 100 }] |] { matchStatus = 200, matchHeaders = [matchContentTypeJson - ,"Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } @@ -205,7 +201,6 @@ spec actualPgVersion = do "" { matchStatus = 204 , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=minimal"] } request methodPatch "/items?id=eq.1&select=id" @@ -215,7 +210,6 @@ spec actualPgVersion = do "" { matchStatus = 204 , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=minimal"] } @@ -227,8 +221,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } request methodPatch "/items" @@ -237,8 +230,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } request methodPatch "/items" @@ -247,8 +239,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } it "makes no updates and returns 204 without return= and with ?select=" $ do @@ -258,8 +249,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } request methodPatch "/items?select=id" @@ -268,8 +258,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } request methodPatch "/items?select=id" @@ -278,8 +267,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "*/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } it "makes no updates and returns 200 with return=rep and without ?select=" $ @@ -287,8 +275,7 @@ spec actualPgVersion = do `shouldRespondWith` "[]" { matchStatus = 200, - matchHeaders = ["Content-Range" <:> "*/*" - , "Preference-Applied" <:> "return=representation"] + matchHeaders = ["Preference-Applied" <:> "return=representation"] } it "makes no updates and returns 200 with return=rep and with ?select=" $ @@ -296,8 +283,7 @@ spec actualPgVersion = do `shouldRespondWith` "[]" { matchStatus = 200, - matchHeaders = ["Content-Range" <:> "*/*" - , "Preference-Applied" <:> "return=representation"] + matchHeaders = ["Preference-Applied" <:> "return=representation"] } it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $ @@ -305,8 +291,7 @@ spec actualPgVersion = do `shouldRespondWith` "[]" { matchStatus = 200, - matchHeaders = ["Content-Range" <:> "*/*" - , "Preference-Applied" <:> "return=representation"] + matchHeaders = ["Preference-Applied" <:> "return=representation"] } context "with unicode values" $ @@ -632,8 +617,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "0-0/*"] + , matchHeaders = [ matchHeaderAbsent hContentType ] } it "parses values in payload and formats individually selected values in return=representation" $ @@ -643,7 +627,6 @@ spec actualPgVersion = do [json| [{"id":2, "label_color": "#221100"}] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } @@ -654,7 +637,6 @@ spec actualPgVersion = do [json| [{"id":2,"name":"Essay","label_color":"#221100","due_at":"2019-01-03T11:00:20Z","icon_image":"3q2+7w==","created_at":1513213350,"budget":"100000000000000.13"}] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } @@ -666,7 +648,6 @@ spec actualPgVersion = do [json| [{"due_at":"2019-01-03T11:00:00Z","id":2,"name":"Essay","label_color":"#221100","due_at":"2019-01-03T11:00:00Z","icon_image":null,"created_at":0,"budget":"100000000000000.13"}] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } context "for multiple rows" $ do @@ -681,7 +662,6 @@ spec actualPgVersion = do ] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-2/*" , "Preference-Applied" <:> "return=representation"] } @@ -696,7 +676,6 @@ spec actualPgVersion = do ] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-2/*" , "Preference-Applied" <:> "return=representation"] } context "with ?columns parameter" $ do @@ -709,7 +688,6 @@ spec actualPgVersion = do ] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } @@ -734,8 +712,7 @@ spec actualPgVersion = do `shouldRespondWith` "" { matchStatus = 204 - , matchHeaders = [ matchHeaderAbsent hContentType - , "Content-Range" <:> "0-0/*" ] + , matchHeaders = [ matchHeaderAbsent hContentType ] } it "parses values in payload and formats individually selected values in return=representation" $ @@ -745,7 +722,6 @@ spec actualPgVersion = do [json| [{"id":2, "label_color": "#221100"}] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } @@ -756,7 +732,6 @@ spec actualPgVersion = do [json| [{"id":2, "name": "Essay", "label_color": "#221100", "dark_color":"#110880", "due_at":"2019-01-03T11:00:20Z"}] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } context "for multiple rows" $ do @@ -771,7 +746,6 @@ spec actualPgVersion = do ] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-2/*" , "Preference-Applied" <:> "return=representation"] } @@ -786,7 +760,6 @@ spec actualPgVersion = do ] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-2/*" , "Preference-Applied" <:> "return=representation"] } context "with ?columns parameter" $ do @@ -799,7 +772,6 @@ spec actualPgVersion = do ] |] { matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8" - , "Content-Range" <:> "0-0/*" , "Preference-Applied" <:> "return=representation"] } diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 4072643355..e0a1ab7710 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 @@ -150,6 +151,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 actualPgVersion) , ("Feature.Query.NullsStripSpec" , Feature.Query.NullsStripSpec.spec) , ("Feature.Query.PgErrorCodeMappingSpec" , Feature.Query.ErrorSpec.pgErrorCodeMapping)