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 c999bba406..5da0d7bf6e 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,3 +1,5 @@ +import { InternalServerErrorException } from '@nestjs/common'; + import { FieldMetadataType } from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; @@ -10,12 +12,22 @@ 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 { + type AggregationField, + getAvailableAggregationsFromObjectFields, +} 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 { 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'; +type OrderByCondition = { + order: 'ASC' | 'DESC'; + nulls?: 'NULLS FIRST' | 'NULLS LAST'; +}; + export class GraphqlQueryOrderFieldParser { private objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; @@ -27,7 +39,16 @@ export class GraphqlQueryOrderFieldParser { orderBy: ObjectRecordOrderBy, objectNameSingular: string, isForwardPagination = true, - ): Record { + isGroupBy = false, + ): Record { + if (isGroupBy) { + return this.parseForGroupBy( + orderBy, + objectNameSingular, + isForwardPagination, + ); + } + return orderBy.reduce( (acc, item) => { Object.entries(item).forEach(([key, value]) => { @@ -65,10 +86,54 @@ export class GraphqlQueryOrderFieldParser { return acc; }, - {} as Record, + {} as Record, ); } + private parseForGroupBy( + orderBy: ObjectRecordOrderBy, + objectNameSingular: string, + isForwardPagination = true, + ): 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, + ); + + if (!expression) { + throw new InternalServerErrorException( + `Aggregate expression not found for ${aggregatedOrderByCondition}`, + ); + } + + orderByExpressionsAndConditions[expression] = + this.convertOrderByToFindOptionsOrder(direction, isForwardPagination); + } + } + + return orderByExpressionsAndConditions; + } + private getOptionalOrderByCasting( fieldMetadata: Pick, ): string { @@ -88,7 +153,7 @@ export class GraphqlQueryOrderFieldParser { value: any, objectNameSingular: string, isForwardPagination = true, - ): Record { + ): Record { const compositeType = compositeTypeDefinitions.get( fieldMetadata.type as CompositeFieldMetadataType, ); @@ -125,23 +190,35 @@ export class GraphqlQueryOrderFieldParser { return acc; }, - {} as Record, + {} as Record, ); } private convertOrderByToFindOptionsOrder( direction: OrderByDirection, isForwardPagination = true, - ): string { + ): OrderByCondition { switch (direction) { case OrderByDirection.AscNullsFirst: - return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS FIRST`; + return { + order: isForwardPagination ? 'ASC' : 'DESC', + nulls: 'NULLS FIRST', + }; case OrderByDirection.AscNullsLast: - return `${isForwardPagination ? 'ASC' : 'DESC'} NULLS LAST`; + return { + order: isForwardPagination ? 'ASC' : 'DESC', + nulls: 'NULLS LAST', + }; case OrderByDirection.DescNullsFirst: - return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS FIRST`; + return { + order: isForwardPagination ? 'DESC' : 'ASC', + nulls: 'NULLS FIRST', + }; case OrderByDirection.DescNullsLast: - return `${isForwardPagination ? 'DESC' : 'ASC'} NULLS LAST`; + return { + order: isForwardPagination ? 'DESC' : 'ASC', + nulls: 'NULLS LAST', + }; default: throw new GraphqlQueryRunnerException( `Invalid direction: ${direction}`, 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 35fb295435..c201fa73bf 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 @@ -1,8 +1,4 @@ -import { - type FindOptionsWhere, - type ObjectLiteral, - type OrderByCondition, -} from 'typeorm'; +import { type FindOptionsWhere, type ObjectLiteral } from 'typeorm'; import { type ObjectRecordFilter, @@ -104,15 +100,35 @@ export class GraphqlQueryParser { orderBy: ObjectRecordOrderBy, objectNameSingular: string, isForwardPagination = true, + isGroupBy = false, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): WorkspaceSelectQueryBuilder { const parsedOrderBys = this.orderFieldParser.parse( orderBy, objectNameSingular, isForwardPagination, + isGroupBy, ); - return queryBuilder.orderBy(parsedOrderBys as OrderByCondition); + 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 parseSelectedFields( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts index 95b027739e..410c8c5e4e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts @@ -9,102 +9,28 @@ export class ProcessAggregateHelper { public static addSelectedAggregatedFieldsQueriesToQueryBuilder = ({ selectedAggregatedFields, queryBuilder, + objectMetadataNameSingular, }: { selectedAggregatedFields: Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder; + objectMetadataNameSingular: string; }) => { queryBuilder.select([]); for (const [aggregatedFieldName, aggregatedField] of Object.entries( selectedAggregatedFields, )) { - if ( - !isDefined(aggregatedField?.fromField) || - !isDefined(aggregatedField?.aggregateOperation) - ) { - continue; - } - - const columnNames = formatColumnNamesFromCompositeFieldAndSubfields( - aggregatedField.fromField, - aggregatedField.fromSubFields, + const aggregateExpression = this.getAggregateExpression( + aggregatedField, + objectMetadataNameSingular, ); - const columnNameForNumericOperation = isDefined( - aggregatedField.subFieldForNumericOperation, - ) - ? formatColumnNamesFromCompositeFieldAndSubfields( - aggregatedField.fromField, - [aggregatedField.subFieldForNumericOperation], - )[0] - : columnNames[0]; - - if ( - !Object.values(AggregateOperations).includes( - aggregatedField.aggregateOperation, - ) - ) { + if (!isDefined(aggregateExpression)) { continue; } - const concatenatedColumns = columnNames - .map((col) => `"${col}"`) - .join(','); - - const columnExpression = `NULLIF(CONCAT(${concatenatedColumns}), '')`; - - switch (aggregatedField.aggregateOperation) { - case AggregateOperations.COUNT_EMPTY: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnExpression}) END`, - `${aggregatedFieldName}`, - ); - break; - case AggregateOperations.COUNT_NOT_EMPTY: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnExpression}) END`, - `${aggregatedFieldName}`, - ); - break; - case AggregateOperations.COUNT_UNIQUE_VALUES: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT ${columnExpression}) END`, - `${aggregatedFieldName}`, - ); - break; - case AggregateOperations.PERCENTAGE_EMPTY: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnExpression})::decimal) / COUNT(*)) AS DECIMAL) END`, - `${aggregatedFieldName}`, - ); - break; - case AggregateOperations.PERCENTAGE_NOT_EMPTY: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnExpression})::decimal / COUNT(*)) AS DECIMAL) END`, - `${aggregatedFieldName}`, - ); - break; - case AggregateOperations.COUNT_TRUE: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = TRUE THEN 1 ELSE NULL END) END`, - `${aggregatedFieldName}`, - ); - break; - - case AggregateOperations.COUNT_FALSE: - queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = FALSE THEN 1 ELSE NULL END) END`, - `${aggregatedFieldName}`, - ); - break; - default: { - queryBuilder.addSelect( - `${aggregatedField.aggregateOperation}("${columnNameForNumericOperation}")`, - `${aggregatedFieldName}`, - ); - } - } + queryBuilder.addSelect(aggregateExpression, aggregatedFieldName); } }; @@ -118,21 +44,104 @@ export class ProcessAggregateHelper { if (concatMatches) { // Extract all column names between quotes after CONCAT - const columnNames = selection - .match(/"([^"]+)"/g) - ?.map((match) => match.slice(1, -1)); + const columnNames = selection.match(/"([^"]+)"/g)?.map((match) => { + const fullColumn = match.slice(1, -1); + // If there's a dot, extract only the column name (part after the dot) + const parts = fullColumn.split('.'); + + return parts[parts.length - 1]; + }); return columnNames || null; } - // For non-CONCAT expressions, match content between double quotes - // Using positive lookbehind and lookahead to match content between quotes without including quotes - const columnMatch = selection.match(/(?<=")([^"]+)(?=")/); + // For non-CONCAT expressions, match table.column pattern within quotes + // Look for patterns like "table"."column" and extract only the column part + const tableColumnMatches = selection.match(/"[^"]+"\."([^"]+)"/g); - if (columnMatch) { - return [columnMatch[0]]; + if (tableColumnMatches) { + const columnNames = tableColumnMatches + .map((match) => { + // Extract the column name from "table"."column" pattern + const columnMatch = match.match(/"[^"]+"\."([^"]+)"/); + + return columnMatch ? columnMatch[1] : null; + }) + .filter(Boolean); + + return columnNames.length > 0 + ? columnNames.filter((c) => isDefined(c)) + : null; + } + + // Fallback: match single quoted content that doesn't contain dots + const singleColumnMatch = selection.match(/"([^".]+)"/); + + if (singleColumnMatch) { + return [singleColumnMatch[1]]; } return null; }; + + public static getAggregateExpression = ( + aggregatedField: AggregationField, + objectMetadataNameSingular: string, + ): string | undefined => { + if ( + !isDefined(aggregatedField?.fromField) || + !isDefined(aggregatedField?.aggregateOperation) + ) { + return; + } + + const columnNames = formatColumnNamesFromCompositeFieldAndSubfields( + aggregatedField.fromField, + aggregatedField.fromSubFields, + ); + + const columnNameForNumericOperation = isDefined( + aggregatedField.subFieldForNumericOperation, + ) + ? formatColumnNamesFromCompositeFieldAndSubfields( + aggregatedField.fromField, + [aggregatedField.subFieldForNumericOperation], + )[0] + : columnNames[0]; + + if ( + !Object.values(AggregateOperations).includes( + aggregatedField.aggregateOperation, + ) + ) { + return; + } + + const concatenatedColumns = columnNames + .map((col) => `"${objectMetadataNameSingular}"."${col}"`) + .join(','); + + const columnExpression = `NULLIF(CONCAT(${concatenatedColumns}), '')`; + + switch (aggregatedField.aggregateOperation) { + case AggregateOperations.COUNT_EMPTY: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnExpression}) END`; + case AggregateOperations.COUNT_NOT_EMPTY: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnExpression}) END`; + case AggregateOperations.COUNT_UNIQUE_VALUES: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT ${columnExpression}) END`; + case AggregateOperations.PERCENTAGE_EMPTY: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnExpression})::decimal) / COUNT(*)) AS DECIMAL) END`; + case AggregateOperations.PERCENTAGE_NOT_EMPTY: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnExpression})::decimal / COUNT(*)) AS DECIMAL) END`; + case AggregateOperations.COUNT_TRUE: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = TRUE THEN 1 ELSE NULL END) END`; + + case AggregateOperations.COUNT_FALSE: + return `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(CASE WHEN ${columnExpression}::boolean = FALSE THEN 1 ELSE NULL END) END`; + default: { + return `${aggregatedField.aggregateOperation}("${objectMetadataNameSingular}"."${columnNameForNumericOperation}")`; + } + } + }; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts index 38a80a0627..90dc96d9ca 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts @@ -152,8 +152,10 @@ export class ProcessNestedRelationsV2Helper { roleId, ); + const targetObjectNameSingular = targetObjectMetadata.nameSingular; + let targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder( - targetObjectMetadata.nameSingular, + targetObjectNameSingular, ); const columnsToSelect = buildColumnsToSelect({ @@ -196,6 +198,7 @@ export class ProcessNestedRelationsV2Helper { limit: limit * parentObjectRecords.length, aggregate, sourceFieldName, + targetObjectNameSingular, }); this.assignRelationResults({ @@ -306,6 +309,7 @@ export class ProcessNestedRelationsV2Helper { limit, aggregate, sourceFieldName, + targetObjectNameSingular, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any referenceQueryBuilder: WorkspaceSelectQueryBuilder; @@ -316,7 +320,7 @@ export class ProcessNestedRelationsV2Helper { // eslint-disable-next-line @typescript-eslint/no-explicit-any aggregate: Record; sourceFieldName: string; - + targetObjectNameSingular: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any }): Promise<{ relationResults: any[]; relationAggregatedFieldsResult: any }> { if (ids.length === 0) { @@ -333,6 +337,7 @@ export class ProcessNestedRelationsV2Helper { ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ selectedAggregatedFields: aggregateForRelation, queryBuilder: aggregateQueryBuilder, + objectMetadataNameSingular: targetObjectNameSingular, }); const aggregatedFieldsValues = await aggregateQueryBuilder diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index 9bc00da1de..03eae939ef 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -45,10 +45,13 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const objectMetadataNameSingular = + objectMetadataItemWithFieldMaps.nameSingular; + const { roleId } = executionArgs; const queryBuilder = executionArgs.repository.createQueryBuilder( - objectMetadataItemWithFieldMaps.nameSingular, + objectMetadataNameSingular, ); const aggregateQueryBuilder = queryBuilder.clone(); @@ -58,7 +61,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve executionArgs.graphqlQueryParser.applyFilterToBuilder( aggregateQueryBuilder, - objectMetadataItemWithFieldMaps.nameSingular, + objectMetadataNameSingular, appliedFilters, ); @@ -93,14 +96,14 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve executionArgs.graphqlQueryParser.applyFilterToBuilder( queryBuilder, - objectMetadataItemWithFieldMaps.nameSingular, + objectMetadataNameSingular, appliedFilters, ); executionArgs.graphqlQueryParser.applyOrderToBuilder( queryBuilder, orderByWithIdCondition, - objectMetadataItemWithFieldMaps.nameSingular, + objectMetadataNameSingular, isForwardPagination, ); @@ -113,6 +116,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve selectedAggregatedFields: executionArgs.graphqlQuerySelectedFieldsResult.aggregate, queryBuilder: aggregateQueryBuilder, + objectMetadataNameSingular, }); const limit = @@ -171,7 +175,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve objectRecordsAggregatedValues: parentObjectRecordsAggregatedValues, selectedAggregatedFields: executionArgs.graphqlQuerySelectedFieldsResult.aggregate, - objectName: objectMetadataItemWithFieldMaps.nameSingular, + objectName: objectMetadataNameSingular, take: limit, totalCount: parentObjectRecordsAggregatedValues?.totalCount, order: orderByWithIdCondition, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts index 28aab51aa4..3b5e93389c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts @@ -1,8 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; - import { GraphqlQueryBaseResolverService, GraphqlQueryResolverExecutionArgs, @@ -20,9 +17,11 @@ 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 { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; @Injectable() export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolverService< @@ -35,33 +34,102 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver ): Promise>[]> { const { objectMetadataItemWithFieldMaps } = executionArgs.options; + const objectMetadataNameSingular = + objectMetadataItemWithFieldMaps.nameSingular; + + let queryBuilder = executionArgs.repository.createQueryBuilder( + objectMetadataNameSingular, + ); + + let appliedFilters = + executionArgs.args.filter ?? ({} as ObjectRecordFilter); + + executionArgs.graphqlQueryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataNameSingular, + appliedFilters, + ); + + executionArgs.graphqlQueryParser.applyDeletedAtToBuilder( + queryBuilder, + appliedFilters, + ); + + ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ + selectedAggregatedFields: + executionArgs.graphqlQuerySelectedFieldsResult.aggregate, + queryBuilder, + objectMetadataNameSingular, + }); + const groupByFields = this.parseGroupByArgs( executionArgs.args, objectMetadataItemWithFieldMaps, ); - const aggregateMetrics = Object.keys( - executionArgs.graphqlQuerySelectedFieldsResult.aggregate, + const groupByColumnsWithQuotes = groupByFields.map((groupByField) => { + return `"${ + formatColumnNamesFromCompositeFieldAndSubfields( + groupByField.fieldMetadata.name, + groupByField.subFieldName ? [groupByField.subFieldName] : undefined, + )[0] + }"`; + }); + + groupByColumnsWithQuotes.forEach((groupByColumn, index) => { + queryBuilder.addSelect(groupByColumn); + + if (index === 0) { + queryBuilder.groupBy(groupByColumn); + } else { + queryBuilder.addGroupBy(groupByColumn); + } + }); + + let forwardPagination; // TODO + const isGroupBy = true; + + executionArgs.graphqlQueryParser.applyOrderToBuilder( + queryBuilder, + executionArgs.args.orderBy ?? [], + objectMetadataNameSingular, + forwardPagination, + isGroupBy, ); - const mockedGroupByValues = Array.from({ length: 3 }, (_, index) => ({ - groupByDimensionValues: groupByFields.map((groupByField) => - this.getFakeDimensionValues(groupByField, index), - ), - ...aggregateMetrics.reduce( - (acc, aggregateMetric, subIndex) => { - acc[aggregateMetric] = this.getFakeAggregateValues(subIndex + index); + const result = await queryBuilder.getRawMany(); - return acc; - }, - {} as Record, - ), - })); + return this.formatResultWithGroupByDimensionValues( + result, + groupByColumnsWithQuotes, + ); + } - return mockedGroupByValues as unknown as IGroupByConnection< + private formatResultWithGroupByDimensionValues( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: any[], + groupByColumnsWithQuotes: string[], + ): IGroupByConnection>[] { + let formattedResult: IGroupByConnection< ObjectRecord, IEdge - >[]; + >[] = []; + + result.forEach((group) => { + let dimensionValues = []; + + for (const groupByColumn of groupByColumnsWithQuotes) { + const groupByColumnWithoutQuotes = groupByColumn.replace(/["']/g, ''); + + dimensionValues.push(group[groupByColumnWithoutQuotes]); + } + formattedResult.push({ + groupByDimensionValues: dimensionValues, + ...group, + }); + }); + + return formattedResult; } private parseGroupByArgs( @@ -118,215 +186,4 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver _args: GroupByResolverArgs, _options: WorkspaceQueryRunnerOptions, ): Promise {} - - //todo : remove these methods after the groupBy logic implementation - private getFakeDimensionValues( - groupByField: { - fieldMetadata: FieldMetadataEntity; - subFieldName?: string; - }, - rank: number, - ): string { - const fakeDimensionValuesByField = { - [FieldMetadataType.TEXT]: [ - 'Text 1', - 'Long long long long and long text 2', - 'Text 3', - ], - [FieldMetadataType.NUMERIC]: [2000, 3000, 4000], - [FieldMetadataType.NUMBER]: [1, 2, 3], - [FieldMetadataType.BOOLEAN]: [true, false, true], - [FieldMetadataType.DATE_TIME]: [ - new Date(2025, 5, 10, 12, 1, 0), - new Date(2025, 5, 10, 12, 1, 0), - new Date(2018, 6, 14, 12, 2, 0), - ], - [FieldMetadataType.DATE]: [ - new Date(2025, 1, 1), - new Date(2025, 1, 2), - new Date(2025, 1, 3), - ], - [FieldMetadataType.UUID]: [ - '00000000-0000-0000-0000-000000000000', - '00000000-0000-0000-0000-000000000001', - '00000000-0000-0000-0000-000000000002', - ], - [FieldMetadataType.SELECT]: ['Option 1', 'Option 2', 'Option 3'], - [FieldMetadataType.MULTI_SELECT]: [ - ['Tag 1', 'Tag 2'], - ['Tag 3'], - ['Tag 4', 'Tag 5', 'Tag 6'], - ], - [FieldMetadataType.RELATION]: [ - { id: 'relation-1', name: 'Relation 1' }, - { id: 'relation-2', name: 'Relation 2' }, - { id: 'relation-3', name: 'Relation 3' }, - ], - [FieldMetadataType.MORPH_RELATION]: [ - { id: 'morph-1', name: 'Morph Relation 1' }, - { id: 'morph-2', name: 'Morph Relation 2' }, - { id: 'morph-3', name: 'Morph Relation 3' }, - ], - [FieldMetadataType.RATING]: ['RATING_3', 'RATING_4', 'RATING_5'], - [FieldMetadataType.RAW_JSON]: [ - { key: 'value1' }, - { key: 'value2', key2: 'value2' }, - {}, - ], - [FieldMetadataType.ARRAY]: [['value1', 'value2'], ['value3'], []], - [FieldMetadataType.POSITION]: [1, 2, 3], - [FieldMetadataType.TS_VECTOR]: ['vector1', 'vector2', 'vector3'], - [FieldMetadataType.EMAILS]: [ - { - primaryEmail: 'tim@twenty.com', - additionalEmails: [ - 'tim@twenty.com', - 'timapple@twenty.com', - 'johnappletim@twenty.com', - ], - }, - { - primaryEmail: 'jane@twenty.com', - additionalEmails: ['jane@twenty.com', 'jane.doe@twenty.com'], - }, - { - primaryEmail: 'john@twenty.com', - additionalEmails: ['john.doe@twenty.com'], - }, - ], - [FieldMetadataType.PHONES]: [ - { - primaryPhoneCallingCode: '+33', - primaryPhoneCountryCode: 'FR', - primaryPhoneNumber: '789012345', - additionalPhones: [ - { number: '617272323', callingCode: '+33', countryCode: 'FR' }, - ], - }, - { - primaryPhoneCallingCode: '+1', - primaryPhoneCountryCode: 'US', - primaryPhoneNumber: '612345789', - additionalPhones: [ - { number: '123456789', callingCode: '+1', countryCode: 'US' }, - { number: '617272323', callingCode: '+1', countryCode: 'US' }, - ], - }, - { - primaryPhoneCallingCode: '+33', - primaryPhoneCountryCode: 'FR', - primaryPhoneNumber: '123456789', - additionalPhones: [], - }, - ], - [FieldMetadataType.CURRENCY]: [ - { amountMicros: 1000000, currencyCode: 'USD' }, - { amountMicros: 2000000, currencyCode: 'EUR' }, - { amountMicros: 3000000, currencyCode: 'GBP' }, - ], - [FieldMetadataType.LINKS]: [ - { - primaryLinkUrl: 'twenty.com', - primaryLinkLabel: '', - secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }], - }, - { - primaryLinkUrl: 'github.com/twentyhq/twenty', - primaryLinkLabel: 'Twenty Repo', - secondaryLinks: [{ url: 'twenty.com', label: '' }], - }, - { - primaryLinkUrl: 'react.dev', - primaryLinkLabel: '', - secondaryLinks: [], - }, - ], - [FieldMetadataType.FULL_NAME]: [ - { firstName: 'John', lastName: 'Doe' }, - { firstName: 'Jane', lastName: 'Doe' }, - { firstName: 'John', lastName: 'Smith' }, - ], - [FieldMetadataType.ADDRESS]: [ - { - addressStreet1: '456 Oak Street', - addressStreet2: '', - addressCity: 'Springfield', - addressState: 'California', - addressCountry: 'United States', - addressPostcode: '90210', - addressLat: 34.0522, - addressLng: -118.2437, - }, - { - addressStreet1: '123 Main Street', - addressStreet2: '', - addressCity: 'New York', - addressState: 'New York', - addressCountry: 'United States', - addressPostcode: '10001', - addressLat: 40.7128, - addressLng: -74.006, - }, - { - addressStreet1: '8 rue Saint-Anne', - addressStreet2: '', - addressCity: 'Paris', - addressState: 'Ile-de-France', - addressCountry: 'France', - addressPostcode: '75001', - addressLat: 40.7128, - addressLng: -74.006, - }, - ], - [FieldMetadataType.ACTOR]: [ - { - source: 'IMPORT', - name: 'name', - workspaceMemberId: 'id', - context: { provider: 'GOOGLE' }, - }, - { - source: 'MANUAL', - name: 'name', - workspaceMemberId: 'id', - context: { provider: 'MICROSOFT' }, - }, - { - source: 'WEBHOOK', - name: 'name', - workspaceMemberId: 'id', - context: {}, - }, - ], - [FieldMetadataType.RICH_TEXT_V2]: [ - { - blocknote: '[{"type":"heading","content":"Hello"}]', - markdown: '# Hello', - }, - { - blocknote: '[{"type":"heading","content":"Hello World"}]', - markdown: '# Hello World', - }, - { - blocknote: '[{"type":"heading","content":"Hello Again"}]', - markdown: '# Hello Again', - }, - ], - [FieldMetadataType.RICH_TEXT]: [], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as Record; - - const fakeDimensionValues = - fakeDimensionValuesByField[groupByField.fieldMetadata.type][rank % 3]; - - return isDefined(groupByField.subFieldName) - ? fakeDimensionValues[groupByField.subFieldName].toString() - : fakeDimensionValues.toString(); - } - - private getFakeAggregateValues(rank: number) { - const fakeAggregateValues = [39, 20, 56, 88, 2]; - - return fakeAggregateValues[rank % fakeAggregateValues.length]; - } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts index cba93ad493..56fc0dd535 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts @@ -4,4 +4,5 @@ export enum GqlInputTypeDefinitionKind { Filter = 'Filter', OrderBy = 'OrderBy', GroupBy = 'GroupBy', + OrderByWithGroupBy = 'OrderByWithGroupBy', } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts index aea9aa940f..936c114116 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts @@ -4,6 +4,7 @@ import { ObjectMetadataCreateGqlInputTypeGenerator } from 'src/engine/api/graphq import { ObjectMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator'; 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 { 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 { ObjectMetadataUpdateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -14,6 +15,7 @@ export class ObjectMetadataGqlInputTypeGenerator { private readonly objectMetadataUpdateGqlInputTypeGenerator: ObjectMetadataUpdateGqlInputTypeGenerator, private readonly objectMetadataFilterGqlInputTypeGenerator: ObjectMetadataFilterGqlInputTypeGenerator, private readonly objectMetadataOrderByGqlInputTypeGenerator: ObjectMetadataOrderByGqlInputTypeGenerator, + private readonly objectMetadataOrderByWithGroupByGqlInputTypeGenerator: ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator, private readonly objectMetadataGroupByGqlInputTypeGenerator: ObjectMetadataGroupByGqlInputTypeGenerator, ) {} @@ -27,9 +29,12 @@ export class ObjectMetadataGqlInputTypeGenerator { this.objectMetadataFilterGqlInputTypeGenerator.buildAndStore( objectMetadata, ); - this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore( + this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({ objectMetadata, - ); + }); + this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({ + objectMetadata, + }); this.objectMetadataGroupByGqlInputTypeGenerator.buildAndStore( objectMetadata, ); 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 514069c7d2..240f7e1bf7 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 @@ -34,7 +34,11 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { private readonly typeMapperService: TypeMapperService, ) {} - public buildAndStore(objectMetadata: ObjectMetadataEntity) { + public buildAndStore({ + objectMetadata, + }: { + objectMetadata: ObjectMetadataEntity; + }) { const inputType = new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderBy.toString()}Input`, description: objectMetadata.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 new file mode 100644 index 0000000000..8a1a481642 --- /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-with-group-by-gql-input-type.generator.ts @@ -0,0 +1,183 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { + GraphQLInputFieldConfigMap, + GraphQLInputObjectType, + isInputObjectType, +} from 'graphql'; +import { isDefined } from 'twenty-shared/utils'; + +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 { 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 { getAvailableAggregationsFromObjectFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.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'; + +@Injectable() +export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { + private readonly logger = new Logger( + ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator.name, + ); + constructor( + private readonly gqlTypesStorage: GqlTypesStorage, + private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator, + private readonly typeMapperService: TypeMapperService, + ) {} + + public buildAndStore({ + objectMetadata, + }: { + objectMetadata: ObjectMetadataEntity; + }) { + const inputType = new GraphQLInputObjectType({ + name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}Input`, + description: objectMetadata.description, + fields: () => this.generateFields(objectMetadata), + }) as GraphQLInputObjectType; + + const key = computeObjectMetadataInputTypeKey( + objectMetadata.nameSingular, + GqlInputTypeDefinitionKind.OrderByWithGroupBy, + ); + + 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); + } + + const aggregations = + this.generateAggregateFieldOrderByInputType(fieldMetadata); + + return aggregations; + } + + 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); + } + + const aggregations = + this.generateAggregateFieldOrderByInputType(fieldMetadata); + + return aggregations; + } + + private generateAggregateFieldOrderByInputType( + fieldMetadata: FieldMetadataEntity, + ) { + const aggregations = getAvailableAggregationsFromObjectFields([ + fieldMetadata, + ]); + + let result: GraphQLInputFieldConfigMap = {}; + + for (const [aggregationKey, aggregationDetails] of Object.entries( + aggregations, + )) { + const orderByWithGroupByType = + this.typeMapperService.mapToOrderByWithGroupByType( + aggregations[aggregationKey].aggregateOperation, + ); + + if (!isDefined(orderByWithGroupByType)) { + const message = `Could not find a GraphQL input type for ${aggregations.type} aggregation`; + + this.logger.error(message, { + aggregations, + }); + throw new Error(message); + } + + result[aggregationKey] = { + type: orderByWithGroupByType, + description: aggregationDetails.description, + }; + } + + return result; + } +} 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 0cc1be0aa0..1638f3e84d 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 { ObjectMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graph 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 { 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'; import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; import { CompositeFieldMetadataUpdateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator'; @@ -45,6 +46,7 @@ export const workspaceSchemaBuilderTypeGenerators = [ ObjectMetadataFilterGqlInputTypeGenerator, ObjectMetadataOrderByGqlInputTypeGenerator, ObjectMetadataGroupByGqlInputTypeGenerator, + ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator, CompositeFieldMetadataGqlObjectTypeGenerator, ObjectMetadataGqlObjectTypeGenerator, RelationConnectGqlInputTypeGenerator, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 0fb3382608..e999851507 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -22,6 +22,7 @@ import { NumberDataType, } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; +import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum'; import { ArrayFilterType, @@ -168,6 +169,27 @@ export class TypeMapperService { return typeOrderByMapping.get(fieldMetadataType); } + mapToOrderByWithGroupByType( + aggregationType: AggregateOperations, + ): GraphQLInputType | undefined { + const typeOrderByMapping = new Map([ + [AggregateOperations.SUM, OrderByDirectionType], + [AggregateOperations.COUNT, OrderByDirectionType], + [AggregateOperations.COUNT_UNIQUE_VALUES, OrderByDirectionType], + [AggregateOperations.COUNT_EMPTY, OrderByDirectionType], + [AggregateOperations.COUNT_NOT_EMPTY, OrderByDirectionType], + [AggregateOperations.COUNT_TRUE, OrderByDirectionType], + [AggregateOperations.COUNT_FALSE, OrderByDirectionType], + [AggregateOperations.PERCENTAGE_EMPTY, OrderByDirectionType], + [AggregateOperations.PERCENTAGE_NOT_EMPTY, OrderByDirectionType], + [AggregateOperations.MIN, OrderByDirectionType], + [AggregateOperations.MAX, OrderByDirectionType], + [AggregateOperations.AVG, OrderByDirectionType], + ]); + + return typeOrderByMapping.get(aggregationType); + } + applyTypeOptions( typeRef: T, options: TypeOptions, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index 466352b1e9..6ee8c33489 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -169,7 +169,7 @@ export const getResolverArgs = ( isNullable: true, }, orderBy: { - kind: GqlInputTypeDefinitionKind.OrderBy, + kind: GqlInputTypeDefinitionKind.OrderByWithGroupBy, isNullable: true, isArray: true, },