diff --git a/src/PostgREST/ApiRequest/QueryParams.hs b/src/PostgREST/ApiRequest/QueryParams.hs index b8f21fd181..abe86ec950 100644 --- a/src/PostgREST/ApiRequest/QueryParams.hs +++ b/src/PostgREST/ApiRequest/QueryParams.hs @@ -79,7 +79,7 @@ data QueryParams = , qsSelect :: [Tree SelectItem] -- ^ &select parameter used to shape the response , qsFilters :: [(EmbedPath, Filter)] - -- ^ Filters on the result from e.g. &id=e.10 + -- ^ Filters on the result from e.g. &id=eq.10 , qsFiltersRoot :: [Filter] -- ^ Subset of the filters that apply on the root table. These are used on UPDATE/DELETE. , qsFiltersNotRoot :: [(EmbedPath, Filter)] @@ -227,15 +227,24 @@ pRequestOnConflict oncStr = -- >>> pRequestFilter True ("id", "val") -- Right ([],Filter {field = ("id",[]), opExpr = NoOpExpr "val"}) pRequestFilter :: Bool -> (Text, Text) -> Either QPError (EmbedPath, Filter) -pRequestFilter isRpcRead (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) +pRequestFilter isRpcRead (k, v) = mapError $ (,) <$> path <*> (flt <*> oper) where - treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k + treePath = P.parse (try pRelTreePath <|> pTreePath') ("failed to parse tree path (" ++ toS k ++ ")") $ toS k + path = fst <$> treePath + flt = snd <$> treePath oper = P.parse parseFlt ("failed to parse filter (" ++ toS v ++ ")") $ toS v parseFlt = if isRpcRead then pOpExpr pSingleVal <|> pure (NoOpExpr v) else pOpExpr pSingleVal - path = fst <$> treePath - fld = snd <$> treePath + + pTreePath' :: Parser (EmbedPath, OpExpr -> Filter) + pTreePath' = second Filter <$> pTreePath + + pRelTreePath :: Parser (EmbedPath, OpExpr -> Filter) + pRelTreePath = do + p <- pFieldName `sepBy1` pDelimiter + f <- between (char '(') (char ')') pField + return (init p, RelFilter (last p) f) pRequestOrder :: (Text, Text) -> Either QPError (EmbedPath, [OrderTerm]) pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord' @@ -813,11 +822,18 @@ pOrder = lexeme (try pOrderRelationTerm <|> pOrderTerm) `sepBy1` char ',' -- unexpected "x" -- expecting logic operator (and, or) pLogicTree :: Parser LogicTree -pLogicTree = Stmnt <$> try pLogicFilter +pLogicTree = try pLogicEmbedFilter + <|> Stmnt <$> try pLogicFilter <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')')) where pLogicFilter :: Parser Filter pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal + pLogicEmbedFilter :: Parser LogicTree + pLogicEmbedFilter = do + pth <- pFieldName + fld <- between (char '(') (char ')') pField + opx <- pDelimiter *> pOpExpr pLogicSingleVal + return $ Stmnt (RelFilter pth fld opx) pNot :: Parser Bool pNot = try (string "not" *> pDelimiter $> True) <|> pure False diff --git a/src/PostgREST/ApiRequest/Types.hs b/src/PostgREST/ApiRequest/Types.hs index a53d008d42..dc6de5390c 100644 --- a/src/PostgREST/ApiRequest/Types.hs +++ b/src/PostgREST/ApiRequest/Types.hs @@ -148,9 +148,14 @@ data LogicOperator data Filter = Filter - { field :: Field - , opExpr :: OpExpr - } + { field :: Field + , opExpr :: OpExpr + } + | RelFilter + { relation :: FieldName + , field :: Field + , opExpr :: OpExpr + } deriving (Eq, Show) data OpExpr diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index c7ad19c841..a62ab58ce2 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -797,19 +797,38 @@ findTable qi@QualifiedIdentifier{..} tableMap = addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree addFilters ctx ApiRequest{..} rReq = - foldr addFilterToNode (Right rReq) flts + foldr addFilterToNode (Right rReq) (flts ++ getFiltersFromLogicTree ++ getNullFiltersFromRelFilters) where QueryParams.QueryParams{..} = iQueryParams flts = case iAction of - ActDb (ActRelationRead _ _) -> qsFilters - ActDb (ActRoutine _ _) -> qsFilters - _ -> qsFiltersNotRoot + ActDb (ActRelationRead _ _) -> newFilter <$> qsFilters + ActDb (ActRoutine _ _) -> newFilter <$> qsFilters + _ -> newFilter <$> qsFiltersNotRoot addFilterToNode :: (EmbedPath, Filter) -> Either Error ReadPlanTree -> Either Error ReadPlanTree addFilterToNode = updateNode (\flt (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest (resolveFilter ctx{qi=fromTable} flt) lf} f) + newFilter :: (EmbedPath, Filter) -> (EmbedPath, Filter) + newFilter (ep, rf@(RelFilter rel _ _)) = (ep ++ [rel], rf) + newFilter flt = flt + + getNullFiltersFromRelFilters :: [(EmbedPath, Filter)] + getNullFiltersFromRelFilters = concatMap (uncurry relToFlt) qsFilters + + relToFlt :: EmbedPath -> Filter -> [(EmbedPath, Filter)] + relToFlt ep (RelFilter rel _ _) = [(ep, relEmbedPathToNotIsNull rel)] + relToFlt _ _ = [] + + getFiltersFromLogicTree :: [(EmbedPath, Filter)] + getFiltersFromLogicTree = concatMap (uncurry logicRelToFlt) qsLogic + + logicRelToFlt :: EmbedPath -> LogicTree -> [(EmbedPath, Filter)] + logicRelToFlt ep (Stmnt rf@(RelFilter path _ _)) = [(ep ++ [path], rf)] + logicRelToFlt ep (Expr _ _ lstTree) = concatMap (logicRelToFlt ep) lstTree + logicRelToFlt _ _ = [] + addOrders :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree addOrders ctx ApiRequest{..} rReq = foldr addOrderToNode (Right rReq) qsOrder where @@ -950,11 +969,16 @@ addLogicTrees ctx ApiRequest{..} rReq = addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f) resolveLogicTree :: ResolverContext -> LogicTree -> CoercibleLogicTree +resolveLogicTree ctx (Stmnt (RelFilter ep _ _)) = CoercibleStmnt $ resolveFilter ctx (relEmbedPathToNotIsNull ep) resolveLogicTree ctx (Stmnt flt) = CoercibleStmnt $ resolveFilter ctx flt resolveLogicTree ctx (Expr b op lts) = CoercibleExpr b op (map (resolveLogicTree ctx) lts) resolveFilter :: ResolverContext -> Filter -> CoercibleFilter resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr} +resolveFilter ctx (RelFilter _ fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr} + +relEmbedPathToNotIsNull :: FieldName -> Filter +relEmbedPathToNotIsNull ep = Filter (ep, []) (OpExpr True (Is IsNull)) -- Find a Node of the Tree and apply a function to it updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either Error ReadPlanTree -> Either Error ReadPlanTree diff --git a/test/spec/Feature/Query/RelatedQueriesSpec.hs b/test/spec/Feature/Query/RelatedQueriesSpec.hs index de0dd8f2d0..942e51bfff 100644 --- a/test/spec/Feature/Query/RelatedQueriesSpec.hs +++ b/test/spec/Feature/Query/RelatedQueriesSpec.hs @@ -378,3 +378,182 @@ spec = describe "related queries" $ do , matchHeaders = [ matchContentTypeJson , "Content-Range" <:> "0-1/952" ] } + + context "related conditions using the embed(column) syntax" $ do + it "works on a many-to-one relationship" $ do + get "/projects?select=name,clients()&clients(name)=eq.Microsoft" `shouldRespondWith` + [json|[ + {"name":"Windows 7"}, + {"name":"Windows 10"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/projects?select=name,computed_clients()&computed_clients(name)=eq.Apple" `shouldRespondWith` + [json|[ + {"name":"IOS"}, + {"name":"OSX"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + + it "works on a one-to-many relationship" $ do + get "/entities?select=name,child_entities()&child_entities(name)=like.child*" `shouldRespondWith` + [json|[ + {"name":"entity 1"}, + {"name":"entity 2"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/entities?select=name,child_entities()&child_entities(name)=like(any).{*1,*2}" `shouldRespondWith` + [json|[ + {"name":"entity 1"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + + it "works on a many-to-many relationship" $ do + get "/users?select=name,tasks()&tasks(id)=eq.1" `shouldRespondWith` + [json|[ + {"name":"Angela Martin"}, + {"name":"Dwight Schrute"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/users?select=name,tasks()&tasks(id)=eq.7" `shouldRespondWith` + [json|[ + {"name":"Michael Scott"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } +{-- TODO(draft): make nesting work + it "works on nested embeds" $ do + get "/entities?select=name,child_entities(name,grandchild_entities())&child_entities(grandchild_entities(name))=like.*1" `shouldRespondWith` + [json|[ + {"name":"entity 1","child_entities":[{"name":"child entity 1"}, {"name":"child entity 2"}]}]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } +--} + it "can do an or across embeds" $ + get "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).like.*Main*,contact(name).like.*Tabby*)" `shouldRespondWith` + [json|[ + {"id":1,"name":"Walmart"}, + {"id":2,"name":"Target"} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + + it "works when the embedding uses the column name" $ do + get "/projects?select=name,c_id:client_id,client_id(name)&client_id(name)=like.Apple" `shouldRespondWith` + [json|[ + {"name":"IOS","c_id":2,"client_id":{"name":"Apple"}}, + {"name":"OSX","c_id":2,"client_id":{"name":"Apple"}} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + get "/projects?select=name,client_id(name)&client_id(name)=like.Apple" `shouldRespondWith` + [json|[ + {"name":"IOS","client_id":{"name":"Apple"}}, + {"name":"OSX","client_id":{"name":"Apple"}} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + + -- "?table=not.is.null" does a "table IS DISTINCT FROM NULL" instead of a "table IS NOT NULL" + -- https://github.com/PostgREST/postgrest/issues/2800#issuecomment-1720315818 + it "embeds verifying that the entire target table row is not null" $ do + get "/table_b?select=name,table_a(name)&table_a(id)=eq(any).{1,2}" `shouldRespondWith` + [json|[ + {"name":"Test 1","table_a":{"name":"Not null 1"}}, + {"name":"Test 2","table_a":{"name":null}} + ]|] + { matchStatus = 200 + , matchHeaders = [matchContentTypeJson] + } + + it "works with count=exact" $ do + request methodGet "/projects?select=name,clients(name)&clients(id)=gt.0" + [("Prefer", "count=exact")] "" + `shouldRespondWith` + [json|[ + {"name":"Windows 7", "clients":{"name":"Microsoft"}}, + {"name":"Windows 10", "clients":{"name":"Microsoft"}}, + {"name":"IOS", "clients":{"name":"Apple"}}, + {"name":"OSX", "clients":{"name":"Apple"}} + ]|] + { matchStatus = 200 + , matchHeaders = [ matchContentTypeJson + , "Content-Range" <:> "0-3/4" ] + } + request methodGet "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).ilike.*main*,contact(name).ilike.*tabby*)" + [("Prefer", "count=exact")] "" + `shouldRespondWith` + [json|[ + {"id":1,"name":"Walmart"}, + {"id":2,"name":"Target"} + ]|] + { matchStatus = 200 + , matchHeaders = [ matchContentTypeJson + , "Content-Range" <:> "0-1/2" ] + } + + it "works with count=planned" $ do + request methodGet "/projects?select=name,clients(name)&clients(id)=gt.0" + [("Prefer", "count=planned")] "" + `shouldRespondWith` + [json|[ + {"name":"Windows 7", "clients":{"name":"Microsoft"}}, + {"name":"Windows 10", "clients":{"name":"Microsoft"}}, + {"name":"IOS", "clients":{"name":"Apple"}}, + {"name":"OSX", "clients":{"name":"Apple"}} + ]|] + { matchStatus = 206 + , matchHeaders = [ matchContentTypeJson + , "Content-Range" <:> "0-3/400" ] + } + request methodGet "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).ilike.*main*,contact(name).ilike.*tabby*)" + [("Prefer", "count=planned")] "" + `shouldRespondWith` + [json|[ + {"id":1,"name":"Walmart"}, + {"id":2,"name":"Target"} + ]|] + { matchStatus = 206 + , matchHeaders = [ matchContentTypeJson + , "Content-Range" <:> "0-1/952" ] + } + + it "works with count=estimated" $ do + request methodGet "/projects?select=name,clients(name)&clients(id)=gt.0" + [("Prefer", "count=estimated")] "" + `shouldRespondWith` + [json|[ + {"name":"Windows 7", "clients":{"name":"Microsoft"}}, + {"name":"Windows 10", "clients":{"name":"Microsoft"}}, + {"name":"IOS", "clients":{"name":"Apple"}}, + {"name":"OSX", "clients":{"name":"Apple"}} + ]|] + { matchStatus = 206 + , matchHeaders = [ matchContentTypeJson + , "Content-Range" <:> "0-3/400" ] + } + request methodGet "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).ilike.*main*,contact(name).ilike.*tabby*)" + [("Prefer", "count=estimated")] "" + `shouldRespondWith` + [json|[ + {"id":1,"name":"Walmart"}, + {"id":2,"name":"Target"} + ]|] + { matchStatus = 206 + , matchHeaders = [ matchContentTypeJson + , "Content-Range" <:> "0-1/952" ] + }