Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,34 @@ fn process_order_object(
match field {
Field::Relation(rf) if rf.is_list() => {
let object: ParsedInputMap<'_> = field_value.try_into()?;
debug_assert!(object.len() <= 1, "to-many relation orderBy object must have at most one field");

path.push(rf.into());
path.push(rf.clone().into());

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

let (sort_order, _) = extract_order_by_args(inner_field_value)?;
Ok(Some(OrderBy::to_many_aggregation(path, sort_order, sort_aggregation)))
if let Some(sort_aggregation) = extract_sort_aggregation(inner_field_name.as_ref()) {
let (sort_order, _) = extract_order_by_args(inner_field_value)?;
Ok(Some(OrderBy::to_many_aggregation(path, sort_order, sort_aggregation)))
} else {
// The field name refers to a scalar field on the related model; order by
// its value via a correlated subquery (LIMIT 1).
let related_model: ParentContainer = rf.related_model().into();
let related_field = related_model
.find_field(&inner_field_name)
.expect("Fields must be valid after validation passed.");

match related_field {
Field::Scalar(sf) => {
let (sort_order, nulls_order) = extract_order_by_args(inner_field_value)?;
Ok(Some(OrderBy::to_many_field(sf, path, sort_order, nulls_order)))
}
_ => Err(QueryGraphBuilderError::InputError(format!(
"Field '{}' on '{}' used in a to-many relation orderBy must be a scalar field or an aggregation function.",
inner_field_name, rf.name()
))),
}
}
}

Field::Relation(rf) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ fn order_definitions(
OrderBy::ScalarAggregation(order_by) => cursor_order_def_aggregation_scalar(order_by, order_by_def),
OrderBy::ToManyAggregation(order_by) => cursor_order_def_aggregation_rel(order_by, order_by_def),
OrderBy::Relevance(order_by) => cursor_order_def_relevance(order_by, order_by_def),
OrderBy::ToManyField(order_by) => cursor_order_def_to_many_field(order_by, order_by_def),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
.collect_vec()
}
Expand Down Expand Up @@ -515,6 +516,26 @@ fn cursor_order_def_relevance(order_by: &OrderByRelevance, order_by_def: &OrderB
}
}

/// Build a CursorOrderDefinition for ordering by a scalar field on a to-many relation.
/// The subquery expression may return NULL when no related records exist, so cursors treat
/// this as a nullable ordering.
fn cursor_order_def_to_many_field(
order_by: &OrderByToManyField,
order_by_def: &OrderByDefinition,
) -> CursorOrderDefinition {
// The OrderByDefinition.joins for ToManyField only covers intermediary hops, not the
// final to-many hop itself, so calling foreign_keys_from_order_path would produce
// length mismatches and cause panics or incorrect alias references. The correlated
// subquery built in ordering.rs handles nullability, so we simply mark this as
// nullable with no extra FK predicates.
CursorOrderDefinition {
sort_order: order_by.sort_order,
order_column: order_by_def.order_column.clone(),
order_fks: None,
on_nullable_fields: true,
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn foreign_keys_from_order_path(path: &[OrderByHop], joins: &[AliasedJoin]) -> Option<Vec<CursorOrderForeignKey>> {
let (before_last_hop, last_hop) = take_last_two_elem(path);

Expand Down
192 changes: 181 additions & 11 deletions query-compiler/query-builders/sql-query-builder/src/ordering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl OrderByBuilder {
self.build_order_aggr_scalar(order_by, needs_reversed_order, ctx)
}
OrderBy::ToManyAggregation(order_by) => self.build_order_aggr_rel(order_by, needs_reversed_order, ctx),
OrderBy::ToManyField(order_by) => self.build_order_to_many_field(order_by, needs_reversed_order, ctx),
OrderBy::Relevance(order_by) => {
reachable_only_with_capability!(ConnectorCapability::NativeFullTextSearch);
self.build_order_relevance(order_by, needs_reversed_order, ctx)
Expand Down Expand Up @@ -145,6 +146,185 @@ impl OrderByBuilder {
}
}

/// Orders by a specific scalar field on a to-many related model using a correlated subquery.
///
/// Generated SQL:
/// ```sql
/// ORDER BY (
/// SELECT <related_table>.<field> FROM <related_table>
/// WHERE <related_table>.<fk> = <parent_table>.<pk>
/// ORDER BY <related_table>.<field> {direction}
/// LIMIT 1
/// ) {direction}
/// ```
fn build_order_to_many_field(
&mut self,
order_by: &OrderByToManyField,
needs_reversed_order: bool,
ctx: &Context<'_>,
) -> OrderByDefinition {
let order: Option<Order> = Some(into_order(
&order_by.sort_order,
order_by.nulls_order.as_ref(),
needs_reversed_order,
));

// The inner subquery uses the original (non-reversed) direction so that LIMIT 1
// consistently picks the representative value regardless of the outer pagination
// direction. Only the outer ORDER BY clause needs to respect needs_reversed_order.
let (intermediary_joins, subquery) =
self.compute_subquery_for_to_many_field(order_by, ctx);

let order_definition: OrderDefinition = (subquery.clone(), order);

OrderByDefinition {
order_column: subquery,
order_definition,
joins: intermediary_joins,
}
}

/// Builds the correlated subquery expression and any intermediary joins for a to-many field ordering.
fn compute_subquery_for_to_many_field(
&mut self,
order_by: &OrderByToManyField,
ctx: &Context<'_>,
) -> (Vec<AliasedJoin>, Expression<'static>) {
let intermediary_hops = order_by.intermediary_hops();
let to_many_hop = order_by.to_many_hop().as_relation_hop().unwrap();

// Build joins for all hops leading up to the to-many relation.
let parent_alias = self.parent_alias.clone();
let intermediary_joins = self.compute_one2m_join(intermediary_hops, parent_alias.as_ref(), ctx);

// The alias for the context table that the correlated subquery references.
let context_alias = intermediary_joins.last().map(|j| j.alias.clone()).or(parent_alias);

let subquery = if to_many_hop.relation().is_many_to_many() {
self.build_m2m_correlated_subquery(to_many_hop, &order_by.field, &order_by.sort_order, order_by.nulls_order.as_ref(), context_alias, ctx)
} else {
self.build_one2m_correlated_subquery(to_many_hop, &order_by.field, &order_by.sort_order, order_by.nulls_order.as_ref(), context_alias, ctx)
};

(intermediary_joins, subquery)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Builds a correlated sub-SELECT for one-to-many relations:
/// `(SELECT field FROM Related WHERE Related.fk = Parent.pk ORDER BY field {dir} LIMIT 1)`
fn build_one2m_correlated_subquery(
&mut self,
rf: &RelationFieldRef,
field: &ScalarFieldRef,
sort_order: &SortOrder,
nulls_order: Option<&NullsOrder>,
context_alias: Option<String>,
ctx: &Context<'_>,
) -> Expression<'static> {
// Alias the inner table so that self-relations don't bind parent-column
// references to the inner table instead of the outer row.
let inner_alias = self.join_prefix();

let (left_fields, right_fields) = if rf.is_inlined_on_enclosing_model() {
// FK is on the parent model side.
(rf.scalar_fields(), rf.referenced_fields())
} else {
// FK is on the related model side.
(
rf.related_field().referenced_fields(),
rf.related_field().scalar_fields(),
)
};

// WHERE right_field (on related/inner table) = left_field (on parent, with alias if applicable)
let conditions: Vec<Expression<'static>> = left_fields
.iter()
.zip(right_fields.iter())
.map(|(left, right)| {
let parent_col = left.as_column(ctx).opt_table(context_alias.clone());
let related_col = right.as_column(ctx).table(inner_alias.clone());
parent_col.equals(related_col).into()
})
.collect();

let field_col = field.as_column(ctx).table(inner_alias.clone());
// Use the original (non-reversed) direction so LIMIT 1 always picks the
// stable representative value for this sort key.
let inner_order = into_order(sort_order, nulls_order, false);
let inner_order_def: OrderDefinition<'static> = (field_col.clone().into(), Some(inner_order));

let subquery = Select::from_table(rf.related_model().as_table(ctx).alias(inner_alias))
.column(field_col)
.so_that(ConditionTree::And(conditions))
.order_by(inner_order_def)
.limit(1);

Expression::from(subquery)
}

/// Builds a correlated sub-SELECT for many-to-many relations (via junction table):
/// ```sql
/// (SELECT field FROM Related
/// INNER JOIN _Junction ON Related.id = _Junction.B
/// WHERE _Junction.A = Parent.id
/// ORDER BY field {dir}
/// LIMIT 1)
/// ```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fn build_m2m_correlated_subquery(
&mut self,
rf: &RelationFieldRef,
field: &ScalarFieldRef,
sort_order: &SortOrder,
nulls_order: Option<&NullsOrder>,
context_alias: Option<String>,
ctx: &Context<'_>,
) -> Expression<'static> {
// Alias the inner child table so that self-relation M2M subqueries
// don't confuse inner vs outer column references.
let inner_alias = self.join_prefix();

let m2m_table = rf.as_table(ctx);
// Column in junction that stores parent IDs (used in WHERE for correlation)
let m2m_parent_col = rf.related_field().m2m_column(ctx);
// Column in junction that stores child IDs (used in INNER JOIN condition)
let m2m_child_col = rf.m2m_column(ctx);
let child_model = rf.related_model();
let child_ids: ModelProjection = child_model.primary_identifier().into();
let parent_ids: ModelProjection = rf.model().primary_identifier().into();

// WHERE _Junction.parent_col = Parent.id (correlated)
let junction_conditions: Vec<Expression<'static>> = parent_ids
.scalar_fields()
.map(|sf| {
let parent_col = sf.as_column(ctx).opt_table(context_alias.clone());
let junction_col = m2m_parent_col.clone();
junction_col.equals(parent_col).into()
})
.collect();

// INNER JOIN _Junction ON Related.id = _Junction.B
let left_join_conditions: Vec<Expression<'static>> = child_ids
.as_columns(ctx)
.map(|c| c.table(inner_alias.clone()).equals(m2m_child_col.clone()).into())
.collect();

let field_col = field.as_column(ctx).table(inner_alias.clone());
// Use the original (non-reversed) direction so LIMIT 1 always picks the
// stable representative value for this sort key.
let inner_order = into_order(sort_order, nulls_order, false);
let inner_order_def: OrderDefinition<'static> = (field_col.clone().into(), Some(inner_order));

// The WHERE clause already filters to a specific parent via junction_conditions, so
// the join on the junction table is effectively mandatory — use an inner join.
let subquery = Select::from_table(child_model.as_table(ctx).alias(inner_alias))
.column(field_col)
.so_that(ConditionTree::And(junction_conditions))
.inner_join(m2m_table.on(ConditionTree::And(left_join_conditions)))
.order_by(inner_order_def)
.limit(1);

Expression::from(subquery)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn compute_joins_aggregation(
&mut self,
order_by: &OrderByToManyAggregation,
Expand All @@ -154,18 +334,8 @@ impl OrderByBuilder {
let aggregation_hop = order_by.aggregation_hop();

// Unwraps are safe because the SQL connector doesn't yet support any other type of orderBy hop but the relation hop.
let mut joins: Vec<AliasedJoin> = vec![];

let parent_alias = self.parent_alias.clone();

for (i, hop) in intermediary_hops.iter().enumerate() {
let previous_join = if i > 0 { joins.get(i - 1) } else { None };

let previous_alias = previous_join.map(|j| j.alias.as_str()).or(parent_alias.as_deref());
let join = compute_one2m_join(hop.as_relation_hop().unwrap(), &self.join_prefix(), previous_alias, ctx);

joins.push(join);
}
let mut joins = self.compute_one2m_join(intermediary_hops, parent_alias.as_ref(), ctx);

let aggregation_type = match order_by.sort_aggregation {
SortAggregation::Count => AggregationType::Count,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,13 @@ pub(crate) trait JoinSelectBuilder {
inner.with_columns(selection.into())
} else {
// select ordering, distinct, filtering & join fields from child selections to order,
// filter & join them on the outer query
// filter & join them on the outer query; include linking_fields so that correlated
// subqueries built by with_ordering (e.g. for OrderBy::ToManyField) can reference
// the parent FK/link columns via inner_alias
let inner_selection: Vec<Column<'_>> = FieldSelection::union(vec![
order_by_selection(rs),
distinct_selection(rs),
linking_fields,
filtering_selection(rs),
relation_selection(rs),
])
Expand Down Expand Up @@ -588,6 +591,10 @@ fn order_by_selection(rs: &RelationSelection) -> FieldSelection {
// Select the linking fields of the first hop so that the outer select can perform a join to traverse the relation.
// This is necessary because the order by is done on a different join. The following hops are handled by the order by builder.
OrderBy::ToManyAggregation(x) => first_hop_linking_fields(x.intermediary_hops()),
// For to-many field ordering, project the parent's linking fields (e.g. the PK
// referenced by the correlated subquery's WHERE clause) into the inner layer so
// that with_ordering can reference them via inner_alias.
OrderBy::ToManyField(x) => first_hop_linking_fields(&x.path),
OrderBy::ScalarAggregation(x) => vec![x.field.clone()],
})
.collect();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"modelName": "User",
"action": "findMany",
"query": {
"arguments": {
"orderBy": {
"posts": {
"title": {
"sort": "asc",
"nulls": "first"
}
}
}
},
"selection": {
"$scalars": true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"modelName": "User",
"action": "findMany",
"query": {
"arguments": {
"orderBy": {
"posts": {
"title": "asc"
}
}
},
"selection": {
"$scalars": true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"modelName": "Post",
"action": "findMany",
"query": {
"arguments": {
"orderBy": {
"categories": {
"name": "desc"
}
}
},
"selection": {
"$scalars": true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ process {
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__" FROM
(SELECT "t3"."__prisma_data__" FROM (SELECT JSONB_BUILD_OBJECT('id',
"t2"."id", 'title', "t2"."title") AS "__prisma_data__", "t2"."id",
"t2"."title" FROM (SELECT "t1".* FROM "public"."Post" AS "t1" WHERE
"t0"."id" = "t1"."userId" /* root select */) AS "t2" /* inner select
*/) AS "t3" ORDER BY "t3"."id" ASC /* middle select */) AS "t4" /*
outer select */) AS "User_posts" ON true»
"t2"."title", "t2"."userId" FROM (SELECT "t1".* FROM "public"."Post"
AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select */) AS "t2" /*
inner select */) AS "t3" ORDER BY "t3"."id" ASC /* middle select */)
AS "t4" /* outer select */) AS "User_posts" ON true»
params [])
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ query «SELECT "t0"."id", "User_posts"."__prisma_data__" AS "posts" FROM
COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__" FROM
(SELECT DISTINCT ON ("t3"."title") "t3"."__prisma_data__" FROM (SELECT
JSONB_BUILD_OBJECT('id', "t2"."id", 'title', "t2"."title") AS
"__prisma_data__", "t2"."title" FROM (SELECT "t1".* FROM "public"."Post"
AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select */) AS "t2" /*
inner select */) AS "t3" /* middle select */) AS "t4" /* outer select */)
AS "User_posts" ON true»
"__prisma_data__", "t2"."title", "t2"."userId" FROM (SELECT "t1".* FROM
"public"."Post" AS "t1" WHERE "t0"."id" = "t1"."userId" /* root select
*/) AS "t2" /* inner select */) AS "t3" /* middle select */) AS "t4" /*
outer select */) AS "User_posts" ON true»
params []
Loading