diff --git a/CHANGELOG.md b/CHANGELOG.md index a21289ca9..36618a34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - #742, Add connection retrying on startup and SIGHUP - @steve-chavez +- #652, Add and/or params for complex boolean logic - @steve-chavez ### Fixed diff --git a/postgrest.cabal b/postgrest.cabal index 925444cbf..b8766d528 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -126,6 +126,7 @@ Test-Suite spec , Feature.SingularSpec , Feature.StructureSpec , Feature.UnicodeSpec + , Feature.AndOrParamsSpec , SpecHelper , TestTypes Build-Depends: aeson diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index b5381f58f..a2a64e2cc 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -86,6 +86,8 @@ data ApiRequest = ApiRequest { , iPreferCount :: Bool -- | Filters on the result ("id", "eq.10") , iFilters :: [(Text, Text)] + -- | &and and &or parameters used for complex boolean logic + , iLogic :: [(Text, Text)] -- | &select parameter used to shape the response , iSelect :: Text -- | &order parameters for each level @@ -116,7 +118,8 @@ userApiRequest schema req reqBody , iPreferRepresentation = representation , iPreferSingleObjectParameter = singleObject , iPreferCount = hasPrefer "count=exact" - , iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset"] k) ] + , iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ] + , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ] , iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iCanonicalQS = toS $ urlEncodeVars diff --git a/src/PostgREST/DbRequestBuilder.hs b/src/PostgREST/DbRequestBuilder.hs index 18859b1c4..8c484cc54 100644 --- a/src/PostgREST/DbRequestBuilder.hs +++ b/src/PostgREST/DbRequestBuilder.hs @@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE DuplicateRecordFields #-} module PostgREST.DbRequestBuilder ( readRequest , mutateRequest @@ -6,6 +7,7 @@ module PostgREST.DbRequestBuilder ( ) where import Control.Applicative +import Control.Arrow ((***)) import Control.Lens.Getter (view) import Control.Lens.Tuple (_1) import qualified Data.ByteString.Char8 as BS @@ -173,7 +175,8 @@ addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> R addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ flip (foldr addFilter) <$> filters, flip (foldr addOrder) <$> orders, - flip (foldr addRange) <$> ranges + flip (foldr addRange) <$> ranges, + flip (foldr addLogicTree) <$> logicForest ] {- The esence of what is going on above is that we are composing tree functions @@ -182,12 +185,13 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ where filters :: Either ApiRequestError [(EmbedPath, Filter)] filters = mapM pRequestFilter flts - where - action = iAction apiRequest - flts - | action == ActionRead = iFilters apiRequest - | action == ActionInvoke = iFilters apiRequest - | otherwise = filter (( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update + logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)] + logicForest = mapM pRequestLogicTree logFrst + action = iAction apiRequest + -- there can be no filters on the root table when we are doing insert/update/delete + (flts, logFrst) + | action == ActionRead || action == ActionInvoke = (iFilters apiRequest, iLogic apiRequest) + | otherwise = join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest) orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])] orders = mapM pRequestOrder $ iOrder apiRequest ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)] @@ -211,6 +215,12 @@ addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest addRange = addProperty addRangeToNode +addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest +addLogicTreeToNode t (Node (q@Select{logic=l},i) f) = Node (q{logic=t:l}::ReadQuery, i) f + +addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest +addLogicTree = addProperty addLogicTreeToNode + addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest addProperty f ([], a) n = f a n addProperty f (path, a) (Node rn forest) = @@ -248,8 +258,8 @@ mutateRequest :: ApiRequest -> [FieldName] -> Either Response MutateRequest mutateRequest apiRequest fldNames = mapLeft apiRequestError $ case action of ActionCreate -> Right $ Insert rootTableName payload returnings - ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> pure returnings - ActionDelete -> Delete rootTableName <$> filters <*> pure returnings + ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> logic_ <*> pure returnings + ActionDelete -> Delete rootTableName <$> filters <*> logic_ <*> pure returnings _ -> Left UnsupportedVerb where action = iAction apiRequest @@ -261,7 +271,11 @@ mutateRequest apiRequest fldNames = mapLeft apiRequestError $ _ -> undefined returnings = if iPreferRepresentation apiRequest == None then [] else fldNames filters = map snd <$> mapM pRequestFilter mutateFilters - where mutateFilters = filter (not . ( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table + logic_ = map snd <$> mapM pRequestLogicTree logicFilters + -- update/delete filters can be only on the root table + mutateFilters = onlyRoot $ iFilters apiRequest + logicFilters = onlyRoot $ iLogic apiRequest + onlyRoot = filter (not . ( "." `isInfixOf` ) . fst) fieldNames :: ReadRequest -> [FieldName] fieldNames (Node (sel, _) forest) = diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs index ebfc7f2fd..4a7f82325 100644 --- a/src/PostgREST/Parsers.hs +++ b/src/PostgREST/Parsers.hs @@ -21,7 +21,7 @@ pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter) pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) where treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k - oper = parse pOperation ("failed to parse filter (" ++ toS v ++ ")") $ toS v + oper = parse (pOperation pVText pVTextL) ("failed to parse filter (" ++ toS v ++ ")") $ toS v path = fst <$> treePath fld = snd <$> treePath @@ -38,6 +38,15 @@ pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k path = fst <$> treePath +pRequestLogicTree :: (Text, Text) -> Either ApiRequestError (EmbedPath, LogicTree) +pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree + where + path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k + embedPath = fst <$> path + op = snd <$> path + -- Concat op and v to make pLogicTree argument regular, in the form of "op(.,.)" + logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v) + ws :: Parser Text ws = toS <$> many (oneOf " \t") @@ -49,7 +58,7 @@ pReadRequest rootNodeName = do fieldTree <- pFieldForest return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree where - readQuery = Select [] [rootNodeName] [] Nothing allRange + readQuery = Select [] [rootNodeName] [] [] Nothing allRange treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) = case fldForest of @@ -57,7 +66,7 @@ pReadRequest rootNodeName = do _ -> Node (q, i) newForest where newForest = - foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest + foldr treeEntry (Node (Select [] [fn] [] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest pTreePath :: Parser (EmbedPath, Field) pTreePath = do @@ -119,13 +128,13 @@ pSelect = lexeme $ s <- pStar return ((s, Nothing), Nothing, Nothing) -pOperation :: Parser Operation -pOperation = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr +pOperation :: Parser Operand -> Parser Operand -> Parser Operation +pOperation parserVText parserVTextL = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr where pExpr :: Parser (Operator, Operand) pExpr = - ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys notInOps)) <*> pVText) - <|> ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys inOps)) <*> pVTextL) + ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys notInOps)) <*> parserVText) + <|> ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys inOps)) <*> parserVTextL) "operator (eq, gt, ...)" inOps = M.filterWithKey (const . flip elem ["in", "notin"]) operators notInOps = M.difference operators inOps @@ -137,7 +146,10 @@ pVTextL :: Parser Operand pVTextL = VTextL <$> lexeme pLValue `sepBy1` char ',' where pLValue :: Parser Text - pLValue = toS <$> (try (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",") ) <|> many (noneOf ",")) + pLValue = try pQuotedValue <|> (toS <$> many (noneOf ",")) + +pQuotedValue :: Parser Text +pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",)")) pDelimiter :: Parser Char pDelimiter = char '.' "delimiter (.)" @@ -161,6 +173,44 @@ pOrderTerm = ) <|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing +pLogicTree :: Parser LogicTree +pLogicTree = Stmnt <$> try pLogicFilter + <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree) <*> (lexeme (char ',') *> pLogicTree <* lexeme (char ')')) + where + pLogicFilter :: Parser Filter + pLogicFilter = Filter <$> pField <* pDelimiter <*> pOperation pLogicVText pLogicVTextL + pNot :: Parser Bool + pNot = try (string "not" *> pDelimiter *> pure True) + <|> pure False + "negation operator (not)" + pLogicOp :: Parser LogicOperator + pLogicOp = try (string "and" *> pure And) + <|> string "or" *> pure Or + "logic operator (and, or)" + +pLogicVText :: Parser Operand +pLogicVText = VText <$> (try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)"))) + where + pPgArray :: Parser Text + pPgArray = do + a <- string "{" + b <- many (noneOf "{}") + c <- string "}" + toS <$> pure (a ++ b ++ c) + +pLogicVTextL :: Parser Operand +pLogicVTextL = VTextL <$> (lexeme (char '(') *> pLValue `sepBy1` char ',' <* lexeme (char ')')) + where + pLValue :: Parser Text + pLValue = try pQuotedValue <|> (toS <$> many (noneOf ",)")) + +pLogicPath :: Parser (EmbedPath, Text) +pLogicPath = do + path <- pFieldName `sepBy1` pDelimiter + let op = last path + notOp = "not." <> op + return (filter (/= "not") (init path), if "not" `elem` path then notOp else op) + mapError :: Either ParseError a -> Either ApiRequestError a mapError = mapLeft translateError where diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 5c790dd4d..c7f94e351 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -194,21 +194,25 @@ pgFmtLit x = requestToCountQuery :: Schema -> DbRequest -> SqlQuery requestToCountQuery _ (DbMutate _) = undefined -requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) = +requestToCountQuery schema (DbRead (Node (Select _ _ conditions logic_ _ _, (mainTbl, _, _)) _)) = unwords [ "SELECT pg_catalog.count(*)", "FROM ", fromQi qi, - ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi) localConditions )) `emptyOnNull` localConditions + -- logic_ doesn't not need localFilter filtering because it doesn't have VForeignKey vals + ("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) localConditions ++ map (pgFmtLogicTree qi) logic_)) + `emptyOnFalse` (null conditions && null logic_) ] where qi = removeSourceCTESchema schema mainTbl - fn Filter{operation=Operation{expr=(_, VText _)}} = True - fn Filter{operation=Operation{expr=(_, VTextL _)}} = True - fn Filter{operation=Operation{expr=(_, VForeignKey _ _)}} = False - localConditions = filter fn conditions + localFilter :: Filter -> Bool + localFilter Filter{operation=Operation{expr=(_, val)}} = case val of + VText _ -> True + VTextL _ -> True + VForeignKey _ _ -> False + localConditions = filter localFilter conditions requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery -requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) = +requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions logic_ ord range, (nodeName, maybeRelation, _)) forest)) = query where mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation) @@ -218,7 +222,8 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions "SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), "FROM ", intercalate ", " (map (fromQi . toQi) tbls), unwords joins, - ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions, + ("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) conditions ++ map (pgFmtLogicTree qi) logic_)) + `emptyOnFalse` (null conditions && null logic_), orderF (fromMaybe [] ord), if isParent then "" else limitF range ] @@ -279,7 +284,7 @@ requestToQuery schema _ (DbMutate (Insert mainTbl (PayloadJSON rows) returnings) ret = if null returnings then "" else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)] -requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions returnings)) = +requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions logic_ returnings)) = case rows V.!? 0 of Just obj -> let assignments = map @@ -287,20 +292,22 @@ requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions unwords [ "UPDATE ", fromQi qi, " SET " <> intercalate "," assignments <> " ", - ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions, - ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings + ("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) conditions ++ map (pgFmtLogicTree qi) logic_)) + `emptyOnFalse` (null conditions && null logic_), + ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings ] Nothing -> undefined where qi = QualifiedIdentifier schema mainTbl -requestToQuery schema _ (DbMutate (Delete mainTbl conditions returnings)) = +requestToQuery schema _ (DbMutate (Delete mainTbl conditions logic_ returnings)) = query where qi = QualifiedIdentifier schema mainTbl query = unwords [ "DELETE FROM ", fromQi qi, - ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions, - ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings + ("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) conditions ++ map (pgFmtLogicTree qi) logic_)) + `emptyOnFalse` (null conditions && null logic_), + ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings ] sourceCTEName :: SqlFragment @@ -384,8 +391,8 @@ getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) = unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b unicodeStatement = H.statement . T.encodeUtf8 -emptyOnNull :: Text -> [a] -> Text -emptyOnNull val x = if null x then "" else val +emptyOnFalse :: Text -> Bool -> Text +emptyOnFalse val cond = if cond then "" else val insertableValue :: JSON.Value -> SqlFragment insertableValue JSON.Null = "null" @@ -439,6 +446,11 @@ pgFmtFilter table (Filter fld (Operation hasNot_ ex)) = notOp <> " " <> case ex else pgFmtFieldOp op <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") " Nothing -> emptyValForIn op +pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment +pgFmtLogicTree qi (Expr hasNot_ op lt rt) = notOp <> " (" <> pgFmtLogicTree qi lt <> " " <> show op <> " " <> pgFmtLogicTree qi rt <> ")" + where notOp = if hasNot_ then "NOT" else "" +pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt + pgFmtJsonPath :: Maybe JsonPath -> SqlFragment pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs ) diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 08ad20685..3d2967bad 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DuplicateRecordFields #-} module PostgREST.Types where import Protolude import qualified GHC.Show @@ -157,6 +158,21 @@ operators = M.fromList [ data Operation = Operation{ hasNot::Bool, expr::(Operator, Operand) } deriving (Eq, Show) data Operand = VText Text | VTextL [Text] | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq) +data LogicOperator = And | Or deriving Eq +instance Show LogicOperator where + show And = "AND" + show Or = "OR" +{-| + Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is: + + And + / \ + name.eq.N Or + / \ + id.eq.1 id.eq.2 +-} +data LogicTree = Expr Bool LogicOperator LogicTree LogicTree | Stmnt Filter deriving (Show, Eq) + type FieldName = Text type JsonPath = [Text] type Field = (FieldName, Maybe JsonPath) @@ -168,10 +184,10 @@ type SelectItem = (Field, Maybe Cast, Maybe Alias) type EmbedPath = [Text] data Filter = Filter { field::Field, operation::Operation } deriving (Show, Eq) -data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq) +data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], logic::[LogicTree], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq) data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] } - | Delete { in_::TableName, where_::[Filter], returning::[FieldName] } - | Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], returning::[FieldName] } deriving (Show, Eq) + | Delete { in_::TableName, where_::[Filter], logic::[LogicTree], returning::[FieldName] } + | Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], logic::[LogicTree], returning::[FieldName] } deriving (Show, Eq) type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) type ReadRequest = Tree ReadNode type MutateRequest = MutateQuery diff --git a/test/Feature/AndOrParamsSpec.hs b/test/Feature/AndOrParamsSpec.hs new file mode 100644 index 000000000..b500fbac2 --- /dev/null +++ b/test/Feature/AndOrParamsSpec.hs @@ -0,0 +1,172 @@ +module Feature.AndOrParamsSpec where +import Test.Hspec +import Test.Hspec.Wai +import Test.Hspec.Wai.JSON +import Network.HTTP.Types + +import Network.Wai (Application) + +import SpecHelper +import Protolude hiding (get) + + +spec :: SpecWith Application +spec = + describe "and/or params used for complex boolean logic" $ do + context "used with GET" $ do + context "or param" $ do + it "can do simple logic" $ + get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] } + it "can negate simple logic" $ + get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith` + [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "can be combined with traditional filters" $ + get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith` + [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] } + + context "embedded levels" $ do + it "can do logic on the second level" $ + get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities{id}" `shouldRespondWith` + [json|[ + {"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []}, + {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []} + ]|] { matchHeaders = [matchContentTypeJson] } + it "can do logic on the third level" $ + get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities{id,grandchild_entities{id}}" `shouldRespondWith` + [json|[ + {"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]}, + {"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]}, + {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []} + ]|] { matchHeaders = [matchContentTypeJson] } + + context "and/or params combined" $ do + it "can be nested inside the same expression" $ + get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] } + it "can be negated while nested" $ + get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "can be combined unnested" $ + get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith` + [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] } + + context "operators inside and/or" $ do + it "can handle eq and neq" $ + get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith` + [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle lt and gt" $ + get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle lte and gte" $ + get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle like and ilike" $ + get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle in" $ + get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle is" $ + get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith` + [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle @@" $ + get "/entities?or=(text_search_vector.@@.bar,text_search_vector.@@.baz)&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] } + it "can handle @> and <@" $ + get "/entities?or=(arr.@>.{1,2,3},arr.<@.{1})&select=id" `shouldRespondWith` + [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] } + context "operators with not" $ do + it "eq, @>, like can be negated" $ + get "/entities?and=(arr.not.@>.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith` + [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] } + it "in, is, @@ can be negated" $ + get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.@@.foo))&select=id" `shouldRespondWith` + [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] } + it "lt, gte, <@ can be negated" $ + get "/entities?and=(arr.not.<@.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith` + [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] } + it "gt, lte, ilike can be negated" $ + get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith` + [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] } + + context "and/or params with quotes" $ do + it "eq can have quotes" $ + get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith` + [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] } + it "like and ilike can have quotes" $ + get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith` + [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] } + it "in can have quotes" $ + get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith` + [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] } + + it "allows whitespace" $ + get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith` + [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] } + + context "used with POST" $ + it "includes related data with filters" $ + request methodPost "/child_entities?entities.or=(id.eq.2,id.eq.3)&select=id,entities{id}" + [("Prefer", "return=representation")] + [json|[{"id":4,"name":"entity 4","parent_id":1}, + {"id":5,"name":"entity 5","parent_id":2}, + {"id":6,"name":"entity 6","parent_id":3}]|] `shouldRespondWith` + [json|[{"id": 4, "entities":null}, {"id": 5, "entities": {"id": 2}}, {"id": 6, "entities": {"id": 3}}]|] + { matchStatus = 201, matchHeaders = [matchContentTypeJson] } + + context "used with PATCH" $ + it "succeeds when using and/or params" $ + request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name" + [("Prefer", "return=representation")] + [json|{ name : "updated grandchild entity"}|] `shouldRespondWith` + [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|] + { matchHeaders = [matchContentTypeJson] } + + context "used with DELETE" $ + it "succeeds when using and/or params" $ + request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name" + [("Prefer", "return=representation")] "" `shouldRespondWith` + [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|] + { matchHeaders = [matchContentTypeJson] } + + it "can query columns that begin with and/or reserved words" $ + get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200 + + it "can query jsonb columns" $ + get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith` + [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] } + + it "fails when using IN without () and provides meaningful error message" $ + get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith` + [json|{ + "details": "unexpected \"1\" expecting \"(\"", + "message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)" + }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] } + + it "fails on malformed query params and provides meaningful error message" $ do + get "/entities?or=()" `shouldRespondWith` + [json|{ + "details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)", + "message": "\"failed to parse logic tree (())\" (line 1, column 4)" + }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] } + get "/entities?or=)(" `shouldRespondWith` + [json|{ + "details": "unexpected \")\" expecting \"(\"", + "message": "\"failed to parse logic tree ()()\" (line 1, column 3)" + }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] } + get "/entities?or=(id.eq.1)" `shouldRespondWith` + [json|{ + "details": "unexpected \")\" expecting \",\"", + "message": "\"failed to parse logic tree ((id.eq.1))\" (line 1, column 11)" + }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] } + get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith` + [json|{ + "details": "unexpected \"d\" expecting \"(\"", + "message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)" + }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] } + get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith` + [json|{ + "details": "unexpected \"x\" expecting logic operator (and, or)", + "message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)" + }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] } diff --git a/test/Main.hs b/test/Main.hs index cdcb9d3c1..5ac8df314 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -26,6 +26,7 @@ import qualified Feature.StructureSpec import qualified Feature.SingularSpec import qualified Feature.UnicodeSpec import qualified Feature.ProxySpec +import qualified Feature.AndOrParamsSpec import Protolude @@ -84,4 +85,5 @@ main = do , ("Feature.RangeSpec" , Feature.RangeSpec.spec) , ("Feature.SingularSpec" , Feature.SingularSpec.spec) , ("Feature.StructureSpec" , Feature.StructureSpec.spec) + , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec) ] diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index 5c4067001..77b8b9cf0 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -297,6 +297,24 @@ INSERT INTO w_or_wo_comma_names VALUES ('Larry Thompson'); TRUNCATE TABLE items_with_different_col_types CASCADE; INSERT INTO items_with_different_col_types VALUES (1, null, null, null, null, null, null, null); +TRUNCATE TABLE entities CASCADE; +INSERT INTO entities VALUES (1, 'entity 1', '{1}', '''bar'':2 ''foo'':1'); +INSERT INTO entities VALUES (2, 'entity 2', '{1,2}', '''baz'':1 ''qux'':2'); +INSERT INTO entities VALUES (3, 'entity 3', '{1,2,3}', null); +INSERT INTO entities VALUES (4, null, null, null); + +TRUNCATE TABLE child_entities CASCADE; +INSERT INTO child_entities VALUES (1, 'child entity 1', 1); +INSERT INTO child_entities VALUES (2, 'child entity 2', 1); +INSERT INTO child_entities VALUES (3, 'child entity 3', 2); + +TRUNCATE TABLE grandchild_entities CASCADE; +INSERT INTO grandchild_entities VALUES (1, 'grandchild entity 1', 1, null, null, null); +INSERT INTO grandchild_entities VALUES (2, 'grandchild entity 2', 1, null, null, null); +INSERT INTO grandchild_entities VALUES (3, 'grandchild entity 3', 2, null, null, null); +INSERT INTO grandchild_entities VALUES (4, '(grandchild,entity,4)', 2, null, null, '{"a": {"b":"foo"}}'); +INSERT INTO grandchild_entities VALUES (5, '(grandchild,entity,5)', 2, null, null, '{"b":"bar"}'); + -- -- PostgreSQL database dump complete -- diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql index de0342034..33579ec89 100644 --- a/test/fixtures/privileges.sql +++ b/test/fixtures/privileges.sql @@ -53,6 +53,9 @@ GRANT ALL ON TABLE , images_base64 , w_or_wo_comma_names , items_with_different_col_types + , entities + , child_entities + , grandchild_entities TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 705fd6207..f29b8db21 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1174,6 +1174,30 @@ create table items_with_different_col_types ( time_data time ); +-- Tables used for testing complex boolean logic with and/or query params + +create table entities ( + id integer primary key, + name text, + arr integer[], + text_search_vector tsvector +); + +create table child_entities ( + id integer primary key, + name text, + parent_id integer references entities(id) +); + +create table grandchild_entities ( + id integer primary key, + name text, + parent_id integer references child_entities(id), + or_starting_col text, + and_starting_col text, + jsonb_col jsonb +); + -- -- PostgreSQL database dump complete --