From 4574f5bec254bfb690e834707e1022bf57bbab1f Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sat, 18 Apr 2026 20:18:05 -0300 Subject: [PATCH 1/7] 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 https://github.com/prisma/prisma/issues/5837 --- .../extractors/query_arguments.rs | 29 ++- .../sql-query-builder/src/cursor_condition.rs | 21 ++ .../sql-query-builder/src/ordering.rs | 192 +++++++++++++++++- .../sql-query-builder/src/select/mod.rs | 9 +- .../data/order-by-to-many-1m-field-nulls.json | 19 ++ .../tests/data/order-by-to-many-1m-field.json | 16 ++ .../data/order-by-to-many-m2m-field.json | 16 ++ ...stinct-incompatible-orderby-join.json.snap | 8 +- ...es__queries@nested-distinct-join.json.snap | 8 +- ...ies@nested-distinct-orderby-join.json.snap | 8 +- ...@nested-distinct-pagination-join.json.snap | 8 +- ...__queries@nested-pagination-join.json.snap | 10 +- ...@order-by-to-many-1m-field-nulls.json.snap | 21 ++ ...ueries@order-by-to-many-1m-field.json.snap | 21 ++ ...eries@order-by-to-many-m2m-field.json.snap | 16 ++ ...eries__queries@query-o2m-lateral.json.snap | 8 +- .../query-structure/src/order_by.rs | 48 +++++ query-compiler/query-structure/src/record.rs | 9 + .../input_types/objects/order_by_objects.rs | 42 +++- query-compiler/schema/src/identifier_type.rs | 8 + 20 files changed, 469 insertions(+), 48 deletions(-) create mode 100644 query-compiler/query-compiler/tests/data/order-by-to-many-1m-field-nulls.json create mode 100644 query-compiler/query-compiler/tests/data/order-by-to-many-1m-field.json create mode 100644 query-compiler/query-compiler/tests/data/order-by-to-many-m2m-field.json create mode 100644 query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field-nulls.json.snap create mode 100644 query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field.json.snap create mode 100644 query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-m2m-field.json.snap diff --git a/query-compiler/core/src/query_graph_builder/extractors/query_arguments.rs b/query-compiler/core/src/query_graph_builder/extractors/query_arguments.rs index 704d3d8cc0f5..beab95a49dcf 100644 --- a/query-compiler/core/src/query_graph_builder/extractors/query_arguments.rs +++ b/query-compiler/core/src/query_graph_builder/extractors/query_arguments.rs @@ -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) => { diff --git a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs index ddaf9d39e71a..e187c83e53ec 100644 --- a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs +++ b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs @@ -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), }) .collect_vec() } @@ -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, + } +} + fn foreign_keys_from_order_path(path: &[OrderByHop], joins: &[AliasedJoin]) -> Option> { let (before_last_hop, last_hop) = take_last_two_elem(path); diff --git a/query-compiler/query-builders/sql-query-builder/src/ordering.rs b/query-compiler/query-builders/sql-query-builder/src/ordering.rs index a5040f41b9ee..720ef0af8c22 100644 --- a/query-compiler/query-builders/sql-query-builder/src/ordering.rs +++ b/query-compiler/query-builders/sql-query-builder/src/ordering.rs @@ -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) @@ -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 . FROM + /// WHERE . = . + /// ORDER BY . {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 = 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, 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) + } + + /// 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, + 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> = 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) + /// ``` + fn build_m2m_correlated_subquery( + &mut self, + rf: &RelationFieldRef, + field: &ScalarFieldRef, + sort_order: &SortOrder, + nulls_order: Option<&NullsOrder>, + context_alias: Option, + 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> = 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> = 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) + } + fn compute_joins_aggregation( &mut self, order_by: &OrderByToManyAggregation, @@ -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 = 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, diff --git a/query-compiler/query-builders/sql-query-builder/src/select/mod.rs b/query-compiler/query-builders/sql-query-builder/src/select/mod.rs index c505d8a95ac5..8644f906de35 100644 --- a/query-compiler/query-builders/sql-query-builder/src/select/mod.rs +++ b/query-compiler/query-builders/sql-query-builder/src/select/mod.rs @@ -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> = FieldSelection::union(vec![ order_by_selection(rs), distinct_selection(rs), + linking_fields, filtering_selection(rs), relation_selection(rs), ]) @@ -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(); diff --git a/query-compiler/query-compiler/tests/data/order-by-to-many-1m-field-nulls.json b/query-compiler/query-compiler/tests/data/order-by-to-many-1m-field-nulls.json new file mode 100644 index 000000000000..68b034c0ffe6 --- /dev/null +++ b/query-compiler/query-compiler/tests/data/order-by-to-many-1m-field-nulls.json @@ -0,0 +1,19 @@ +{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": { + "orderBy": { + "posts": { + "title": { + "sort": "asc", + "nulls": "first" + } + } + } + }, + "selection": { + "$scalars": true + } + } +} diff --git a/query-compiler/query-compiler/tests/data/order-by-to-many-1m-field.json b/query-compiler/query-compiler/tests/data/order-by-to-many-1m-field.json new file mode 100644 index 000000000000..fef0cad45226 --- /dev/null +++ b/query-compiler/query-compiler/tests/data/order-by-to-many-1m-field.json @@ -0,0 +1,16 @@ +{ + "modelName": "User", + "action": "findMany", + "query": { + "arguments": { + "orderBy": { + "posts": { + "title": "asc" + } + } + }, + "selection": { + "$scalars": true + } + } +} diff --git a/query-compiler/query-compiler/tests/data/order-by-to-many-m2m-field.json b/query-compiler/query-compiler/tests/data/order-by-to-many-m2m-field.json new file mode 100644 index 000000000000..f79cfdbb6899 --- /dev/null +++ b/query-compiler/query-compiler/tests/data/order-by-to-many-m2m-field.json @@ -0,0 +1,16 @@ +{ + "modelName": "Post", + "action": "findMany", + "query": { + "arguments": { + "orderBy": { + "categories": { + "name": "desc" + } + } + }, + "selection": { + "$scalars": true + } + } +} diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-incompatible-orderby-join.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-incompatible-orderby-join.json.snap index 397ea68c2dc5..4220c6e5102f 100644 --- a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-incompatible-orderby-join.json.snap +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-incompatible-orderby-join.json.snap @@ -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 []) diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-join.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-join.json.snap index 5246e4248c5a..0881e4f489e9 100644 --- a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-join.json.snap +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-join.json.snap @@ -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 [] diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-orderby-join.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-orderby-join.json.snap index 7533b39e7790..602dccf4bafb 100644 --- a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-orderby-join.json.snap +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-orderby-join.json.snap @@ -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" ORDER BY "t3"."title" ASC /* 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" ORDER BY "t3"."title" ASC /* + middle select */) AS "t4" /* outer select */) AS "User_posts" ON true» params [] diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-pagination-join.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-pagination-join.json.snap index eb13c25dd91f..66b0faaa2801 100644 --- a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-pagination-join.json.snap +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-distinct-pagination-join.json.snap @@ -25,8 +25,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 []) diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-pagination-join.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-pagination-join.json.snap index d5f4e613f28e..6a4aa694f060 100644 --- a/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-pagination-join.json.snap +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@nested-pagination-join.json.snap @@ -14,9 +14,9 @@ query «SELECT "t0"."id", "User_posts"."__prisma_data__" AS "posts" FROM "public"."User" AS "t0" LEFT JOIN LATERAL (SELECT 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" 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 LIMIT $1 OFFSET $2 /* middle select */) AS "t4" /* - outer select */) AS "User_posts" ON true» + "t2"."id", 'title', "t2"."title") AS "__prisma_data__", "t2"."id", + "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 LIMIT $1 OFFSET $2 /* middle select */) AS + "t4" /* outer select */) AS "User_posts" ON true» params [const(BigInt(10)), const(BigInt(20))] diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field-nulls.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field-nulls.json.snap new file mode 100644 index 000000000000..82388f2849d1 --- /dev/null +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field-nulls.json.snap @@ -0,0 +1,21 @@ +--- +source: query-compiler/query-compiler/tests/queries.rs +expression: pretty +input_file: query-compiler/query-compiler/tests/data/order-by-to-many-1m-field-nulls.json +--- +dataMap { + id: Int (id) + email: String (email) + role: Enum (role) +} +enums { + Role: { + admin: ADMIN + user: USER + } +} +query «SELECT "t0"."id", "t0"."email", "t0"."role"::text FROM "public"."User" AS + "t0" ORDER BY (SELECT "orderby_1"."title" FROM "public"."Post" AS + "orderby_1" WHERE ("t0"."id" = "orderby_1"."userId") ORDER BY + "orderby_1"."title"ASC NULLS FIRST LIMIT $1)ASC NULLS FIRST» +params [const(BigInt(1))] diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field.json.snap new file mode 100644 index 000000000000..58a1b3f8861d --- /dev/null +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-1m-field.json.snap @@ -0,0 +1,21 @@ +--- +source: query-compiler/query-compiler/tests/queries.rs +expression: pretty +input_file: query-compiler/query-compiler/tests/data/order-by-to-many-1m-field.json +--- +dataMap { + id: Int (id) + email: String (email) + role: Enum (role) +} +enums { + Role: { + admin: ADMIN + user: USER + } +} +query «SELECT "t0"."id", "t0"."email", "t0"."role"::text FROM "public"."User" AS + "t0" ORDER BY (SELECT "orderby_1"."title" FROM "public"."Post" AS + "orderby_1" WHERE ("t0"."id" = "orderby_1"."userId") ORDER BY + "orderby_1"."title" ASC LIMIT $1) ASC» +params [const(BigInt(1))] diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-m2m-field.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-m2m-field.json.snap new file mode 100644 index 000000000000..1fbef0ade1d5 --- /dev/null +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@order-by-to-many-m2m-field.json.snap @@ -0,0 +1,16 @@ +--- +source: query-compiler/query-compiler/tests/queries.rs +expression: pretty +input_file: query-compiler/query-compiler/tests/data/order-by-to-many-m2m-field.json +--- +dataMap { + id: Int (id) + title: String (title) + userId: Int (userId) +} +query «SELECT "t0"."id", "t0"."title", "t0"."userId" FROM "public"."Post" AS + "t0" ORDER BY (SELECT "orderby_1"."name" FROM "public"."Category" AS + "orderby_1" INNER JOIN "public"."_CategoryToPost" ON ("orderby_1"."id" = + "public"."_CategoryToPost"."A") WHERE ("public"."_CategoryToPost"."B" = + "t0"."id") ORDER BY "orderby_1"."name" DESC LIMIT $1) DESC» +params [const(BigInt(1))] diff --git a/query-compiler/query-compiler/tests/snapshots/queries__queries@query-o2m-lateral.json.snap b/query-compiler/query-compiler/tests/snapshots/queries__queries@query-o2m-lateral.json.snap index 75ee2a9f0915..4bb85f6a32f1 100644 --- a/query-compiler/query-compiler/tests/snapshots/queries__queries@query-o2m-lateral.json.snap +++ b/query-compiler/query-compiler/tests/snapshots/queries__queries@query-o2m-lateral.json.snap @@ -17,8 +17,8 @@ query «SELECT "t0"."id", "t0"."email", "User_activations"."__prisma_data__" AS COALESCE(JSONB_AGG("__prisma_data__"), '[]') AS "__prisma_data__" FROM (SELECT "t3"."__prisma_data__" FROM (SELECT JSONB_BUILD_OBJECT('issued', "t2"."issued", 'secret', "t2"."secret", 'done', "t2"."done") AS - "__prisma_data__" FROM (SELECT "t1".* FROM "public"."Activation" AS "t1" - WHERE "t0"."id" = "t1"."userId" /* root select */) AS "t2" /* inner - select */) AS "t3" /* middle select */) AS "t4" /* outer select */) AS - "User_activations" ON true» + "__prisma_data__", "t2"."userId" FROM (SELECT "t1".* FROM + "public"."Activation" AS "t1" WHERE "t0"."id" = "t1"."userId" /* root + select */) AS "t2" /* inner select */) AS "t3" /* middle select */) AS + "t4" /* outer select */) AS "User_activations" ON true» params [] diff --git a/query-compiler/query-structure/src/order_by.rs b/query-compiler/query-structure/src/order_by.rs index c9c4a2b96d67..7d1aefc068d5 100644 --- a/query-compiler/query-structure/src/order_by.rs +++ b/query-compiler/query-structure/src/order_by.rs @@ -28,6 +28,10 @@ pub enum OrderBy { Scalar(OrderByScalar), ScalarAggregation(OrderByScalarAggregation), ToManyAggregation(OrderByToManyAggregation), + /// Order by the value of a scalar field on a to-many related model, using a correlated + /// subquery with LIMIT 1. The selected value is the first one when the relation rows are + /// sorted by the same field in the same direction. + ToManyField(OrderByToManyField), Relevance(OrderByRelevance), } @@ -36,6 +40,7 @@ impl OrderBy { match self { OrderBy::Scalar(o) => Some(&o.path), OrderBy::ToManyAggregation(o) => Some(&o.path), + OrderBy::ToManyField(o) => Some(&o.path), OrderBy::ScalarAggregation(_) => None, OrderBy::Relevance(_) => None, } @@ -46,6 +51,7 @@ impl OrderBy { OrderBy::Scalar(o) => o.sort_order, OrderBy::ScalarAggregation(o) => o.sort_order, OrderBy::ToManyAggregation(o) => o.sort_order, + OrderBy::ToManyField(o) => o.sort_order, OrderBy::Relevance(o) => o.sort_order, } } @@ -54,6 +60,7 @@ impl OrderBy { match self { OrderBy::Scalar(o) => Some(o.field.clone()), OrderBy::ScalarAggregation(o) => Some(o.field.clone()), + OrderBy::ToManyField(o) => Some(o.field.clone()), OrderBy::ToManyAggregation(_) => None, OrderBy::Relevance(_) => None, } @@ -100,6 +107,20 @@ impl OrderBy { }) } + pub fn to_many_field( + field: ScalarFieldRef, + path: Vec, + sort_order: SortOrder, + nulls_order: Option, + ) -> Self { + Self::ToManyField(OrderByToManyField { + path, + field, + sort_order, + nulls_order, + }) + } + pub fn relevance( fields: Vec, search: PrismaValue, @@ -203,6 +224,33 @@ impl OrderByToManyAggregation { } } +/// Orders by a scalar field on a to-many related model via a correlated subquery (LIMIT 1). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OrderByToManyField { + pub path: Vec, + pub field: ScalarFieldRef, + pub sort_order: SortOrder, + pub nulls_order: Option, +} + +impl OrderByToManyField { + /// All path hops except the last one (the actual to-many relation hop). + pub fn intermediary_hops(&self) -> &[OrderByHop] { + let (_, rest) = self + .path + .split_last() + .expect("An order by to-many field has to have at least one hop"); + rest + } + + /// The last hop in the path: the to-many relation being ordered by. + pub fn to_many_hop(&self) -> &OrderByHop { + self.path + .last() + .expect("An order by to-many field has to have at least one hop") + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct OrderByRelevance { pub fields: Vec, diff --git a/query-compiler/query-structure/src/record.rs b/query-compiler/query-structure/src/record.rs index cfa527d9f9bf..dc638fb6ec61 100644 --- a/query-compiler/query-structure/src/record.rs +++ b/query-compiler/query-structure/src/record.rs @@ -79,6 +79,15 @@ impl ManyRecords { } OrderBy::ScalarAggregation(_) => unimplemented!(), OrderBy::ToManyAggregation(_) => unimplemented!(), + // ToManyField ordering is always resolved by a correlated subquery in the + // database layer. In-memory sorting on this variant is not supported because + // the subquery result is not materialized in `ManyRecords`. Reaching this + // branch indicates that the query planner incorrectly routed a to-many field + // orderBy to in-memory processing. + OrderBy::ToManyField(_) => panic!( + "OrderBy::ToManyField cannot be applied in-memory: \ + to-many field ordering must be resolved by the database via a correlated subquery" + ), OrderBy::Relevance(_) => unimplemented!(), }); diff --git a/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs b/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs index a668441b536c..b92fca08ddf2 100644 --- a/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs +++ b/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs @@ -115,7 +115,7 @@ fn orderby_field_mapper<'a>( // To-many relation field. ModelField::Relation(rf) if rf.is_list() && options.include_relations => { let related_model = rf.related_model(); - let to_many_aggregate_type = order_by_to_many_aggregate_object_type(&related_model.into()); + let to_many_aggregate_type = order_by_to_many_aggregate_object_type(&related_model.into(), ctx); Some(simple_input_field(rf.name().to_owned(), InputType::object(to_many_aggregate_type), None).optional()) } @@ -141,7 +141,7 @@ fn orderby_field_mapper<'a>( // Composite field. ModelField::Composite(cf) if cf.is_list() => { - let to_many_aggregate_type = order_by_to_many_aggregate_object_type(&(cf.typ()).into()); + let to_many_aggregate_type = order_by_to_many_aggregate_object_type(&(cf.typ()).into(), ctx); Some(simple_input_field(cf.name().to_owned(), InputType::object(to_many_aggregate_type), None).optional()) } @@ -216,14 +216,44 @@ fn order_by_object_type_aggregate<'a>( input_object } -fn order_by_to_many_aggregate_object_type<'a>(container: &ParentContainer) -> InputObjectType<'a> { - let ident = Identifier::new_prisma(IdentifierType::OrderByToManyAggregateInput(container.clone())); +fn order_by_to_many_aggregate_object_type<'a>(container: &ParentContainer, ctx: &'a QuerySchema) -> InputObjectType<'a> { + // For model relations we expose both _count and individual scalar fields under a + // dedicated identifier so that aggregate-only consumers (composite types) continue + // to receive the pure-aggregate OrderByToManyAggregateInput type. + let ident = match container { + ParentContainer::Model(_) => { + Identifier::new_prisma(IdentifierType::OrderByToManyScalarFieldsInput(container.clone())) + } + _ => Identifier::new_prisma(IdentifierType::OrderByToManyAggregateInput(container.clone())), + }; let mut input_object = init_input_object_type(ident); input_object.set_container(container.clone()); input_object.require_exactly_one_field(); - input_object.set_fields(|| { + + let container = container.clone(); + input_object.set_fields(move || { let sort_order_enum = InputType::Enum(sort_order_enum()); - vec![simple_input_field(aggregations::UNDERSCORE_COUNT, sort_order_enum, None).optional()] + let mut fields = vec![simple_input_field(aggregations::UNDERSCORE_COUNT, sort_order_enum.clone(), None).optional()]; + + // For model relations (not composite types), expose individual scalar fields so callers can + // order by a specific field value rather than just a count. Each field uses a correlated + // subquery (LIMIT 1) at query compile time. + if let ParentContainer::Model(_) = &container { + for field in container.fields() { + if let ModelField::Scalar(sf) = &field { + let mut types = vec![sort_order_enum.clone()]; + // The correlated subquery can always return NULL (when the parent has no + // related records), so we always expose SortOrderInput (nulls ordering) + // irrespective of whether the field itself is nullable. + if ctx.has_capability(ConnectorCapability::OrderByNullsFirstLast) && !sf.is_list() { + types.push(InputType::object(sort_nulls_object_type())); + } + fields.push(input_field(sf.name().to_owned(), types, None).optional()); + } + } + } + + fields }); input_object } diff --git a/query-compiler/schema/src/identifier_type.rs b/query-compiler/schema/src/identifier_type.rs index d05e6002b1c1..b2c2a7b566ff 100644 --- a/query-compiler/schema/src/identifier_type.rs +++ b/query-compiler/schema/src/identifier_type.rs @@ -34,6 +34,11 @@ pub enum IdentifierType { OrderByRelevanceFieldEnum(ParentContainer), OrderByRelevanceInput(ParentContainer), OrderByToManyAggregateInput(ParentContainer), + /// Used for the combined orderBy input on to-many model relations, which exposes both + /// the aggregate `_count` field and individual scalar fields (ordered by correlated + /// subquery). Kept separate from `OrderByToManyAggregateInput` so aggregate-only + /// consumers (e.g. composite types) keep the original pure-aggregate type. + OrderByToManyScalarFieldsInput(ParentContainer), RelationCreateInput(RelationField, RelationField, bool), RelationLoadStrategy, RelationUpdateInput(RelationField, RelationField, bool), @@ -88,6 +93,9 @@ impl std::fmt::Display for IdentifierType { write!(f, "{}OrderBy{}AggregateInput", container.name(), container_type) } + IdentifierType::OrderByToManyScalarFieldsInput(container) => { + write!(f, "{}OrderByRelationInput", container.name()) + } IdentifierType::OrderByRelevanceInput(container) => { write!(f, "{}OrderByRelevanceInput", container.name()) } From 0bde1ce6187802b4c2fccc40a1621f1469213957 Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sat, 18 Apr 2026 20:41:56 -0300 Subject: [PATCH 2/7] refactor(schema): skip non-orderable scalar types in to-many orderBy input --- .../schema/src/build/input_types/objects/order_by_objects.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs b/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs index b92fca08ddf2..8e178ddaacb9 100644 --- a/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs +++ b/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs @@ -241,6 +241,11 @@ fn order_by_to_many_aggregate_object_type<'a>(container: &ParentContainer, ctx: if let ParentContainer::Model(_) = &container { for field in container.fields() { if let ModelField::Scalar(sf) = &field { + // Skip non-orderable types (Json, Bytes, Unsupported) — they have + // no meaningful SQL ordering semantics. + if matches!(sf.type_identifier(), TypeIdentifier::Json | TypeIdentifier::Bytes | TypeIdentifier::Unsupported) { + continue; + } let mut types = vec![sort_order_enum.clone()]; // The correlated subquery can always return NULL (when the parent has no // related records), so we always expose SortOrderInput (nulls ordering) From dbee15c2ba14b4fde259c2e21c8e5922502e35b0 Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sat, 18 Apr 2026 20:59:34 -0300 Subject: [PATCH 3/7] fix(schema): preserve OrderByRelationAggregateInput name, honor nulls_order in cursor, rename helper --- .../sql-query-builder/src/cursor_condition.rs | 49 +++++++++++++++---- .../input_types/objects/order_by_objects.rs | 16 ++---- query-compiler/schema/src/identifier_type.rs | 8 --- 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs index e187c83e53ec..c6022ff5320d 100644 --- a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs +++ b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs @@ -19,6 +19,9 @@ struct CursorOrderDefinition { pub(crate) order_fks: Option>, /// Indicates whether the ordering is performed on nullable field(s) pub(crate) on_nullable_fields: bool, + /// Explicit nulls placement (NULLS FIRST / LAST). When set, cursor NULL + /// predicates are only emitted when NULLs fall on the paginated side. + pub(crate) nulls_order: Option, } #[derive(Debug)] @@ -358,10 +361,21 @@ fn map_orderby_condition( // If we have null values in the ordering or comparison row, those are automatically included because we can't make a // statement over their order relative to the cursor. let order_expr = if order_definition.on_nullable_fields { - order_expr - .or(cloned_order_column.is_null()) - .or(Expression::from(cloned_cmp_column).is_null()) - .into() + // When an explicit nulls_order is provided, only include NULLs + // when they fall on the side we are paginating toward. + let include_nulls = match order_definition.nulls_order { + Some(NullsOrder::First) => reverse, // NULLs at start → include when going backward + Some(NullsOrder::Last) => !reverse, // NULLs at end → include when going forward + None => true, // No explicit placement → conservative inclusion + }; + if include_nulls { + order_expr + .or(cloned_order_column.is_null()) + .or(Expression::from(cloned_cmp_column).is_null()) + .into() + } else { + order_expr + } } else { order_expr }; @@ -396,12 +410,21 @@ fn map_equality_condition( // If we have null values in the ordering or comparison row, those are automatically included because we can't make a // statement over their order relative to the cursor. if order_definition.on_nullable_fields { - order_column - .clone() - .equals(cmp_column.clone()) - .or(Expression::from(cmp_column).is_null()) - .or(order_column.is_null()) - .into() + let include_nulls = match order_definition.nulls_order { + Some(NullsOrder::First) => false, // NULLs at start → equality never matches + Some(NullsOrder::Last) => false, // NULLs at end → equality never matches + None => true, // No explicit placement → conservative inclusion + }; + if include_nulls { + order_column + .clone() + .equals(cmp_column.clone()) + .or(Expression::from(cmp_column).is_null()) + .or(order_column.is_null()) + .into() + } else { + order_column.equals(cmp_column).into() + } } else { order_column.equals(cmp_column).into() } @@ -428,6 +451,7 @@ fn order_definitions( order_column: f.as_column(ctx).into(), order_fks: None, on_nullable_fields: !f.is_required(), + nulls_order: None, }) .collect(); } @@ -459,6 +483,7 @@ fn cursor_order_def_scalar(order_by: &OrderByScalar, order_by_def: &OrderByDefin order_column: order_by_def.order_column.clone(), order_fks: fks, on_nullable_fields: !order_by.field.is_required(), + nulls_order: None, } } @@ -478,6 +503,7 @@ fn cursor_order_def_aggregation_scalar( order_column: order_column.clone(), order_fks: None, on_nullable_fields: false, + nulls_order: None, } } @@ -501,6 +527,7 @@ fn cursor_order_def_aggregation_rel( order_column: order_column.clone(), order_fks: fks, on_nullable_fields: false, + nulls_order: None, } } @@ -513,6 +540,7 @@ fn cursor_order_def_relevance(order_by: &OrderByRelevance, order_by_def: &OrderB order_column: order_column.clone(), order_fks: None, on_nullable_fields: false, + nulls_order: None, } } @@ -533,6 +561,7 @@ fn cursor_order_def_to_many_field( order_column: order_by_def.order_column.clone(), order_fks: None, on_nullable_fields: true, + nulls_order: order_by.nulls_order.clone(), } } diff --git a/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs b/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs index 8e178ddaacb9..4039e4a7cb0a 100644 --- a/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs +++ b/query-compiler/schema/src/build/input_types/objects/order_by_objects.rs @@ -115,7 +115,7 @@ fn orderby_field_mapper<'a>( // To-many relation field. ModelField::Relation(rf) if rf.is_list() && options.include_relations => { let related_model = rf.related_model(); - let to_many_aggregate_type = order_by_to_many_aggregate_object_type(&related_model.into(), ctx); + let to_many_aggregate_type = order_by_to_many_object_type(&related_model.into(), ctx); Some(simple_input_field(rf.name().to_owned(), InputType::object(to_many_aggregate_type), None).optional()) } @@ -141,7 +141,7 @@ fn orderby_field_mapper<'a>( // Composite field. ModelField::Composite(cf) if cf.is_list() => { - let to_many_aggregate_type = order_by_to_many_aggregate_object_type(&(cf.typ()).into(), ctx); + let to_many_aggregate_type = order_by_to_many_object_type(&(cf.typ()).into(), ctx); Some(simple_input_field(cf.name().to_owned(), InputType::object(to_many_aggregate_type), None).optional()) } @@ -216,16 +216,8 @@ fn order_by_object_type_aggregate<'a>( input_object } -fn order_by_to_many_aggregate_object_type<'a>(container: &ParentContainer, ctx: &'a QuerySchema) -> InputObjectType<'a> { - // For model relations we expose both _count and individual scalar fields under a - // dedicated identifier so that aggregate-only consumers (composite types) continue - // to receive the pure-aggregate OrderByToManyAggregateInput type. - let ident = match container { - ParentContainer::Model(_) => { - Identifier::new_prisma(IdentifierType::OrderByToManyScalarFieldsInput(container.clone())) - } - _ => Identifier::new_prisma(IdentifierType::OrderByToManyAggregateInput(container.clone())), - }; +fn order_by_to_many_object_type<'a>(container: &ParentContainer, ctx: &'a QuerySchema) -> InputObjectType<'a> { + let ident = Identifier::new_prisma(IdentifierType::OrderByToManyAggregateInput(container.clone())); let mut input_object = init_input_object_type(ident); input_object.set_container(container.clone()); input_object.require_exactly_one_field(); diff --git a/query-compiler/schema/src/identifier_type.rs b/query-compiler/schema/src/identifier_type.rs index b2c2a7b566ff..d05e6002b1c1 100644 --- a/query-compiler/schema/src/identifier_type.rs +++ b/query-compiler/schema/src/identifier_type.rs @@ -34,11 +34,6 @@ pub enum IdentifierType { OrderByRelevanceFieldEnum(ParentContainer), OrderByRelevanceInput(ParentContainer), OrderByToManyAggregateInput(ParentContainer), - /// Used for the combined orderBy input on to-many model relations, which exposes both - /// the aggregate `_count` field and individual scalar fields (ordered by correlated - /// subquery). Kept separate from `OrderByToManyAggregateInput` so aggregate-only - /// consumers (e.g. composite types) keep the original pure-aggregate type. - OrderByToManyScalarFieldsInput(ParentContainer), RelationCreateInput(RelationField, RelationField, bool), RelationLoadStrategy, RelationUpdateInput(RelationField, RelationField, bool), @@ -93,9 +88,6 @@ impl std::fmt::Display for IdentifierType { write!(f, "{}OrderBy{}AggregateInput", container.name(), container_type) } - IdentifierType::OrderByToManyScalarFieldsInput(container) => { - write!(f, "{}OrderByRelationInput", container.name()) - } IdentifierType::OrderByRelevanceInput(container) => { write!(f, "{}OrderByRelevanceInput", container.name()) } From cc80d1b44f6e6220acff926c5d7c4e57e138b671 Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sun, 19 Apr 2026 05:37:58 -0300 Subject: [PATCH 4/7] fix(cursor): handle row-null/cursor-null cases separately and propagate scalar nulls_order --- .../sql-query-builder/src/cursor_condition.rs | 96 +++++++++++++------ 1 file changed, 67 insertions(+), 29 deletions(-) diff --git a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs index c6022ff5320d..5ef3a1699713 100644 --- a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs +++ b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs @@ -361,20 +361,52 @@ fn map_orderby_condition( // If we have null values in the ordering or comparison row, those are automatically included because we can't make a // statement over their order relative to the cursor. let order_expr = if order_definition.on_nullable_fields { - // When an explicit nulls_order is provided, only include NULLs - // when they fall on the side we are paginating toward. - let include_nulls = match order_definition.nulls_order { - Some(NullsOrder::First) => reverse, // NULLs at start → include when going backward - Some(NullsOrder::Last) => !reverse, // NULLs at end → include when going forward - None => true, // No explicit placement → conservative inclusion - }; - if include_nulls { - order_expr - .or(cloned_order_column.is_null()) - .or(Expression::from(cloned_cmp_column).is_null()) - .into() - } else { - order_expr + match order_definition.nulls_order { + Some(ref nulls_order) => { + let include_nulls = match nulls_order { + NullsOrder::First => reverse, + NullsOrder::Last => !reverse, + }; + + // Handle row-null and cursor-null cases separately to avoid + // a NULL cursor value matching every candidate row: + // 1. row NULL, cursor non-NULL → include when NULLs are on the paginated side + // 2. row non-NULL, cursor NULL → include when non-NULLs are on the paginated side + // 3. both NULL → treat as equal (include for lenient comparisons) + let row_null_cursor_not: Expression<'static> = cloned_order_column + .clone() + .is_null() + .and(Expression::from(cloned_cmp_column.clone()).is_not_null()) + .into(); + let row_not_cursor_null: Expression<'static> = cloned_order_column + .clone() + .is_not_null() + .and(Expression::from(cloned_cmp_column.clone()).is_null()) + .into(); + let both_null: Expression<'static> = cloned_order_column + .is_null() + .and(Expression::from(cloned_cmp_column).is_null()) + .into(); + + let mut result: Expression<'static> = order_expr; + if include_nulls { + result = result.or(row_null_cursor_not).into(); + } + if !include_nulls { + result = result.or(row_not_cursor_null).into(); + } + if include_eq { + result = result.or(both_null).into(); + } + result + } + None => { + // No explicit placement → conservative inclusion + order_expr + .or(cloned_order_column.is_null()) + .or(Expression::from(cloned_cmp_column).is_null()) + .into() + } } } else { order_expr @@ -410,20 +442,26 @@ fn map_equality_condition( // If we have null values in the ordering or comparison row, those are automatically included because we can't make a // statement over their order relative to the cursor. if order_definition.on_nullable_fields { - let include_nulls = match order_definition.nulls_order { - Some(NullsOrder::First) => false, // NULLs at start → equality never matches - Some(NullsOrder::Last) => false, // NULLs at end → equality never matches - None => true, // No explicit placement → conservative inclusion - }; - if include_nulls { - order_column - .clone() - .equals(cmp_column.clone()) - .or(Expression::from(cmp_column).is_null()) - .or(order_column.is_null()) - .into() - } else { - order_column.equals(cmp_column).into() + match order_definition.nulls_order { + Some(_) => { + // For prefix equality with explicit nulls placement, NULL = NULL + // must match so multi-field cursor pagination works inside the + // NULL group. + order_column + .clone() + .equals(cmp_column.clone()) + .or(order_column.is_null().and(Expression::from(cmp_column).is_null())) + .into() + } + None => { + // No explicit placement → conservative inclusion + order_column + .clone() + .equals(cmp_column.clone()) + .or(Expression::from(cmp_column).is_null()) + .or(order_column.is_null()) + .into() + } } } else { order_column.equals(cmp_column).into() @@ -483,7 +521,7 @@ fn cursor_order_def_scalar(order_by: &OrderByScalar, order_by_def: &OrderByDefin order_column: order_by_def.order_column.clone(), order_fks: fks, on_nullable_fields: !order_by.field.is_required(), - nulls_order: None, + nulls_order: order_by.nulls_order.clone(), } } From 43508ef27c17cfb31e6639a5b9dec3de6f162d8f Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sun, 19 Apr 2026 05:50:05 -0300 Subject: [PATCH 5/7] fix(cursor): respect nulls_order in FK IS NULL predicates --- .../sql-query-builder/src/cursor_condition.rs | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs index 5ef3a1699713..23892d037255 100644 --- a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs +++ b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs @@ -412,21 +412,33 @@ fn map_orderby_condition( order_expr }; - // Add OR statements for the foreign key fields too if they are nullable + // Add OR statements for the foreign key fields too if they are nullable. + // When an explicit nulls_order is set, only include FK IS NULL when NULLs + // fall on the paginated side; otherwise, skip the predicate. if let Some(fks) = &order_definition.order_fks { - fks.iter() - .filter(|fk| !fk.field.is_required()) - .fold(order_expr, |acc, fk| { - let col = if let Some(alias) = &fk.alias { - Column::from((alias.to_owned(), fk.field.db_name().to_owned())) - } else { - fk.field.as_column(ctx) - } - .is_null(); + let include_fk_nulls = match order_definition.nulls_order { + Some(NullsOrder::First) => reverse, + Some(NullsOrder::Last) => !reverse, + None => true, + }; + + if include_fk_nulls { + fks.iter() + .filter(|fk| !fk.field.is_required()) + .fold(order_expr, |acc, fk| { + let col = if let Some(alias) = &fk.alias { + Column::from((alias.to_owned(), fk.field.db_name().to_owned())) + } else { + fk.field.as_column(ctx) + } + .is_null(); - acc.or(col).into() - }) + acc.or(col).into() + }) + } else { + order_expr + } } else { order_expr } From e47faf2a3d3e9017e548827a269f5580ab623ab8 Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sun, 19 Apr 2026 11:52:19 -0300 Subject: [PATCH 6/7] fix(cursor): set on_nullable_fields=true when FK hops are optional --- .../sql-query-builder/src/cursor_condition.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs index 23892d037255..b59c17a16249 100644 --- a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs +++ b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs @@ -528,11 +528,17 @@ fn cursor_order_def_scalar(order_by: &OrderByScalar, order_by_def: &OrderByDefin // cf: part #2 of the SQL query above, when a field is nullable. let fks = foreign_keys_from_order_path(&order_by.path, &order_by_def.joins); + // The ordering column can be NULL either because the leaf field itself is nullable, + // or because an optional relation hop makes the subquery return NULL. + let has_nullable_fks = fks + .as_ref() + .is_some_and(|fks| fks.iter().any(|fk| !fk.field.is_required())); + CursorOrderDefinition { sort_order: order_by.sort_order, order_column: order_by_def.order_column.clone(), order_fks: fks, - on_nullable_fields: !order_by.field.is_required(), + on_nullable_fields: !order_by.field.is_required() || has_nullable_fks, nulls_order: order_by.nulls_order.clone(), } } From 9cefb1f37c71e99e9c3b5fb6b5eba03c1afd7413 Mon Sep 17 00:00:00 2001 From: DevFigueiredo Date: Sun, 19 Apr 2026 12:31:14 -0300 Subject: [PATCH 7/7] refactor(cursor): use split-case NULL logic in None branches, lazy-allocate expressions --- .../sql-query-builder/src/cursor_condition.rs | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs index b59c17a16249..e9157fc5a687 100644 --- a/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs +++ b/query-compiler/query-builders/sql-query-builder/src/cursor_condition.rs @@ -373,40 +373,59 @@ fn map_orderby_condition( // 1. row NULL, cursor non-NULL → include when NULLs are on the paginated side // 2. row non-NULL, cursor NULL → include when non-NULLs are on the paginated side // 3. both NULL → treat as equal (include for lenient comparisons) + let mut result: Expression<'static> = order_expr; + if include_nulls { + let row_null_cursor_not: Expression<'static> = cloned_order_column + .clone() + .is_null() + .and(Expression::from(cloned_cmp_column.clone()).is_not_null()) + .into(); + result = result.or(row_null_cursor_not).into(); + } + if !include_nulls { + let row_not_cursor_null: Expression<'static> = cloned_order_column + .clone() + .is_not_null() + .and(Expression::from(cloned_cmp_column.clone()).is_null()) + .into(); + result = result.or(row_not_cursor_null).into(); + } + if include_eq { + let both_null: Expression<'static> = cloned_order_column + .is_null() + .and(Expression::from(cloned_cmp_column).is_null()) + .into(); + result = result.or(both_null).into(); + } + result + } + None => { + // No explicit placement → conservative: include rows where + // either side is NULL, but use the split-case pattern to avoid + // a NULL cursor value universally matching all candidate rows. + let mut result: Expression<'static> = order_expr; + // row NULL, cursor non-NULL let row_null_cursor_not: Expression<'static> = cloned_order_column .clone() .is_null() .and(Expression::from(cloned_cmp_column.clone()).is_not_null()) .into(); + result = result.or(row_null_cursor_not).into(); + // row non-NULL, cursor NULL let row_not_cursor_null: Expression<'static> = cloned_order_column .clone() .is_not_null() .and(Expression::from(cloned_cmp_column.clone()).is_null()) .into(); + result = result.or(row_not_cursor_null).into(); + // both NULL → treat as equal let both_null: Expression<'static> = cloned_order_column .is_null() .and(Expression::from(cloned_cmp_column).is_null()) .into(); - - let mut result: Expression<'static> = order_expr; - if include_nulls { - result = result.or(row_null_cursor_not).into(); - } - if !include_nulls { - result = result.or(row_not_cursor_null).into(); - } - if include_eq { - result = result.or(both_null).into(); - } + result = result.or(both_null).into(); result } - None => { - // No explicit placement → conservative inclusion - order_expr - .or(cloned_order_column.is_null()) - .or(Expression::from(cloned_cmp_column).is_null()) - .into() - } } } else { order_expr @@ -466,12 +485,12 @@ fn map_equality_condition( .into() } None => { - // No explicit placement → conservative inclusion + // No explicit placement → NULL = NULL must match for prefix + // equality (same as Some branch), but avoid blanket cmp IS NULL. order_column .clone() .equals(cmp_column.clone()) - .or(Expression::from(cmp_column).is_null()) - .or(order_column.is_null()) + .or(order_column.is_null().and(Expression::from(cmp_column).is_null())) .into() } }