Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 38 additions & 13 deletions src/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} []
where
rootDepth = 0
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing Nothing [] rootDepth
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing Nothing [] [] rootDepth
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
treeEntry depth (Node si fldForest) (Node q rForest) =
let nxtDepth = succ depth in
Expand Down Expand Up @@ -848,21 +848,46 @@ resolveOrder ctx (OrderTerm fld dir nulls) = CoercibleOrderTerm (resolveTypeOrUn
-- and if it's a to-one relationship, it adds the right alias to the OrderRelationTerm so the generated query can succeed.
addRelatedOrders :: ReadPlanTree -> Either Error ReadPlanTree
addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
newOrder <- newRelOrder `traverse` order
Node rp{order=newOrder} <$> addRelatedOrders `traverse` forest
(newOrder, newForest) <- foldM addRelOrder ([], forest) (zip [1..] order)
Node rp{order=reverse newOrder} <$> addRelatedOrders `traverse` newForest
where
newRelOrder cot@CoercibleOrderTerm{} = Right cot
newRelOrder cot@CoercibleOrderRelationTerm{coRelation} =
let foundRP = rootLabel <$> find (\(Node ReadPlan{relName, relAlias} _) -> coRelation == fromMaybe relName relAlias) forest in
case foundRP of
Just ReadPlan{relName,relAlias,relAggAlias,relToParent} ->
let isToOne = relIsToOne <$> relToParent
name = fromMaybe relName relAlias in
if isToOne == Just True
then Right $ cot{coRelation=relAggAlias}
else Left $ ApiRequestError $ RelatedOrderNotToOne (qiName from) name
addRelOrder (ords, curForest) (_idx, cot@CoercibleOrderTerm{}) =
Right (cot : ords, curForest)
addRelOrder (ords, curForest) (idx, cot@CoercibleOrderRelationTerm{coRelation, coRelTerm, coDirection, coNullOrder}) =
case findTarget coRelation curForest of
Nothing ->
Left $ ApiRequestError $ NotEmbedded coRelation
Just (Node ReadPlan{relName, relAlias, relAggAlias, relToParent} _) ->
let isToOne = relIsToOne <$> relToParent in
case isToOne of
Just True ->
Right (cot{coRelation=relAggAlias} : ords, curForest)
Just False ->
let
ordAlias = toManyOrderAlias relAggAlias idx
relOrder = RelOrderAgg ordAlias (CoercibleOrderTerm (unknownField (fst coRelTerm) (snd coRelTerm)) coDirection coNullOrder)
newForest = updateTarget coRelation (addRelOrderAgg relOrder) curForest
newOrder = cot{coRelation=relAggAlias, coRelTerm=(ordAlias, [])}
in
Right (newOrder : ords, newForest)
Nothing ->
Left $ ApiRequestError $ RelatedOrderNotToOne (qiName from) (fromMaybe relName relAlias)

addRelOrderAgg ro rpChild =
rpChild{relOrderAgg = relOrderAgg rpChild <> [ro]}

findTarget rel =
find (\(Node ReadPlan{relName, relAlias} _) -> rel == fromMaybe relName relAlias)

updateTarget rel f =
map (\node@(Node rpChild forestChild) ->
if rel == fromMaybe (relName rpChild) (relAlias rpChild)
then Node (f rpChild) forestChild
else node)

toManyOrderAlias :: Alias -> Integer -> FieldName
toManyOrderAlias relAggAlias idx =
relAggAlias <> "_ord_" <> show idx

-- | Searches for null filters on embeds, e.g. `projects=not.is.null` on `GET /clients?select=*,projects(*)&projects=not.is.null`
--
Expand Down
8 changes: 8 additions & 0 deletions src/PostgREST/Plan/ReadPlan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module PostgREST.Plan.ReadPlan
( ReadPlanTree
, ReadPlan(..)
, JoinCondition(..)
, RelOrderAgg(..)
, SpreadType(..)
) where

Expand Down Expand Up @@ -30,6 +31,12 @@ data JoinCondition =
(QualifiedIdentifier, FieldName)
deriving (Eq, Show)

data RelOrderAgg = RelOrderAgg
{ roaAlias :: FieldName
, roaOrderTerm :: CoercibleOrderTerm
}
deriving (Eq, Show)

-- TODO: Enforce uniqueness of columns by changing to a Set instead of a List where applicable
data ReadPlan = ReadPlan
{ select :: [CoercibleSelectField]
Expand All @@ -46,6 +53,7 @@ data ReadPlan = ReadPlan
, relHint :: Maybe Hint
, relJoinType :: Maybe JoinType
, relSpread :: Maybe SpreadType
, relOrderAgg :: [RelOrderAgg]
, relSelect :: [RelSelectField]
, depth :: Depth
-- ^ used for aliasing
Expand Down
31 changes: 28 additions & 3 deletions src/PostgREST/Query/QueryBuilder.hs
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,54 @@ getJoins (Node ReadPlan{relSelect} forest) =
) relSelect

getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet
getJoin fld node@(Node ReadPlan{relJoinType, relSpread} _) =
getJoin fld node@(Node ReadPlan{relJoinType, relSpread, relOrderAgg, order} _) =
let
correlatedSubquery sub al cond =
" " <> (if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond
subquery = readPlanToQuery node
aggAlias = pgFmtIdent $ rsAggAlias fld
selectSubqAgg = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias
orderAggSelects = pgFmtRelOrderAgg (rsAggAlias fld) <$> relOrderAgg
jsonAggOrder =
if all isDirectOrderTerm order
then orderF (QualifiedIdentifier "" (rsAggAlias fld)) order
else mempty
jsonAgg =
"json_agg(" <> aggAlias <>
(if null order then mempty else " " <> jsonAggOrder) <>
")::jsonb AS " <> aggAlias
selectSubqAgg =
"SELECT " <> jsonAgg <>
(if null orderAggSelects then mempty else ", " <> intercalateSnippet ", " orderAggSelects)
fromSubqAgg = " FROM (" <> subquery <> " ) AS " <> aggAlias
joinCondition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE"
isDirectOrderTerm ot = case ot of
CoercibleOrderTerm{} -> True
_ -> False
in
case fld of
JsonEmbed{rsEmbedMode = JsonObject} ->
correlatedSubquery subquery aggAlias "TRUE"
Spread{rsSpreadSel, rsAggAlias} ->
case relSpread of
Just (ToManySpread _ sprOrder) ->
let selSpread = selectSubqAgg <> (if null rsSpreadSel then mempty else ", ") <> intercalateSnippet ", " (pgFmtSpreadJoinSelectItem rsAggAlias sprOrder <$> rsSpreadSel)
let selSpread = selectSubqAgg <>
(if null rsSpreadSel then mempty else ", ") <>
intercalateSnippet ", " (pgFmtSpreadJoinSelectItem rsAggAlias sprOrder <$> rsSpreadSel)
in correlatedSubquery (selSpread <> fromSubqAgg) aggAlias joinCondition
_ ->
correlatedSubquery subquery aggAlias "TRUE"
JsonEmbed{rsEmbedMode = JsonArray} ->
correlatedSubquery (selectSubqAgg <> fromSubqAgg) aggAlias joinCondition

pgFmtRelOrderAgg :: Alias -> RelOrderAgg -> SQL.Snippet
pgFmtRelOrderAgg aggAlias RelOrderAgg{roaAlias, roaOrderTerm} =
"array_agg(" <> fmtField <> " " <> fmtOrder <> ") AS " <> pgFmtIdent roaAlias
where
fmtField = case roaOrderTerm of
CoercibleOrderTerm{coField} -> pgFmtField (QualifiedIdentifier "" aggAlias) coField
CoercibleOrderRelationTerm{} -> pgFmtIdent roaAlias
fmtOrder = orderF (QualifiedIdentifier "" aggAlias) [roaOrderTerm]

mutatePlanToQuery :: MutatePlan -> SQL.Snippet
mutatePlanToQuery (Insert mainQi iCols body onConflict putConditions returnings _ applyDefaults) =
"INSERT INTO " <> fromQi mainQi <> (if null iCols then " " else "(" <> cols <> ") ") <>
Expand Down
1 change: 1 addition & 0 deletions src/PostgREST/Query/SqlFragment.hs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module PostgREST.Query.SqlFragment
, noLocationF
, orderF
, pgFmtColumn
, pgFmtField
, pgFmtFilter
, pgFmtIdent
, pgFmtJoinCondition
Expand Down
44 changes: 19 additions & 25 deletions test/spec/Feature/Query/RelatedQueriesSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -106,35 +106,29 @@ spec = describe "related queries" $ do
, matchHeaders = [matchContentTypeJson]
}

it "fails when is not a to-one relationship" $ do
get "/clients?select=*,projects(*)&order=projects(id)" `shouldRespondWith`
[json|{
"code":"PGRST118",
"details":"'clients' and 'projects' do not form a many-to-one or one-to-one relationship",
"hint":null,
"message":"A related order on 'projects' is not possible"
}|]
{ matchStatus = 400
it "works on a to-many relationship" $ do
get "/clients?select=*,projects(*)&order=projects(id)&projects.order=id.asc" `shouldRespondWith`
Comment on lines -109 to +110
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these test case changes are changing something like order=x(y) to order.x=y. But that's not the same, right?

I believe the order.x=y ordering worked before already and just sorts the embedding, not the top-level.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. I will adjust the tests. thanks

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[json|[
{"id":1,"name":"Microsoft","projects":[{"id":1,"name":"Windows 7","client_id":1},{"id":2,"name":"Windows 10","client_id":1}]},
{"id":2,"name":"Apple","projects":[{"id":3,"name":"IOS","client_id":2},{"id":4,"name":"OSX","client_id":2}]}
]|]
Comment on lines +109 to +114
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since there are multiple rows in the to-many end, how do we decide which row we order into?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean exactly? can you please give me an example?

Copy link
Copy Markdown
Member

@laurenceisla laurenceisla Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be misinterpreting @steve-chavez, but in this example the nested relationship has two rows for the first client:

"projects":[
  {"id":1,"name":"Windows 7","client_id":1},
  {"id":2,"name":"Windows 10","client_id":1}
]

So, does it take {"id":1,"name":"Windows 7", ...} or {"id": 2,"name":"Winodws 10", ...} to order the top relationship?

From what I see in other example, it should order the top relationship according to the first element of the nested relationship (in this case {"id": 1 ...}}), right? Or does it behave in a different way?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I understand what you mean. It works like the internal postgres array sorting mechanism. I attached an example image from my application. For your info, the application uses the uuid behind the displayName of the account to sort.

Example matrix:
data:
Name|Accounts
Person-2|Account-2, Account-1
Person-1|Account-1, Account-2
Person-3|Account-2

results in:
Person-1|Account-1, Account-2
Person-2|Account-1, Account-2
Person-3|Account-2,

image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I see in other example, it should order the top relationship according to the first element of the nested relationship (in this case {"id": 1 ...}}), right? Or does it behave in a different way?

