Skip to content

Commit e0eb1da

Browse files
committed
feat(query-compiler): support orderBy on to-many relation scalar fields
Implement ordering by scalar fields of to-many (1:m and m:n) relations using correlated subqueries. The correlated subquery selects the field value from the first matching related record (LIMIT 1), naturally producing NULL when no related records exist. Given the following schema: model Item { id Int @id localization ItemI18n[] } model ItemI18n { id Int @id name String itemId Int item Item @relation(fields: [itemId], references: [id]) } This PR enables: prisma.item.findMany({ orderBy: { localization: { name: 'asc' } } }) prisma.item.findMany({ orderBy: { localization: { name: { sort: 'asc', nulls: 'first' } } } }) which was previously limited to aggregate-only ordering (`_count`). Changes: - order_by.rs: add `OrderByToManyField` variant and `OrderBy::to_many_field` constructor - order_by_objects.rs: expose scalar fields in `*OrderByRelationAggregateInput` DMMF type - query_arguments.rs: parse new scalar field name inside a to-many relation orderBy - ordering.rs: generate correlated sub-SELECT for 1:m and m:n relations - cursor_condition.rs: handle `ToManyField` in cursor-based pagination - select/mod.rs: skip join building for `ToManyField` (subquery is self-contained) - record.rs: extract field value for `ToManyField` in sort record comparison Fixes prisma/prisma#5837
1 parent 7b80cc5 commit e0eb1da

20 files changed

Lines changed: 461 additions & 48 deletions

query-compiler/core/src/query_graph_builder/extractors/query_arguments.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,34 @@ fn process_order_object(
119119
match field {
120120
Field::Relation(rf) if rf.is_list() => {
121121
let object: ParsedInputMap<'_> = field_value.try_into()?;
122+
debug_assert!(object.len() <= 1, "to-many relation orderBy object must have at most one field");
122123

123-
path.push(rf.into());
124+
path.push(rf.clone().into());
124125

125126
let (inner_field_name, inner_field_value) = object.into_iter().next().unwrap();
126-
let sort_aggregation = extract_sort_aggregation(inner_field_name.as_ref())
127-
.expect("To-many relation orderBy must be an aggregation ordering.");
128127

129-
let (sort_order, _) = extract_order_by_args(inner_field_value)?;
130-
Ok(Some(OrderBy::to_many_aggregation(path, sort_order, sort_aggregation)))
128+
if let Some(sort_aggregation) = extract_sort_aggregation(inner_field_name.as_ref()) {
129+
let (sort_order, _) = extract_order_by_args(inner_field_value)?;
130+
Ok(Some(OrderBy::to_many_aggregation(path, sort_order, sort_aggregation)))
131+
} else {
132+
// The field name refers to a scalar field on the related model; order by
133+
// its value via a correlated subquery (LIMIT 1).
134+
let related_model: ParentContainer = rf.related_model().into();
135+
let related_field = related_model
136+
.find_field(&inner_field_name)
137+
.expect("Fields must be valid after validation passed.");
138+
139+
match related_field {
140+
Field::Scalar(sf) => {
141+
let (sort_order, nulls_order) = extract_order_by_args(inner_field_value)?;
142+
Ok(Some(OrderBy::to_many_field(sf, path, sort_order, nulls_order)))
143+
}
144+
_ => Err(QueryGraphBuilderError::InputError(format!(
145+
"Field '{}' on '{}' used in a to-many relation orderBy must be a scalar field or an aggregation function.",
146+
inner_field_name, rf.name()
147+
))),
148+
}
149+
}
131150
}
132151

133152
Field::Relation(rf) => {

query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ fn order_definitions(
442442
OrderBy::ScalarAggregation(order_by) => cursor_order_def_aggregation_scalar(order_by, order_by_def),
443443
OrderBy::ToManyAggregation(order_by) => cursor_order_def_aggregation_rel(order_by, order_by_def),
444444
OrderBy::Relevance(order_by) => cursor_order_def_relevance(order_by, order_by_def),
445+
OrderBy::ToManyField(order_by) => cursor_order_def_to_many_field(order_by, order_by_def),
445446
})
446447
.collect_vec()
447448
}
@@ -515,6 +516,26 @@ fn cursor_order_def_relevance(order_by: &OrderByRelevance, order_by_def: &OrderB
515516
}
516517
}
517518

