feat: add sorting on relation fields (Many-to-One) (#17021)
## Summary
This PR enables sorting records by fields of related objects. For
example, sorting **People by their Company's name**.
### Before
Only scalar and composite fields could be sorted. Relation fields showed
in the sort dropdown but produced errors.
### After
Many-to-One relation fields can now be sorted using the related object's
**label identifier field** (e.g., Company's `name`).
---
## Changes
### Frontend
- Added `RELATION` to sortable field types (restricted to `MANY_TO_ONE`
relations)
- New `getOrderByForRelationField()` generates nested orderBy structures
using the related object's label identifier
- Updated `turnSortsIntoOrderBy()` to handle relation fields by looking
up related object metadata
### Backend
- Extended `GraphqlQueryOrderFieldParser.parse()` to detect nested
relation ordering like `{ company: { name: 'AscNullsLast' } }`
- Returns `ParseOrderByResult` containing both `orderBy` conditions and
`relationJoins` info
- Added LEFT JOINs for relation ordering in `applyOrderToBuilder()`
- Added `addRelationOrderColumnsToBuilder()` for TypeORM DISTINCT
compatibility
### Tests
- Added unit tests for `filterSortableFieldMetadataItems`,
`getOrderByForRelationField`, and `turnSortsIntoOrderBy`
- Added integration tests covering ascending/descending order and
composite label identifiers
---
## TypeORM Bug Workaround
We encountered a significant TypeORM limitation when implementing this
feature. When using `getMany()` with `ORDER BY` on joined relation
columns, TypeORM generates a DISTINCT subquery that has specific
requirements:
### Issue 1: Alias Parsing
TypeORM's `orderBy()` method fails with **"alias not found"** when using
quoted SQL identifiers like `"company"."name"`. TypeORM internally
expects unquoted property paths (e.g., `company.name`) for its alias
resolution mechanism.
### Issue 2: setFindOptions Clears addSelect
`setFindOptions({ select })` **clears any previously added `addSelect()`
columns**. This caused `"column distinctAlias.company_name does not
exist"` errors because the relation columns needed for ORDER BY were
being removed.
### Solution
We split the logic into two methods:
1. `applyOrderToBuilder()` - adds JOINs and ORDER BY (before
`setFindOptions`)
2. `addRelationOrderColumnsToBuilder()` - adds relation columns for
SELECT (AFTER `setFindOptions`)
This ensures the relation columns are present in the final SQL query's
SELECT clause with the proper underscore aliases (`company_name`) that
TypeORM's DISTINCT subquery expects.
**Related TypeORM issue**:
https://github.com/typeorm/typeorm/issues/9921
---
## Screenshots/Demo
_Add screenshots if applicable_
This commit is contained in:
+8
-1
@@ -129,7 +129,11 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
args.selectedFields,
|
||||
);
|
||||
|
||||
this.validateQueryComplexity(selectedFieldsResult, args);
|
||||
this.validateQueryComplexity(
|
||||
selectedFieldsResult,
|
||||
args,
|
||||
queryRunnerContext,
|
||||
);
|
||||
|
||||
const processedArgs = {
|
||||
...(await this.processArgs(args, queryRunnerContext, this.operationName)),
|
||||
@@ -174,6 +178,7 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
protected computeQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
_args: CommonInput<Args>,
|
||||
_queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): number {
|
||||
const simpleFieldsComplexity = 1;
|
||||
const selectedFieldsComplexity =
|
||||
@@ -406,6 +411,7 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
private validateQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
args: CommonInput<Args>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
) {
|
||||
const maximumComplexity = this.twentyConfigService.get(
|
||||
'COMMON_QUERY_COMPLEXITY_LIMIT',
|
||||
@@ -424,6 +430,7 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
const queryComplexity = this.computeQueryComplexity(
|
||||
selectedFieldsResult,
|
||||
args,
|
||||
queryRunnerContext,
|
||||
);
|
||||
|
||||
if (queryComplexity > maximumComplexity) {
|
||||
|
||||
+68
-7
@@ -29,13 +29,23 @@ import {
|
||||
CommonQueryNames,
|
||||
FindManyQueryArgs,
|
||||
} from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { getPageInfo } from 'src/engine/api/common/utils/get-page-info.util';
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
|
||||
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
|
||||
import { getCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
|
||||
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
|
||||
import {
|
||||
countRelationFieldsInOrderBy,
|
||||
hasRelationFieldInOrderBy,
|
||||
} from 'src/engine/api/utils/validate-and-get-order-by.utils';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
@Injectable()
|
||||
@@ -90,6 +100,25 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
const cursor = getCursor(args);
|
||||
|
||||
if (cursor) {
|
||||
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
|
||||
if (
|
||||
hasRelationFieldInOrderBy(
|
||||
args.orderBy ?? [],
|
||||
flatFieldMetadataMaps,
|
||||
fieldIdByName,
|
||||
)
|
||||
) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'Cursor-based pagination is not supported with relation field ordering. Use offset pagination instead.',
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const cursorArgFilter = computeCursorArgFilter(
|
||||
cursor,
|
||||
orderByWithIdCondition,
|
||||
@@ -111,7 +140,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
appliedFilters,
|
||||
);
|
||||
|
||||
commonQueryParser.applyOrderToBuilder(
|
||||
const parsedOrderBy = commonQueryParser.applyOrderToBuilder(
|
||||
queryBuilder,
|
||||
orderByWithIdCondition,
|
||||
flatObjectMetadata.nameSingular,
|
||||
@@ -140,12 +169,17 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
queryBuilder.skip(args.offset);
|
||||
}
|
||||
|
||||
const objectRecords = (await queryBuilder
|
||||
.setFindOptions({
|
||||
select: columnsToSelect,
|
||||
})
|
||||
.take(limit + 1)
|
||||
.getMany()) as ObjectRecord[];
|
||||
queryBuilder.setFindOptions({ select: columnsToSelect });
|
||||
queryBuilder.take(limit + 1);
|
||||
|
||||
// Add relation order columns AFTER setFindOptions (setFindOptions clears addSelect)
|
||||
commonQueryParser.addRelationOrderColumnsToBuilder(
|
||||
queryBuilder,
|
||||
parsedOrderBy,
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
const objectRecords = (await queryBuilder.getMany()) as ObjectRecord[];
|
||||
|
||||
const pageInfo = getPageInfo(
|
||||
objectRecords,
|
||||
@@ -275,4 +309,31 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected override computeQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
args: CommonInput<FindManyQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): number {
|
||||
const baseComplexity = super.computeQueryComplexity(
|
||||
selectedFieldsResult,
|
||||
args,
|
||||
queryRunnerContext,
|
||||
);
|
||||
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
|
||||
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
|
||||
const orderByRelationCount = countRelationFieldsInOrderBy(
|
||||
args.orderBy ?? [],
|
||||
flatFieldMetadataMaps,
|
||||
fieldIdByName,
|
||||
);
|
||||
|
||||
return baseComplexity + orderByRelationCount;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -410,6 +410,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
protected override computeQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
args: CommonInput<GroupByQueryArgs>,
|
||||
_queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): number {
|
||||
const groupByQueryComplexity = 1;
|
||||
const simpleFieldsComplexity = 1;
|
||||
|
||||
Reference in New Issue
Block a user