Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions src/PostgREST/ApiRequest/QueryParams.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions src/PostgREST/ApiRequest/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,14 @@

data Filter
= Filter
{ field :: Field
, opExpr :: OpExpr
}
{ field :: Field
, opExpr :: OpExpr

Check warning on line 152 in src/PostgREST/ApiRequest/Types.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/ApiRequest/Types.hs#L151-L152

Added lines #L151 - L152 were not covered by tests
}
| RelFilter
{ relation :: FieldName

Check warning on line 155 in src/PostgREST/ApiRequest/Types.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/ApiRequest/Types.hs#L155

Added line #L155 was not covered by tests
, field :: Field
, opExpr :: OpExpr
}
deriving (Eq, Show)

data OpExpr
Expand Down
32 changes: 28 additions & 4 deletions src/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions test/spec/Feature/Query/RelatedQueriesSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Loading