@Fridious Could you answer/clarify the above? I don't quite get your example.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so basically this is the main query without the WITH pgrst wrapper that log-query = true shows:

SELECT "test"."clients".*,
       COALESCE("clients_projects_1"."clients_projects_1", '[]') AS "projects"
FROM "test"."clients"
LEFT JOIN LATERAL
 (SELECT json_agg("clients_projects_1" ORDER BY "clients_projects_1"."id" ASC)::jsonb AS "clients_projects_1",
         array_agg("clients_projects_1"."id" ORDER BY "clients_projects_1"."id") AS "clients_projects_1_ord_1"
  FROM
    (SELECT "projects_1".*
     FROM "test"."projects" AS "projects_1"
     WHERE "projects_1"."client_id" = "test"."clients"."id"
     ORDER BY "projects_1"."id" ASC) AS "clients_projects_1") AS "clients_projects_1" ON TRUE
ORDER BY "clients_projects_1"."clients_projects_1_ord_1"

So it would order by the result of the array_agg(...) aggregate (an integer[] in this case). However it's a bit confusing when using both orderings at the same time, should the first ordering affect the other? Like for example, add a new project here:

insert into test.projects values (20,'Test',1);

Ordering the internal by projects.order=id.desc and then the top by order=projects(id).asc, it shows the "id": 20 first instead of the ones with less id:

