diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index 5da0d7bf6e..e486e2535a 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -1,29 +1,43 @@ -import { InternalServerErrorException } from '@nestjs/common'; - -import { FieldMetadataType } from 'twenty-shared/types'; -import { capitalize } from 'twenty-shared/utils'; +import { isObject } from 'class-validator'; +import { + FieldMetadataType, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { - type ObjectRecordOrderBy, OrderByDirection, + type AggregateOrderByWithGroupByField, + type ObjectRecordOrderBy, + type ObjectRecordOrderByForCompositeField, + type ObjectRecordOrderByForScalarField, + type ObjectRecordOrderByWithGroupByDateField, + type OrderByWithGroupBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { GraphqlQueryRunnerException, 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 { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; +import { + type GroupByDateField, + type GroupByField, +} 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, + type AggregationField, } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; -import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; -type OrderByCondition = { +export type OrderByCondition = { order: 'ASC' | 'DESC'; nulls?: 'NULLS FIRST' | 'NULLS LAST'; }; @@ -39,16 +53,7 @@ export class GraphqlQueryOrderFieldParser { orderBy: ObjectRecordOrderBy, objectNameSingular: string, isForwardPagination = true, - isGroupBy = false, ): Record { - if (isGroupBy) { - return this.parseForGroupBy( - orderBy, - objectNameSingular, - isForwardPagination, - ); - } - return orderBy.reduce( (acc, item) => { Object.entries(item).forEach(([key, value]) => { @@ -64,7 +69,7 @@ export class GraphqlQueryOrderFieldParser { } if (isCompositeFieldMetadataType(fieldMetadata.type)) { - const compositeOrder = this.parseCompositeFieldForOrder( + const compositeOrder = parseCompositeFieldForOrder( fieldMetadata, value, objectNameSingular, @@ -77,7 +82,7 @@ export class GraphqlQueryOrderFieldParser { this.getOptionalOrderByCasting(fieldMetadata); acc[`"${objectNameSingular}"."${key}"${orderByCasting}`] = - this.convertOrderByToFindOptionsOrder( + convertOrderByToFindOptionsOrder( value as OrderByDirection, isForwardPagination, ); @@ -90,51 +95,104 @@ export class GraphqlQueryOrderFieldParser { ); } - private parseForGroupBy( - orderBy: ObjectRecordOrderBy, - objectNameSingular: string, - isForwardPagination = true, - ): Record { + parseForGroupBy({ + orderBy, + groupByFields, + }: { + orderBy: OrderByWithGroupBy; + groupByFields: GroupByField[]; + }): Record[] { + let parsedOrderBy: Record[] = []; + const availableAggregations: Record = getAvailableAggregationsFromObjectFields( Object.values(this.objectMetadataMapItem.fieldsById), ); - let orderByExpressionsAndConditions: Record = {}; - - for (const orderByCondition of orderBy) { - for (const [aggregatedOrderByCondition, direction] of Object.entries( - orderByCondition, - )) { - const selectedAggregation = - availableAggregations[aggregatedOrderByCondition]; - - if (!selectedAggregation) { - throw new InternalServerErrorException( - `Selected aggregation not found for ${aggregatedOrderByCondition}`, - ); - } - - const expression = ProcessAggregateHelper.getAggregateExpression( - selectedAggregation, - objectNameSingular, + for (const orderByArg of orderBy) { + if (this.isAggregateOrderByArg(orderByArg)) { + const parsedAggregateOrderBy = this.parseAggregateOrderByArg( + availableAggregations, + orderByArg, + this.objectMetadataMapItem, ); - if (!expression) { - throw new InternalServerErrorException( - `Aggregate expression not found for ${aggregatedOrderByCondition}`, - ); + parsedOrderBy.push(parsedAggregateOrderBy); + continue; + } + + const fieldName = Object.keys(orderByArg)[0]; + const fieldMetadataId = + this.objectMetadataMapItem.fieldIdByName[fieldName]; + const fieldMetadata = + this.objectMetadataMapItem.fieldsById[fieldMetadataId]; + + if (!isDefined(fieldMetadata)) { + throw new UserInputError(`Cannot orderBy unknown field: ${fieldName}.`); + } + + if (this.isObjectRecordOrderByForScalarField(orderByArg)) { + const parsedOrderByForScalarField = + this.parseObjectRecordOrderByForScalarField({ + groupByFields, + orderByArg, + objectMetadataItemWithFieldMaps: this.objectMetadataMapItem, + fieldMetadata, + }); + + if (!isDefined(parsedOrderByForScalarField)) { + continue; } - orderByExpressionsAndConditions[expression] = - this.convertOrderByToFindOptionsOrder(direction, 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.isObjectRecordOrderByForCompositeField(orderByArg)) { + const parsedOrderByForCompositeField = + this.parseObjectRecordOrderByForCompositeField({ + groupByFields, + orderByArg, + objectMetadataItemWithFieldMaps: this.objectMetadataMapItem, + fieldMetadata, + }); + + if (!isDefined(parsedOrderByForCompositeField)) { + continue; + } + + parsedOrderBy.push(parsedOrderByForCompositeField); + continue; + } + + throw new UserInputError(`Unknown orderBy value: ${orderByArg}`); } - return orderByExpressionsAndConditions; + return parsedOrderBy; } - private getOptionalOrderByCasting( + getOptionalOrderByCasting( fieldMetadata: Pick, ): string { if ( @@ -147,87 +205,299 @@ export class GraphqlQueryOrderFieldParser { return ''; } - private parseCompositeFieldForOrder( - fieldMetadata: FieldMetadataEntity, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, - objectNameSingular: string, - isForwardPagination = true, - ): Record { - const compositeType = compositeTypeDefinitions.get( - fieldMetadata.type as CompositeFieldMetadataType, - ); + isAggregateOrderByArg = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | AggregateOrderByWithGroupByField + | ObjectRecordOrderByWithGroupByDateField, + ): orderByArg is AggregateOrderByWithGroupByField => { + return isDefined(orderByArg.aggregate); + }; - if (!compositeType) { - throw new Error( - `Composite type definition not found for type: ${fieldMetadata.type}`, + isObjectRecordOrderByForScalarField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField, + ): orderByArg is ObjectRecordOrderByForScalarField => { + if (Object.keys(orderByArg).length > 1) { + throw new UserInputError( + 'Please provide orderBy field criteria one by one in orderBy array', ); } - return Object.entries(value).reduce( - (acc, [subFieldKey, subFieldValue]) => { - const subFieldMetadata = compositeType.properties.find( - (property) => property.name === subFieldKey, - ); + const scalarFieldOrCompositeFieldOrderByValue = + Object.values(orderByArg)[0]; - if (!subFieldMetadata) { - throw new Error( - `Sub field metadata not found for composite type: ${fieldMetadata.type}`, - ); - } - - const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`; - - if (!this.isOrderByDirection(subFieldValue)) { - throw new Error( - `Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`, - ); - } - acc[fullFieldName] = this.convertOrderByToFindOptionsOrder( - subFieldValue, - isForwardPagination, - ); - - return acc; - }, - {} as Record, - ); - } - - private convertOrderByToFindOptionsOrder( - direction: OrderByDirection, - isForwardPagination = true, - ): OrderByCondition { - switch (direction) { - case OrderByDirection.AscNullsFirst: - return { - order: isForwardPagination ? 'ASC' : 'DESC', - nulls: 'NULLS FIRST', - }; - case OrderByDirection.AscNullsLast: - return { - order: isForwardPagination ? 'ASC' : 'DESC', - nulls: 'NULLS LAST', - }; - case OrderByDirection.DescNullsFirst: - return { - order: isForwardPagination ? 'DESC' : 'ASC', - nulls: 'NULLS FIRST', - }; - case OrderByDirection.DescNullsLast: - return { - order: isForwardPagination ? 'DESC' : 'ASC', - nulls: 'NULLS LAST', - }; - default: - throw new GraphqlQueryRunnerException( - `Invalid direction: ${direction}`, - GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION, - ); + if ( + Object.values(OrderByDirection).includes( + scalarFieldOrCompositeFieldOrderByValue, + ) + ) { + return true; } - } - private isOrderByDirection(value: unknown): value is OrderByDirection { - return Object.values(OrderByDirection).includes(value as OrderByDirection); - } + return false; + }; + + isObjectRecordOrderByForCompositeField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField, + ): 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, + 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, + orderByArg: AggregateOrderByWithGroupByField, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + ): Record => { + 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, + objectMetadataItemWithFieldMaps.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, + objectMetadataItemWithFieldMaps, + fieldMetadata, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByForScalarField; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + fieldMetadata: FieldMetadataEntity; + }): Record | 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 { + [`"${objectMetadataItemWithFieldMaps.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]: + convertOrderByToFindOptionsOrder(orderByDirection), + }; + }; + + parseObjectRecordOrderByForCompositeField = ({ + groupByFields, + orderByArg, + objectMetadataItemWithFieldMaps, + fieldMetadata, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByForCompositeField; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + fieldMetadata: FieldMetadataEntity; + }): Record | 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.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, + objectMetadataItemWithFieldMaps.nameSingular, + ); + }; + + parseObjectRecordOrderByWithGroupByDateField = ({ + groupByFields, + orderByArg, + fieldMetadataId, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByWithGroupByDateField; + fieldMetadataId: string; + }): Record | 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, + ); + + 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), + }; + }; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts new file mode 100644 index 0000000000..0c3d2b5e77 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order.ts @@ -0,0 +1,40 @@ +import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +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'; + +export const convertOrderByToFindOptionsOrder = ( + direction: OrderByDirection, + isForwardPagination = true, +): OrderByCondition => { + switch (direction) { + case OrderByDirection.AscNullsFirst: + return { + order: isForwardPagination ? 'ASC' : 'DESC', + nulls: 'NULLS FIRST', + }; + case OrderByDirection.AscNullsLast: + return { + order: isForwardPagination ? 'ASC' : 'DESC', + nulls: 'NULLS LAST', + }; + case OrderByDirection.DescNullsFirst: + return { + order: isForwardPagination ? 'DESC' : 'ASC', + nulls: 'NULLS FIRST', + }; + case OrderByDirection.DescNullsLast: + return { + order: isForwardPagination ? 'DESC' : 'ASC', + nulls: 'NULLS LAST', + }; + default: + throw new GraphqlQueryRunnerException( + `Invalid direction: ${direction}`, + GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION, + ); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts new file mode 100644 index 0000000000..add47f6b12 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts @@ -0,0 +1,60 @@ +import { capitalize } from 'twenty-shared/utils'; + +import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type OrderByCondition } 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 { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; + +export const parseCompositeFieldForOrder = ( + fieldMetadata: FieldMetadataEntity, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + objectNameSingular: string, + isForwardPagination = true, +): Record => { + const compositeType = compositeTypeDefinitions.get( + fieldMetadata.type as CompositeFieldMetadataType, + ); + + if (!compositeType) { + throw new Error( + `Composite type definition not found for type: ${fieldMetadata.type}`, + ); + } + + return Object.entries(value).reduce( + (acc, [subFieldKey, subFieldValue]) => { + const subFieldMetadata = compositeType.properties.find( + (property) => property.name === subFieldKey, + ); + + if (!subFieldMetadata) { + throw new Error( + `Sub field metadata not found for composite type: ${fieldMetadata.type}`, + ); + } + + const fullFieldName = `"${objectNameSingular}"."${fieldMetadata.name}${capitalize(subFieldKey)}"`; + + if (!isOrderByDirection(subFieldValue)) { + throw new Error( + `Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`, + ); + } + acc[fullFieldName] = convertOrderByToFindOptionsOrder( + subFieldValue, + isForwardPagination, + ); + + return acc; + }, + {} as Record, + ); +}; + +const isOrderByDirection = (value: unknown): value is OrderByDirection => { + return Object.values(OrderByDirection).includes(value as OrderByDirection); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index c201fa73bf..f58456f8f0 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -3,6 +3,7 @@ import { type FindOptionsWhere, type ObjectLiteral } from 'typeorm'; import { type ObjectRecordFilter, type ObjectRecordOrderBy, + type OrderByWithGroupBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { @@ -15,6 +16,7 @@ import { GraphqlQuerySelectedFieldsParser, type GraphqlQuerySelectedFieldsResult, } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; +import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; @@ -97,40 +99,45 @@ export class GraphqlQueryParser { public applyOrderToBuilder( // eslint-disable-next-line @typescript-eslint/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, - orderBy: ObjectRecordOrderBy, + orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, objectNameSingular: string, isForwardPagination = true, - isGroupBy = false, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): WorkspaceSelectQueryBuilder { const parsedOrderBys = this.orderFieldParser.parse( - orderBy, + orderBy as ObjectRecordOrderBy, objectNameSingular, isForwardPagination, - isGroupBy, ); - if (isGroupBy) { - Object.entries(parsedOrderBys).forEach( - ([expression, direction], index) => { - if (index === 0) { - queryBuilder.orderBy(expression, direction.order, direction.nulls); - } else { - queryBuilder.addOrderBy( - expression, - direction.order, - direction.nulls, - ); - } - }, - ); - - return queryBuilder; - } - return queryBuilder.orderBy(parsedOrderBys); } + public applyGroupByOrderToBuilder( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryBuilder: WorkspaceSelectQueryBuilder, + orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, + groupByFields: GroupByField[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): WorkspaceSelectQueryBuilder { + const parsedOrderBys = this.orderFieldParser.parseForGroupBy({ + orderBy, + groupByFields, + }); + + parsedOrderBys.forEach((orderByField, index) => { + Object.entries(orderByField).forEach(([expression, direction]) => { + if (index === 0) { + queryBuilder.orderBy(expression, direction.order, direction.nulls); + } else { + queryBuilder.addOrderBy(expression, direction.order, direction.nulls); + } + }); + }); + + return queryBuilder; + } + public parseSelectedFields( parentObjectMetadata: ObjectMetadataItemWithFieldMaps, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts index c1f2367362..6a74300393 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts @@ -137,15 +137,10 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver } }); - let forwardPagination; // TODO - const isGroupBy = true; - - executionArgs.graphqlQueryParser.applyOrderToBuilder( + executionArgs.graphqlQueryParser.applyGroupByOrderToBuilder( queryBuilder, executionArgs.args.orderBy ?? [], - objectMetadataNameSingular, - forwardPagination, - isGroupBy, + groupByFields, ); const result = await queryBuilder.getRawMany(); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index 5d12b0944a..8c74336473 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -46,6 +46,26 @@ export type ObjectRecordOrderBy = Array< ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField >; +export type AggregateOrderByWithGroupByField = { + aggregate: { + [x: string]: OrderByDirection; + }; +}; + +export type ObjectRecordOrderByWithGroupByDateField = { + [Property in keyof ObjectRecord]?: { + orderBy: OrderByDirection; + granularity: ObjectRecordGroupByDateGranularity; + }; +}; + +export type OrderByWithGroupBy = Array< + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | AggregateOrderByWithGroupByField +>; + export type ObjectRecordOrderByForScalarField = { [Property in keyof ObjectRecord]?: OrderByDirection; }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 4f82cb3ace..b83c805a28 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -5,6 +5,7 @@ import { type ObjectRecordFilter, type ObjectRecordGroupBy, type ObjectRecordOrderBy, + type OrderByWithGroupBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories'; @@ -70,7 +71,7 @@ export interface GroupByResolverArgs { filter?: Filter; groupBy: ObjectRecordGroupBy; viewId?: string; - orderBy?: ObjectRecordOrderBy; + orderBy?: OrderByWithGroupBy; } export interface UpdateOneResolverArgs< diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts index e644681e6d..b5edc34922 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts @@ -2,40 +2,77 @@ import { Injectable } from '@nestjs/common'; import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; -export const GROUP_BY_DATE_GRANULARITY_INPUT_KEY = 'GroupByDateInput'; +export const GROUP_BY_DATE_GRANULARITY_INPUT_KEY = + 'GroupByDateGranularityInput'; +export const ORDER_BY_DATE_GRANULARITY_INPUT_KEY = + 'OrderByDateGranularityInput'; @Injectable() export class GroupByDateGranularityInputTypeGenerator { constructor(private readonly gqlTypesStorage: GqlTypesStorage) {} public buildAndStore() { - const key = GROUP_BY_DATE_GRANULARITY_INPUT_KEY; - const type = new GraphQLInputObjectType({ + this.gqlTypesStorage.addGqlType( + 'DateGranularityEnum', + new GraphQLEnumType({ + name: 'DateGranularityEnum', + values: Object.entries(ObjectRecordGroupByDateGranularity).reduce( + (acc, [key, value]) => { + acc[key] = { value }; + + return acc; + }, + {} as Record, + ), + description: + 'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)', + }), + ); + + const dateGranularityEnum = this.gqlTypesStorage.getGqlTypeByKey( + 'DateGranularityEnum', + ) as GraphQLEnumType; + + if (!isDefined(dateGranularityEnum)) { + throw new Error('DateGranularityEnum not found'); + } + + const groupByDateField = new GraphQLInputObjectType({ name: GROUP_BY_DATE_GRANULARITY_INPUT_KEY, fields: { granularity: { - type: new GraphQLEnumType({ - name: 'DateGranularityEnum', - values: Object.entries(ObjectRecordGroupByDateGranularity).reduce( - (acc, [key, value]) => { - acc[key] = { value }; - - return acc; - }, - {} as Record, - ), - description: - 'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)', - }), + type: dateGranularityEnum, description: 'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)', }, }, }); - this.gqlTypesStorage.addGqlType(key, type); + this.gqlTypesStorage.addGqlType( + GROUP_BY_DATE_GRANULARITY_INPUT_KEY, + groupByDateField, + ); + + const orderByDateField = new GraphQLInputObjectType({ + name: ORDER_BY_DATE_GRANULARITY_INPUT_KEY, + fields: { + orderBy: { + type: OrderByDirectionType, + }, + granularity: { + type: dateGranularityEnum, + }, + }, + }); + + this.gqlTypesStorage.addGqlType( + ORDER_BY_DATE_GRANULARITY_INPUT_KEY, + orderByDateField, + ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts new file mode 100644 index 0000000000..dcbd4a6043 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts @@ -0,0 +1,164 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { GraphQLInputFieldConfigMap, isInputObjectType } from 'graphql'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; +import { ORDER_BY_DATE_GRANULARITY_INPUT_KEY } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator'; +import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; +import { + TypeMapperService, + TypeOptions, +} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; +import { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util'; +import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; +import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; + +@Injectable() +export class ObjectMetadataOrderByBaseGenerator { + constructor( + private readonly gqlTypesStorage: GqlTypesStorage, + private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator, + private readonly typeMapperService: TypeMapperService, + ) {} + + public generateFields({ + objectMetadata, + logger, + orderByDateGranularity, + }: { + objectMetadata: ObjectMetadataEntity; + logger: Logger; + orderByDateGranularity?: boolean; + }): GraphQLInputFieldConfigMap { + const allGeneratedFields: GraphQLInputFieldConfigMap = {}; + + for (const fieldMetadata of objectMetadata.fields) { + fieldMetadata.isNullable = true; + + const typeOptions = computeFieldInputTypeOptions( + fieldMetadata, + GqlInputTypeDefinitionKind.OrderBy, + ); + + let generatedFields; + + if (isFieldMetadataRelationOrMorphRelation(fieldMetadata)) { + generatedFields = + this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldOrderByInputType( + { + fieldMetadata, + typeOptions, + }, + ); + } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateCompositeFieldOrderByInputType( + fieldMetadata, + typeOptions, + logger, + ); + } else { + generatedFields = this.generateAtomicFieldOrderByInputType({ + fieldMetadata, + typeOptions, + orderByDateGranularity, + logger, + }); + } + + Object.assign(allGeneratedFields, generatedFields); + } + + return allGeneratedFields; + } + + private generateCompositeFieldOrderByInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + logger: Logger, + ) { + const key = computeCompositeFieldInputTypeKey( + fieldMetadata.type, + GqlInputTypeDefinitionKind.OrderBy, + ); + + const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: compositeType, + description: fieldMetadata.description, + }, + }; + } + + private generateAtomicFieldOrderByInputType({ + fieldMetadata, + typeOptions, + logger, + orderByDateGranularity, + }: { + fieldMetadata: FieldMetadataEntity; + typeOptions: TypeOptions; + logger: Logger; + orderByDateGranularity?: boolean; + }) { + if ( + orderByDateGranularity === true && + (fieldMetadata.type === FieldMetadataType.DATE || + fieldMetadata.type === FieldMetadataType.DATE_TIME) + ) { + const orderByDateGranularityInputType = + this.gqlTypesStorage.getGqlTypeByKey( + ORDER_BY_DATE_GRANULARITY_INPUT_KEY, + ); + + if (!isDefined(orderByDateGranularityInputType)) { + throw new Error('OrderByDateGranularityInputType not found'); + } + + return { + [fieldMetadata.name]: { + type: orderByDateGranularityInputType, + description: fieldMetadata.description, + }, + }; + } + + const orderByType = this.typeMapperService.mapToOrderByType( + fieldMetadata.type, + ); + + if (!isDefined(orderByType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: orderByType, + description: fieldMetadata.description, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts index 240f7e1bf7..dcadf0d363 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts @@ -1,25 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; -import { - GraphQLInputFieldConfigMap, - GraphQLInputObjectType, - isInputObjectType, -} from 'graphql'; -import { isDefined } from 'twenty-shared/utils'; +import { GraphQLInputObjectType } from 'graphql'; import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; -import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; -import { - TypeMapperService, - TypeOptions, -} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +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 { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util'; -import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; -import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { pascalCase } from 'src/utils/pascal-case'; @@ -30,8 +16,7 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { ); constructor( private readonly gqlTypesStorage: GqlTypesStorage, - private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator, - private readonly typeMapperService: TypeMapperService, + private readonly objectMetadataOrderByBaseGenerator: ObjectMetadataOrderByBaseGenerator, ) {} public buildAndStore({ @@ -42,7 +27,11 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { const inputType = new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderBy.toString()}Input`, description: objectMetadata.description, - fields: () => this.generateFields(objectMetadata), + fields: () => + this.objectMetadataOrderByBaseGenerator.generateFields({ + objectMetadata, + logger: this.logger, + }), }) as GraphQLInputObjectType; const key = computeObjectMetadataInputTypeKey( @@ -52,100 +41,4 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { this.gqlTypesStorage.addGqlType(key, inputType); } - - private generateFields( - objectMetadata: ObjectMetadataEntity, - ): GraphQLInputFieldConfigMap { - const allGeneratedFields: GraphQLInputFieldConfigMap = {}; - - for (const fieldMetadata of objectMetadata.fields) { - fieldMetadata.isNullable = true; - - const typeOptions = computeFieldInputTypeOptions( - fieldMetadata, - GqlInputTypeDefinitionKind.OrderBy, - ); - - let generatedFields; - - if (isFieldMetadataRelationOrMorphRelation(fieldMetadata)) { - generatedFields = - this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldOrderByInputType( - { - fieldMetadata, - typeOptions, - }, - ); - } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { - generatedFields = this.generateCompositeFieldOrderByInputType( - fieldMetadata, - typeOptions, - ); - } else { - generatedFields = this.generateAtomicFieldOrderByInputType( - fieldMetadata, - typeOptions, - ); - } - - Object.assign(allGeneratedFields, generatedFields); - } - - return allGeneratedFields; - } - - private generateCompositeFieldOrderByInputType( - fieldMetadata: FieldMetadataEntity, - typeOptions: TypeOptions, - ) { - const key = computeCompositeFieldInputTypeKey( - fieldMetadata.type, - GqlInputTypeDefinitionKind.OrderBy, - ); - - const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - fieldMetadata, - typeOptions, - }); - throw new Error(message); - } - - return { - [fieldMetadata.name]: { - type: compositeType, - description: fieldMetadata.description, - }, - }; - } - - private generateAtomicFieldOrderByInputType( - fieldMetadata: FieldMetadataEntity, - typeOptions: TypeOptions, - ) { - const orderByType = this.typeMapperService.mapToOrderByType( - fieldMetadata.type, - ); - - if (!isDefined(orderByType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - fieldMetadata, - typeOptions, - }); - throw new Error(message); - } - - return { - [fieldMetadata.name]: { - type: orderByType, - description: fieldMetadata.description, - }, - }; - } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts index 8a1a481642..a17d4b0fe6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts @@ -8,6 +8,7 @@ import { import { isDefined } from 'twenty-shared/utils'; 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 { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; import { TypeMapperService, @@ -32,6 +33,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { constructor( private readonly gqlTypesStorage: GqlTypesStorage, private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator, + private readonly objectMetadataOrderByBaseGenerator: ObjectMetadataOrderByBaseGenerator, private readonly typeMapperService: TypeMapperService, ) {} @@ -57,7 +59,16 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { private generateFields( objectMetadata: ObjectMetadataEntity, ): GraphQLInputFieldConfigMap { - const allGeneratedFields: GraphQLInputFieldConfigMap = {}; + return { + ...this.generateOrderByOnAggregateFields(objectMetadata), + ...this.generateOrderByOnDimensionValuesFields(objectMetadata), + }; + } + + private generateOrderByOnAggregateFields( + objectMetadata: ObjectMetadataEntity, + ): GraphQLInputFieldConfigMap { + const allAggregatedFields: GraphQLInputFieldConfigMap = {}; for (const fieldMetadata of objectMetadata.fields) { fieldMetadata.isNullable = true; @@ -89,10 +100,31 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { ); } - Object.assign(allGeneratedFields, generatedFields); + Object.assign(allAggregatedFields, generatedFields); } - return allGeneratedFields; + const aggregateInputType = new GraphQLInputObjectType({ + name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}AggregateInput`, + description: 'Aggregate-based ordering', + fields: () => allAggregatedFields, + }) as GraphQLInputObjectType; + + return { + aggregate: { + type: aggregateInputType, + description: 'Order by aggregate values', + }, + }; + } + + private generateOrderByOnDimensionValuesFields( + objectMetadata: ObjectMetadataEntity, + ) { + return this.objectMetadataOrderByBaseGenerator.generateFields({ + objectMetadata, + logger: this.logger, + orderByDateGranularity: true, + }); } private generateCompositeFieldOrderByInputType( diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts index 5279158a45..6310331156 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts @@ -11,6 +11,7 @@ import { GroupByDateGranularityInputTypeGenerator } from 'src/engine/api/graphql import { ObjectMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator'; import { ObjectMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator'; import { CompositeFieldMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator'; +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 { ObjectMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator'; import { ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator'; import { RelationConnectGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-connect-gql-input-type.generator'; @@ -48,6 +49,7 @@ export const workspaceSchemaBuilderTypeGenerators = [ ObjectMetadataOrderByGqlInputTypeGenerator, ObjectMetadataGroupByGqlInputTypeGenerator, ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator, + ObjectMetadataOrderByBaseGenerator, GroupByDateGranularityInputTypeGenerator, CompositeFieldMetadataGqlObjectTypeGenerator, ObjectMetadataGqlObjectTypeGenerator, diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts index e6a766bb7c..d35ec9ac1b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts @@ -64,6 +64,7 @@ export const RESERVED_METADATA_NAME_KEYWORDS = [ 'index', 'relation', 'relations', + 'aggregate', ]; export const validateMetadataNameIsNotReservedKeywordOrThrow = ( diff --git a/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts new file mode 100644 index 0000000000..28dde66dd3 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts @@ -0,0 +1,569 @@ +import { randomUUID } from 'crypto'; + +import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; +import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +describe('group-by resolvers - order by', () => { + const testCompanyId1 = randomUUID(); + const testCompanyId2 = randomUUID(); + const testCompanyId3 = randomUUID(); + const testCompanyId4 = randomUUID(); + const testCompanyId5 = randomUUID(); + const testCompanyId6 = randomUUID(); + const testCompanyId7 = randomUUID(); + + beforeAll(async () => { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId1, + createdAt: '2025-03-03T09:30:00.000Z', // Monday + address: { addressCity: 'Cuzco' }, + employees: 20, + annualRecurringRevenue: { amountMicros: 100 }, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId7, + createdAt: '2025-03-03T09:30:00.000Z', // Monday + address: { addressCity: 'Anvers' }, + employees: 19, + annualRecurringRevenue: { amountMicros: 100 }, + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId2, + createdAt: '2025-03-03T09:30:00.000Z', // Monday + address: { addressCity: 'Cuzco' }, + employees: 19, + annualRecurringRevenue: { amountMicros: 105 }, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId3, + createdAt: '2025-03-03T09:30:00.000Z', // Monday + address: { addressCity: 'Dallas' }, + employees: 2, + annualRecurringRevenue: { amountMicros: 100 }, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId4, + createdAt: '2025-01-02T12:00:00.000Z', // Thursday + address: { addressCity: 'Paris' }, + employees: 10, + annualRecurringRevenue: { amountMicros: 100 }, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId5, + createdAt: '2025-01-08T08:00:00.000Z', // Wednesday + address: { addressCity: 'Barcelona' }, + employees: 5, + annualRecurringRevenue: { amountMicros: 100 }, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId6, + createdAt: '2025-01-08T08:00:00.000Z', // Wednesday + address: { addressCity: 'Barcelona' }, + employees: 1, + annualRecurringRevenue: { amountMicros: 100 }, + }, + }), + ); + }); + + afterAll(async () => { + // cleanup created companies + for (const id of [ + testCompanyId1, + testCompanyId2, + testCompanyId3, + testCompanyId4, + testCompanyId5, + testCompanyId6, + testCompanyId7, + ]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + const filter2025 = { + and: [ + { + createdAt: { + gte: '2025-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + const groupByAddressCreatedAtAndARR = (orderBy: object[]) => { + return groupByOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + groupBy: [ + { address: { addressCity: true } }, + { createdAt: { granularity: 'DAY_OF_THE_WEEK' } }, + { + annualRecurringRevenue: { + amountMicros: true, + }, + }, + ], + orderBy, + filter: filter2025, + gqlFields: ` + avgEmployees + `, + }); + }; + + describe('valid cases', () => { + it('should order results in the right order - createdAt, avgEmployees, addressCity', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { + createdAt: { + granularity: 'DAY_OF_THE_WEEK', + orderBy: 'AscNullsFirst', + }, + }, + { + aggregate: { + avgEmployees: 'AscNullsFirst', + }, + }, + { + address: { + addressCity: 'AscNullsFirst', + }, + }, + ]), + ); + + const groups = response.body.data.companiesGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + // Extract group info for easier assertions + const groupInfos = groups.map((g: any) => ({ + city: g.groupByDimensionValues?.[0], + dayOfWeek: g.groupByDimensionValues?.[1], + annualRecurringRevenue: g.groupByDimensionValues?.[2], + avgEmployees: g.avgEmployees, + totalCount: g.totalCount, + })); + + // Order by dayOfWeek then avgEmployees then city + expect(groupInfos).toEqual([ + { + city: 'Dallas', + dayOfWeek: 'Monday', + avgEmployees: 2, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Anvers', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '105', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 20, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Paris', + dayOfWeek: 'Thursday', + avgEmployees: 10, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Barcelona', + dayOfWeek: 'Wednesday', + avgEmployees: 3, + totalCount: 2, + annualRecurringRevenue: '100', + }, + ]); + }); + it('should order results in the right order - createdAt, addressCity, avgEmployees', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { + createdAt: { + granularity: 'DAY_OF_THE_WEEK', + orderBy: 'AscNullsFirst', + }, + }, + { + address: { + addressCity: 'AscNullsFirst', + }, + }, + { + aggregate: { + avgEmployees: 'AscNullsFirst', + }, + }, + ]), + ); + const groups = response.body.data.companiesGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const groupInfos = groups.map((g: any) => ({ + city: g.groupByDimensionValues?.[0], + dayOfWeek: g.groupByDimensionValues?.[1], + annualRecurringRevenue: g.groupByDimensionValues?.[2], + avgEmployees: g.avgEmployees, + totalCount: g.totalCount, + })); + + expect(groupInfos).toEqual([ + { + city: 'Anvers', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '105', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 20, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Dallas', + dayOfWeek: 'Monday', + avgEmployees: 2, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Paris', + dayOfWeek: 'Thursday', + avgEmployees: 10, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Barcelona', + dayOfWeek: 'Wednesday', + avgEmployees: 3, + totalCount: 2, + annualRecurringRevenue: '100', + }, + ]); + }); + it('should order results in the right order - addressCity, createdAt, avgEmployees', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { + address: { + addressCity: 'AscNullsFirst', + }, + }, + { + createdAt: { + granularity: 'DAY_OF_THE_WEEK', + orderBy: 'AscNullsFirst', + }, + }, + { + aggregate: { + avgEmployees: 'AscNullsFirst', + }, + }, + ]), + ); + const groups = response.body.data.companiesGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const groupInfos = groups.map((g: any) => ({ + city: g.groupByDimensionValues?.[0], + dayOfWeek: g.groupByDimensionValues?.[1], + annualRecurringRevenue: g.groupByDimensionValues?.[2], + avgEmployees: g.avgEmployees, + totalCount: g.totalCount, + })); + + expect(groupInfos).toEqual([ + { + city: 'Anvers', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Barcelona', + dayOfWeek: 'Wednesday', + avgEmployees: 3, + totalCount: 2, + annualRecurringRevenue: '100', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '105', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 20, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Dallas', + dayOfWeek: 'Monday', + avgEmployees: 2, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Paris', + dayOfWeek: 'Thursday', + avgEmployees: 10, + totalCount: 1, + annualRecurringRevenue: '100', + }, + ]); + }); + it('should order results in the right order - avgEmployees, createdAt, addressCity', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { + aggregate: { + avgEmployees: 'AscNullsFirst', + }, + }, + { + createdAt: { + granularity: 'DAY_OF_THE_WEEK', + orderBy: 'AscNullsFirst', + }, + }, + { + address: { + addressCity: 'AscNullsFirst', + }, + }, + ]), + ); + const groups = response.body.data.companiesGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const groupInfos = groups.map((g: any) => ({ + city: g.groupByDimensionValues?.[0], + dayOfWeek: g.groupByDimensionValues?.[1], + annualRecurringRevenue: g.groupByDimensionValues?.[2], + avgEmployees: g.avgEmployees, + totalCount: g.totalCount, + })); + + expect(groupInfos).toEqual([ + { + city: 'Dallas', + dayOfWeek: 'Monday', + avgEmployees: 2, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Barcelona', + dayOfWeek: 'Wednesday', + avgEmployees: 3, + totalCount: 2, + annualRecurringRevenue: '100', + }, + { + city: 'Paris', + dayOfWeek: 'Thursday', + avgEmployees: 10, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Anvers', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '100', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 19, + totalCount: 1, + annualRecurringRevenue: '105', + }, + { + city: 'Cuzco', + dayOfWeek: 'Monday', + avgEmployees: 20, + totalCount: 1, + annualRecurringRevenue: '100', + }, + ]); + }); + }); + + describe('invalid cases', () => { + it('should fail if attempt to order by a field that is not part of the groupBy', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([{ employees: 'AscNullsFirst' }]), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].message).toBe( + 'Cannot order by a field that is not an aggregate nor in groupBy criteria: employees.', + ); + }); + + it('should fail if attempt to order by a date granularity that is not the same as in the groupBy', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { createdAt: { granularity: 'MONTH', orderBy: 'AscNullsFirst' } }, + ]), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].message).toBe( + 'Cannot order by a date granularity that is not in groupBy criteria: MONTH', + ); + }); + + it('should fail if attempt to order by a date without indicating the granularity', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { createdAt: { orderBy: 'AscNullsFirst' } }, + ]), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].message).toContain( + 'Cannot order by a field that is not in groupBy or that is not an aggregate field', + ); + }); + + it('should fail if attempt to indicate more than one orderBy field at the time (aggregate)', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { + aggregate: { + avgEmployees: 'AscNullsFirst', + avgAnnualRecurringRevenueAmountMicros: 'AscNullsFirst', + }, + }, + ]), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].message).toBe( + 'Please provide aggregate criteria one by one in orderBy array', + ); + }); + + it('should fail if attempt to indicate more than one orderBy field at the time', async () => { + const response = await makeGraphqlAPIRequest( + groupByAddressCreatedAtAndARR([ + { + employees: 'AscNullsFirst', + name: 'AscNullsFirst', + }, + ]), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].message).toBe( + 'Please provide orderBy field criteria one by one in orderBy array', + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts index 47ca97cb90..573439b1c5 100644 --- a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts @@ -8,6 +8,7 @@ type GroupByOperationFactoryParams = { filter?: object; orderBy?: object[]; viewId?: string; + gqlFields?: string; }; export const groupByOperationFactory = ({ @@ -17,10 +18,12 @@ export const groupByOperationFactory = ({ filter = {}, orderBy = [], viewId, + gqlFields, }: GroupByOperationFactoryParams) => ({ query: gql` query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID) { ${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId) { + ${gqlFields ? gqlFields : ''} groupByDimensionValues totalCount }