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:
+100
@@ -0,0 +1,100 @@
|
||||
import { filterSortableFieldMetadataItems } from '@/object-metadata/utils/filterSortableFieldMetadataItems';
|
||||
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('filterSortableFieldMetadataItems', () => {
|
||||
it('should allow TEXT field type', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.TEXT,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow NUMBER field type', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.NUMBER,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow DATE_TIME field type', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow MANY_TO_ONE relation field type', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.RELATION,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
relation: {
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT allow ONE_TO_MANY relation field type', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.RELATION,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
relation: {
|
||||
type: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT allow RELATION field without relation property', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.RELATION,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT allow system fields', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.TEXT,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT allow inactive fields', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.TEXT,
|
||||
isSystem: false,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT allow unsortable field types like RICH_TEXT', () => {
|
||||
const field = {
|
||||
type: FieldMetadataType.RICH_TEXT,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(filterSortableFieldMetadataItems(field)).toBe(false);
|
||||
});
|
||||
});
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getOrderByForRelationField } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('getOrderByForRelationField', () => {
|
||||
it('should generate nested orderBy for relation with TEXT label identifier', () => {
|
||||
const field: Pick<FieldMetadataItem, 'name'> = {
|
||||
name: 'company',
|
||||
};
|
||||
|
||||
const relatedObjectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'fields' | 'labelIdentifierFieldMetadataId'
|
||||
> = {
|
||||
labelIdentifierFieldMetadataId: 'name-field-id',
|
||||
fields: [
|
||||
{
|
||||
id: 'name-field-id',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
} as FieldMetadataItem,
|
||||
],
|
||||
};
|
||||
|
||||
const result = getOrderByForRelationField(
|
||||
field,
|
||||
relatedObjectMetadataItem,
|
||||
'AscNullsLast',
|
||||
);
|
||||
|
||||
// Should produce nested structure: { company: { name: 'AscNullsLast' } }
|
||||
expect(result).toEqual([{ company: { name: 'AscNullsLast' } }]);
|
||||
});
|
||||
|
||||
it('should generate nested orderBy for relation with FULL_NAME label identifier', () => {
|
||||
const field: Pick<FieldMetadataItem, 'name'> = {
|
||||
name: 'person',
|
||||
};
|
||||
|
||||
const relatedObjectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'fields' | 'labelIdentifierFieldMetadataId'
|
||||
> = {
|
||||
labelIdentifierFieldMetadataId: 'name-field-id',
|
||||
fields: [
|
||||
{
|
||||
id: 'name-field-id',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
} as FieldMetadataItem,
|
||||
],
|
||||
};
|
||||
|
||||
const result = getOrderByForRelationField(
|
||||
field,
|
||||
relatedObjectMetadataItem,
|
||||
'DescNullsLast',
|
||||
);
|
||||
|
||||
// Should produce nested structure with composite field
|
||||
expect(result).toEqual([
|
||||
{
|
||||
person: {
|
||||
name: {
|
||||
firstName: 'DescNullsLast',
|
||||
lastName: 'DescNullsLast',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fallback to FK when no label identifier field is found', () => {
|
||||
const field: Pick<FieldMetadataItem, 'name'> = {
|
||||
name: 'company',
|
||||
};
|
||||
|
||||
const relatedObjectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'fields' | 'labelIdentifierFieldMetadataId'
|
||||
> = {
|
||||
labelIdentifierFieldMetadataId: 'non-existent-field-id',
|
||||
fields: [],
|
||||
};
|
||||
|
||||
const result = getOrderByForRelationField(
|
||||
field,
|
||||
relatedObjectMetadataItem,
|
||||
'AscNullsLast',
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ companyId: 'AscNullsLast' }]);
|
||||
});
|
||||
|
||||
it('should use default "name" field when labelIdentifierFieldMetadataId is not set', () => {
|
||||
const field: Pick<FieldMetadataItem, 'name'> = {
|
||||
name: 'company',
|
||||
};
|
||||
|
||||
const relatedObjectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'fields' | 'labelIdentifierFieldMetadataId'
|
||||
> = {
|
||||
labelIdentifierFieldMetadataId: undefined as unknown as string,
|
||||
fields: [
|
||||
{
|
||||
id: 'name-field-id',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
} as FieldMetadataItem,
|
||||
],
|
||||
};
|
||||
|
||||
const result = getOrderByForRelationField(
|
||||
field,
|
||||
relatedObjectMetadataItem,
|
||||
'AscNullsLast',
|
||||
);
|
||||
|
||||
// When labelIdentifierFieldMetadataId is not set, isLabelIdentifierField
|
||||
// falls back to checking for a field named 'name'
|
||||
expect(result).toEqual([{ company: { name: 'AscNullsLast' } }]);
|
||||
});
|
||||
|
||||
it('should handle descending direction', () => {
|
||||
const field: Pick<FieldMetadataItem, 'name'> = {
|
||||
name: 'company',
|
||||
};
|
||||
|
||||
const relatedObjectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'fields' | 'labelIdentifierFieldMetadataId'
|
||||
> = {
|
||||
labelIdentifierFieldMetadataId: 'name-field-id',
|
||||
fields: [
|
||||
{
|
||||
id: 'name-field-id',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
} as FieldMetadataItem,
|
||||
],
|
||||
};
|
||||
|
||||
const result = getOrderByForRelationField(
|
||||
field,
|
||||
relatedObjectMetadataItem,
|
||||
'DescNullsLast',
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ company: { name: 'DescNullsLast' } }]);
|
||||
});
|
||||
});
|
||||
+18
-3
@@ -1,7 +1,14 @@
|
||||
import { SORTABLE_FIELD_METADATA_TYPES } from '@/object-metadata/constants/SortableFieldMetadataTypes';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const filterSortableFieldMetadataItems = (field: FieldMetadataItem) => {
|
||||
type SortableFieldInput = {
|
||||
isSystem?: boolean | null;
|
||||
isActive?: boolean | null;
|
||||
type: FieldMetadataType;
|
||||
relation?: { type: RelationType } | null;
|
||||
};
|
||||
|
||||
export const filterSortableFieldMetadataItems = (field: SortableFieldInput) => {
|
||||
const isSystemField = field.isSystem;
|
||||
const isFieldActive = field.isActive;
|
||||
|
||||
@@ -9,5 +16,13 @@ export const filterSortableFieldMetadataItems = (field: FieldMetadataItem) => {
|
||||
field.type,
|
||||
);
|
||||
|
||||
return !isSystemField && isFieldActive && isFieldTypeSortable;
|
||||
const isRelationFieldSortable =
|
||||
field.type === FieldMetadataType.RELATION &&
|
||||
field.relation?.type === RelationType.MANY_TO_ONE;
|
||||
|
||||
return (
|
||||
!isSystemField &&
|
||||
isFieldActive &&
|
||||
(isFieldTypeSortable || isRelationFieldSortable)
|
||||
);
|
||||
};
|
||||
|
||||
+26
@@ -1,4 +1,6 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
|
||||
import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import {
|
||||
@@ -71,3 +73,27 @@ export const getOrderByForFieldMetadataType = (
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrderByForRelationField = (
|
||||
field: Pick<FieldMetadataItem, 'name'>,
|
||||
relatedObjectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'fields' | 'labelIdentifierFieldMetadataId'
|
||||
>,
|
||||
direction: OrderBy,
|
||||
): RecordGqlOperationOrderBy => {
|
||||
const labelIdentifierField = getLabelIdentifierFieldMetadataItem(
|
||||
relatedObjectMetadataItem,
|
||||
);
|
||||
|
||||
if (!labelIdentifierField) {
|
||||
return [{ [`${field.name}Id`]: direction }];
|
||||
}
|
||||
|
||||
const labelFieldOrderBy = getOrderByForFieldMetadataType(
|
||||
labelIdentifierField,
|
||||
direction,
|
||||
);
|
||||
|
||||
return [{ [field.name]: labelFieldOrderBy[0] }];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user