Skip to content

Commit 26e5ed9

Browse files
committed
feat: add related filters
1 parent feb5b7d commit 26e5ed9

File tree

4 files changed

+240
-13
lines changed

4 files changed

+240
-13
lines changed

src/PostgREST/ApiRequest/QueryParams.hs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ data QueryParams =
7979
, qsSelect :: [Tree SelectItem]
8080
-- ^ &select parameter used to shape the response
8181
, qsFilters :: [(EmbedPath, Filter)]
82-
-- ^ Filters on the result from e.g. &id=e.10
82+
-- ^ Filters on the result from e.g. &id=eq.10
8383
, qsFiltersRoot :: [Filter]
8484
-- ^ Subset of the filters that apply on the root table. These are used on UPDATE/DELETE.
8585
, qsFiltersNotRoot :: [(EmbedPath, Filter)]
@@ -118,6 +118,7 @@ parse isRpcRead qs = do
118118
rLogic <- pRequestLogicTree `traverse` logic
119119
rCols <- pRequestColumns columns
120120
rSel <- pRequestSelect select
121+
-- rRelFilters <- pRequestRelFilter `traverse` relFilters
121122
(rFlts, params) <- L.partition hasOp <$> pRequestFilter isRpcRead `traverse` filters
122123
(rFltsRoot, rFltsNotRoot) <- pure $ L.partition hasRootFilter rFlts
123124
rOnConflict <- pRequestOnConflict `traverse` onConflict
@@ -160,6 +161,8 @@ parse isRpcRead qs = do
160161
endingIn xx key = lastWord `elem` xx
161162
where lastWord = L.last $ T.split (== '.') key
162163

164+
-- (filters, relFilters) = L.partition (endingIn [")"] . fst) allFilters
165+
-- allFilters = filter (isFilter . fst) nonemptyParams
163166
filters = filter (isFilter . fst) nonemptyParams
164167
isFilter k = not (endingIn reservedEmbeddable k) && notElem k reserved
165168
reserved = ["select", "columns", "on_conflict"]
@@ -227,15 +230,24 @@ pRequestOnConflict oncStr =
227230
-- >>> pRequestFilter True ("id", "val")
228231
-- Right ([],Filter {field = ("id",[]), opExpr = NoOpExpr "val"})
229232
pRequestFilter :: Bool -> (Text, Text) -> Either QPError (EmbedPath, Filter)
230-
pRequestFilter isRpcRead (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
233+
pRequestFilter isRpcRead (k, v) = mapError $ (,) <$> path <*> (flt <*> oper)
231234
where
232-
treePath = P.parse pTreePath ("failed to parse tree path (" ++ toS k ++ ")") $ toS k
235+
treePath = P.parse (try pRelTreePath <|> pTreePath') ("failed to parse tree path (" ++ toS k ++ ")") $ toS k
236+
path = fst <$> treePath
237+
flt = snd <$> treePath
233238
oper = P.parse parseFlt ("failed to parse filter (" ++ toS v ++ ")") $ toS v
234239
parseFlt = if isRpcRead
235240
then pOpExpr pSingleVal <|> pure (NoOpExpr v)
236241
else pOpExpr pSingleVal
237-
path = fst <$> treePath
238-
fld = snd <$> treePath
242+
243+
pTreePath' :: Parser (EmbedPath, OpExpr -> Filter)
244+
pTreePath' = (\(e, f) -> (e, Filter f)) <$> pTreePath
245+
246+
pRelTreePath :: Parser (EmbedPath, OpExpr -> Filter)
247+
pRelTreePath = do
248+
p <- pFieldName `sepBy1` pDelimiter
249+
f <- between (char '(') (char ')') pField
250+
return (init p, RelFilter (last p) f)
239251

240252
pRequestOrder :: (Text, Text) -> Either QPError (EmbedPath, [OrderTerm])
241253
pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'
@@ -813,11 +825,18 @@ pOrder = lexeme (try pOrderRelationTerm <|> pOrderTerm) `sepBy1` char ','
813825
-- unexpected "x"
814826
-- expecting logic operator (and, or)
815827
pLogicTree :: Parser LogicTree
816-
pLogicTree = Stmnt <$> try pLogicFilter
828+
pLogicTree = try pLogicEmbedFilter
829+
<|> Stmnt <$> try pLogicFilter
817830
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))
818831
where
819832
pLogicFilter :: Parser Filter
820833
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
834+
pLogicEmbedFilter :: Parser LogicTree
835+
pLogicEmbedFilter = do
836+
pth <- pFieldName
837+
fld <- between (char '(') (char ')') pField
838+
opx <- pDelimiter *> pOpExpr pLogicSingleVal
839+
return $ Stmnt (RelFilter pth fld opx)
821840
pNot :: Parser Bool
822841
pNot = try (string "not" *> pDelimiter $> True)
823842
<|> pure False

