Limit embeded items

This commit is contained in:
Ruslan Talpa
2016-05-26 09:56:28 +03:00
parent e76de196e0
commit 7c83edc402
11 changed files with 208 additions and 63 deletions
+2
View File
@@ -11,6 +11,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Support column/node renaming `alias:column` - @ruslantalpa - Support column/node renaming `alias:column` - @ruslantalpa
- Accept posts from HTML forms - @begriffs - Accept posts from HTML forms - @begriffs
- Ability to order embedded entities - @ruslantalpa - 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
### Fixed ### Fixed
- Return 401 or 403 for access denied rather than 404 - @begriffs - Return 401 or 403 for access denied rather than 404 - @begriffs
+18 -2
View File
@@ -172,9 +172,9 @@ GET /people?order=age.nullsfirst
GET /people?order=age.desc.nullslast 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 ```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.ask&tasks.order=name.ask
``` ```
@@ -214,6 +214,15 @@ Range: 0-4
You can also use open-ended ranges for an offset with no limit: You can also use open-ended ranges for an offset with no limit:
`Range: 10-`. `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 #### Suppressing Counts
Sometimes knowing the total row count of a query is unnecessary and 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
<div class="admonition note"> <div class="admonition note">
<p class="admonition-title">Design Consideration</p> <p class="admonition-title">Design Consideration</p>
<p>In order for this feature to work as expected after a schema change, PostgREST currently requires to be restarted.</p> <p>In order for this feature to work as expected after a schema change, PostgREST currently requires to be restarted.</p>
+17 -7
View File
@@ -21,7 +21,7 @@ import Network.HTTP.Types.Header (hAuthorization)
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.Wai (Request (..)) import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept) import Network.Wai.Parse (parseHttpAccept)
import PostgREST.RangeQuery (NonnegRange, rangeRequested) import PostgREST.RangeQuery (NonnegRange, rangeRequested, limitToRange, toRange )
import PostgREST.Types (QualifiedIdentifier (..), import PostgREST.Types (QualifiedIdentifier (..),
Schema, Payload(..), Schema, Payload(..),
UniformObjects(..)) UniformObjects(..))
@@ -60,7 +60,7 @@ data ApiRequest = ApiRequest {
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST -- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
iAction :: Action iAction :: Action
-- | Requested range of rows within response -- | Requested range of rows within response
, iRange :: NonnegRange , iRange :: M.HashMap String NonnegRange
-- | The target, be it calling a proc or accessing a table -- | The target, be it calling a proc or accessing a table
, iTarget :: Target , iTarget :: Target
-- | The content type the client most desires (or JSON if undecided) -- | The content type the client most desires (or JSON if undecided)
@@ -140,18 +140,20 @@ userApiRequest schema req reqBody =
ApiRequest { ApiRequest {
iAction = action iAction = action
, iRange = if singular then singletonRange 0 else rangeRequested hdrs
, iTarget = target , iTarget = target
, iRange = setTopLevelRange headerRange $
setTopLevelRange urlRange $
M.fromList [(cs k, limitToRange $ cs $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["limit"] k ]
, iAccepts = pickContentType $ lookupHeader "accept" , iAccepts = pickContentType $ lookupHeader "accept"
, iPayload = relevantPayload , iPayload = relevantPayload
, iPreferRepresentation = representation , iPreferRepresentation = representation
, iPreferSingular = singular , iPreferSingular = singular
, iPreferCount = not $ singular || hasPrefer "count=none" , 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" , iSelect = if method == "DELETE"
then "*" then "*"
else fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams 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 , iCanonicalQS = urlEncodeVars
. sortBy (comparing fst) . sortBy (comparing fst)
. map (join (***) cs) . map (join (***) cs)
@@ -181,10 +183,18 @@ userApiRequest schema req reqBody =
tokenStr = case T.split (== ' ') (cs auth) of tokenStr = case T.split (== ' ') (cs auth) of
("Bearer" : t : _) -> t ("Bearer" : t : _) -> t
_ -> "" _ -> ""
endingIn:: T.Text -> T.Text -> Bool endingIn:: [T.Text] -> T.Text -> Bool
endingIn word key = word == lastWord endingIn xx key = lastWord `elem` xx
where lastWord = last $ T.split (=='.') key where lastWord = last $ T.split (=='.') key
headerRange = if singular then Just (singletonRange 0) else rangeRequested hdrs
urlRange = toRange (join $ lookup "limit" qParams) (join $ lookup "offset" qParams)
setTopLevelRange :: Maybe NonnegRange -> M.HashMap String NonnegRange -> M.HashMap String NonnegRange
setTopLevelRange Nothing ranges = ranges
setTopLevelRange (Just r) ranges = M.insert "limit" r ranges
-- PRIVATE --------------------------------------------------------------- -- PRIVATE ---------------------------------------------------------------
{-| {-|
+71 -34
View File
@@ -36,6 +36,8 @@ import Data.Time.Clock.POSIX (getPOSIXTime)
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Hasql.Transaction as H import qualified Hasql.Transaction as H
import qualified Data.HashMap.Strict as M
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..) import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
, Action(..), Target(..) , Action(..), Target(..)
, PreferRepresentation (..) , PreferRepresentation (..)
@@ -101,7 +103,7 @@ app dbStructure conf apiRequest =
Left e -> return $ responseLBS status400 [jsonH] $ cs e Left e -> return $ responseLBS status400 [jsonH] $ cs e
Right (q, cq) -> do Right (q, cq) -> do
let singular = iPreferSingular apiRequest let singular = iPreferSingular apiRequest
stm = createReadStatement q cq range singular stm = createReadStatement q cq singular
shouldCount (contentType == TextCSV) shouldCount (contentType == TextCSV)
respondToRange $ do respondToRange $ do
row <- H.query () stm row <- H.query () stm
@@ -187,7 +189,7 @@ app dbStructure conf apiRequest =
let p = V.head payload let p = V.head payload
jwtSecret = configJwtSecret conf jwtSecret = configJwtSecret conf
respondToRange $ do respondToRange $ do
row <- H.query () (callProc qi p range shouldCount) row <- H.query () (callProc qi p topLevelRange shouldCount)
returnJWT <- H.query qi doesProcReturnJWT returnJWT <- H.query qi doesProcReturnJWT
let (tableTotal, queryTotal, body) = fromMaybe (Just 0, 0, emptyArray) row let (tableTotal, queryTotal, body) = fromMaybe (Just 0, 0, emptyArray) row
(status, contentRange) = rangeHeader queryTotal tableTotal (status, contentRange) = rangeHeader queryTotal tableTotal
@@ -219,18 +221,18 @@ app dbStructure conf apiRequest =
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
schema = cs $ configSchema conf schema = cs $ configSchema conf
shouldCount = iPreferCount apiRequest shouldCount = iPreferCount apiRequest
range = restrictRange (configMaxRows conf) $ iRange apiRequest topLevelRange = fromMaybe (rangeGeq 0) $ M.lookup "limit" $ iRange apiRequest
readDbRequest = DbRead <$> buildReadRequest (dbRelations dbStructure) apiRequest readDbRequest = DbRead <$> buildReadRequest (configMaxRows conf) (dbRelations dbStructure) apiRequest
mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest
selectQuery = requestToQuery schema <$> readDbRequest selectQuery = requestToQuery schema <$> readDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest countQuery = requestToCountQuery schema <$> readDbRequest
mutateQuery = requestToQuery schema <$> mutateDbRequest mutateQuery = requestToQuery schema <$> mutateDbRequest
readSqlParts = (,) <$> selectQuery <*> countQuery readSqlParts = (,) <$> selectQuery <*> countQuery
mutateSqlParts = (,) <$> selectQuery <*> mutateQuery mutateSqlParts = (,) <$> selectQuery <*> mutateQuery
respondToRange response = if range == emptyRange respondToRange response = if topLevelRange == emptyRange
then return $ errResponse status416 "HTTP Range error" then return $ errResponse status416 "HTTP Range error"
else response else response
rangeHeader queryTotal tableTotal = let frm = rangeOffset range rangeHeader queryTotal tableTotal = let frm = rangeOffset topLevelRange
to = frm + toInteger queryTotal - 1 to = frm + toInteger queryTotal - 1
contentRange = contentRangeH frm to (toInteger <$> tableTotal) contentRange = contentRangeH frm to (toInteger <$> tableTotal)
status = rangeStatus 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 (first formatRelationError . addRelations schema allRels Nothing) request
>>= addJoinConditions schema >>= addJoinConditions schema
buildReadRequest :: [Relation] -> ApiRequest -> Either Text ReadRequest addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)
buildReadRequest allRels apiRequest = addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
augumentRequestWithJoin schema rels =<< flip (foldr addFilter) <$> filters,
first formatParserError (foldr addFilter <$> (foldr addOrder <$> readRequest <*> ords) <*> flts) 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 where
selStr = iSelect apiRequest
action = iAction apiRequest
target = iTarget apiRequest
(schema, rootTableName) = fromJust $ -- Make it safe (schema, rootTableName) = fromJust $ -- Make it safe
let target = iTarget apiRequest in
case target of case target of
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t) (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
_ -> Nothing _ -> Nothing
rootName = if action == ActionRead action :: Action
then rootTableName action = iAction apiRequest
else sourceCTEName
filters = if action == ActionRead readRequest :: Either ParseError ReadRequest
then iFilters apiRequest readRequest = addFiltersOrdersRanges apiRequest <*>
else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr
rels = case action of where
selStr = iSelect apiRequest
rootName = if action == ActionRead
then rootTableName
else sourceCTEName
relations :: [Relation]
relations = case action of
ActionCreate -> fakeSourceRelations ++ allRels ActionCreate -> fakeSourceRelations ++ allRels
ActionUpdate -> fakeSourceRelations ++ allRels ActionUpdate -> fakeSourceRelations ++ allRels
_ -> allRels _ -> allRels
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation 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 -> Either Text MutateRequest
buildMutateRequest apiRequest = buildMutateRequest apiRequest = case action of
mutateApiRequest ActionCreate -> Insert rootTableName <$> pure payload
ActionUpdate -> Update rootTableName <$> pure payload <*> filters
ActionDelete -> Delete rootTableName <$> filters
_ -> Left "Unsupported HTTP verb"
where where
action = iAction apiRequest action = iAction apiRequest
target = iTarget apiRequest
payload = fromJust $ iPayload apiRequest payload = fromJust $ iPayload apiRequest
rootTableName = -- TODO: Make it safe rootTableName = -- TODO: Make it safe
let target = iTarget apiRequest in
case target of case target of
(TargetIdent (QualifiedIdentifier _ t) ) -> t (TargetIdent (QualifiedIdentifier _ t) ) -> t
_ -> undefined _ -> undefined
mutateApiRequest = case action of filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
ActionCreate -> Insert rootTableName <$> pure payload where mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
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
addFilterToNode :: Filter -> ReadRequest -> ReadRequest addFilterToNode :: Filter -> ReadRequest -> ReadRequest
addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f 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 :: (Path, [OrderTerm]) -> ReadRequest -> ReadRequest
addOrder = addProperty addOrderToNode addOrder = addProperty addOrderToNode
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
addRangeToNode r (Node (q,i) f) = Node (q{range_=Just r}, i) f
addRange :: (Path, NonnegRange) -> ReadRequest -> ReadRequest
addRange = addProperty addRangeToNode
addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest
addProperty f ([], a) n = f a n addProperty f ([], a) n = f a n
addProperty f (path, a) (Node rn forest) = addProperty f (path, a) (Node rn forest) =
+9 -2
View File
@@ -11,6 +11,7 @@ import Data.Tree
import PostgREST.QueryBuilder (operators) import PostgREST.QueryBuilder (operators)
import PostgREST.Types import PostgREST.Types
import Text.ParserCombinators.Parsec hiding (many, (<|>)) import Text.ParserCombinators.Parsec hiding (many, (<|>))
import PostgREST.RangeQuery (NonnegRange)
pRequestSelect :: Text -> Parser ReadRequest pRequestSelect :: Text -> Parser ReadRequest
@@ -18,7 +19,7 @@ pRequestSelect rootNodeName = do
fieldTree <- pFieldForest fieldTree <- pFieldForest
return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree
where where
readQuery = Select [] [rootNodeName] [] Nothing readQuery = Select [] [rootNodeName] [] Nothing Nothing
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) = treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =
case fldForest of case fldForest of
@@ -26,7 +27,7 @@ pRequestSelect rootNodeName = do
_ -> Node (q, i) newForest _ -> Node (q, i) newForest
where where
newForest = newForest =
foldr treeEntry (Node (Select [] [fn] [] Nothing, (fn, Nothing, alias)) []) fldForest:rForest foldr treeEntry (Node (Select [] [fn] [] Nothing Nothing, (fn, Nothing, alias)) []) fldForest:rForest
pRequestFilter :: (String, String) -> Either ParseError (Path, Filter) pRequestFilter :: (String, String) -> Either ParseError (Path, Filter)
pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val) pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
@@ -45,6 +46,12 @@ pRequestOrder (k, v) = (,) <$> path <*> ord
path = fst <$> treePath path = fst <$> treePath
ord = parse pOrder ("failed to parse order (" ++ v ++ ")") v 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 :: Parser Text
ws = cs <$> many (oneOf " \t") ws = cs <$> many (oneOf " \t")
+7 -6
View File
@@ -96,14 +96,14 @@ encodeUniformObjs :: HE.Params UniformObjects
encodeUniformObjs = encodeUniformObjs =
contramap (JSON.Array . V.map JSON.Object . unUniformObjects) (HE.value HE.json) 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 H.Query () ResultsWithCount
createReadStatement selectQuery countQuery range isSingle countTotal asCsv = createReadStatement selectQuery countQuery isSingle countTotal asCsv =
unicodeStatement sql HE.unit decodeStandard True unicodeStatement sql HE.unit decodeStandard True
where where
sql = [qc| sql = [qc|
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols} 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" countResultF = if countTotal then "("<>countQuery<>")" else "null"
cols = intercalate ", " [ cols = intercalate ", " [
countResultF <> " AS total_result_set", countResultF <> " AS total_result_set",
@@ -260,7 +260,7 @@ pgFmtLit x =
requestToCountQuery :: Schema -> DbRequest -> SqlQuery requestToCountQuery :: Schema -> DbRequest -> SqlQuery
requestToCountQuery _ (DbMutate _) = undefined requestToCountQuery _ (DbMutate _) = undefined
requestToCountQuery schema (DbRead (Node (Select _ _ conditions _, (mainTbl, _, _)) _)) = requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) =
unwords [ unwords [
"SELECT pg_catalog.count(1)", "SELECT pg_catalog.count(1)",
"FROM ", fromQi $ QualifiedIdentifier schema mainTbl, "FROM ", fromQi $ QualifiedIdentifier schema mainTbl,
@@ -274,7 +274,7 @@ requestToCountQuery schema (DbRead (Node (Select _ _ conditions _, (mainTbl, _,
requestToQuery :: Schema -> DbRequest -> SqlQuery requestToQuery :: Schema -> DbRequest -> SqlQuery
requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined
requestToQuery _ (DbMutate (Update _ (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 query
where where
-- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name -- 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), "FROM ", intercalate ", " (map (fromQi . toQi) tbls),
unwords joins, unwords joins,
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions, ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
orderF (fromMaybe [] ord) orderF (fromMaybe [] ord),
fromMaybe "" $ limitF <$> range
] ]
orderF ts = orderF ts =
if null ts if null ts
+23 -5
View File
@@ -4,13 +4,16 @@ module PostgREST.RangeQuery (
, rangeLimit , rangeLimit
, rangeOffset , rangeOffset
, restrictRange , restrictRange
, rangeGeq
, NonnegRange , NonnegRange
, limitToRange
, toRange
) where ) where
import Control.Applicative import Control.Applicative
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import PostgREST.Types () import Data.Monoid ((<>))
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.Ranged.Boundaries import Data.Ranged.Boundaries
@@ -38,12 +41,13 @@ rangeParse range = do
rangeIntersection lower upper rangeIntersection lower upper
Nothing -> rangeGeq 0 Nothing -> rangeGeq 0
rangeRequested :: RequestHeaders -> NonnegRange rangeRequested :: RequestHeaders -> Maybe NonnegRange
rangeRequested = rangeParse . fromMaybe "" . lookup hRange rangeRequested headers = rangeParse <$> lookup hRange headers
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange restrictRange :: Maybe Integer -> Maybe NonnegRange -> Maybe NonnegRange
restrictRange Nothing r = r restrictRange Nothing r = r
restrictRange (Just limit) r = restrictRange (Just limit) Nothing = Just $ rangeIntersection (rangeGeq 0) (rangeLeq (limit - 1))
restrictRange (Just limit) (Just r) = Just $
rangeIntersection r $ rangeIntersection r $
Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1) Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1)
@@ -66,3 +70,17 @@ rangeGeq n =
rangeLeq :: Integer -> NonnegRange rangeLeq :: Integer -> NonnegRange
rangeLeq n = rangeLeq n =
Range BoundaryBelowAll (BoundaryAbove n) Range BoundaryBelowAll (BoundaryAbove n)
limitToRange :: BS.ByteString -> NonnegRange
limitToRange l = rangeParse ("0-" <> cs (show (l' - 1)))
where l' = fromMaybe 0 (readMaybe $ cs l)::Integer
toRange :: Maybe String -> Maybe String -> Maybe NonnegRange
toRange Nothing Nothing = Nothing
toRange Nothing (Just o) = Just $ rangeParse $ cs $ show o' <> "-"
where o' = fromMaybe 0 (readMaybe $ cs o)::Integer
toRange (Just l) Nothing = Just $ limitToRange $ cs l
toRange (Just l) (Just o) = Just $ rangeParse $ cs $ show o' <> "-" <> show (o' + l' - 1)
where
l' = fromMaybe 0 (readMaybe $ cs l)::Integer
o' = fromMaybe 0 (readMaybe $ cs o)::Integer
+2 -1
View File
@@ -6,6 +6,7 @@ import Data.Int (Int32)
import Data.Text import Data.Text
import Data.Tree import Data.Tree
import qualified Data.Vector as V import qualified Data.Vector as V
import PostgREST.RangeQuery (NonnegRange)
data DbStructure = DbStructure { data DbStructure = DbStructure {
dbTables :: [Table] dbTables :: [Table]
@@ -111,7 +112,7 @@ type Cast = Text
type NodeName = Text type NodeName = Text
type SelectItem = (Field, Maybe Cast, Maybe Alias) type SelectItem = (Field, Maybe Cast, Maybe Alias)
type Path = [Text] 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_::Maybe NonnegRange } deriving (Show, Eq)
data MutateQuery = Insert { in_::TableName, qPayload::Payload } data MutateQuery = Insert { in_::TableName, qPayload::Payload }
| Delete { in_::TableName, where_::[Filter] } | Delete { in_::TableName, where_::[Filter] }
| Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq) | Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq)
+13 -5
View File
@@ -5,7 +5,7 @@ import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus)) import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
import Text.Heredoc
import SpecHelper import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
@@ -15,15 +15,23 @@ spec =
it "restricts results" $ it "restricts results" $
get "/items" get "/items"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just [json| [{"id":1},{"id":2},{"id":3}] |] matchBody = Just [json| [{"id":1},{"id":2}] |]
, matchStatus = 206 , matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-2/15"] , matchHeaders = ["Content-Range" <:> "0-1/15"]
} }
it "respects additional client limiting" $ do it "respects additional client limiting" $ do
r <- request methodGet "/items" r <- request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 0 1) "" (rangeHdrs $ ByteRangeFromTo 0 0) ""
liftIO $ do liftIO $ do
simpleHeaders r `shouldSatisfy` simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-1/15" matchHeader "Content-Range" "0-0/15"
simpleStatus r `shouldBe` partialContent206 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"]
}
+45
View File
@@ -9,6 +9,7 @@ import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import SpecHelper import SpecHelper
import Text.Heredoc
import Network.Wai (Application) import Network.Wai (Application)
defaultRange :: BL.ByteString defaultRange :: BL.ByteString
@@ -142,6 +143,50 @@ spec = do
, matchHeaders = ["Content-Range" <:> "0-0/*"] , 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 "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
+1 -1
View File
@@ -27,7 +27,7 @@ testUnicodeCfg =
testLtdRowsCfg :: AppConfig testLtdRowsCfg :: AppConfig
testLtdRowsCfg = 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 :: IO ()
setupDb = do setupDb = do