curl 'localhost:3000/clients?select=*,projects(*)&projects.order=id.desc&order=projects(id).asc'
[
  {"id":1,"name":"Microsoft","projects":[
    {"id": 20, "name": "Test", "client_id": 1},
    {"id": 2, "name": "Windows 10", "client_id": 1},
    {"id": 1, "name": "Windows 7", "client_id": 1}
  ]},
  {"id":2,"name":"Apple","projects":[
    {"id": 4, "name": "OSX", "client_id": 2},
    {"id": 3, "name": "IOS", "client_id": 2}
  ]}
]

Is this expected? If so it looks confusing from a user perspective.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I explained it a little too confusing. To me, this looks correct.
If you change the query to &projects.order=id.desc&order=projects(id).desc, then the result should be the same because the nested objects are responsible for sorting the parent object. I didn't change any of the code for this. It's the same as before.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so order=projects(id).asc is the main order here and then the projects.order=id.desc is done after or separately from that. Makes sense from the perspective of: "ordering the nested resource does not affect the top order", however this could be confusing at first glance IMO... this would need a doc explanation, I think.

It's the same as before.

There are many new things here since we didn't allow to-many ordering before, that's why we're doing many questions here to understand better the implementation. The next step would be to check the integration with other features like aggregates, spread operator, nested resources, etc. I'll be reviewing those and check if anything breaks (these would need to be added to tests too).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks for the clarification. Please let me know if there’s anything you’d like me to change or add on my side.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For starters, this would need a Documentation entry, but in order to do that we need to define the use case with clarity as mentioned here #4592 (comment) (what exactly does the user expect to get with this feature).

