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:
Félix Malfait
2026-01-10 11:02:16 +01:00
committed by GitHub
parent 5a33b36b85
commit 1a5675d63e
30 changed files with 2011 additions and 688 deletions
@@ -1,5 +1,13 @@
import { type OrderBy } from '@/types/OrderBy';
// Recursive type for nested orderBy values
// Supports: OrderBy | { field: OrderBy } | { field: { subField: OrderBy } } | ...
type OrderByValue = OrderBy | { [fieldName: string]: OrderByValue };
// Supports nested ordering for:
// - Scalar fields: { field: OrderBy }
// - Composite fields: { field: { subField: OrderBy } }
// - Relation + composite: { relation: { compositeLabel: { subField: OrderBy } } }
export type RecordGqlOperationOrderBy = Array<{
[fieldName: string]: OrderBy | { [subFieldName: string]: OrderBy };
[fieldName: string]: OrderByValue;
}>;
@@ -6,6 +6,7 @@ import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { type EachTestingContext } from 'twenty-shared/testing';
import {
FieldMetadataType,
RelationType,
ViewSortDirection,
} from '~/generated-metadata/graphql';
@@ -165,4 +166,144 @@ describe('turnSortsIntoOrderBy', () => {
).toEqual(expected);
},
);
describe('relation field sorting', () => {
const companyObjectMetadataItem: ObjectMetadataItem = {
id: 'company-object-id',
fields: [
{
id: 'company-name-field-id',
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
isActive: true,
} as FieldMetadataItem,
],
readableFields: [],
updatableFields: [],
indexMetadatas: [],
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
nameSingular: 'company',
namePlural: 'companies',
labelIdentifierFieldMetadataId: 'company-name-field-id',
icon: 'IconBuildingSkyscraper',
isActive: true,
isSystem: false,
isUIReadOnly: false,
isCustom: false,
isRemote: false,
isSearchable: false,
labelPlural: 'Companies',
labelSingular: 'Company',
isLabelSyncedWithName: true,
};
const personObjectMetadataItem: ObjectMetadataItem = {
id: 'person-object-id',
fields: [
{
id: 'company-relation-field-id',
name: 'company',
type: FieldMetadataType.RELATION,
label: 'Company',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
isActive: true,
relation: {
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
nameSingular: 'company',
},
},
} as unknown as FieldMetadataItem,
{
id: 'position-field-id',
name: 'position',
type: FieldMetadataType.POSITION,
label: 'Position',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
} as FieldMetadataItem,
],
readableFields: [],
updatableFields: [],
indexMetadatas: [],
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
nameSingular: 'person',
namePlural: 'people',
labelIdentifierFieldMetadataId: 'person-name-field-id',
icon: 'IconUser',
isActive: true,
isSystem: false,
isUIReadOnly: false,
isCustom: false,
isRemote: false,
isSearchable: false,
labelPlural: 'People',
labelSingular: 'Person',
isLabelSyncedWithName: true,
};
it('should sort by relation field using label identifier', () => {
const sorts: RecordSort[] = [
{
id: 'sort-1',
fieldMetadataId: 'company-relation-field-id',
direction: ViewSortDirection.ASC,
},
];
const result = turnSortsIntoOrderBy(personObjectMetadataItem, sorts, [
companyObjectMetadataItem,
]);
// Should produce nested structure for GraphQL: { company: { name: 'AscNullsFirst' } }
expect(result).toEqual([
{ company: { name: 'AscNullsFirst' } },
{ position: 'AscNullsFirst' },
]);
});
it('should sort by relation field descending', () => {
const sorts: RecordSort[] = [
{
id: 'sort-1',
fieldMetadataId: 'company-relation-field-id',
direction: ViewSortDirection.DESC,
},
];
const result = turnSortsIntoOrderBy(personObjectMetadataItem, sorts, [
companyObjectMetadataItem,
]);
// Should produce nested structure for GraphQL: { company: { name: 'DescNullsLast' } }
expect(result).toEqual([
{ company: { name: 'DescNullsLast' } },
{ position: 'AscNullsFirst' },
]);
});
it('should fallback to FK when related object not found', () => {
const sorts: RecordSort[] = [
{
id: 'sort-1',
fieldMetadataId: 'company-relation-field-id',
direction: ViewSortDirection.ASC,
},
];
// Pass empty objectMetadataItems array - related object not found
const result = turnSortsIntoOrderBy(personObjectMetadataItem, sorts, []);
expect(result).toEqual([
{ companyId: 'AscNullsFirst' },
{ position: 'AscNullsFirst' },
]);
});
});
});
@@ -4,20 +4,22 @@ import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/Re
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
import {
getOrderByForFieldMetadataType,
getOrderByForRelationField,
} from '@/object-metadata/utils/getOrderByForFieldMetadataType';
import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { type OrderBy } from '@/types/OrderBy';
import { isDefined } from 'twenty-shared/utils';
import { ViewSortDirection } from '~/generated/graphql';
import { FieldMetadataType, ViewSortDirection } from '~/generated/graphql';
export const turnSortsIntoOrderBy = (
objectMetadataItem: ObjectMetadataItem,
sorts: RecordSort[],
objectMetadataItems: ObjectMetadataItem[] = [],
): RecordGqlOperationOrderBy => {
const fields: Pick<FieldMetadataItem, 'id' | 'name' | 'type'>[] =
objectMetadataItem?.fields ?? [];
const fields = objectMetadataItem?.fields ?? [];
const fieldsById = mapArrayToObject(fields, ({ id }) => id);
@@ -34,6 +36,25 @@ export const turnSortsIntoOrderBy = (
? 'AscNullsFirst'
: 'DescNullsLast';
// Handle RELATION fields by looking up related object metadata
if (correspondingField.type === FieldMetadataType.RELATION) {
const relatedObjectName =
correspondingField.relation?.targetObjectMetadata?.nameSingular;
const relatedObjectMetadata = objectMetadataItems.find(
(item) => item.nameSingular === relatedObjectName,
);
if (isDefined(relatedObjectMetadata)) {
return getOrderByForRelationField(
correspondingField,
relatedObjectMetadata,
direction,
);
}
// Fallback if related object not found - sort by FK
return [{ [`${correspondingField.name}Id`]: direction }];
}
return getOrderByForFieldMetadataType(correspondingField, direction);
})
.filter(isDefined);
@@ -1,4 +1,5 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -20,6 +21,7 @@ export const useFindManyRecordIndexTableParams = (
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { recordGroupFilter } = useRecordGroupFilter(
objectMetadataItem?.fields,
@@ -58,7 +60,11 @@ export const useFindManyRecordIndexTableParams = (
filterValue: anyFieldFilterValue,
});
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts);
const orderBy = turnSortsIntoOrderBy(
objectMetadataItem,
currentRecordSorts,
objectMetadataItems,
);
return {
objectNameSingular,
@@ -1,3 +1,4 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
@@ -18,6 +19,7 @@ import {
export const useRecordIndexGroupCommonQueryVariables = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { objectMetadataItems } = useObjectMetadataItems();
const currentRecordFilterGroups = useRecoilComponentValue(
currentRecordFilterGroupsComponentState,
@@ -50,7 +52,11 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
filterValue: anyFieldFilterValue,
});
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts);
const orderBy = turnSortsIntoOrderBy(
objectMetadataItem,
currentRecordSorts,
objectMetadataItems,
);
const recordGroupFieldMetadata = useRecoilComponentValue(
recordIndexGroupFieldMetadataItemComponentState,