519+
/// Build a CursorOrderDefinition for ordering by a scalar field on a to-many relation.
520+
/// The subquery expression may return NULL when no related records exist, so cursors treat
521+
/// this as a nullable ordering.
522+
fn cursor_order_def_to_many_field(
523+
order_by: &OrderByToManyField,
524+
order_by_def: &OrderByDefinition,
525+
) -> CursorOrderDefinition {
526+
// The OrderByDefinition.joins for ToManyField only covers intermediary hops, not the
527+
// final to-many hop itself, so calling foreign_keys_from_order_path would produce
528+
// length mismatches and cause panics or incorrect alias references. The correlated
529+
// subquery built in ordering.rs handles nullability, so we simply mark this as
530+
// nullable with no extra FK predicates.
531+
CursorOrderDefinition {
532+
sort_order: order_by.sort_order,
533+
order_column: order_by_def.order_column.clone(),
534+
order_fks: None,
535+
on_nullable_fields: true,
536+
}
537+
}
538+
518539
fn foreign_keys_from_order_path(path: &[OrderByHop], joins: &[AliasedJoin]) -> Option<Vec<CursorOrderForeignKey>> {
519540
let (before_last_hop, last_hop) = take_last_two_elem(path);
520541

query-compiler/query-builders/sql-query-builder/src/ordering.rs

Lines changed: 173 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ impl OrderByBuilder {
5050
self.build_order_aggr_scalar(order_by, needs_reversed_order, ctx)
5151
}
5252
OrderBy::ToManyAggregation(order_by) => self.build_order_aggr_rel(order_by, needs_reversed_order, ctx),
53+
OrderBy::ToManyField(order_by) => self.build_order_to_many_field(order_by, needs_reversed_order, ctx),
5354
OrderBy::Relevance(order_by) => {
5455
reachable_only_with_capability!(ConnectorCapability::NativeFullTextSearch);
5556
self.build_order_relevance(order_by, needs_reversed_order, ctx)
@@ -145,6 +146,177 @@ impl OrderByBuilder {
145146
}
146147
}
147148

149+
/// Orders by a specific scalar field on a to-many related model using a correlated subquery.
150+
///
151+
/// Generated SQL:
152+
/// ```sql
153+
/// ORDER BY (
154+
/// SELECT <related_table>.<field> FROM <related_table>
155+
/// WHERE <related_table>.<fk> = <parent_table>.<pk>
156+
/// ORDER BY <related_table>.<field> {direction}
157+
/// LIMIT 1
158+
/// ) {direction}
159+
/// ```
160+
fn build_order_to_many_field(
161+
&mut self,
162+
order_by: &OrderByToManyField,
163+
needs_reversed_order: bool,
164+
ctx: &Context<'_>,
165+
) -> OrderByDefinition {
166+
let order: Option<Order> = Some(into_order(
167+
&order_by.sort_order,
168+
order_by.nulls_order.as_ref(),
169+
needs_reversed_order,
170+
));
171+
172+
// The inner subquery uses the original (non-reversed) direction so that LIMIT 1
173+
// consistently picks the representative value regardless of the outer pagination
174+
// direction. Only the outer ORDER BY clause needs to respect needs_reversed_order.
175+
let (intermediary_joins, subquery) =
176+
self.compute_subquery_for_to_many_field(order_by, ctx);
177+
178+
let order_definition: OrderDefinition = (subquery.clone(), order);
179+
180+
OrderByDefinition {
181+
order_column: subquery,
182+
order_definition,
183+
joins: intermediary_joins,
184+
}
185+
}
186+
187+
/// Builds the correlated subquery expression and any intermediary joins for a to-many field ordering.
188+
fn compute_subquery_for_to_many_field(
189+
&mut self,
190+
order_by: &OrderByToManyField,
191+
ctx: &Context<'_>,
192+
) -> (Vec<AliasedJoin>, Expression<'static>) {
193+
let intermediary_hops = order_by.intermediary_hops();
194+
let to_many_hop = order_by.to_many_hop().as_relation_hop().unwrap();
195+
196+
// Build joins for all hops leading up to the to-many relation.
197+
let parent_alias = self.parent_alias.clone();
198+
let intermediary_joins = self.compute_one2m_join(intermediary_hops, parent_alias.as_ref(), ctx);
199+
200+
// The alias for the context table that the correlated subquery references.
201+
let context_alias = intermediary_joins.last().map(|j| j.alias.clone()).or(parent_alias);
202+
203+
let subquery = if to_many_hop.relation().is_many_to_many() {
204+
self.build_m2m_correlated_subquery(to_many_hop, &order_by.field, &order_by.sort_order, order_by.nulls_order.as_ref(), context_alias, ctx)
205+
} else {
206+
self.build_one2m_correlated_subquery(to_many_hop, &order_by.field, &order_by.sort_order, order_by.nulls_order.as_ref(), context_alias, ctx)
207+
};
208+
209+
(intermediary_joins, subquery)
210+
}
211+
212+
/// Builds a correlated sub-SELECT for one-to-many relations:
213+
/// `(SELECT field FROM Related WHERE Related.fk = Parent.pk ORDER BY field {dir} LIMIT 1)`
214+
fn build_one2m_correlated_subquery(
215+
&mut self,
216+
rf: &RelationFieldRef,
217+
field: &ScalarFieldRef,
218+
sort_order: &SortOrder,
219+
nulls_order: Option<&NullsOrder>,
220+
context_alias: Option<String>,
221+
ctx: &Context<'_>,
222+
) -> Expression<'static> {
223+
let (left_fields, right_fields) = if rf.is_inlined_on_enclosing_model() {
224+
// FK is on the parent model side.
225+
(rf.scalar_fields(), rf.referenced_fields())
226+
} else {
227+
// FK is on the related model side.
228+
(
229+
rf.related_field().referenced_fields(),
230+
rf.related_field().scalar_fields(),
231+
)
232+
};
233+
234+
// WHERE right_field (on related table) = left_field (on parent, with alias if applicable)
235+
let conditions: Vec<Expression<'static>> = left_fields
236+
.iter()
237+
.zip(right_fields.iter())
238+
.map(|(left, right)| {
239+
let parent_col = left.as_column(ctx).opt_table(context_alias.clone());
240+
let related_col = right.as_column(ctx);
241+
parent_col.equals(related_col).into()
242+
})
243+
.collect();
244+
245+
let field_col = field.as_column(ctx);
246+
// Use the original (non-reversed) direction so LIMIT 1 always picks the
247+
// stable representative value for this sort key.
248+
let inner_order = into_order(sort_order, nulls_order, false);
249+
let inner_order_def: OrderDefinition<'static> = (field_col.clone().into(), Some(inner_order));
250+
251+
let subquery = Select::from_table(rf.related_model().as_table(ctx))
252+
.column(field_col)
253+
.so_that(ConditionTree::And(conditions))
254+
.order_by(inner_order_def)
255+
.limit(1);
256+
257+
Expression::from(subquery)
258+
}
259+
260+
/// Builds a correlated sub-SELECT for many-to-many relations (via junction table):
261+
/// ```sql
262+
/// (SELECT field FROM Related
263+
/// INNER JOIN _Junction ON Related.id = _Junction.B
264+
/// WHERE _Junction.A = Parent.id
265+
/// ORDER BY field {dir}
266+
/// LIMIT 1)
267+
/// ```
268+
fn build_m2m_correlated_subquery(
269+
&mut self,
270+
rf: &RelationFieldRef,
271+
field: &ScalarFieldRef,
272+
sort_order: &SortOrder,
273+
nulls_order: Option<&NullsOrder>,
274+
context_alias: Option<String>,
275+
ctx: &Context<'_>,
276+
) -> Expression<'static> {
277+
let m2m_table = rf.as_table(ctx);
278+
// Column in junction that stores parent IDs (used in WHERE for correlation)
279+
let m2m_parent_col = rf.related_field().m2m_column(ctx);
280+
// Column in junction that stores child IDs (used in INNER JOIN condition)
281+
let m2m_child_col = rf.m2m_column(ctx);
282+
let child_model = rf.related_model();
283+
let child_ids: ModelProjection = child_model.primary_identifier().into();
284+
let parent_ids: ModelProjection = rf.model().primary_identifier().into();
285+
286+
// WHERE _Junction.parent_col = Parent.id (correlated)
287+
let junction_conditions: Vec<Expression<'static>> = parent_ids
288+
.scalar_fields()
289+
.map(|sf| {
290+
let parent_col = sf.as_column(ctx).opt_table(context_alias.clone());
291+
let junction_col = m2m_parent_col.clone();
292+
junction_col.equals(parent_col).into()
293+
})
294+
.collect();
295+
296+
// INNER JOIN _Junction ON Related.id = _Junction.B
297+
let left_join_conditions: Vec<Expression<'static>> = child_ids
298+
.as_columns(ctx)
299+
.map(|c| c.equals(m2m_child_col.clone()).into())
300+
.collect();
301+
302+
let field_col = field.as_column(ctx);
303+
// Use the original (non-reversed) direction so LIMIT 1 always picks the
304+
// stable representative value for this sort key.
305+
let inner_order = into_order(sort_order, nulls_order, false);
306+
let inner_order_def: OrderDefinition<'static> = (field_col.clone().into(), Some(inner_order));
307+
308+
// The WHERE clause already filters to a specific parent via junction_conditions, so
309+
// the join on the junction table is effectively mandatory — use an inner join.
310+
let subquery = Select::from_table(child_model.as_table(ctx))
311+
.column(field_col)
312+
.so_that(ConditionTree::And(junction_conditions))
313+
.inner_join(m2m_table.on(ConditionTree::And(left_join_conditions)))
314+
.order_by(inner_order_def)
315+
.limit(1);
316+
317+
Expression::from(subquery)
318+
}
319+
148320
fn compute_joins_aggregation(
149321
&mut self,
150322
order_by: &OrderByToManyAggregation,
@@ -154,18 +326,8 @@ impl OrderByBuilder {
154326
let aggregation_hop = order_by.aggregation_hop();
155327

156328
// Unwraps are safe because the SQL connector doesn't yet support any other type of orderBy hop but the relation hop.
157-
let mut joins: Vec<AliasedJoin> = vec![];
158-
159329
let parent_alias = self.parent_alias.clone();
160-
161-
for (i, hop) in intermediary_hops.iter().enumerate() {
162-
let previous_join = if i > 0 { joins.get(i - 1) } else { None };
163-
164-
let previous_alias = previous_join.map(|j| j.alias.as_str()).or(parent_alias.as_deref());
165-
let join = compute_one2m_join(hop.as_relation_hop().unwrap(), &self.join_prefix(), previous_alias, ctx);
166-
167-
joins.push(join);
168-
}
330+
let mut joins = self.compute_one2m_join(intermediary_hops, parent_alias.as_ref(), ctx);
169331

170332
let aggregation_type = match order_by.sort_aggregation {
171333
SortAggregation::Count => AggregationType::Count,

query-compiler/query-builders/sql-query-builder/src/select/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,13 @@ pub(crate) trait JoinSelectBuilder {
186186
inner.with_columns(selection.into())
187187
} else {
188188
// select ordering, distinct, filtering & join fields from child selections to order,
189-
// filter & join them on the outer query
189+
// filter & join them on the outer query; include linking_fields so that correlated
190+
// subqueries built by with_ordering (e.g. for OrderBy::ToManyField) can reference
191+
// the parent FK/link columns via inner_alias
190192
let inner_selection: Vec<Column<'_>> = FieldSelection::union(vec![
191193
order_by_selection(rs),
192194
distinct_selection(rs),
195+
linking_fields,
193196
filtering_selection(rs),
194197
relation_selection(rs),
195198
])
@@ -588,6 +591,10 @@ fn order_by_selection(rs: &RelationSelection) -> FieldSelection {
588591
// Select the linking fields of the first hop so that the outer select can perform a join to traverse the relation.
589592
// This is necessary because the order by is done on a different join. The following hops are handled by the order by builder.
590593
OrderBy::ToManyAggregation(x) => first_hop_linking_fields(x.intermediary_hops()),
594+
// For to-many field ordering, project the parent's linking fields (e.g. the PK
595+
// referenced by the correlated subquery's WHERE clause) into the inner layer so
596+
// that with_ordering can reference them via inner_alias.
597+
OrderBy::ToManyField(x) => first_hop_linking_fields(&x.path),
591598
OrderBy::ScalarAggregation(x) => vec![x.field.clone()],
592599
})
593600
.collect();
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"modelName": "User",
3+
"action": "findMany",
4+
"query": {
5+
"arguments": {
6+
"orderBy": {
7+
"posts": {
8+
"title": {
9+
"sort": "asc",
10+
"nulls": "first"
11+
}
12+
}
13+
}
14+
},
15+
"selection": {
16+
"$scalars": true
17+
}
18+
}
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"modelName": "User",
3+
"action": "findMany",
4+
"query": {
5+
"arguments": {
6+
"orderBy": {
7+
"posts": {
8+
"title": "asc"
9+
}
10+
}
11+
},
12+
"selection": {
13+
"$scalars": true
14+
}
15+
}
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"modelName": "Post",
3+
"action": "findMany",
4+
"query": {
5+
"arguments": {
6+
"orderBy": {
7+
"categories": {
8+
"name": "desc"
9+
}
10+
}
11+
},
12+
"selection": {
13+
"$scalars": true
14+
}
15+
}
16+
}