{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
get "/clients?select=*,pros:projects(*)&order=pros(id)" `shouldRespondWith`
[json|{
"code":"PGRST118",
"details":"'clients' and 'pros' do not form a many-to-one or one-to-one relationship",
"hint":null,
"message":"A related order on 'pros' is not possible"
}|]
{ matchStatus = 400
get "/clients?select=*,pros:projects(*)&order=pros(id)&pros.order=id.asc" `shouldRespondWith`
[json|[
{"id":1,"name":"Microsoft","pros":[{"id":1,"name":"Windows 7","client_id":1},{"id":2,"name":"Windows 10","client_id":1}]},
{"id":2,"name":"Apple","pros":[{"id":3,"name":"IOS","client_id":2},{"id":4,"name":"OSX","client_id":2}]}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
get "/designers?select=id,computed_videogames(id)&order=computed_videogames(id).desc" `shouldRespondWith`
[json|{
"code":"PGRST118",
"details":"'designers' and 'computed_videogames' do not form a many-to-one or one-to-one relationship",
"hint":null,
"message":"A related order on 'computed_videogames' is not possible"
}|]
{ matchStatus = 400
get "/designers?select=id,computed_videogames(id)&order=computed_videogames(id).desc&computed_videogames.order=id.asc" `shouldRespondWith`
[json|[
{"id":2,"computed_videogames":[{"id":3},{"id":4}]},
{"id":1,"computed_videogames":[{"id":1},{"id":2}]}
]|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}

Expand Down