src/PostgREST/ApiRequest/Types.hs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,14 @@ data LogicOperator
148148

149149
data Filter
150150
= Filter
151-
{ field :: Field
152-
, opExpr :: OpExpr
153-
}
151+
{ field :: Field
152+
, opExpr :: OpExpr
153+
}
154+
| RelFilter
155+
{ relation :: FieldName
156+
, field :: Field
157+
, opExpr :: OpExpr
158+
}
154159
deriving (Eq, Show)
155160

156161
data OpExpr

src/PostgREST/Plan.hs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -797,19 +797,38 @@ findTable qi@QualifiedIdentifier{..} tableMap =
797797

798798
addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
799799
addFilters ctx ApiRequest{..} rReq =
800-
foldr addFilterToNode (Right rReq) flts
800+
foldr addFilterToNode (Right rReq) (flts ++ getFiltersFromLogicTree ++ getNullFiltersFromRelFilters)
801801
where
802802
QueryParams.QueryParams{..} = iQueryParams
803803
flts =
804804
case iAction of
805-
ActDb (ActRelationRead _ _) -> qsFilters
806-
ActDb (ActRoutine _ _) -> qsFilters
807-
_ -> qsFiltersNotRoot
805+
ActDb (ActRelationRead _ _) -> newFilter <$> qsFilters
806+
ActDb (ActRoutine _ _) -> newFilter <$> qsFilters
807+
_ -> newFilter <$> qsFiltersNotRoot
808808

809809
addFilterToNode :: (EmbedPath, Filter) -> Either Error ReadPlanTree -> Either Error ReadPlanTree
810810
addFilterToNode =
811811
updateNode (\flt (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=addFilterToLogicForest (resolveFilter ctx{qi=fromTable} flt) lf} f)
812812

813+
newFilter :: (EmbedPath, Filter) -> (EmbedPath, Filter)
814+
newFilter (ep, rf@(RelFilter rel _ _)) = (ep ++ [rel], rf)
815+
newFilter flt = flt
816+
817+
getNullFiltersFromRelFilters :: [(EmbedPath, Filter)]
818+
getNullFiltersFromRelFilters = concatMap (uncurry relToFlt) qsFilters
819+
820+
relToFlt :: EmbedPath -> Filter -> [(EmbedPath, Filter)]
821+
relToFlt ep (RelFilter rel _ _) = [(ep, relEmbedPathToNotIsNull rel)]
822+
relToFlt _ _ = []
823+
824+
getFiltersFromLogicTree :: [(EmbedPath, Filter)]
825+
getFiltersFromLogicTree = concatMap (uncurry logicRelToFlt) qsLogic
826+
827+
logicRelToFlt :: EmbedPath -> LogicTree -> [(EmbedPath, Filter)]
828+
logicRelToFlt ep (Stmnt rf@(RelFilter path _ _)) = [(ep ++ [path], rf)]
829+
logicRelToFlt ep (Expr _ _ lstTree) = concatMap (logicRelToFlt ep) lstTree
830+
logicRelToFlt _ _ = []
831+
813832
addOrders :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
814833
addOrders ctx ApiRequest{..} rReq = foldr addOrderToNode (Right rReq) qsOrder
815834
where
@@ -950,11 +969,16 @@ addLogicTrees ctx ApiRequest{..} rReq =
950969
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f)
951970

952971
resolveLogicTree :: ResolverContext -> LogicTree -> CoercibleLogicTree
972+
resolveLogicTree ctx (Stmnt (RelFilter ep _ _)) = CoercibleStmnt $ resolveFilter ctx (relEmbedPathToNotIsNull ep)
953973
resolveLogicTree ctx (Stmnt flt) = CoercibleStmnt $ resolveFilter ctx flt
954974
resolveLogicTree ctx (Expr b op lts) = CoercibleExpr b op (map (resolveLogicTree ctx) lts)
955975

