Add and/or params for complex boolean logic
* Add support for and/or params in GET, POST, PATCH and DELETE * Restrict usage of IN in and/or to be inside parens e.g. in.(1,2,3) * Allow quoting operators for values that have ',)' chars inside and/or e.g. eq."(entity,1)"
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ Test-Suite spec
|
||||
, Feature.SingularSpec
|
||||
, Feature.StructureSpec
|
||||
, Feature.UnicodeSpec
|
||||
, Feature.AndOrParamsSpec
|
||||
, SpecHelper
|
||||
, TestTypes
|
||||
Build-Depends: aeson
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 )
|
||||
|
||||
+19
-3
@@ -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
|
||||
|
||||
@@ -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] }
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
Vendored
+18
@@ -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
|
||||
--
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
|
||||
Vendored
+24
@@ -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
|
||||
--
|
||||
|
||||
Reference in New Issue
Block a user