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:
+56
-10
@@ -1,11 +1,27 @@
|
||||
import { type Reference, type StoreObject } from '@apollo/client';
|
||||
import { type ReadFieldFunction } from '@apollo/client/cache/core/types/common';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
|
||||
import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { type OrderBy } from '@/types/OrderBy';
|
||||
import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort';
|
||||
|
||||
// Extracts the OrderBy direction from a nested orderBy structure
|
||||
// Handles up to 3 levels: field, field.subField, or relation.compositeField.subField
|
||||
const extractOrderByDirection = (value: unknown): OrderBy | null => {
|
||||
if (isNonEmptyString(value)) {
|
||||
return value as OrderBy;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > 0) {
|
||||
return extractOrderByDirection(entries[0][1]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const sortCachedObjectEdges = ({
|
||||
edges,
|
||||
@@ -16,11 +32,20 @@ export const sortCachedObjectEdges = ({
|
||||
orderBy: RecordGqlOperationOrderBy;
|
||||
readCacheField: ReadFieldFunction;
|
||||
}) => {
|
||||
const [orderByFieldName, orderByFieldValue] = Object.entries(orderBy[0])[0];
|
||||
const [orderBySubFieldName, orderBySubFieldValue] =
|
||||
typeof orderByFieldValue === 'string'
|
||||
const [orderByFieldName, orderByFieldValueOrDirection] = Object.entries(
|
||||
orderBy[0],
|
||||
)[0];
|
||||
const [orderBySubFieldName, orderBySubFieldValueOrDirection] =
|
||||
isNonEmptyString(orderByFieldValueOrDirection)
|
||||
? []
|
||||
: Object.entries(orderByFieldValue)[0];
|
||||
: Object.entries(orderByFieldValueOrDirection)[0];
|
||||
|
||||
// For relation fields with composite nested fields (e.g., accountOwner.name.firstName)
|
||||
const [orderBySubSubFieldNameOrDirection] =
|
||||
isNonEmptyString(orderBySubFieldValueOrDirection) ||
|
||||
!isDefined(orderBySubFieldValueOrDirection)
|
||||
? []
|
||||
: Object.entries(orderBySubFieldValueOrDirection)[0];
|
||||
|
||||
const readFieldValueToSort = (
|
||||
edge: RecordGqlRefEdge,
|
||||
@@ -36,18 +61,39 @@ export const sortCachedObjectEdges = ({
|
||||
if (!isSubFieldFilter) return fieldValue as string | number | null;
|
||||
|
||||
const subFieldValue =
|
||||
readCacheField<string | number | null>(
|
||||
readCacheField<Reference | StoreObject | string | number | null>(
|
||||
orderBySubFieldName,
|
||||
fieldValue as Reference | StoreObject,
|
||||
) ?? null;
|
||||
|
||||
return subFieldValue;
|
||||
// Handle 3-level nesting: relation -> composite field -> sub-field
|
||||
// e.g., accountOwner (relation) -> name (composite FULL_NAME) -> firstName
|
||||
if (
|
||||
isDefined(subFieldValue) &&
|
||||
isDefined(orderBySubSubFieldNameOrDirection)
|
||||
) {
|
||||
const subSubFieldValue =
|
||||
readCacheField<string | number | null>(
|
||||
orderBySubSubFieldNameOrDirection,
|
||||
// At this level, subFieldValue is the composite object (not a Reference)
|
||||
subFieldValue as Reference | StoreObject,
|
||||
) ?? null;
|
||||
return subSubFieldValue;
|
||||
}
|
||||
|
||||
return subFieldValue as string | number | null;
|
||||
};
|
||||
|
||||
const orderByValue = orderBySubFieldValue || (orderByFieldValue as OrderBy);
|
||||
const orderByDirection =
|
||||
extractOrderByDirection(orderBySubFieldValueOrDirection) ||
|
||||
extractOrderByDirection(orderByFieldValueOrDirection);
|
||||
|
||||
const isAsc = orderByValue.startsWith('Asc');
|
||||
const isNullsFirst = orderByValue.endsWith('NullsFirst');
|
||||
if (!orderByDirection) {
|
||||
return edges;
|
||||
}
|
||||
|
||||
const isAsc = orderByDirection.startsWith('Asc');
|
||||
const isNullsFirst = orderByDirection.endsWith('NullsFirst');
|
||||
|
||||
return [...edges].sort((edgeA, edgeB) => {
|
||||
const fieldValueA = readFieldValueToSort(edgeA);
|
||||
|
||||
+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] }];
|
||||
};
|
||||
|
||||
+9
-1
@@ -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;
|
||||
}>;
|
||||
|
||||
+141
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+26
-5
@@ -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);
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
@@ -10,6 +11,8 @@ export const useQueryVariablesFromParentView = ({
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
}) => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const recordShowParentView = useRecoilComponentValue(
|
||||
contextStoreRecordShowParentViewComponentState,
|
||||
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
||||
@@ -22,6 +25,7 @@ export const useQueryVariablesFromParentView = ({
|
||||
recordFilters: recordShowParentView?.parentViewFilters ?? [],
|
||||
recordSorts: recordShowParentView?.parentViewSorts ?? [],
|
||||
objectMetadataItem,
|
||||
objectMetadataItems,
|
||||
filterValueDependencies,
|
||||
});
|
||||
|
||||
|
||||
+7
-1
@@ -11,12 +11,14 @@ export const getQueryVariablesFromFiltersAndSorts = ({
|
||||
recordFilters,
|
||||
recordSorts,
|
||||
objectMetadataItem,
|
||||
objectMetadataItems = [],
|
||||
filterValueDependencies,
|
||||
}: {
|
||||
recordFilterGroups: RecordFilterGroup[];
|
||||
recordFilters: RecordFilter[];
|
||||
recordSorts: RecordSort[];
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
objectMetadataItems?: ObjectMetadataItem[];
|
||||
filterValueDependencies: RecordFilterValueDependencies;
|
||||
}) => {
|
||||
const filter = computeRecordGqlOperationFilter({
|
||||
@@ -26,7 +28,11 @@ export const getQueryVariablesFromFiltersAndSorts = ({
|
||||
recordFilters,
|
||||
});
|
||||
|
||||
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, recordSorts);
|
||||
const orderBy = turnSortsIntoOrderBy(
|
||||
objectMetadataItem,
|
||||
recordSorts,
|
||||
objectMetadataItems,
|
||||
);
|
||||
|
||||
return {
|
||||
filter,
|
||||
|
||||
+5
-1
@@ -273,7 +273,11 @@ export const WorkflowEditActionFindRecords = ({
|
||||
|
||||
const gqlOperationOrderBy =
|
||||
sorts.length > 0
|
||||
? turnSortsIntoOrderBy(selectedObjectMetadataItem, sorts)
|
||||
? turnSortsIntoOrderBy(
|
||||
selectedObjectMetadataItem,
|
||||
sorts,
|
||||
objectMetadataItems,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newFormData: FindRecordsFormData = {
|
||||
|
||||
+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;
|
||||
|
||||
+660
@@ -0,0 +1,660 @@
|
||||
import { isObject } from 'class-validator';
|
||||
import {
|
||||
type AggregateOrderByWithGroupByField,
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
type ObjectRecordOrderByForCompositeField,
|
||||
type ObjectRecordOrderByForRelationField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
type ObjectRecordOrderByWithGroupByDateField,
|
||||
OrderByDirection,
|
||||
type OrderByWithGroupBy,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
|
||||
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
|
||||
import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util';
|
||||
import {
|
||||
type GroupByDateField,
|
||||
type GroupByField,
|
||||
type GroupByRegularField,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
|
||||
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
|
||||
import {
|
||||
type AggregationField,
|
||||
getAvailableAggregationsFromObjectFields,
|
||||
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
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';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
|
||||
|
||||
import { type OrderByClause } from './types/order-by-condition.type';
|
||||
|
||||
export class GraphqlQueryOrderGroupByParser {
|
||||
private flatObjectMetadata: FlatObjectMetadata;
|
||||
private flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
private flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
private fieldIdByName: Record<string, string>;
|
||||
|
||||
constructor(
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
) {
|
||||
this.flatObjectMetadata = flatObjectMetadata;
|
||||
this.flatObjectMetadataMaps = flatObjectMetadataMaps;
|
||||
this.flatFieldMetadataMaps = flatFieldMetadataMaps;
|
||||
|
||||
const fieldMaps = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
|
||||
this.fieldIdByName = fieldMaps.fieldIdByName;
|
||||
}
|
||||
|
||||
parse({
|
||||
orderBy,
|
||||
groupByFields,
|
||||
}: {
|
||||
orderBy: OrderByWithGroupBy;
|
||||
groupByFields: GroupByField[];
|
||||
}): Record<string, OrderByClause>[] {
|
||||
const parsedOrderBy: Record<string, OrderByClause>[] = [];
|
||||
|
||||
const fields = this.flatObjectMetadata.fieldMetadataIds
|
||||
.map((id) => this.flatFieldMetadataMaps.byId[id])
|
||||
.filter(isDefined);
|
||||
|
||||
const availableAggregations: Record<string, AggregationField> =
|
||||
getAvailableAggregationsFromObjectFields(fields);
|
||||
|
||||
for (const orderByArg of orderBy) {
|
||||
if (this.isAggregateOrderByArg(orderByArg)) {
|
||||
const parsedAggregateOrderBy = this.parseAggregateOrderByArg(
|
||||
availableAggregations,
|
||||
orderByArg,
|
||||
this.flatObjectMetadata,
|
||||
);
|
||||
|
||||
parsedOrderBy.push(parsedAggregateOrderBy);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(orderByArg).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const fieldName = Object.keys(orderByArg)[0];
|
||||
const fieldMetadataId = this.fieldIdByName[fieldName];
|
||||
const fieldMetadata = this.flatFieldMetadataMaps.byId[fieldMetadataId];
|
||||
|
||||
if (!isDefined(fieldMetadata)) {
|
||||
throw new UserInputError(`Cannot orderBy unknown field: ${fieldName}.`);
|
||||
}
|
||||
|
||||
if (this.isObjectRecordOrderByForScalarField(orderByArg)) {
|
||||
const parsedOrderByForScalarField =
|
||||
this.parseObjectRecordOrderByForScalarField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForScalarField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForScalarField);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isObjectRecordOrderByWithGroupByDateField(
|
||||
orderByArg,
|
||||
fieldMetadata.type,
|
||||
)
|
||||
) {
|
||||
const parsedOrderByForGroupByDateField =
|
||||
this.parseObjectRecordOrderByWithGroupByDateField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForGroupByDateField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForGroupByDateField);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata)
|
||||
) {
|
||||
const parsedOrderByForRelationField =
|
||||
this.parseObjectRecordOrderByForRelationField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForRelationField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForRelationField);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isObjectRecordOrderByForCompositeField(orderByArg)) {
|
||||
const parsedOrderByForCompositeField =
|
||||
this.parseObjectRecordOrderByForCompositeField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForCompositeField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForCompositeField);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new UserInputError(`Unknown orderBy value: ${orderByArg}`);
|
||||
}
|
||||
|
||||
return parsedOrderBy;
|
||||
}
|
||||
|
||||
private isAggregateOrderByArg = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| AggregateOrderByWithGroupByField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
): orderByArg is AggregateOrderByWithGroupByField => {
|
||||
return isDefined(orderByArg.aggregate);
|
||||
};
|
||||
|
||||
private isObjectRecordOrderByForScalarField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
): orderByArg is ObjectRecordOrderByForScalarField => {
|
||||
if (Object.keys(orderByArg).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const scalarFieldOrCompositeFieldOrderByValue =
|
||||
Object.values(orderByArg)[0];
|
||||
|
||||
if (
|
||||
Object.values(OrderByDirection).includes(
|
||||
scalarFieldOrCompositeFieldOrderByValue,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private isObjectRecordOrderByForCompositeField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
): orderByArg is ObjectRecordOrderByForCompositeField => {
|
||||
const compositeFieldOrderByValue = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isObject(compositeFieldOrderByValue)) {
|
||||
throw new UserInputError(
|
||||
`Unknown orderBy value: ${compositeFieldOrderByValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.values(compositeFieldOrderByValue).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const compositeFieldOrderByDirection = Object.values(
|
||||
compositeFieldOrderByValue,
|
||||
)[0];
|
||||
|
||||
if (
|
||||
Object.values(OrderByDirection).includes(
|
||||
compositeFieldOrderByDirection as OrderByDirection,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private isObjectRecordOrderByWithGroupByDateField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| AggregateOrderByWithGroupByField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
): orderByArg is ObjectRecordOrderByWithGroupByDateField => {
|
||||
if (
|
||||
fieldMetadataType !== FieldMetadataType.DATE &&
|
||||
fieldMetadataType !== FieldMetadataType.DATE_TIME
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.keys(orderByArg).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const dateFieldOrderByValue = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isDefined(dateFieldOrderByValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDefined(dateFieldOrderByValue.orderBy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(OrderByDirection).includes(dateFieldOrderByValue.orderBy)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDefined(dateFieldOrderByValue.granularity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(ObjectRecordGroupByDateGranularity).includes(
|
||||
dateFieldOrderByValue.granularity,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private isObjectRecordOrderByForRelationField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
): orderByArg is ObjectRecordOrderByForRelationField => {
|
||||
if (!isMorphOrRelationFlatFieldMetadata(fieldMetadata)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relationFieldOrderByValue = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isObject(relationFieldOrderByValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.keys(relationFieldOrderByValue).length > 0;
|
||||
};
|
||||
|
||||
private parseAggregateOrderByArg = (
|
||||
availableAggregations: Record<string, AggregationField>,
|
||||
orderByArg: AggregateOrderByWithGroupByField,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
): Record<string, OrderByClause> => {
|
||||
const aggregate = orderByArg.aggregate;
|
||||
|
||||
if (Object.keys(aggregate).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide aggregate criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateField = availableAggregations[Object.keys(aggregate)[0]];
|
||||
|
||||
if (!aggregateField) {
|
||||
throw new UserInputError(
|
||||
`Unknown aggregate field: ${Object.keys(aggregate)[0]}`,
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateExpression = ProcessAggregateHelper.getAggregateExpression(
|
||||
aggregateField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
if (!isDefined(aggregateExpression)) {
|
||||
throw new UserInputError(
|
||||
`Cannot find expression for aggregate field: ${Object.keys(aggregate)[0]}`,
|
||||
);
|
||||
}
|
||||
const orderByDirection = Object.values(aggregate)[0];
|
||||
const convertedDirection =
|
||||
convertOrderByToFindOptionsOrder(orderByDirection);
|
||||
|
||||
return {
|
||||
[aggregateExpression]: convertedDirection,
|
||||
};
|
||||
};
|
||||
|
||||
private parseObjectRecordOrderByForScalarField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForScalarField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const fieldIsInGroupBy = groupByFields.some(
|
||||
(groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id,
|
||||
);
|
||||
|
||||
if (!fieldIsInGroupBy) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const orderByCasting = getOptionalOrderByCasting(fieldMetadata);
|
||||
const orderByDirection = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isDefined(orderByDirection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
[`"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]:
|
||||
convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
};
|
||||
};
|
||||
|
||||
private parseObjectRecordOrderByForCompositeField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForCompositeField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const fieldName = Object.keys(orderByArg)[0];
|
||||
const orderBySubField = orderByArg[fieldName];
|
||||
|
||||
if (!isDefined(orderBySubField)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(orderBySubField).length > 1) {
|
||||
throw new UserInputError(
|
||||
`Subfields must be provided one by one in orderBy array.`,
|
||||
);
|
||||
}
|
||||
|
||||
const subFieldName = Object.keys(orderBySubField)[0];
|
||||
|
||||
if (
|
||||
!groupByFields.some(
|
||||
(groupByField) =>
|
||||
groupByField.fieldMetadata.id === fieldMetadata.id &&
|
||||
(groupByField as GroupByRegularField).subFieldName === subFieldName,
|
||||
)
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a field that is not in groupBy or that is not an aggregate field: ${subFieldName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
orderBySubField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
};
|
||||
|
||||
private parseObjectRecordOrderByWithGroupByDateField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadataId,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByWithGroupByDateField;
|
||||
fieldMetadataId: string;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const orderByDirection = Object.values(orderByArg)[0]?.orderBy;
|
||||
|
||||
if (!isDefined(orderByDirection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const granularity = Object.values(orderByArg)[0]?.granularity;
|
||||
|
||||
if (!isDefined(granularity)) {
|
||||
throw new UserInputError(
|
||||
`Missing date granularity for field ${Object.keys(orderByArg)[0]}`,
|
||||
);
|
||||
}
|
||||
|
||||
const associatedGroupByField = groupByFields.find(
|
||||
(groupByField) =>
|
||||
groupByField.fieldMetadata.id === fieldMetadataId &&
|
||||
(groupByField as GroupByDateField).dateGranularity === granularity,
|
||||
) as GroupByDateField | undefined;
|
||||
|
||||
if (!isDefined(associatedGroupByField)) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const columnNameWithQuotes = `"${
|
||||
formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
associatedGroupByField.fieldMetadata.name,
|
||||
associatedGroupByField.subFieldName
|
||||
? [associatedGroupByField.subFieldName]
|
||||
: undefined,
|
||||
)[0]
|
||||
}"`;
|
||||
|
||||
const expression = getGroupByExpression({
|
||||
groupByField: associatedGroupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
return {
|
||||
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
};
|
||||
};
|
||||
|
||||
private parseObjectRecordOrderByForRelationField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForRelationField;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const {
|
||||
associatedGroupByField,
|
||||
nestedFieldMetadata,
|
||||
nestedFieldOrderByValue,
|
||||
} = prepareForOrderByRelationFieldParsing({
|
||||
orderByArg,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps: this.flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: this.flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(associatedGroupByField) ||
|
||||
!isDefined(nestedFieldMetadata) ||
|
||||
!isDefined(nestedFieldOrderByValue)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle composite fields
|
||||
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
|
||||
if (!isObject(nestedFieldOrderByValue)) {
|
||||
throw new UserInputError(
|
||||
`Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`,
|
||||
);
|
||||
}
|
||||
|
||||
const compositeSubFields = Object.keys(nestedFieldOrderByValue);
|
||||
|
||||
if (compositeSubFields.length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide composite subfield criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const nestedSubFieldName = compositeSubFields[0];
|
||||
const orderByDirection = (
|
||||
nestedFieldOrderByValue as Record<string, OrderByDirection>
|
||||
)[nestedSubFieldName];
|
||||
|
||||
if (!isDefined(orderByDirection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(associatedGroupByField.nestedSubFieldName) ||
|
||||
associatedGroupByField.nestedSubFieldName !== nestedSubFieldName
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const joinAlias = fieldMetadata.name;
|
||||
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
nestedFieldMetadata.name,
|
||||
[nestedSubFieldName],
|
||||
)[0];
|
||||
|
||||
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
|
||||
|
||||
return {
|
||||
[columnNameWithQuotes]:
|
||||
convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
};
|
||||
}
|
||||
|
||||
const isGroupByDateField =
|
||||
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
|
||||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
|
||||
isObject(nestedFieldOrderByValue) &&
|
||||
'orderBy' in nestedFieldOrderByValue &&
|
||||
'granularity' in nestedFieldOrderByValue;
|
||||
|
||||
if (isGroupByDateField) {
|
||||
const orderByDirection = (
|
||||
nestedFieldOrderByValue as {
|
||||
orderBy: OrderByDirection;
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
}
|
||||
).orderBy;
|
||||
const granularity = (
|
||||
nestedFieldOrderByValue as {
|
||||
orderBy: OrderByDirection;
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
}
|
||||
).granularity;
|
||||
|
||||
if (
|
||||
!isDefined(associatedGroupByField.dateGranularity) ||
|
||||
associatedGroupByField.dateGranularity !== granularity
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const joinAlias = fieldMetadata.name;
|
||||
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
nestedFieldMetadata.name,
|
||||
associatedGroupByField.nestedSubFieldName
|
||||
? [associatedGroupByField.nestedSubFieldName]
|
||||
: undefined,
|
||||
)[0];
|
||||
|
||||
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
|
||||
|
||||
const expression = getGroupByExpression({
|
||||
groupByField: associatedGroupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
return {
|
||||
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular nested fields
|
||||
if (
|
||||
typeof nestedFieldOrderByValue === 'string' &&
|
||||
Object.values(OrderByDirection).includes(
|
||||
nestedFieldOrderByValue as OrderByDirection,
|
||||
)
|
||||
) {
|
||||
const orderByDirection = nestedFieldOrderByValue as OrderByDirection;
|
||||
|
||||
const joinAlias = fieldMetadata.name;
|
||||
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
nestedFieldMetadata.name,
|
||||
associatedGroupByField.nestedSubFieldName
|
||||
? [associatedGroupByField.nestedSubFieldName]
|
||||
: undefined,
|
||||
)[0];
|
||||
|
||||
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
|
||||
|
||||
return {
|
||||
[columnNameWithQuotes]:
|
||||
convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
+147
-632
@@ -1,15 +1,4 @@
|
||||
import { isObject } from 'class-validator';
|
||||
import {
|
||||
type AggregateOrderByWithGroupByField,
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
type ObjectRecordOrderByForCompositeField,
|
||||
type ObjectRecordOrderByForRelationField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
type ObjectRecordOrderByWithGroupByDateField,
|
||||
OrderByDirection,
|
||||
type OrderByWithGroupBy,
|
||||
} 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';
|
||||
@@ -20,33 +9,22 @@ import {
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
|
||||
import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util';
|
||||
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
|
||||
import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util';
|
||||
import {
|
||||
type GroupByDateField,
|
||||
type GroupByField,
|
||||
type GroupByRegularField,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
|
||||
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
|
||||
import {
|
||||
type AggregationField,
|
||||
getAvailableAggregationsFromObjectFields,
|
||||
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
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';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util';
|
||||
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
|
||||
|
||||
export type OrderByCondition = {
|
||||
order: 'ASC' | 'DESC';
|
||||
nulls?: 'NULLS FIRST' | 'NULLS LAST';
|
||||
};
|
||||
import { type OrderByClause } from './types/order-by-condition.type';
|
||||
import { type ParseOrderByResult } from './types/parse-order-by-result.type';
|
||||
import { type RelationJoinInfo } from './types/relation-join-info.type';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export { OrderByClause, ParseOrderByResult, RelationJoinInfo };
|
||||
|
||||
export class GraphqlQueryOrderFieldParser {
|
||||
private flatObjectMetadata: FlatObjectMetadata;
|
||||
@@ -77,665 +55,202 @@ export class GraphqlQueryOrderFieldParser {
|
||||
orderBy: ObjectRecordOrderBy,
|
||||
objectNameSingular: string,
|
||||
isForwardPagination = true,
|
||||
): Record<string, OrderByCondition> {
|
||||
return orderBy.reduce(
|
||||
(acc, item) => {
|
||||
Object.entries(item).forEach(([fieldName, orderByDirection]) => {
|
||||
const fieldMetadataId =
|
||||
this.fieldIdByName[fieldName] ||
|
||||
this.fieldIdByJoinColumnName[fieldName];
|
||||
const fieldMetadata =
|
||||
this.flatFieldMetadataMaps.byId[fieldMetadataId];
|
||||
): ParseOrderByResult {
|
||||
const orderByConditions: Record<string, OrderByClause> = {};
|
||||
const relationJoins: RelationJoinInfo[] = [];
|
||||
const addedJoinAliases = new Set<string>();
|
||||
|
||||
if (!fieldMetadata || orderByDirection === undefined) {
|
||||
for (const item of orderBy) {
|
||||
for (const [fieldName, orderByDirection] of Object.entries(item)) {
|
||||
// Check if accessed by relation name (company) vs FK name (companyId)
|
||||
const isAccessedByRelationName = !!this.fieldIdByName[fieldName];
|
||||
const fieldMetadataId =
|
||||
this.fieldIdByName[fieldName] ||
|
||||
this.fieldIdByJoinColumnName[fieldName];
|
||||
const fieldMetadata = this.flatFieldMetadataMaps.byId[fieldMetadataId];
|
||||
|
||||
if (!fieldMetadata || orderByDirection === undefined) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Field "${fieldName}" does not exist or is not sortable`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
// Only treat as relation if accessed by relation name (not FK like companyId)
|
||||
if (
|
||||
isAccessedByRelationName &&
|
||||
isMorphOrRelationFlatFieldMetadata(fieldMetadata)
|
||||
) {
|
||||
if (!isObject(orderByDirection)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Field "${fieldName}" does not exist or is not sortable`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
`Relation field "${fieldName}" requires nested field ordering (e.g., { ${fieldName}: { fieldName: 'AscNullsFirst' } })`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
const compositeOrder = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
orderByDirection,
|
||||
objectNameSingular,
|
||||
isForwardPagination,
|
||||
);
|
||||
const relationOrderResult = this.parseRelationFieldOrder({
|
||||
fieldMetadata,
|
||||
orderByDirection: orderByDirection as Record<string, unknown>,
|
||||
isForwardPagination,
|
||||
});
|
||||
|
||||
Object.assign(acc, compositeOrder);
|
||||
} else {
|
||||
const orderByCasting =
|
||||
this.getOptionalOrderByCasting(fieldMetadata);
|
||||
if (relationOrderResult) {
|
||||
Object.assign(orderByConditions, relationOrderResult.orderBy);
|
||||
|
||||
const columnName = isMorphOrRelationFlatFieldMetadata(fieldMetadata)
|
||||
? formatColumnNameForRelationField(
|
||||
fieldMetadata.name,
|
||||
fieldMetadata.settings,
|
||||
)
|
||||
: fieldName;
|
||||
|
||||
acc[`"${objectNameSingular}"."${columnName}"${orderByCasting}`] =
|
||||
convertOrderByToFindOptionsOrder(
|
||||
orderByDirection as OrderByDirection,
|
||||
isForwardPagination,
|
||||
);
|
||||
if (!addedJoinAliases.has(relationOrderResult.joinInfo.joinAlias)) {
|
||||
relationJoins.push(relationOrderResult.joinInfo);
|
||||
addedJoinAliases.add(relationOrderResult.joinInfo.joinAlias);
|
||||
}
|
||||
}
|
||||
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
if (!isObject(orderByDirection)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Composite field "${fieldName}" requires subfield ordering (e.g., { ${fieldName}: { subFieldName: 'AscNullsFirst' } })`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, OrderByCondition>,
|
||||
);
|
||||
}
|
||||
|
||||
parseForGroupBy({
|
||||
orderBy,
|
||||
groupByFields,
|
||||
}: {
|
||||
orderBy: OrderByWithGroupBy;
|
||||
groupByFields: GroupByField[];
|
||||
}): Record<string, OrderByCondition>[] {
|
||||
let parsedOrderBy: Record<string, OrderByCondition>[] = [];
|
||||
|
||||
const fields = this.flatObjectMetadata.fieldMetadataIds
|
||||
.map((id) => this.flatFieldMetadataMaps.byId[id])
|
||||
.filter(isDefined);
|
||||
|
||||
const availableAggregations: Record<string, AggregationField> =
|
||||
getAvailableAggregationsFromObjectFields(fields);
|
||||
|
||||
for (const orderByArg of orderBy) {
|
||||
if (this.isAggregateOrderByArg(orderByArg)) {
|
||||
const parsedAggregateOrderBy = this.parseAggregateOrderByArg(
|
||||
availableAggregations,
|
||||
orderByArg,
|
||||
this.flatObjectMetadata,
|
||||
);
|
||||
|
||||
parsedOrderBy.push(parsedAggregateOrderBy);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(orderByArg).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const fieldName = Object.keys(orderByArg)[0];
|
||||
const fieldMetadataId = this.fieldIdByName[fieldName];
|
||||
const fieldMetadata = this.flatFieldMetadataMaps.byId[fieldMetadataId];
|
||||
|
||||
if (!isDefined(fieldMetadata)) {
|
||||
throw new UserInputError(`Cannot orderBy unknown field: ${fieldName}.`);
|
||||
}
|
||||
|
||||
if (this.isObjectRecordOrderByForScalarField(orderByArg)) {
|
||||
const parsedOrderByForScalarField =
|
||||
this.parseObjectRecordOrderByForScalarField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
const compositeOrder = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
});
|
||||
orderByDirection as Record<string, unknown>,
|
||||
objectNameSingular,
|
||||
isForwardPagination,
|
||||
);
|
||||
|
||||
if (!isDefined(parsedOrderByForScalarField)) {
|
||||
continue;
|
||||
Object.assign(orderByConditions, compositeOrder);
|
||||
} else {
|
||||
if (!isOrderByDirection(orderByDirection)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Scalar field "${fieldName}" requires a direction value (AscNullsFirst, AscNullsLast, DescNullsFirst, DescNullsLast)`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const orderByCasting = getOptionalOrderByCasting(fieldMetadata);
|
||||
|
||||
orderByConditions[
|
||||
`${objectNameSingular}.${fieldName}${orderByCasting}`
|
||||
] = convertOrderByToFindOptionsOrder(
|
||||
orderByDirection,
|
||||
isForwardPagination,
|
||||
);
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForScalarField);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isObjectRecordOrderByWithGroupByDateField(
|
||||
orderByArg,
|
||||
fieldMetadata.type,
|
||||
)
|
||||
) {
|
||||
const parsedOrderByForGroupByDateField =
|
||||
this.parseObjectRecordOrderByWithGroupByDateField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForGroupByDateField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForGroupByDateField);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata)
|
||||
) {
|
||||
const parsedOrderByForRelationField =
|
||||
this.parseObjectRecordOrderByForRelationField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForRelationField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForRelationField);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isObjectRecordOrderByForCompositeField(orderByArg)) {
|
||||
const parsedOrderByForCompositeField =
|
||||
this.parseObjectRecordOrderByForCompositeField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
if (!isDefined(parsedOrderByForCompositeField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedOrderBy.push(parsedOrderByForCompositeField);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new UserInputError(`Unknown orderBy value: ${orderByArg}`);
|
||||
}
|
||||
|
||||
return parsedOrderBy;
|
||||
}
|
||||
|
||||
getOptionalOrderByCasting(
|
||||
fieldMetadata: Pick<FlatFieldMetadata, 'type'>,
|
||||
): string {
|
||||
if (
|
||||
fieldMetadata.type === FieldMetadataType.SELECT ||
|
||||
fieldMetadata.type === FieldMetadataType.MULTI_SELECT
|
||||
) {
|
||||
return '::text';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
isAggregateOrderByArg = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| AggregateOrderByWithGroupByField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
): orderByArg is AggregateOrderByWithGroupByField => {
|
||||
return isDefined(orderByArg.aggregate);
|
||||
};
|
||||
|
||||
isObjectRecordOrderByForScalarField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
): orderByArg is ObjectRecordOrderByForScalarField => {
|
||||
if (Object.keys(orderByArg).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const scalarFieldOrCompositeFieldOrderByValue =
|
||||
Object.values(orderByArg)[0];
|
||||
|
||||
if (
|
||||
Object.values(OrderByDirection).includes(
|
||||
scalarFieldOrCompositeFieldOrderByValue,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
isObjectRecordOrderByForCompositeField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
): orderByArg is ObjectRecordOrderByForCompositeField => {
|
||||
const compositeFieldOrderByValue = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isObject(compositeFieldOrderByValue)) {
|
||||
throw new UserInputError(
|
||||
`Unknown orderBy value: ${compositeFieldOrderByValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.values(compositeFieldOrderByValue).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const compositeFieldOrderByDirection = Object.values(
|
||||
compositeFieldOrderByValue,
|
||||
)[0];
|
||||
|
||||
if (
|
||||
Object.values(OrderByDirection).includes(
|
||||
compositeFieldOrderByDirection as OrderByDirection,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
isObjectRecordOrderByWithGroupByDateField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| AggregateOrderByWithGroupByField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
): orderByArg is ObjectRecordOrderByWithGroupByDateField => {
|
||||
if (
|
||||
fieldMetadataType !== FieldMetadataType.DATE &&
|
||||
fieldMetadataType !== FieldMetadataType.DATE_TIME
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.keys(orderByArg).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide orderBy field criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const dateFieldOrderByValue = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isDefined(dateFieldOrderByValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDefined(dateFieldOrderByValue.orderBy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(OrderByDirection).includes(dateFieldOrderByValue.orderBy)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDefined(dateFieldOrderByValue.granularity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(ObjectRecordGroupByDateGranularity).includes(
|
||||
dateFieldOrderByValue.granularity,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
parseAggregateOrderByArg = (
|
||||
availableAggregations: Record<string, AggregationField>,
|
||||
orderByArg: AggregateOrderByWithGroupByField,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
): Record<string, OrderByCondition> => {
|
||||
const aggregate = orderByArg.aggregate;
|
||||
|
||||
if (Object.keys(aggregate).length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide aggregate criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateField = availableAggregations[Object.keys(aggregate)[0]];
|
||||
|
||||
if (!aggregateField) {
|
||||
throw new UserInputError(
|
||||
`Unknown aggregate field: ${Object.keys(aggregate)[0]}`,
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateExpression = ProcessAggregateHelper.getAggregateExpression(
|
||||
aggregateField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
if (!isDefined(aggregateExpression)) {
|
||||
throw new UserInputError(
|
||||
`Cannot find expression for aggregate field: ${Object.keys(aggregate)[0]}`,
|
||||
);
|
||||
}
|
||||
const orderByDirection = Object.values(aggregate)[0];
|
||||
const convertedDirection =
|
||||
convertOrderByToFindOptionsOrder(orderByDirection);
|
||||
|
||||
return {
|
||||
[aggregateExpression]: convertedDirection,
|
||||
};
|
||||
};
|
||||
|
||||
parseObjectRecordOrderByForScalarField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForScalarField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByCondition> | null => {
|
||||
const fieldIsInGroupBy = groupByFields.some(
|
||||
(groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id,
|
||||
);
|
||||
|
||||
if (!fieldIsInGroupBy) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const orderByCasting = this.getOptionalOrderByCasting(fieldMetadata);
|
||||
const orderByDirection = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isDefined(orderByDirection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
[`"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]:
|
||||
convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
orderBy: orderByConditions,
|
||||
relationJoins,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
parseObjectRecordOrderByForCompositeField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
private parseRelationFieldOrder({
|
||||
fieldMetadata,
|
||||
orderByDirection,
|
||||
isForwardPagination,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForCompositeField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByCondition> | null => {
|
||||
const fieldName = Object.keys(orderByArg)[0];
|
||||
const orderBySubField = orderByArg[fieldName];
|
||||
|
||||
if (!isDefined(orderBySubField)) {
|
||||
orderByDirection: Record<string, unknown>;
|
||||
isForwardPagination: boolean;
|
||||
}): {
|
||||
orderBy: Record<string, OrderByClause>;
|
||||
joinInfo: RelationJoinInfo;
|
||||
} | null {
|
||||
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(orderBySubField).length > 1) {
|
||||
throw new UserInputError(
|
||||
`Subfields must be provided one by one in orderBy array.`,
|
||||
);
|
||||
}
|
||||
const targetObjectMetadata =
|
||||
this.flatObjectMetadataMaps.byId[
|
||||
fieldMetadata.relationTargetObjectMetadataId
|
||||
];
|
||||
|
||||
const subFieldName = Object.keys(orderBySubField)[0];
|
||||
|
||||
if (
|
||||
!groupByFields.some(
|
||||
(groupByField) =>
|
||||
groupByField.fieldMetadata.id === fieldMetadata.id &&
|
||||
(groupByField as GroupByRegularField).subFieldName === subFieldName,
|
||||
)
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a field that is not in groupBy or that is not an aggregate field: ${subFieldName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
orderBySubField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
};
|
||||
|
||||
parseObjectRecordOrderByWithGroupByDateField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadataId,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByWithGroupByDateField;
|
||||
fieldMetadataId: string;
|
||||
}): Record<string, OrderByCondition> | null => {
|
||||
const orderByDirection = Object.values(orderByArg)[0]?.orderBy;
|
||||
|
||||
if (!isDefined(orderByDirection)) {
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const granularity = Object.values(orderByArg)[0]?.granularity;
|
||||
const nestedFieldName = Object.keys(orderByDirection)[0];
|
||||
const nestedFieldOrderByValue = orderByDirection[nestedFieldName];
|
||||
|
||||
if (!isDefined(granularity)) {
|
||||
throw new UserInputError(
|
||||
`Missing date granularity for field ${Object.keys(orderByArg)[0]}`,
|
||||
if (!isDefined(nestedFieldOrderByValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { fieldIdByName: targetFieldIdByName } =
|
||||
buildFieldMapsFromFlatObjectMetadata(
|
||||
this.flatFieldMetadataMaps,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
|
||||
const nestedFieldMetadataId = targetFieldIdByName[nestedFieldName];
|
||||
|
||||
if (!isDefined(nestedFieldMetadataId)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`,
|
||||
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const associatedGroupByField = groupByFields.find(
|
||||
(groupByField) =>
|
||||
groupByField.fieldMetadata.id === fieldMetadataId &&
|
||||
(groupByField as GroupByDateField).dateGranularity === granularity,
|
||||
) as GroupByDateField | undefined;
|
||||
const nestedFieldMetadata =
|
||||
this.flatFieldMetadataMaps.byId[nestedFieldMetadataId];
|
||||
|
||||
if (!isDefined(associatedGroupByField)) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
|
||||
);
|
||||
if (!isDefined(nestedFieldMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnNameWithQuotes = `"${
|
||||
formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
associatedGroupByField.fieldMetadata.name,
|
||||
associatedGroupByField.subFieldName
|
||||
? [associatedGroupByField.subFieldName]
|
||||
: undefined,
|
||||
)[0]
|
||||
}"`;
|
||||
const joinAlias = fieldMetadata.name;
|
||||
|
||||
const expression = getGroupByExpression({
|
||||
groupByField: associatedGroupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
|
||||
return {
|
||||
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
const joinInfo: RelationJoinInfo = {
|
||||
joinAlias,
|
||||
};
|
||||
};
|
||||
|
||||
parseObjectRecordOrderByForRelationField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForRelationField;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByCondition> | null => {
|
||||
const {
|
||||
associatedGroupByField,
|
||||
nestedFieldMetadata,
|
||||
nestedFieldOrderByValue,
|
||||
} = prepareForOrderByRelationFieldParsing({
|
||||
orderByArg,
|
||||
fieldMetadata,
|
||||
flatObjectMetadataMaps: this.flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: this.flatFieldMetadataMaps,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(associatedGroupByField) ||
|
||||
!isDefined(nestedFieldMetadata) ||
|
||||
!isDefined(nestedFieldOrderByValue)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle composite fields
|
||||
if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) {
|
||||
if (!isObject(nestedFieldOrderByValue)) {
|
||||
throw new UserInputError(
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
|
||||
const compositeSubFields = Object.keys(nestedFieldOrderByValue);
|
||||
const compositeOrder = parseCompositeFieldForOrder(
|
||||
nestedFieldMetadata,
|
||||
nestedFieldOrderByValue as Record<string, unknown>,
|
||||
joinAlias,
|
||||
isForwardPagination,
|
||||
);
|
||||
|
||||
if (compositeSubFields.length > 1) {
|
||||
throw new UserInputError(
|
||||
'Please provide composite subfield criteria one by one in orderBy array',
|
||||
);
|
||||
}
|
||||
|
||||
const nestedSubFieldName = compositeSubFields[0];
|
||||
const orderByDirection = (
|
||||
nestedFieldOrderByValue as Record<string, OrderByDirection>
|
||||
)[nestedSubFieldName];
|
||||
|
||||
if (!isDefined(orderByDirection)) {
|
||||
if (Object.keys(compositeOrder).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(associatedGroupByField.nestedSubFieldName) ||
|
||||
associatedGroupByField.nestedSubFieldName !== nestedSubFieldName
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const joinAlias = fieldMetadata.name;
|
||||
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
nestedFieldMetadata.name,
|
||||
[nestedSubFieldName],
|
||||
)[0];
|
||||
|
||||
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
|
||||
|
||||
return {
|
||||
[columnNameWithQuotes]:
|
||||
convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
orderBy: compositeOrder,
|
||||
joinInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const isGroupByDateField =
|
||||
(nestedFieldMetadata.type === FieldMetadataType.DATE ||
|
||||
nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) &&
|
||||
isObject(nestedFieldOrderByValue) &&
|
||||
'orderBy' in nestedFieldOrderByValue &&
|
||||
'granularity' in nestedFieldOrderByValue;
|
||||
|
||||
if (isGroupByDateField) {
|
||||
const orderByDirection = (
|
||||
nestedFieldOrderByValue as {
|
||||
orderBy: OrderByDirection;
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
}
|
||||
).orderBy;
|
||||
const granularity = (
|
||||
nestedFieldOrderByValue as {
|
||||
orderBy: OrderByDirection;
|
||||
granularity: ObjectRecordGroupByDateGranularity;
|
||||
}
|
||||
).granularity;
|
||||
|
||||
if (
|
||||
!isDefined(associatedGroupByField.dateGranularity) ||
|
||||
associatedGroupByField.dateGranularity !== granularity
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const joinAlias = fieldMetadata.name;
|
||||
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
nestedFieldMetadata.name,
|
||||
associatedGroupByField.nestedSubFieldName
|
||||
? [associatedGroupByField.nestedSubFieldName]
|
||||
: undefined,
|
||||
)[0];
|
||||
|
||||
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
|
||||
|
||||
const expression = getGroupByExpression({
|
||||
groupByField: associatedGroupByField,
|
||||
columnNameWithQuotes,
|
||||
});
|
||||
if (isOrderByDirection(nestedFieldOrderByValue)) {
|
||||
const nestedColumnName = nestedFieldMetadata.name;
|
||||
const orderByCasting = getOptionalOrderByCasting(nestedFieldMetadata);
|
||||
|
||||
return {
|
||||
[expression]: convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular nested fields
|
||||
if (
|
||||
typeof nestedFieldOrderByValue === 'string' &&
|
||||
Object.values(OrderByDirection).includes(
|
||||
nestedFieldOrderByValue as OrderByDirection,
|
||||
)
|
||||
) {
|
||||
const orderByDirection = nestedFieldOrderByValue as OrderByDirection;
|
||||
|
||||
const joinAlias = fieldMetadata.name;
|
||||
const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
nestedFieldMetadata.name,
|
||||
associatedGroupByField.nestedSubFieldName
|
||||
? [associatedGroupByField.nestedSubFieldName]
|
||||
: undefined,
|
||||
)[0];
|
||||
|
||||
const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`;
|
||||
|
||||
return {
|
||||
[columnNameWithQuotes]:
|
||||
convertOrderByToFindOptionsOrder(orderByDirection),
|
||||
orderBy: {
|
||||
[`${joinAlias}.${nestedColumnName}${orderByCasting}`]:
|
||||
convertOrderByToFindOptionsOrder(
|
||||
nestedFieldOrderByValue,
|
||||
isForwardPagination,
|
||||
),
|
||||
},
|
||||
joinInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
isObjectRecordOrderByForRelationField = (
|
||||
orderByArg:
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField,
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
): orderByArg is ObjectRecordOrderByForRelationField => {
|
||||
if (!isMorphOrRelationFlatFieldMetadata(fieldMetadata)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relationFieldOrderByValue = Object.values(orderByArg)[0];
|
||||
|
||||
if (!isObject(relationFieldOrderByValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.keys(relationFieldOrderByValue).length > 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type OrderByClause = {
|
||||
order: 'ASC' | 'DESC';
|
||||
nulls?: 'NULLS FIRST' | 'NULLS LAST';
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type OrderByClause } from './order-by-condition.type';
|
||||
import { type RelationJoinInfo } from './relation-join-info.type';
|
||||
|
||||
export type ParseOrderByResult = {
|
||||
orderBy: Record<string, OrderByClause>;
|
||||
relationJoins: RelationJoinInfo[];
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type RelationJoinInfo = {
|
||||
joinAlias: string;
|
||||
};
|
||||
+2
-2
@@ -5,12 +5,12 @@ import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { type OrderByCondition } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
|
||||
export const convertOrderByToFindOptionsOrder = (
|
||||
direction: OrderByDirection,
|
||||
isForwardPagination = true,
|
||||
): OrderByCondition => {
|
||||
): OrderByClause => {
|
||||
switch (direction) {
|
||||
case OrderByDirection.AscNullsFirst:
|
||||
return {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getOptionalOrderByCasting = (
|
||||
fieldMetadata: Pick<FlatFieldMetadata, 'type'>,
|
||||
): string => {
|
||||
if (
|
||||
fieldMetadata.type === FieldMetadataType.SELECT ||
|
||||
fieldMetadata.type === FieldMetadataType.MULTI_SELECT
|
||||
) {
|
||||
return '::text';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { OrderByDirection } from 'twenty-shared/types';
|
||||
|
||||
export const isOrderByDirection = (
|
||||
value: unknown,
|
||||
): value is OrderByDirection => {
|
||||
return (
|
||||
isNonEmptyString(value) &&
|
||||
Object.values(OrderByDirection).includes(value as OrderByDirection)
|
||||
);
|
||||
};
|
||||
+8
-15
@@ -1,21 +1,18 @@
|
||||
import {
|
||||
OrderByDirection,
|
||||
compositeTypeDefinitions,
|
||||
} from 'twenty-shared/types';
|
||||
import { compositeTypeDefinitions } from 'twenty-shared/types';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
import { type OrderByCondition } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util';
|
||||
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const parseCompositeFieldForOrder = (
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any,
|
||||
objectNameSingular: string,
|
||||
value: Record<string, unknown>,
|
||||
prefix: string,
|
||||
isForwardPagination = true,
|
||||
): Record<string, OrderByCondition> => {
|
||||
): Record<string, OrderByClause> => {
|
||||
const compositeType = compositeTypeDefinitions.get(
|
||||
fieldMetadata.type as CompositeFieldMetadataType,
|
||||
);
|
||||
@@ -38,7 +35,7 @@ export const parseCompositeFieldForOrder = (
|
||||
);
|
||||
}
|
||||
|
||||
const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`;
|
||||
const fullFieldName = `${prefix}.${fieldMetadata.name}${capitalize(subFieldKey)}`;
|
||||
|
||||
if (!isOrderByDirection(subFieldValue)) {
|
||||
throw new Error(
|
||||
@@ -52,10 +49,6 @@ export const parseCompositeFieldForOrder = (
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, OrderByCondition>,
|
||||
{} as Record<string, OrderByClause>,
|
||||
);
|
||||
};
|
||||
|
||||
const isOrderByDirection = (value: unknown): value is OrderByDirection => {
|
||||
return Object.values(OrderByDirection).includes(value as OrderByDirection);
|
||||
};
|
||||
|
||||
+53
-8
@@ -9,7 +9,11 @@ import {
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser';
|
||||
import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser';
|
||||
import {
|
||||
GraphqlQueryOrderFieldParser,
|
||||
type OrderByClause,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
import {
|
||||
GraphqlQuerySelectedFieldsParser,
|
||||
type GraphqlQuerySelectedFieldsResult,
|
||||
@@ -26,6 +30,7 @@ export class GraphqlQueryParser {
|
||||
private flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
private filterConditionParser: GraphqlQueryFilterConditionParser;
|
||||
private orderFieldParser: GraphqlQueryOrderFieldParser;
|
||||
private orderGroupByParser: GraphqlQueryOrderGroupByParser;
|
||||
|
||||
constructor(
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
@@ -45,6 +50,11 @@ export class GraphqlQueryParser {
|
||||
this.flatObjectMetadataMaps,
|
||||
this.flatFieldMetadataMaps,
|
||||
);
|
||||
this.orderGroupByParser = new GraphqlQueryOrderGroupByParser(
|
||||
this.flatObjectMetadata,
|
||||
this.flatObjectMetadataMaps,
|
||||
this.flatFieldMetadataMaps,
|
||||
);
|
||||
}
|
||||
|
||||
public applyFilterToBuilder(
|
||||
@@ -106,15 +116,50 @@ export class GraphqlQueryParser {
|
||||
orderBy: ObjectRecordOrderBy | OrderByWithGroupBy,
|
||||
objectNameSingular: string,
|
||||
isForwardPagination = true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): WorkspaceSelectQueryBuilder<any> {
|
||||
const parsedOrderBys = this.orderFieldParser.parse(
|
||||
): Record<string, OrderByClause> {
|
||||
const parseResult = this.orderFieldParser.parse(
|
||||
orderBy as ObjectRecordOrderBy,
|
||||
objectNameSingular,
|
||||
isForwardPagination,
|
||||
);
|
||||
|
||||
return queryBuilder.orderBy(parsedOrderBys);
|
||||
// Add LEFT JOINs for relation ordering
|
||||
for (const joinInfo of parseResult.relationJoins) {
|
||||
queryBuilder.leftJoin(
|
||||
`${objectNameSingular}.${joinInfo.joinAlias}`,
|
||||
joinInfo.joinAlias,
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.orderBy(parseResult.orderBy);
|
||||
|
||||
// Return parsed orderBy so caller can add relation columns after setFindOptions
|
||||
return parseResult.orderBy;
|
||||
}
|
||||
|
||||
public addRelationOrderColumnsToBuilder(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryBuilder: WorkspaceSelectQueryBuilder<any>,
|
||||
parsedOrderBy: Record<string, OrderByClause>,
|
||||
objectNameSingular: string,
|
||||
): void {
|
||||
// Add relation ORDER BY columns with underscore alias for DISTINCT compatibility
|
||||
// This must be called AFTER setFindOptions because setFindOptions clears addSelect
|
||||
for (const orderByKey of Object.keys(parsedOrderBy)) {
|
||||
const parts = orderByKey.split('.');
|
||||
|
||||
if (parts.length === 2) {
|
||||
const [alias, column] = parts;
|
||||
|
||||
// Only add select for joined relation columns, not main entity columns
|
||||
if (alias !== objectNameSingular) {
|
||||
queryBuilder.addSelect(
|
||||
`"${alias}"."${column}"`,
|
||||
`${alias}_${column}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getOrderByRawSQL(
|
||||
@@ -122,13 +167,13 @@ export class GraphqlQueryParser {
|
||||
objectNameSingular: string,
|
||||
isForwardPagination = true,
|
||||
): string {
|
||||
const parsedOrderBys = this.orderFieldParser.parse(
|
||||
const parseResult = this.orderFieldParser.parse(
|
||||
orderBy as ObjectRecordOrderBy,
|
||||
objectNameSingular,
|
||||
isForwardPagination,
|
||||
);
|
||||
|
||||
const orderByRawSQLClauseArray = Object.entries(parsedOrderBys).map(
|
||||
const orderByRawSQLClauseArray = Object.entries(parseResult.orderBy).map(
|
||||
([orderByField, orderByCondition]) => {
|
||||
const nullsCondition = isDefined(orderByCondition.nulls)
|
||||
? ` ${orderByCondition.nulls}`
|
||||
@@ -154,7 +199,7 @@ export class GraphqlQueryParser {
|
||||
groupByFields: GroupByField[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): WorkspaceSelectQueryBuilder<any> {
|
||||
const parsedOrderBys = this.orderFieldParser.parseForGroupBy({
|
||||
const parsedOrderBys = this.orderGroupByParser.parse({
|
||||
orderBy,
|
||||
groupByFields,
|
||||
});
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ export class ObjectMetadataGqlInputTypeGenerator {
|
||||
this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({
|
||||
flatObjectMetadata,
|
||||
fields,
|
||||
context,
|
||||
});
|
||||
this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({
|
||||
flatObjectMetadata,
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import { GraphQLInputObjectType } from 'graphql';
|
||||
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
|
||||
import { ObjectMetadataOrderByBaseGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator';
|
||||
import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage';
|
||||
import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-schema-builder/types/schema-generation-context.type';
|
||||
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
@@ -23,9 +24,11 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator {
|
||||
public buildAndStore({
|
||||
flatObjectMetadata,
|
||||
fields,
|
||||
context,
|
||||
}: {
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fields: FlatFieldMetadata[];
|
||||
context: SchemaGenerationContext;
|
||||
}) {
|
||||
const inputType = new GraphQLInputObjectType({
|
||||
name: `${pascalCase(flatObjectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderBy.toString()}Input`,
|
||||
@@ -34,6 +37,7 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator {
|
||||
this.objectMetadataOrderByBaseGenerator.generateFields({
|
||||
fields,
|
||||
logger: this.logger,
|
||||
context,
|
||||
}),
|
||||
}) as GraphQLInputObjectType;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type ObjectRecord,
|
||||
type ObjectRecordOrderByForCompositeField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
@@ -13,6 +14,8 @@ 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);
|
||||
@@ -91,3 +94,31 @@ export const validateAndGetOrderByForCompositeField = (
|
||||
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
|
||||
const TEST_COMPANY_IDS = {
|
||||
ALPHA: '20202020-aaaa-4000-8000-000000000001',
|
||||
BETA: '20202020-aaaa-4000-8000-000000000002',
|
||||
GAMMA: '20202020-aaaa-4000-8000-000000000003',
|
||||
};
|
||||
|
||||
const TEST_PERSON_IDS = [
|
||||
'20202020-bbbb-4000-8000-000000000001',
|
||||
'20202020-bbbb-4000-8000-000000000002',
|
||||
'20202020-bbbb-4000-8000-000000000003',
|
||||
'20202020-bbbb-4000-8000-000000000004',
|
||||
'20202020-bbbb-4000-8000-000000000005',
|
||||
'20202020-bbbb-4000-8000-000000000006',
|
||||
'20202020-bbbb-4000-8000-000000000007',
|
||||
'20202020-bbbb-4000-8000-000000000008',
|
||||
'20202020-bbbb-4000-8000-000000000009',
|
||||
'20202020-bbbb-4000-8000-000000000010',
|
||||
];
|
||||
|
||||
describe('Order by relation field (e2e)', () => {
|
||||
beforeAll(async () => {
|
||||
// Create test companies with distinct names for sorting verification
|
||||
const createCompanies = createManyOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
objectMetadataPluralName: 'companies',
|
||||
gqlFields: 'id name',
|
||||
data: [
|
||||
{ id: TEST_COMPANY_IDS.ALPHA, name: 'Alpha Corp' },
|
||||
{ id: TEST_COMPANY_IDS.BETA, name: 'Beta Inc' },
|
||||
{ id: TEST_COMPANY_IDS.GAMMA, name: 'Gamma LLC' },
|
||||
],
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createCompanies);
|
||||
|
||||
// Create test people with company relations and some without (for null testing)
|
||||
const createPeople = createManyOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
gqlFields: 'id',
|
||||
data: [
|
||||
// People with companies (for testing sorting)
|
||||
{ id: TEST_PERSON_IDS[0], companyId: TEST_COMPANY_IDS.ALPHA },
|
||||
{ id: TEST_PERSON_IDS[1], companyId: TEST_COMPANY_IDS.ALPHA },
|
||||
{ id: TEST_PERSON_IDS[2], companyId: TEST_COMPANY_IDS.BETA },
|
||||
{ id: TEST_PERSON_IDS[3], companyId: TEST_COMPANY_IDS.BETA },
|
||||
{ id: TEST_PERSON_IDS[4], companyId: TEST_COMPANY_IDS.GAMMA },
|
||||
{ id: TEST_PERSON_IDS[5], companyId: TEST_COMPANY_IDS.GAMMA },
|
||||
// People without companies (for testing NULLS LAST)
|
||||
{ id: TEST_PERSON_IDS[6], companyId: null },
|
||||
{ id: TEST_PERSON_IDS[7], companyId: null },
|
||||
{ id: TEST_PERSON_IDS[8], companyId: null },
|
||||
{ id: TEST_PERSON_IDS[9], companyId: null },
|
||||
],
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
await makeGraphqlAPIRequest(createPeople);
|
||||
});
|
||||
|
||||
it('should sort people by company name ascending', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
company {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const edges = response.body.data.people.edges;
|
||||
|
||||
expect(Array.isArray(edges)).toBe(true);
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify company names are in ascending order (excluding nulls at the end)
|
||||
const companyNames = edges
|
||||
.map(
|
||||
(edge: { node: { company?: { name: string } } }) =>
|
||||
edge.node.company?.name,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const sortedCompanyNames = [...companyNames].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
expect(companyNames).toEqual(sortedCompanyNames);
|
||||
});
|
||||
|
||||
it('should sort people by company name descending', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
company {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'DescNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const edges = response.body.data.people.edges;
|
||||
|
||||
expect(Array.isArray(edges)).toBe(true);
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify company names are in descending order (excluding nulls at the end)
|
||||
const companyNames = edges
|
||||
.map(
|
||||
(edge: { node: { company?: { name: string } } }) =>
|
||||
edge.node.company?.name,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const sortedCompanyNames = [...companyNames].sort((a, b) =>
|
||||
b.localeCompare(a),
|
||||
);
|
||||
|
||||
expect(companyNames).toEqual(sortedCompanyNames);
|
||||
});
|
||||
|
||||
it('should handle null relations with NULLS LAST', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
company {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const edges = response.body.data.people.edges;
|
||||
|
||||
expect(Array.isArray(edges)).toBe(true);
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that null companies appear at the end
|
||||
let seenNull = false;
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.node.company === null) {
|
||||
seenNull = true;
|
||||
} else if (seenNull) {
|
||||
// If we already saw a null, subsequent non-nulls mean order is wrong
|
||||
throw new Error('Records with null company should appear at the end');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with offset pagination', async () => {
|
||||
// First request to get initial data
|
||||
const firstQueryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
$limit: Int
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: $limit) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
limit: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const firstResponse = await makeGraphqlAPIRequest(firstQueryData);
|
||||
|
||||
expect(firstResponse.body.data).toBeDefined();
|
||||
expect(firstResponse.body.errors).toBeUndefined();
|
||||
|
||||
const firstPageEdges = firstResponse.body.data.people.edges;
|
||||
const totalCount = firstResponse.body.data.people.totalCount;
|
||||
|
||||
expect(Array.isArray(firstPageEdges)).toBe(true);
|
||||
expect(firstPageEdges.length).toBeGreaterThan(0);
|
||||
expect(totalCount).toBeGreaterThan(3);
|
||||
|
||||
// Second request using offset (matching frontend behavior)
|
||||
const secondQueryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
) {
|
||||
people(
|
||||
orderBy: $orderBy
|
||||
filter: $filter
|
||||
first: $limit
|
||||
offset: $offset
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
limit: 3,
|
||||
offset: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const secondResponse = await makeGraphqlAPIRequest(secondQueryData);
|
||||
|
||||
expect(secondResponse.body.data).toBeDefined();
|
||||
expect(secondResponse.body.errors).toBeUndefined();
|
||||
|
||||
const secondPageEdges = secondResponse.body.data.people.edges;
|
||||
|
||||
expect(Array.isArray(secondPageEdges)).toBe(true);
|
||||
|
||||
// Verify different records are returned (no overlap)
|
||||
const firstPageIds = firstPageEdges.map(
|
||||
(edge: { node: { id: string } }) => edge.node.id,
|
||||
);
|
||||
const secondPageIds = secondPageEdges.map(
|
||||
(edge: { node: { id: string } }) => edge.node.id,
|
||||
);
|
||||
const overlap = firstPageIds.filter((id: string) =>
|
||||
secondPageIds.includes(id),
|
||||
);
|
||||
|
||||
expect(overlap.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return clear error when using cursor pagination with relation orderBy', async () => {
|
||||
// First get a cursor by fetching records
|
||||
const firstQueryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: 3) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
},
|
||||
};
|
||||
|
||||
const firstResponse = await makeGraphqlAPIRequest(firstQueryData);
|
||||
|
||||
expect(firstResponse.body.data).toBeDefined();
|
||||
expect(firstResponse.body.errors).toBeUndefined();
|
||||
|
||||
const pageInfo = firstResponse.body.data.people.pageInfo;
|
||||
|
||||
// Assert we have enough data for pagination test
|
||||
expect(pageInfo.hasNextPage).toBe(true);
|
||||
expect(pageInfo.endCursor).toBeDefined();
|
||||
|
||||
// Try to use cursor with relation orderBy - should fail with clear error
|
||||
const secondQueryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
$after: String
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: 3, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ company: { name: 'AscNullsLast' } }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
after: pageInfo.endCursor,
|
||||
},
|
||||
};
|
||||
|
||||
const secondResponse = await makeGraphqlAPIRequest(secondQueryData);
|
||||
|
||||
expect(secondResponse.body.errors).toBeDefined();
|
||||
expect(secondResponse.body.errors[0].message).toContain(
|
||||
'Cursor-based pagination is not supported with relation field ordering',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow sorting by relation FK (backward compatibility)', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query People(
|
||||
$orderBy: [PersonOrderByInput]
|
||||
$filter: PersonFilterInput
|
||||
) {
|
||||
people(orderBy: $orderBy, filter: $filter, first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
companyId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderBy: [{ companyId: 'AscNullsLast' }],
|
||||
filter: { id: { in: TEST_PERSON_IDS } },
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const edges = response.body.data.people.edges;
|
||||
|
||||
expect(Array.isArray(edges)).toBe(true);
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user