956976
resolveFilter :: ResolverContext -> Filter -> CoercibleFilter
957977
resolveFilter ctx (Filter fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr}
978+
resolveFilter ctx (RelFilter _ fld opExpr) = CoercibleFilter{field=resolveQueryInputField ctx fld opExpr, opExpr=opExpr}
979+
980+
relEmbedPathToNotIsNull :: FieldName -> Filter
981+
relEmbedPathToNotIsNull ep = Filter (ep, []) (OpExpr True (Is IsNull))
958982

959983
-- Find a Node of the Tree and apply a function to it
960984
updateNode :: (a -> ReadPlanTree -> ReadPlanTree) -> (EmbedPath, a) -> Either Error ReadPlanTree -> Either Error ReadPlanTree

test/spec/Feature/Query/RelatedQueriesSpec.hs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,182 @@ spec = describe "related queries" $ do
378378
, matchHeaders = [ matchContentTypeJson
379379
, "Content-Range" <:> "0-1/952" ]
380380
}
381+
382+
context "related conditions using the embed(column) syntax" $ do
383+
it "works on a many-to-one relationship" $ do
384+
get "/projects?select=name,clients()&clients(name)=eq.Microsoft" `shouldRespondWith`
385+
[json|[
386+
{"name":"Windows 7"},
387+
{"name":"Windows 10"}
388+
]|]
389+
{ matchStatus = 200
390+
, matchHeaders = [matchContentTypeJson]
391+
}
392+
get "/projects?select=name,computed_clients()&computed_clients(name)=eq.Apple" `shouldRespondWith`
393+
[json|[
394+
{"name":"IOS"},
395+
{"name":"OSX"}
396+
]|]
397+
{ matchStatus = 200
398+
, matchHeaders = [matchContentTypeJson]
399+
}
400+
401+
it "works on a one-to-many relationship" $ do
402+
get "/entities?select=name,child_entities()&child_entities(name)=like.child*" `shouldRespondWith`
403+
[json|[
404+
{"name":"entity 1"},
405+
{"name":"entity 2"}
406+
]|]
407+
{ matchStatus = 200
408+
, matchHeaders = [matchContentTypeJson]
409+
}
410+
get "/entities?select=name,child_entities()&child_entities(name)=like(any).{*1,*2}" `shouldRespondWith`
411+
[json|[
412+
{"name":"entity 1"}
413+
]|]
414+
{ matchStatus = 200
415+
, matchHeaders = [matchContentTypeJson]
416+
}
417+
418+
it "works on a many-to-many relationship" $ do
419+
get "/users?select=name,tasks()&tasks(id)=eq.1" `shouldRespondWith`
420+
[json|[
421+
{"name":"Angela Martin"},
422+
{"name":"Dwight Schrute"}
423+
]|]
424+
{ matchStatus = 200
425+
, matchHeaders = [matchContentTypeJson]
426+
}
427+
get "/users?select=name,tasks()&tasks(id)=eq.7" `shouldRespondWith`
428+
[json|[
429+
{"name":"Michael Scott"}
430+
]|]
431+
{ matchStatus = 200
432+
, matchHeaders = [matchContentTypeJson]
433+
}
434+
{-- TODO(draft): make nesting work
435+
it "works on nested embeds" $ do
436+
get "/entities?select=name,child_entities(name,grandchild_entities())&child_entities(grandchild_entities(name))=like.*1" `shouldRespondWith`
437+
[json|[
438+
{"name":"entity 1","child_entities":[{"name":"child entity 1"}, {"name":"child entity 2"}]}]|]
439+
{ matchStatus = 200
440+
, matchHeaders = [matchContentTypeJson]
441+
}
442+
--}
443+
it "can do an or across embeds" $
444+
get "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).like.*Main*,contact(name).like.*Tabby*)" `shouldRespondWith`
445+
[json|[
446+
{"id":1,"name":"Walmart"},
447+
{"id":2,"name":"Target"}
448+
]|]
449+
{ matchStatus = 200
450+
, matchHeaders = [matchContentTypeJson]
451+
}
452+
453+
it "works when the embedding uses the column name" $ do
454+
get "/projects?select=name,c_id:client_id,client_id(name)&client_id(name)=like.Apple" `shouldRespondWith`
455+
[json|[
456+
{"name":"IOS","c_id":2,"client_id":{"name":"Apple"}},
457+
{"name":"OSX","c_id":2,"client_id":{"name":"Apple"}}
458+
]|]
459+
{ matchStatus = 200
460+
, matchHeaders = [matchContentTypeJson]
461+
}
462+
get "/projects?select=name,client_id(name)&client_id(name)=like.Apple" `shouldRespondWith`
463+
[json|[
464+
{"name":"IOS","client_id":{"name":"Apple"}},
465+
{"name":"OSX","client_id":{"name":"Apple"}}
466+
]|]
467+
{ matchStatus = 200
468+
, matchHeaders = [matchContentTypeJson]
469+
}
470+
471+
-- "?table=not.is.null" does a "table IS DISTINCT FROM NULL" instead of a "table IS NOT NULL"
472+
-- https://github.com/PostgREST/postgrest/issues/2800#issuecomment-1720315818
473+
it "embeds verifying that the entire target table row is not null" $ do
474+
get "/table_b?select=name,table_a(name)&table_a(id)=eq(any).{1,2}" `shouldRespondWith`
475+
[json|[
476+
{"name":"Test 1","table_a":{"name":"Not null 1"}},
477+
{"name":"Test 2","table_a":{"name":null}}
478+
]|]
479+
{ matchStatus = 200
480+
, matchHeaders = [matchContentTypeJson]
481+
}
482+
483+
it "works with count=exact" $ do
484+
request methodGet "/projects?select=name,clients(name)&clients(id)=gt.0"
485+
[("Prefer", "count=exact")] ""
486+
`shouldRespondWith`
487+
[json|[
488+
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
489+
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
490+
{"name":"IOS", "clients":{"name":"Apple"}},
491+
{"name":"OSX", "clients":{"name":"Apple"}}
492+
]|]
493+
{ matchStatus = 200
494+
, matchHeaders = [ matchContentTypeJson
495+
, "Content-Range" <:> "0-3/4" ]
496+
}
497+
request methodGet "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).ilike.*main*,contact(name).ilike.*tabby*)"
498+
[("Prefer", "count=exact")] ""
499+
`shouldRespondWith`
500+
[json|[
501+
{"id":1,"name":"Walmart"},
502+
{"id":2,"name":"Target"}
503+
]|]
504+
{ matchStatus = 200
505+
, matchHeaders = [ matchContentTypeJson
506+
, "Content-Range" <:> "0-1/2" ]
507+
}
508+
509+
it "works with count=planned" $ do
510+
request methodGet "/projects?select=name,clients(name)&clients(id)=gt.0"
511+
[("Prefer", "count=planned")] ""
512+
`shouldRespondWith`
513+
[json|[
514+
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
515+
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
516+
{"name":"IOS", "clients":{"name":"Apple"}},
517+
{"name":"OSX", "clients":{"name":"Apple"}}
518+
]|]
519+
{ matchStatus = 206
520+
, matchHeaders = [ matchContentTypeJson
521+
, "Content-Range" <:> "0-3/400" ]
522+
}
523+
request methodGet "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).ilike.*main*,contact(name).ilike.*tabby*)"
524+
[("Prefer", "count=planned")] ""
525+
`shouldRespondWith`
526+
[json|[
527+
{"id":1,"name":"Walmart"},
528+
{"id":2,"name":"Target"}
529+
]|]
530+
{ matchStatus = 206
531+
, matchHeaders = [ matchContentTypeJson
532+
, "Content-Range" <:> "0-1/952" ]
533+
}
534+
535+
it "works with count=estimated" $ do
536+
request methodGet "/projects?select=name,clients(name)&clients(id)=gt.0"
537+
[("Prefer", "count=estimated")] ""
538+
`shouldRespondWith`
539+
[json|[
540+
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
541+
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
542+
{"name":"IOS", "clients":{"name":"Apple"}},
543+
{"name":"OSX", "clients":{"name":"Apple"}}
544+
]|]
545+
{ matchStatus = 206
546+
, matchHeaders = [ matchContentTypeJson
547+
, "Content-Range" <:> "0-3/400" ]
548+
}
549+
request methodGet "/client?select=*,clientinfo(),contact()&or=(clientinfo(other).ilike.*main*,contact(name).ilike.*tabby*)"
550+
[("Prefer", "count=estimated")] ""
551+
`shouldRespondWith`
552+
[json|[
553+
{"id":1,"name":"Walmart"},
554+
{"id":2,"name":"Target"}
555+
]|]
556+
{ matchStatus = 206
557+
, matchHeaders = [ matchContentTypeJson
558+
, "Content-Range" <:> "0-1/952" ]
559+
}

0 commit comments

Comments
 (0)