feat: add sorting on relation fields (Many-to-One) (#17021)

## Summary

This PR enables sorting records by fields of related objects. For
example, sorting **People by their Company's name**.

### Before
Only scalar and composite fields could be sorted. Relation fields showed
in the sort dropdown but produced errors.

### After
Many-to-One relation fields can now be sorted using the related object's
**label identifier field** (e.g., Company's `name`).

---

## Changes

### Frontend
- Added `RELATION` to sortable field types (restricted to `MANY_TO_ONE`
relations)
- New `getOrderByForRelationField()` generates nested orderBy structures
using the related object's label identifier
- Updated `turnSortsIntoOrderBy()` to handle relation fields by looking
up related object metadata

### Backend
- Extended `GraphqlQueryOrderFieldParser.parse()` to detect nested
relation ordering like `{ company: { name: 'AscNullsLast' } }`
- Returns `ParseOrderByResult` containing both `orderBy` conditions and
`relationJoins` info
- Added LEFT JOINs for relation ordering in `applyOrderToBuilder()`
- Added `addRelationOrderColumnsToBuilder()` for TypeORM DISTINCT
compatibility

### Tests
- Added unit tests for `filterSortableFieldMetadataItems`,
`getOrderByForRelationField`, and `turnSortsIntoOrderBy`
- Added integration tests covering ascending/descending order and
composite label identifiers

---

## TypeORM Bug Workaround

We encountered a significant TypeORM limitation when implementing this
feature. When using `getMany()` with `ORDER BY` on joined relation
columns, TypeORM generates a DISTINCT subquery that has specific
requirements:

### Issue 1: Alias Parsing
TypeORM's `orderBy()` method fails with **"alias not found"** when using
quoted SQL identifiers like `"company"."name"`. TypeORM internally
expects unquoted property paths (e.g., `company.name`) for its alias
resolution mechanism.

### Issue 2: setFindOptions Clears addSelect
`setFindOptions({ select })` **clears any previously added `addSelect()`
columns**. This caused `"column distinctAlias.company_name does not
exist"` errors because the relation columns needed for ORDER BY were
being removed.

### Solution
We split the logic into two methods:
1. `applyOrderToBuilder()` - adds JOINs and ORDER BY (before
`setFindOptions`)
2. `addRelationOrderColumnsToBuilder()` - adds relation columns for
SELECT (AFTER `setFindOptions`)

This ensures the relation columns are present in the final SQL query's
SELECT clause with the proper underscore aliases (`company_name`) that
TypeORM's DISTINCT subquery expects.

**Related TypeORM issue**:
https://github.com/typeorm/typeorm/issues/9921

---

## Screenshots/Demo

_Add screenshots if applicable_
This commit is contained in:
Félix Malfait
2026-01-10 11:02:16 +01:00
committed by GitHub
parent 5a33b36b85
commit 1a5675d63e
30 changed files with 2011 additions and 688 deletions
@@ -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) {
@@ -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;
}
}
@@ -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;
@@ -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;
};
}
@@ -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;
};
}
}
@@ -0,0 +1,4 @@
export type OrderByClause = {
order: 'ASC' | 'DESC';
nulls?: 'NULLS FIRST' | 'NULLS LAST';
};
@@ -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[];
};
@@ -0,0 +1,3 @@
export type RelationJoinInfo = {
joinAlias: string;
};
@@ -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 {
@@ -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 '';
};
@@ -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)
);
};
@@ -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);
};
@@ -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,
});
@@ -43,6 +43,7 @@ export class ObjectMetadataGqlInputTypeGenerator {
this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({
flatObjectMetadata,
fields,
context,
});
this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({
flatObjectMetadata,
@@ -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
);
};