diff --git a/CHANGELOG.md b/CHANGELOG.md index 04796870f..21be5a72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Support column/node renaming `alias:column` - @ruslantalpa - Accept posts from HTML forms - @begriffs - Ability to order embedded entities - @ruslantalpa +- Ability to paginate using &limit and &offset parameters - @ruslantalpa +- Ability to apply limits to embedded entities and enforce --max-rows on all levels - @ruslantalpa, @begriffs ### Fixed - Return 401 or 403 for access denied rather than 404 - @begriffs diff --git a/docs/api/reading.md b/docs/api/reading.md index 6d12917f3..00e1e828c 100644 --- a/docs/api/reading.md +++ b/docs/api/reading.md @@ -172,9 +172,9 @@ GET /people?order=age.nullsfirst GET /people?order=age.desc.nullslast ``` -To filter the embedded items, you need to specify the tree path for the order param like so. +To order the embedded items, you need to specify the tree path for the order param like so. ```HTTP -GET /projects?select=id,name,tasks{id,name}&order=id.asc&tasks.order=name.ask +GET /projects?select=id,name,tasks{id,name}&order=id.asc&tasks.order=name.asc ``` @@ -214,6 +214,15 @@ Range: 0-4 You can also use open-ended ranges for an offset with no limit: `Range: 10-`. +In addition to the `Range` header, you can use `&limit` and `&offset` parameters +to achieve the same result. + +You can also set a limit (but not offset) for the embedded items like so +```HTTP +/posts?select=id,title,body,comments{id,email,body}&limit=10&comments.limit=3 +``` +The above request will return the first 10 posts and for each of the posts, 3 comments at most + #### Suppressing Counts Sometimes knowing the total row count of a query is unnecessary and @@ -310,6 +319,13 @@ GET /orders?id=eq.1&select=orderId:id, customer:customer_id{customerId:id, custo ] ``` +If you want to apply filters to the embedded items, you can do that like so: +```HTTP +GET /clients?id=eq.42&select=id,name,projects{id,name,is_active}&projects.is_active=eq.true +``` +The above request will return the client with id=42 and all the projects for that client that are still active + +

Design Consideration

In order for this feature to work as expected after a schema change, PostgREST currently requires to be restarted.

diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index 41494e6af..07d1f68be 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -15,17 +15,18 @@ import Data.Monoid ((<>)) import Data.Ord (comparing) import Data.String.Conversions (cs) import qualified Data.Text as T +import Text.Read (readMaybe) import qualified Data.Vector as V import Network.HTTP.Base (urlEncodeVars) import Network.HTTP.Types.Header (hAuthorization) import Network.HTTP.Types.URI (parseSimpleQuery) import Network.Wai (Request (..)) import Network.Wai.Parse (parseHttpAccept) -import PostgREST.RangeQuery (NonnegRange, rangeRequested) +import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange) import PostgREST.Types (QualifiedIdentifier (..), Schema, Payload(..), UniformObjects(..)) -import Data.Ranged.Ranges (singletonRange) +import Data.Ranged.Ranges (singletonRange, rangeIntersection) type RequestBody = BL.ByteString @@ -60,7 +61,7 @@ data ApiRequest = ApiRequest { -- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST iAction :: Action -- | Requested range of rows within response - , iRange :: NonnegRange + , iRange :: M.HashMap String NonnegRange -- | The target, be it calling a proc or accessing a table , iTarget :: Target -- | The content type the client most desires (or JSON if undecided) @@ -140,18 +141,19 @@ userApiRequest schema req reqBody = ApiRequest { iAction = action - , iRange = if singular then singletonRange 0 else rangeRequested hdrs , iTarget = target + , iRange = M.insert "limit" (rangeIntersection headerRange urlRange) $ + M.fromList [ (cs k, restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k ] , iAccepts = pickContentType $ lookupHeader "accept" , iPayload = relevantPayload , iPreferRepresentation = representation , iPreferSingular = singular , iPreferCount = not $ singular || hasPrefer "count=none" - , iFilters = [ (cs k, fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn "order" k) ] + , iFilters = [ (cs k, fromJust v) | (k,v) <- qParams, isJust v, k /= "select", k /= "offset", not (endingIn ["order", "limit"] k) ] , iSelect = if method == "DELETE" then "*" else fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams - , iOrder = [(cs k, fromJust v) | (k,v) <- qParams, isJust v, endingIn "order" k ] + , iOrder = [(cs k, fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iCanonicalQS = urlEncodeVars . sortBy (comparing fst) . map (join (***) cs) @@ -181,10 +183,17 @@ userApiRequest schema req reqBody = tokenStr = case T.split (== ' ') (cs auth) of ("Bearer" : t : _) -> t _ -> "" - endingIn:: T.Text -> T.Text -> Bool - endingIn word key = word == lastWord + endingIn:: [T.Text] -> T.Text -> Bool + endingIn xx key = lastWord `elem` xx where lastWord = last $ T.split (=='.') key + headerRange = if singular then singletonRange 0 else rangeRequested hdrs + urlOffsetRange = rangeGeq . fromMaybe (0::Integer) $ + readMaybe =<< join (lookup "offset" qParams) + urlRange = restrictRange + (readMaybe =<< join (lookup "limit" qParams)) + urlOffsetRange + -- PRIVATE --------------------------------------------------------------- {-| diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index acf65cc54..e07b26366 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -36,6 +36,8 @@ import Data.Time.Clock.POSIX (getPOSIXTime) import qualified Data.Vector as V import qualified Hasql.Transaction as H +import qualified Data.HashMap.Strict as M + import PostgREST.ApiRequest (ApiRequest(..), ContentType(..) , Action(..), Target(..) , PreferRepresentation (..) @@ -45,7 +47,7 @@ import PostgREST.Config (AppConfig (..)) import PostgREST.DbStructure import PostgREST.Error (errResponse, pgErrResponse) import PostgREST.Parsers -import PostgREST.RangeQuery +import PostgREST.RangeQuery (NonnegRange, allRange, rangeOffset, restrictRange) import PostgREST.Middleware import PostgREST.QueryBuilder ( callProc , addJoinConditions @@ -101,7 +103,7 @@ app dbStructure conf apiRequest = Left e -> return $ responseLBS status400 [jsonH] $ cs e Right (q, cq) -> do let singular = iPreferSingular apiRequest - stm = createReadStatement q cq range singular + stm = createReadStatement q cq singular shouldCount (contentType == TextCSV) respondToRange $ do row <- H.query () stm @@ -187,7 +189,7 @@ app dbStructure conf apiRequest = let p = V.head payload jwtSecret = configJwtSecret conf respondToRange $ do - row <- H.query () (callProc qi p range shouldCount) + row <- H.query () (callProc qi p topLevelRange shouldCount) returnJWT <- H.query qi doesProcReturnJWT let (tableTotal, queryTotal, body) = fromMaybe (Just 0, 0, emptyArray) row (status, contentRange) = rangeHeader queryTotal tableTotal @@ -219,18 +221,18 @@ app dbStructure conf apiRequest = allOrigins = ("Access-Control-Allow-Origin", "*") :: Header schema = cs $ configSchema conf shouldCount = iPreferCount apiRequest - range = restrictRange (configMaxRows conf) $ iRange apiRequest - readDbRequest = DbRead <$> buildReadRequest (dbRelations dbStructure) apiRequest + topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest + readDbRequest = DbRead <$> buildReadRequest (configMaxRows conf) (dbRelations dbStructure) apiRequest mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest selectQuery = requestToQuery schema <$> readDbRequest countQuery = requestToCountQuery schema <$> readDbRequest mutateQuery = requestToQuery schema <$> mutateDbRequest readSqlParts = (,) <$> selectQuery <*> countQuery mutateSqlParts = (,) <$> selectQuery <*> mutateQuery - respondToRange response = if range == emptyRange + respondToRange response = if topLevelRange == emptyRange then return $ errResponse status416 "HTTP Range error" else response - rangeHeader queryTotal tableTotal = let frm = rangeOffset range + rangeHeader queryTotal tableTotal = let frm = rangeOffset topLevelRange to = frm + toInteger queryTotal - 1 contentRange = contentRangeH frm to (toInteger <$> tableTotal) status = rangeStatus frm to (toInteger <$> tableTotal) @@ -287,53 +289,82 @@ augumentRequestWithJoin schema allRels request = (first formatRelationError . addRelations schema allRels Nothing) request >>= addJoinConditions schema -buildReadRequest :: [Relation] -> ApiRequest -> Either Text ReadRequest -buildReadRequest allRels apiRequest = - augumentRequestWithJoin schema rels =<< - first formatParserError (foldr addFilter <$> (foldr addOrder <$> readRequest <*> ords) <*> flts) +addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest) +addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ + flip (foldr addFilter) <$> filters, + flip (foldr addOrder) <$> orders, + flip (foldr addRange) <$> ranges + ] + {- + The esence of what is going on above is that we are composing tree functions + of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context + -} + where + filters :: Either ParseError [(Path, Filter)] + filters = mapM pRequestFilter flts + where + action = iAction apiRequest + flts = if action == ActionRead + then iFilters apiRequest + else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update + orders :: Either ParseError [(Path, [OrderTerm])] + orders = mapM pRequestOrder $ iOrder apiRequest + ranges :: Either ParseError [(Path, NonnegRange)] + ranges = mapM pRequestRange $ M.toList $ iRange apiRequest + +treeRestrictRange :: Maybe Integer -> ReadRequest -> Either Text ReadRequest +treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request + where + nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode + nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i) + +buildReadRequest :: Maybe Integer -> [Relation] -> ApiRequest -> Either Text ReadRequest +buildReadRequest maxRows allRels apiRequest = + treeRestrictRange maxRows =<< + augumentRequestWithJoin schema relations =<< + first formatParserError readRequest where - selStr = iSelect apiRequest - action = iAction apiRequest - target = iTarget apiRequest (schema, rootTableName) = fromJust $ -- Make it safe + let target = iTarget apiRequest in case target of (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t) _ -> Nothing - rootName = if action == ActionRead - then rootTableName - else sourceCTEName - filters = if action == ActionRead - then iFilters apiRequest - else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update - rels = case action of + action :: Action + action = iAction apiRequest + + readRequest :: Either ParseError ReadRequest + readRequest = addFiltersOrdersRanges apiRequest <*> + parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr + where + selStr = iSelect apiRequest + rootName = if action == ActionRead + then rootTableName + else sourceCTEName + + relations :: [Relation] + relations = case action of ActionCreate -> fakeSourceRelations ++ allRels ActionUpdate -> fakeSourceRelations ++ allRels _ -> allRels where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation - readRequest = parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr - flts = mapM pRequestFilter filters - orders = iOrder apiRequest - ords = mapM pRequestOrder orders buildMutateRequest :: ApiRequest -> Either Text MutateRequest -buildMutateRequest apiRequest = - mutateApiRequest +buildMutateRequest apiRequest = case action of + ActionCreate -> Insert rootTableName <$> pure payload + ActionUpdate -> Update rootTableName <$> pure payload <*> filters + ActionDelete -> Delete rootTableName <$> filters + _ -> Left "Unsupported HTTP verb" where action = iAction apiRequest - target = iTarget apiRequest payload = fromJust $ iPayload apiRequest rootTableName = -- TODO: Make it safe + let target = iTarget apiRequest in case target of (TargetIdent (QualifiedIdentifier _ t) ) -> t _ -> undefined - mutateApiRequest = case action of - ActionCreate -> Insert rootTableName <$> pure payload - ActionUpdate -> Update rootTableName <$> pure payload <*> cond - ActionDelete -> Delete rootTableName <$> cond - _ -> Left "Unsupported HTTP verb" - mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table - cond = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters + filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters + where mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table addFilterToNode :: Filter -> ReadRequest -> ReadRequest addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f @@ -347,6 +378,12 @@ addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f addOrder :: (Path, [OrderTerm]) -> ReadRequest -> ReadRequest addOrder = addProperty addOrderToNode +addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest +addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f + +addRange :: (Path, NonnegRange) -> ReadRequest -> ReadRequest +addRange = addProperty addRangeToNode + addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest addProperty f ([], a) n = f a n addProperty f (path, a) (Node rn forest) = diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs index 6e369bddf..cae5479b5 100644 --- a/src/PostgREST/Parsers.hs +++ b/src/PostgREST/Parsers.hs @@ -11,14 +11,14 @@ import Data.Tree import PostgREST.QueryBuilder (operators) import PostgREST.Types import Text.ParserCombinators.Parsec hiding (many, (<|>)) - +import PostgREST.RangeQuery (NonnegRange,allRange) pRequestSelect :: Text -> Parser ReadRequest pRequestSelect rootNodeName = do fieldTree <- pFieldForest return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree where - readQuery = Select [] [rootNodeName] [] Nothing + readQuery = Select [] [rootNodeName] [] Nothing allRange treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) = case fldForest of @@ -26,7 +26,7 @@ pRequestSelect rootNodeName = do _ -> Node (q, i) newForest where newForest = - foldr treeEntry (Node (Select [] [fn] [] Nothing, (fn, Nothing, alias)) []) fldForest:rForest + foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest pRequestFilter :: (String, String) -> Either ParseError (Path, Filter) pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val) @@ -45,6 +45,12 @@ pRequestOrder (k, v) = (,) <$> path <*> ord path = fst <$> treePath ord = parse pOrder ("failed to parse order (" ++ v ++ ")") v +pRequestRange :: (String, NonnegRange) -> Either ParseError (Path, NonnegRange) +pRequestRange (k, v) = (,) <$> path <*> pure v + where + treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k + path = fst <$> treePath + ws :: Parser Text ws = cs <$> many (oneOf " \t") diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 10ed4b88d..8d784252f 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -35,7 +35,7 @@ import qualified Hasql.Decoders as HD import qualified Data.Aeson as JSON import Data.Int (Int64) -import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset) +import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange) import Control.Error (note, fromMaybe) import Data.Functor.Contravariant (contramap) import qualified Data.HashMap.Strict as HM @@ -96,14 +96,14 @@ encodeUniformObjs :: HE.Params UniformObjects encodeUniformObjs = contramap (JSON.Array . V.map JSON.Object . unUniformObjects) (HE.value HE.json) -createReadStatement :: SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> +createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> H.Query () ResultsWithCount -createReadStatement selectQuery countQuery range isSingle countTotal asCsv = +createReadStatement selectQuery countQuery isSingle countTotal asCsv = unicodeStatement sql HE.unit decodeStandard True where sql = [qc| WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols} - FROM ( SELECT * FROM {sourceCTEName} {limitF range}) t |] + FROM ( SELECT * FROM {sourceCTEName}) t |] countResultF = if countTotal then "("<>countQuery<>")" else "null" cols = intercalate ", " [ countResultF <> " AS total_result_set", @@ -260,7 +260,7 @@ pgFmtLit x = requestToCountQuery :: Schema -> DbRequest -> SqlQuery requestToCountQuery _ (DbMutate _) = undefined -requestToCountQuery schema (DbRead (Node (Select _ _ conditions _, (mainTbl, _, _)) _)) = +requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) = unwords [ "SELECT pg_catalog.count(1)", "FROM ", fromQi $ QualifiedIdentifier schema mainTbl, @@ -274,7 +274,7 @@ requestToCountQuery schema (DbRead (Node (Select _ _ conditions _, (mainTbl, _, requestToQuery :: Schema -> DbRequest -> SqlQuery requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined -requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (nodeName, maybeRelation, _)) forest)) = +requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) = query where -- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name @@ -288,7 +288,8 @@ requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (nod "FROM ", intercalate ", " (map (fromQi . toQi) tbls), unwords joins, ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions, - orderF (fromMaybe [] ord) + orderF (fromMaybe [] ord), + limitF range ] orderF ts = if null ts @@ -408,7 +409,9 @@ locationF pKeys = ) <> ")" limitF :: NonnegRange -> SqlFragment -limitF r = "LIMIT " <> limit <> " OFFSET " <> offset +limitF r = if r == allRange + then "" + else "LIMIT " <> limit <> " OFFSET " <> offset where limit = maybe "ALL" (cs . show) $ rangeLimit r offset = cs . show $ rangeOffset r diff --git a/src/PostgREST/RangeQuery.hs b/src/PostgREST/RangeQuery.hs index e65ce4b3f..f14fb04a2 100644 --- a/src/PostgREST/RangeQuery.hs +++ b/src/PostgREST/RangeQuery.hs @@ -4,13 +4,14 @@ module PostgREST.RangeQuery ( , rangeLimit , rangeOffset , restrictRange +, rangeGeq +, allRange , NonnegRange ) where import Control.Applicative import Network.HTTP.Types.Header -import PostgREST.Types () import qualified Data.ByteString.Char8 as BS import Data.Ranged.Boundaries @@ -34,18 +35,19 @@ rangeParse range = do Just parsedRange -> let [_, from, to] = readMaybe . cs <$> parsedRange lower = fromMaybe emptyRange (rangeGeq <$> from) - upper = fromMaybe (rangeGeq 0) (rangeLeq <$> to) in + upper = fromMaybe allRange (rangeLeq <$> to) in rangeIntersection lower upper - Nothing -> rangeGeq 0 + Nothing -> allRange rangeRequested :: RequestHeaders -> NonnegRange -rangeRequested = rangeParse . fromMaybe "" . lookup hRange +rangeRequested headers = fromMaybe allRange $ + rangeParse <$> lookup hRange headers restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange restrictRange Nothing r = r restrictRange (Just limit) r = - rangeIntersection r $ - Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1) + rangeIntersection r $ + Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1) rangeLimit :: NonnegRange -> Maybe Integer rangeLimit range = @@ -63,6 +65,9 @@ rangeGeq :: Integer -> NonnegRange rangeGeq n = Range (BoundaryBelow n) BoundaryAboveAll +allRange :: NonnegRange +allRange = rangeGeq 0 + rangeLeq :: Integer -> NonnegRange rangeLeq n = Range BoundaryBelowAll (BoundaryAbove n) diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 6bd24c5bb..b4a225cae 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -6,6 +6,7 @@ import Data.Int (Int32) import Data.Text import Data.Tree import qualified Data.Vector as V +import PostgREST.RangeQuery (NonnegRange) data DbStructure = DbStructure { dbTables :: [Table] @@ -111,7 +112,7 @@ type Cast = Text type NodeName = Text type SelectItem = (Field, Maybe Cast, Maybe Alias) type Path = [Text] -data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm] } deriving (Show, Eq) +data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq) data MutateQuery = Insert { in_::TableName, qPayload::Payload } | Delete { in_::TableName, where_::[Filter] } | Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq) diff --git a/test/Feature/QueryLimitedSpec.hs b/test/Feature/QueryLimitedSpec.hs index 95d8b9223..80d78006d 100644 --- a/test/Feature/QueryLimitedSpec.hs +++ b/test/Feature/QueryLimitedSpec.hs @@ -5,7 +5,7 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON import Network.HTTP.Types import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus)) - +import Text.Heredoc import SpecHelper import Network.Wai (Application) @@ -15,15 +15,23 @@ spec = it "restricts results" $ get "/items" `shouldRespondWith` ResponseMatcher { - matchBody = Just [json| [{"id":1},{"id":2},{"id":3}] |] + matchBody = Just [json| [{"id":1},{"id":2}] |] , matchStatus = 206 - , matchHeaders = ["Content-Range" <:> "0-2/15"] + , matchHeaders = ["Content-Range" <:> "0-1/15"] } it "respects additional client limiting" $ do r <- request methodGet "/items" - (rangeHdrs $ ByteRangeFromTo 0 1) "" + (rangeHdrs $ ByteRangeFromTo 0 0) "" liftIO $ do simpleHeaders r `shouldSatisfy` - matchHeader "Content-Range" "0-1/15" + matchHeader "Content-Range" "0-0/15" simpleStatus r `shouldBe` partialContent206 + + it "limit works on all levels" $ + get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc" + `shouldRespondWith` ResponseMatcher { + matchBody = Just [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|] + , matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-1/3"] + } diff --git a/test/Feature/RangeSpec.hs b/test/Feature/RangeSpec.hs index 8a9e75e75..3e7bc6d9c 100644 --- a/test/Feature/RangeSpec.hs +++ b/test/Feature/RangeSpec.hs @@ -9,6 +9,7 @@ import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus)) import qualified Data.ByteString.Lazy as BL import SpecHelper +import Text.Heredoc import Network.Wai (Application) defaultRange :: BL.ByteString @@ -142,6 +143,50 @@ spec = do , matchHeaders = ["Content-Range" <:> "0-0/*"] } + context "with limit/offset parameters" $ do + it "no parameters return everything" $ + get "/items?select=id&order=id.asc" + `shouldRespondWith` ResponseMatcher { + matchBody = Just [str|[{"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/15"] + } + it "top level limit with parameter" $ + get "/items?select=id&order=id.asc&limit=3" + `shouldRespondWith` ResponseMatcher { + matchBody = Just [str|[{"id":1},{"id":2},{"id":3}]|] + , matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-2/15"] + } + it "headers override get parameters" $ + request methodGet "/items?select=id&order=id.asc&limit=3" + (rangeHdrs $ ByteRangeFromTo 0 1) "" + `shouldRespondWith` ResponseMatcher { + matchBody = Just [str|[{"id":1},{"id":2}]|] + , matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-1/15"] + } + + 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=1&projects.tasks.order=id.asc&projects.tasks.limit=2" + `shouldRespondWith` ResponseMatcher { + matchBody = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]}]}]|] + , matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "0-0/2"] + } + + it "fails on offset specified below level 1" $ + get "/clients?select=id,projects{id,tasks{id}}&projects.offset=2&projects.limit=1" + `shouldRespondWith` 400 + + it "limit and offset works on first level" $ + get "/items?select=id&order=id.asc&limit=3&offset=2" + `shouldRespondWith` ResponseMatcher { + matchBody = Just [str|[{"id":3},{"id":4},{"id":5}]|] + , matchStatus = 206 + , matchHeaders = ["Content-Range" <:> "2-4/15"] + } + context "with range headers" $ do context "of acceptable range" $ do diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 498e2f847..e837887fb 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -27,7 +27,7 @@ testUnicodeCfg = testLtdRowsCfg :: AppConfig testLtdRowsCfg = - AppConfig testDbConn "postgrest_test_anonymous" "test" 3000 (secret "safe") 10 (Just 3) True + AppConfig testDbConn "postgrest_test_anonymous" "test" 3000 (secret "safe") 10 (Just 2) True setupDb :: IO () setupDb = do