@@ -2,9 +2,10 @@ import { invariant } from '@zenstackhq/common-helpers';
22import { type AliasableExpression , type Expression , type ExpressionBuilder , type SelectQueryBuilder } from 'kysely' ;
33import type { FieldDef , GetModels , SchemaDef } from '../../../schema' ;
44import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants' ;
5- import type { FindArgs } from '../../crud-types' ;
5+ import type { FindArgs , NullsOrder , SortOrder } from '../../crud-types' ;
66import {
77 buildJoinPairs ,
8+ ensureArray ,
89 getDelegateDescendantModels ,
910 getManyToManyRelation ,
1011 isRelationField ,
@@ -23,7 +24,10 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
2324 /**
2425 * Builds an array aggregation expression.
2526 */
26- protected abstract buildArrayAgg ( arg : Expression < any > ) : AliasableExpression < any > ;
27+ protected abstract buildArrayAgg (
28+ arg : Expression < any > ,
29+ orderBy ?: { expr : Expression < any > ; sort : SortOrder ; nulls ?: NullsOrder } [ ] ,
30+ ) : AliasableExpression < any > ;
2731
2832 override buildRelationSelection (
2933 query : SelectQueryBuilder < any , any , any > ,
@@ -172,7 +176,8 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
172176 ) ;
173177
174178 if ( relationFieldDef . array ) {
175- return this . buildArrayAgg ( this . buildJsonObject ( objArgs ) ) . as ( '$data' ) ;
179+ const orderBy = this . buildRelationOrderByExpressions ( relationModel , relationModelAlias , payload ) ;
180+ return this . buildArrayAgg ( this . buildJsonObject ( objArgs ) , orderBy ) . as ( '$data' ) ;
176181 } else {
177182 return this . buildJsonObject ( objArgs ) . as ( '$data' ) ;
178183 }
@@ -181,6 +186,50 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
181186 return qb ;
182187 }
183188
189+ /**
190+ * Extracts scalar `orderBy` clauses from the relation payload and maps them to
191+ * the array-aggregation ordering format.
192+ *
193+ * For to-many relations aggregated into a JSON array (via lateral joins), this
194+ * lets us preserve a stable ordering by passing `{ expr, sort, nulls? }` into
195+ * the dialect's `buildArrayAgg` implementation.
196+ */
197+ private buildRelationOrderByExpressions (
198+ model : string ,
199+ modelAlias : string ,
200+ payload : true | FindArgs < Schema , GetModels < Schema > , any , true > ,
201+ ) : { expr : Expression < any > ; sort : SortOrder ; nulls ?: NullsOrder } [ ] | undefined {
202+ if ( payload === true || ! payload . orderBy ) {
203+ return undefined ;
204+ }
205+
206+ type ScalarSortValue = SortOrder | { sort : SortOrder ; nulls ?: NullsOrder } ;
207+ const items : { expr : Expression < any > ; sort : SortOrder ; nulls ?: NullsOrder } [ ] = [ ] ;
208+
209+ for ( const orderBy of ensureArray ( payload . orderBy ) ) {
210+ for ( const [ field , value ] of Object . entries ( orderBy ) as [ string , ScalarSortValue | undefined ] [ ] ) {
211+ if ( ! value || requireField ( this . schema , model , field ) . relation ) {
212+ continue ;
213+ }
214+
215+ const expr = this . fieldRef ( model , field , modelAlias ) ;
216+ let sort = typeof value === 'string' ? value : value . sort ;
217+ if ( payload . take !== undefined && payload . take < 0 ) {
218+ // negative `take` requires negated sorting, and the result order
219+ // will be corrected during post-read processing
220+ sort = this . negateSort ( sort , true ) ;
221+ }
222+ if ( typeof value === 'string' ) {
223+ items . push ( { expr, sort } ) ;
224+ } else {
225+ items . push ( { expr, sort, nulls : value . nulls } ) ;
226+ }
227+ }
228+ }
229+
230+ return items . length > 0 ? items : undefined ;
231+ }
232+
184233 private buildRelationObjectArgs (
185234 relationModel : string ,
186235 relationModelAlias : string ,
0 commit comments