1a5675d63e
## 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_
125 lines
3.9 KiB
TypeScript
125 lines
3.9 KiB
TypeScript
import {
|
|
FieldMetadataType,
|
|
type ObjectRecord,
|
|
type ObjectRecordOrderByForCompositeField,
|
|
type ObjectRecordOrderByForScalarField,
|
|
OrderByDirection,
|
|
} from 'twenty-shared/types';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
|
|
|
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
|
import {
|
|
GraphqlQueryRunnerException,
|
|
GraphqlQueryRunnerExceptionCode,
|
|
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
|
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
|
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
|
|
|
const isOrderByDirection = (value: unknown): value is OrderByDirection => {
|
|
return Object.values(OrderByDirection).includes(value as OrderByDirection);
|
|
};
|
|
|
|
const isOrderByForScalarField = (
|
|
orderByLeaf: Record<string, unknown>,
|
|
key: keyof ObjectRecord,
|
|
): orderByLeaf is ObjectRecordOrderByForScalarField => {
|
|
const value = orderByLeaf[key as string];
|
|
|
|
return isDefined(value) && isOrderByDirection(value);
|
|
};
|
|
|
|
const isOrderByForCompositeField = (
|
|
orderByLeaf: Record<string, unknown>,
|
|
key: keyof ObjectRecord,
|
|
): orderByLeaf is ObjectRecordOrderByForCompositeField => {
|
|
const value = orderByLeaf[key as string];
|
|
|
|
return (
|
|
isDefined(value) &&
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
!isOrderByDirection(value) &&
|
|
Object.values(value as Record<string, unknown>).every(isOrderByDirection)
|
|
);
|
|
};
|
|
|
|
export const validateAndGetOrderByForScalarField = (
|
|
key: keyof ObjectRecord,
|
|
orderBy: ObjectRecordOrderBy,
|
|
): ObjectRecordOrderByForScalarField => {
|
|
const keyOrderBy = orderBy.find((order) => key in order);
|
|
|
|
if (!isDefined(keyOrderBy)) {
|
|
throw new GraphqlQueryRunnerException(
|
|
'Invalid cursor',
|
|
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
|
|
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
|
);
|
|
}
|
|
|
|
if (!isOrderByForScalarField(keyOrderBy, key)) {
|
|
throw new GraphqlQueryRunnerException(
|
|
'Expected non-composite field order by',
|
|
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
|
|
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
|
);
|
|
}
|
|
|
|
return keyOrderBy;
|
|
};
|
|
|
|
export const validateAndGetOrderByForCompositeField = (
|
|
key: keyof ObjectRecord,
|
|
orderBy: ObjectRecordOrderBy,
|
|
): ObjectRecordOrderByForCompositeField => {
|
|
const keyOrderBy = orderBy.find((order) => key in order);
|
|
|
|
if (!isDefined(keyOrderBy)) {
|
|
throw new GraphqlQueryRunnerException(
|
|
'Invalid cursor',
|
|
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
|
|
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
|
);
|
|
}
|
|
|
|
if (!isOrderByForCompositeField(keyOrderBy, key)) {
|
|
throw new GraphqlQueryRunnerException(
|
|
'Expected composite field order by',
|
|
GraphqlQueryRunnerExceptionCode.INVALID_CURSOR,
|
|
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
|
);
|
|
}
|
|
|
|
return keyOrderBy;
|
|
};
|
|
|
|
export const countRelationFieldsInOrderBy = (
|
|
orderBy: ObjectRecordOrderBy,
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
|
fieldIdByName: Record<string, string>,
|
|
): number => {
|
|
return orderBy.filter((orderByItem) => {
|
|
const fieldName = Object.keys(orderByItem)[0];
|
|
const fieldMetadataId = fieldIdByName[fieldName];
|
|
const fieldMetadata = flatFieldMetadataMaps.byId[fieldMetadataId];
|
|
|
|
return fieldMetadata?.type === FieldMetadataType.RELATION;
|
|
}).length;
|
|
};
|
|
|
|
export const hasRelationFieldInOrderBy = (
|
|
orderBy: ObjectRecordOrderBy,
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
|
fieldIdByName: Record<string, string>,
|
|
): boolean => {
|
|
return (
|
|
countRelationFieldsInOrderBy(
|
|
orderBy,
|
|
flatFieldMetadataMaps,
|
|
fieldIdByName,
|
|
) > 0
|
|
);
|
|
};
|