query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-incompatible-orderby-join.json.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ process {
2121
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__" FROM
2222
(SELECT "t3"."__prisma_data__" FROM (SELECT JSONB_BUILD_OBJECT('id',
2323
"t2"."id", 'title', "t2"."title") AS "__prisma_data__", "t2"."id",
24-
"t2"."title" FROM (SELECT "t1".* FROM "public"."Post" AS "t1" WHERE
25-
"t0"."id" = "t1"."userId" /* root select */) AS "t2" /* inner select
26-
*/) AS "t3" ORDER BY "t3"."id" ASC /* middle select */) AS "t4" /*
27-
outer select */) AS "User_posts" ON true»
24+
"t2"."title", "t2"."userId" FROM (SELECT "t1".* FROM "public"."Post"
25+
AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select */) AS "t2" /*
26+
inner select */) AS "t3" ORDER BY "t3"."id" ASC /* middle select */)
27+
AS "t4" /* outer select */) AS "User_posts" ON true»
2828
params [])

query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-join.json.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ query «SELECT "t0"."id", "User_posts"."__prisma_data__" AS "posts" FROM
1515
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__" FROM
1616
(SELECT DISTINCT ON ("t3"."title") "t3"."__prisma_data__" FROM (SELECT
1717
JSONB_BUILD_OBJECT('id', "t2"."id", 'title', "t2"."title") AS
18-
"__prisma_data__", "t2"."title" FROM (SELECT "t1".* FROM "public"."Post"
19-
AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select */) AS "t2" /*
20-
inner select */) AS "t3" /* middle select */) AS "t4" /* outer select */)
21-
AS "User_posts" ON true»
18+
"__prisma_data__", "t2"."title", "t2"."userId" FROM (SELECT "t1".* FROM
19+
"public"."Post" AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select
20+
*/) AS "t2" /* inner select */) AS "t3" /* middle select */) AS "t4" /*
21+
outer select */) AS "User_posts" ON true»
2222
params []

query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-orderby-join.json.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ query «SELECT "t0"."id", "User_posts"."__prisma_data__" AS "posts" FROM
1515
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__" FROM
1616
(SELECT DISTINCT ON ("t3"."title") "t3"."__prisma_data__" FROM (SELECT
1717
JSONB_BUILD_OBJECT('id', "t2"."id", 'title', "t2"."title") AS
18-
"__prisma_data__", "t2"."title" FROM (SELECT "t1".* FROM "public"."Post"
19-
AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select */) AS "t2" /*
20-
inner select */) AS "t3" ORDER BY "t3"."title" ASC /* middle select */)
21-
AS "t4" /* outer select */) AS "User_posts" ON true»
18+
"__prisma_data__", "t2"."title", "t2"."userId" FROM (SELECT "t1".* FROM
19+
"public"."Post" AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select
20+
*/) AS "t2" /* inner select */) AS "t3" ORDER BY "t3"."title" ASC /*
21+
middle select */) AS "t4" /* outer select */) AS "User_posts" ON true»
2222
params []

0 commit comments

Comments
 (0)