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..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 @@ -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,29 +361,103 @@ 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() + 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 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(); + result = result.or(both_null).into(); + result + } + } } else { 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 } @@ -396,12 +473,27 @@ 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() + 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 → 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(order_column.is_null().and(Expression::from(cmp_column).is_null())) + .into() + } + } } else { order_column.equals(cmp_column).into() } @@ -428,6 +520,7 @@ fn order_definitions( order_column: f.as_column(ctx).into(), order_fks: None, on_nullable_fields: !f.is_required(), + nulls_order: None, }) .collect(); } @@ -442,6 +535,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() } @@ -453,11 +547,18 @@ 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(), } } @@ -477,6 +578,7 @@ fn cursor_order_def_aggregation_scalar( order_column: order_column.clone(), order_fks: None, on_nullable_fields: false, + nulls_order: None, } } @@ -500,6 +602,7 @@ fn cursor_order_def_aggregation_rel( order_column: order_column.clone(), order_fks: fks, on_nullable_fields: false, + nulls_order: None, } } @@ -512,6 +615,28 @@ 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, + } +} + +/// 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, + nulls_order: order_by.nulls_order.clone(), } } 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..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()); + 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()); + 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,14 +216,41 @@ fn order_by_object_type_aggregate<'a>( input_object } -fn order_by_to_many_aggregate_object_type<'a>(container: &ParentContainer) -> InputObjectType<'a> { +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(); - 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 { + // 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) + // 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 }