diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts index bf805377e3..e24089c8cb 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; @@ -13,7 +14,6 @@ import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/gr import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service'; import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; -import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service'; import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service'; import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service'; import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service'; 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/group-by/resolvers/graphql-query-group-by-resolver.service.ts similarity index 69% rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts rename to packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts index a907852ba3..bc63d78981 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/group-by/resolvers/graphql-query-group-by-resolver.service.ts @@ -27,10 +27,11 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; +import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; +import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; +import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util'; +import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util'; 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 { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity'; @@ -38,10 +39,8 @@ import { ViewFilterGroupService } from 'src/engine/core-modules/view/services/vi import { ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service'; import { ViewService } from 'src/engine/core-modules/view/services/view.service'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; -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< GroupByResolverArgs, @@ -97,27 +96,44 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver objectMetadataNameSingular, }); - const groupByFields = this.parseGroupByArgs( + const groupByFields = parseGroupByArgs( executionArgs.args, objectMetadataItemWithFieldMaps, ); - const groupByColumnsWithQuotes = groupByFields.map((groupByField) => { - return `"${ + const groupByDefinitions = groupByFields.map((groupByField) => { + const columnNameWithQuotes = `"${ formatColumnNamesFromCompositeFieldAndSubfields( groupByField.fieldMetadata.name, groupByField.subFieldName ? [groupByField.subFieldName] : undefined, )[0] }"`; + const alias = + removeQuotes(columnNameWithQuotes) + + (isGroupByDateField(groupByField) + ? `_${groupByField.dateGranularity}` + : ''); + + return { + columnNameWithQuotes, + expression: getGroupByExpression({ + groupByField, + columnNameWithQuotes, + }), + alias, + dateGranularity: isGroupByDateField(groupByField) + ? groupByField.dateGranularity + : undefined, + }; }); - groupByColumnsWithQuotes.forEach((groupByColumn, index) => { - queryBuilder.addSelect(groupByColumn); + groupByDefinitions.forEach((groupByColumn, index) => { + queryBuilder.addSelect(groupByColumn.expression, groupByColumn.alias); if (index === 0) { - queryBuilder.groupBy(groupByColumn); + queryBuilder.groupBy(groupByColumn.expression); } else { - queryBuilder.addGroupBy(groupByColumn); + queryBuilder.addGroupBy(groupByColumn.expression); } }); @@ -134,87 +150,7 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver const result = await queryBuilder.getRawMany(); - return this.formatResultWithGroupByDimensionValues( - result, - groupByColumnsWithQuotes, - ); - } - - 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( - args: GroupByResolverArgs, - objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, - ): { fieldMetadata: FieldMetadataEntity; subFieldName?: string }[] { - const groupByFieldNames = args.groupBy; - - const groupByFields = []; - - for (const fieldNames of groupByFieldNames) { - if (Object.keys(fieldNames).length > 1) { - throw new GraphqlQueryRunnerException( - 'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput', - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } - for (const fieldName of Object.keys(fieldNames)) { - const fieldMetadataId = - objectMetadataItemWithFieldMaps.fieldIdByName[fieldName]; - const fieldMetadata = - objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId]; - - if (fieldNames[fieldName] === true) { - groupByFields.push({ - fieldMetadata, - subFieldName: undefined, - }); - continue; - } else if (typeof fieldNames[fieldName] === 'object') { - if (Object.keys(fieldNames[fieldName]).length > 1) { - throw new GraphqlQueryRunnerException( - 'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput', - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } - for (const subFieldName of Object.keys(fieldNames[fieldName])) { - if (fieldNames[fieldName][subFieldName] === true) { - groupByFields.push({ - fieldMetadata, - subFieldName, - }); - continue; - } - } - } - } - } - - return groupByFields; + return formatResultWithGroupByDimensionValues(result, groupByDefinitions); } private async addFiltersFromView({ diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts new file mode 100644 index 0000000000..172e3de5ca --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts @@ -0,0 +1,14 @@ +import { type ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export type GroupByRegularField = { + fieldMetadata: FieldMetadataEntity; + subFieldName?: string; +}; +export type GroupByDateField = { + fieldMetadata: FieldMetadataEntity; + subFieldName?: string; + dateGranularity: ObjectRecordGroupByDateGranularity; +}; +export type GroupByField = GroupByRegularField | GroupByDateField; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts new file mode 100644 index 0000000000..cbb017733a --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts @@ -0,0 +1,113 @@ +import { t } from '@lingui/core/macro'; + +import { + ObjectRecordGroupByDateGranularity, + type ObjectRecord, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; +import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface'; + +import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util'; + +export const formatResultWithGroupByDimensionValues = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: any[], + groupByColumnsWithQuotes: { + columnNameWithQuotes: string; + alias: string; + dateGranularity?: ObjectRecordGroupByDateGranularity; + }[], +): IGroupByConnection>[] => { + let formattedResult: IGroupByConnection>[] = + []; + + result.forEach((group) => { + let dimensionValues = []; + + for (const groupByColumn of groupByColumnsWithQuotes) { + dimensionValues.push(group[groupByColumn.alias]); + } + const groupWithValueMappedToUnaliasedColumn = { + ...group, + ...groupByColumnsWithQuotes.reduce>( + (acc, groupByColumn) => { + const value = group[groupByColumn.alias]; + + acc[removeQuotes(groupByColumn.columnNameWithQuotes)] = + getTranslatedValueIfApplicable( + value, + groupByColumn.dateGranularity, + ); + + return acc; + }, + {}, + ), + }; + + formattedResult.push({ + groupByDimensionValues: dimensionValues, + ...groupWithValueMappedToUnaliasedColumn, + }); + }); + + return formattedResult; +}; + +const getTranslatedValueIfApplicable = ( + value: unknown, + dateGranularity?: ObjectRecordGroupByDateGranularity, +) => { + switch (dateGranularity) { + case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK: + switch (value) { + case 'Monday': + return t`Monday`; + case 'Tuesday': + return t`Tuesday`; + case 'Wednesday': + return t`Wednesday`; + case 'Thursday': + return t`Thursday`; + case 'Friday': + return t`Friday`; + case 'Saturday': + return t`Saturday`; + case 'Sunday': + return t`Sunday`; + default: + return value; + } + case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR: + switch (value) { + case 'January': + return t`January`; + case 'February': + return t`February`; + case 'March': + return t`March`; + case 'April': + return t`April`; + case 'May': + return t`May`; + case 'June': + return t`June`; + case 'July': + return t`July`; + case 'August': + return t`August`; + case 'September': + return t`September`; + case 'October': + return t`October`; + case 'November': + return t`November`; + case 'December': + return t`December`; + default: + return value; + } + default: + return value; + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts new file mode 100644 index 0000000000..d096dcb71f --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts @@ -0,0 +1,35 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; +import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; + +export const getGroupByExpression = ({ + groupByField, + columnNameWithQuotes, +}: { + groupByField: GroupByField; + columnNameWithQuotes: string; +}) => { + if (!isGroupByDateField(groupByField)) { + return columnNameWithQuotes; + } + switch (groupByField.dateGranularity) { + case ObjectRecordGroupByDateGranularity.NONE: + return columnNameWithQuotes; + case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK: + return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMDay'))`; + case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR: + return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMMonth'))`; + case ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR: + return `TRIM(TO_CHAR(${columnNameWithQuotes}, '"Q"Q'))`; + case ObjectRecordGroupByDateGranularity.DAY: + case ObjectRecordGroupByDateGranularity.MONTH: + case ObjectRecordGroupByDateGranularity.QUARTER: + case ObjectRecordGroupByDateGranularity.YEAR: + return `DATE_TRUNC('${groupByField.dateGranularity}', ${columnNameWithQuotes})`; + default: + assertUnreachable(groupByField.dateGranularity); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts new file mode 100644 index 0000000000..8afd9260f7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts @@ -0,0 +1,10 @@ +import { + type GroupByDateField, + type GroupByField, +} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; + +export const isGroupByDateField = ( + groupByField: GroupByField, +): groupByField is GroupByDateField => { + return 'dateGranularity' in groupByField; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts new file mode 100644 index 0000000000..d82ed30902 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts @@ -0,0 +1,114 @@ +import { isDefined } from 'class-validator'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +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'; + +const isGroupByDateFieldDefinition = ( + fieldGroupByDefinition: + | boolean + | Record + | { granularity: ObjectRecordGroupByDateGranularity } + | undefined, +): fieldGroupByDefinition is { + granularity: ObjectRecordGroupByDateGranularity; +} => { + if ( + typeof fieldGroupByDefinition !== 'object' || + !isDefined(fieldGroupByDefinition) + ) { + return false; + } + if (!('granularity' in fieldGroupByDefinition)) { + return false; + } + + const granularity = fieldGroupByDefinition.granularity; + + return ( + isDefined(granularity) && + typeof granularity === 'string' && + Object.values(ObjectRecordGroupByDateGranularity).includes( + granularity as ObjectRecordGroupByDateGranularity, + ) + ); +}; + +export const parseGroupByArgs = ( + args: GroupByResolverArgs, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, +): GroupByField[] => { + const groupByFieldNames = args.groupBy; + + const groupByFields: GroupByField[] = []; + + for (const fieldNames of groupByFieldNames) { + if (Object.keys(fieldNames).length > 1) { + throw new GraphqlQueryRunnerException( + 'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + for (const fieldName of Object.keys(fieldNames)) { + const fieldMetadataId = + objectMetadataItemWithFieldMaps.fieldIdByName[fieldName]; + const fieldMetadata = + objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId]; + + if ( + fieldMetadata.type === FieldMetadataType.DATE || + fieldMetadata.type === FieldMetadataType.DATE_TIME + ) { + const fieldGroupByDefinition = fieldNames[fieldName]; + + const shouldGroupByDateGranularity = isGroupByDateFieldDefinition( + fieldGroupByDefinition, + ); + + if (shouldGroupByDateGranularity) { + groupByFields.push({ + fieldMetadata, + dateGranularity: fieldGroupByDefinition.granularity, + }); + continue; + } + } + + if (fieldNames[fieldName] === true) { + groupByFields.push({ + fieldMetadata, + subFieldName: undefined, + }); + continue; + } else if (typeof fieldNames[fieldName] === 'object') { + if (Object.keys(fieldNames[fieldName]).length > 1) { + throw new GraphqlQueryRunnerException( + 'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + for (const subFieldName of Object.keys(fieldNames[fieldName])) { + if ( + (fieldNames[fieldName] as Record)[subFieldName] === + true + ) { + groupByFields.push({ + fieldMetadata, + subFieldName, + }); + continue; + } + } + } + } + } + + return groupByFields; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts new file mode 100644 index 0000000000..df6f3361ba --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts @@ -0,0 +1,3 @@ +export const removeQuotes = (string: string): string => { + return string.replace(/["']/g, ''); +}; 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 e0a9a167e3..e0c2d1d793 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 @@ -13,7 +13,9 @@ export type ObjectRecordFilter = Partial<{ }>; export type ObjectRecordGroupBy = Array< - ObjectRecordGroupByForAtomicField | ObjectRecordGroupByForCompositeField + | ObjectRecordGroupByForAtomicField + | ObjectRecordGroupByForCompositeField + | ObjectRecordGroupByForDateField >; export type ObjectRecordGroupByForAtomicField = Partial<{ @@ -24,6 +26,12 @@ export type ObjectRecordGroupByForCompositeField = Partial<{ [Property in keyof ObjectRecord]: Record; }>; +export type ObjectRecordGroupByForDateField = Partial<{ + [Property in keyof ObjectRecord]: { + granularity: ObjectRecordGroupByDateGranularity; + }; +}>; + export enum OrderByDirection { AscNullsFirst = 'AscNullsFirst', AscNullsLast = 'AscNullsLast', @@ -31,6 +39,17 @@ export enum OrderByDirection { DescNullsLast = 'DescNullsLast', } +export enum ObjectRecordGroupByDateGranularity { + DAY = 'DAY', + MONTH = 'MONTH', + QUARTER = 'QUARTER', + YEAR = 'YEAR', + DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK', + QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR', + MONTH_OF_THE_YEAR = 'MONTH_OF_THE_YEAR', + NONE = 'NONE', +} + export type ObjectRecordOrderBy = Array< ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField >; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts index f839725b96..a8c80b454c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts @@ -8,7 +8,7 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; -import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service'; +import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts index bf95730f08..2d3a1ea49b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/gql-type.generator.ts @@ -5,6 +5,7 @@ import { type CompositeType } from 'src/engine/metadata-modules/field-metadata/i import { CompositeFieldMetadataGqlEnumTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/enum-types/composite-field-metadata-gql-enum-type.generator'; import { EnumFieldMetadataGqlEnumTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/enum-types/enum-field-metadata-gql-enum-type.generator'; import { CompositeFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/composite-field-metadata-gql-input-type.generator'; +import { GroupByDateGranularityInputTypeGenerator } 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 { ObjectMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-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 { CompositeFieldMetadataGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator'; @@ -36,6 +37,7 @@ export class GqlTypeGenerator { private readonly groupByConnectionGqlObjectTypeGenerator: GroupByConnectionGqlObjectTypeGenerator, private readonly objectMetadataWithRelationsGqlObjectTypeGenerator: ObjectMetadataWithRelationsGqlObjectTypeGenerator, private readonly relationConnectGqlInputTypeGenerator: RelationConnectGqlInputTypeGenerator, + private readonly groupByDateGranularityInputTypeGenerator: GroupByDateGranularityInputTypeGenerator, private readonly queryTypeGenerator: QueryTypeGenerator, private readonly mutationTypeGenerator: MutationTypeGenerator, ) {} @@ -47,6 +49,7 @@ export class GqlTypeGenerator { const compositeTypeCollection = [...compositeTypeDefinitions.values()]; this.buildAndStoreCompositeFieldMetadataGqlTypes(compositeTypeCollection); + this.buildAndStoreDateFieldMetadataGroupByGqlTypes(); this.buildAndStoreObjectMetadataGqlTypes(objectMetadataCollection); await this.queryTypeGenerator.buildAndStore( objectMetadataCollection, @@ -71,6 +74,10 @@ export class GqlTypeGenerator { } } + private buildAndStoreDateFieldMetadataGroupByGqlTypes() { + this.groupByDateGranularityInputTypeGenerator.buildAndStore(); + } + private buildAndStoreObjectMetadataGqlTypes( dynamicObjectMetadataCollection: ObjectMetadataEntity[], ) { 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 new file mode 100644 index 0000000000..be7e010d1e --- /dev/null +++ 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 @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; + +import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql'; + +import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; + +export const GROUP_BY_DATE_GRANULARITY_INPUT_KEY = 'GroupByDateInput'; + +@Injectable() +export class GroupByDateGranularityInputTypeGenerator { + constructor(private readonly gqlTypesStorage: GqlTypesStorage) {} + + public buildAndStore() { + const key = GROUP_BY_DATE_GRANULARITY_INPUT_KEY; + const type = 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)', + }), + 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); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts index bea7d37853..4398f8fac8 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts @@ -4,11 +4,15 @@ import { GraphQLBoolean, GraphQLInputFieldConfigMap, GraphQLInputObjectType, + GraphQLInputType, + GraphQLUnionType, 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 { GROUP_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 } 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'; @@ -70,7 +74,30 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator { if (isCompositeFieldMetadataType(fieldMetadata.type)) return this.generateCompositeFieldGroupByInputType(fieldMetadata); - const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {}); + let type: GraphQLInputType | GraphQLUnionType; + + if ( + fieldMetadata.type === FieldMetadataType.DATE || + fieldMetadata.type === FieldMetadataType.DATE_TIME + ) { + const groupByDateGranularityInputType = + this.gqlTypesStorage.getGqlTypeByKey( + GROUP_BY_DATE_GRANULARITY_INPUT_KEY, + ); + + if ( + !isDefined(groupByDateGranularityInputType) || + !isInputObjectType(groupByDateGranularityInputType) + ) { + throw new Error( + 'Could not find a GraphQL input type for GroupByDateGranularityInput', + ); + } + + type = groupByDateGranularityInputType; + } else { + type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {}); + } return { [fieldMetadata.name]: { 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 1638f3e84d..5279158a45 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 @@ -7,6 +7,7 @@ import { ObjectMetadataCreateGqlInputTypeGenerator } from 'src/engine/api/graphq import { CompositeFieldMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/composite-field-metadata-filter-gql-input-types.generator'; 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 { CompositeFieldMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/composite-field-metadata-group-by-gql-input-type.generator'; +import { GroupByDateGranularityInputTypeGenerator } 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 { 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'; @@ -47,6 +48,7 @@ export const workspaceSchemaBuilderTypeGenerators = [ ObjectMetadataOrderByGqlInputTypeGenerator, ObjectMetadataGroupByGqlInputTypeGenerator, ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator, + GroupByDateGranularityInputTypeGenerator, CompositeFieldMetadataGqlObjectTypeGenerator, ObjectMetadataGqlObjectTypeGenerator, RelationConnectGqlInputTypeGenerator, diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts index c5d2f3d960..c73e4b68bf 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts @@ -16,7 +16,7 @@ import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metad import { ViewFilterGroupLogicalOperator } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity'; describe('group-by resolvers (integration)', () => { - describe('with viewId undefined', () => { + describe('standard case', () => { const testPersonId = randomUUID(); const testPerson2Id = randomUUID(); const testPerson3Id = randomUUID(); @@ -105,10 +105,245 @@ describe('group-by resolvers (integration)', () => { }); }); + describe('date range', () => { + const testPersonId = randomUUID(); + const testPerson2Id = randomUUID(); + const testPerson3Id = randomUUID(); + + beforeAll(async () => { + const idJan2 = testPersonId; + const idJan8 = testPerson2Id; + const idMar3 = testPerson3Id; + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: idJan2, + createdAt: '2025-01-02T12:00:00.000Z', // thursday, january, Q1, 2025 + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: idJan8, createdAt: '2025-01-08T08:00:00.000Z' }, // wednesday, january, Q1, 2025 + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: idMar3, createdAt: '2025-03-03T09:30:00.000Z' }, // monday, march, Q1, 2025 + }), + ); + }); + + afterAll(async () => { + // cleanup created people + for (const id of [testPersonId, testPerson2Id, testPerson3Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + const filter2025 = { + and: [ + { + createdAt: { + gte: '2025-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + it('datetime field - groups by createdAt MONTH', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ createdAt: { granularity: 'MONTH' } }], + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + // Expect two groups: January 2025 and March 2025 + const janGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('2025-01-01'), + ); + const marGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('2025-03-01'), + ); + + expect(janGroup).toBeDefined(); + expect(marGroup).toBeDefined(); + + expect(janGroup.totalCount).toBe(2); + expect(marGroup.totalCount).toBe(1); + }); + + describe('cyclic date', () => { + const filter2024And2025 = { + and: [ + { + createdAt: { + gte: '2024-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + const testPersonId2024 = randomUUID(); + + beforeAll(async () => { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPersonId2024, + createdAt: '2024-04-11T12:00:00.000Z', // thursday, april, Q2, 2024 + }, + }), + ); + }); + + afterAll(async () => { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: testPersonId2024, + }), + ); + }); + it('datetime field - groups by createdAt DAY_OF_THE_WEEK', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ createdAt: { granularity: 'DAY_OF_THE_WEEK' } }], + // adding a filter for test not to fail when we are in january again + filter: filter2024And2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const thursdayGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Thursday'), + ); + const mondayGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Monday'), + ); + const wednesdayGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday'), + ); + + expect(thursdayGroup).toBeDefined(); + expect(mondayGroup).toBeDefined(); + expect(wednesdayGroup).toBeDefined(); + + expect(thursdayGroup.totalCount).toBe(2); + expect(mondayGroup.totalCount).toBe(1); + expect(wednesdayGroup.totalCount).toBe(1); + }); + + it('datetime field - groups by createdAt MONTH_OF_THE_YEAR', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ createdAt: { granularity: 'MONTH_OF_THE_YEAR' } }], + filter: filter2024And2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const janGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('January'), + ); + const marGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('March'), + ); + const aprGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('April'), + ); + + expect(janGroup).toBeDefined(); + expect(marGroup).toBeDefined(); + expect(aprGroup).toBeDefined(); + + expect(janGroup.totalCount).toBe(2); + expect(marGroup.totalCount).toBe(1); + expect(aprGroup.totalCount).toBe(1); + }); + + it('datetime field - groups by createdAt QUARTER_OF_THE_YEAR', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ createdAt: { granularity: 'QUARTER_OF_THE_YEAR' } }], + filter: filter2024And2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const q1Group = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Q1'), + ); + const q2Group = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Q2'), + ); + + expect(q1Group).toBeDefined(); + expect(q2Group).toBeDefined(); + + expect(q1Group.totalCount).toBe(3); + expect(q2Group.totalCount).toBe(1); + }); + }); + }); + describe('with viewId defined', () => { const testPersonId = randomUUID(); const testPerson2Id = randomUUID(); const testPerson3Id = randomUUID(); + let viewId: string; let personObjectMetadataId: string; let personObject: ObjectMetadataDTO & { fieldsList?: FieldMetadataDTO[]; @@ -139,25 +374,21 @@ describe('group-by resolvers (integration)', () => { afterEach(async () => { // cleanup created people + for (const id of [testPersonId, testPerson2Id, testPerson3Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: id, + }), + ); + } + await makeGraphqlAPIRequest( destroyOneOperationFactory({ - objectMetadataSingularName: 'person', + objectMetadataSingularName: 'view', gqlFields: 'id', - recordId: testPersonId, - }), - ); - await makeGraphqlAPIRequest( - destroyOneOperationFactory({ - objectMetadataSingularName: 'person', - gqlFields: 'id', - recordId: testPerson2Id, - }), - ); - await makeGraphqlAPIRequest( - destroyOneOperationFactory({ - objectMetadataSingularName: 'person', - gqlFields: 'id', - recordId: testPerson3Id, + recordId: viewId, }), ); }); @@ -196,7 +427,7 @@ describe('group-by resolvers (integration)', () => { }), ); - const viewId = createViewResponse.body.data.createCoreView.id as string; + viewId = createViewResponse.body.data.createCoreView.id as string; // create a filter group and a filter for the view const viewFilterGroupResponse = await makeGraphqlAPIRequest( @@ -288,7 +519,7 @@ describe('group-by resolvers (integration)', () => { }), ); - const viewId = createViewResponse.body.data.createCoreView.id as string; + viewId = createViewResponse.body.data.createCoreView.id as string; const response = await makeGraphqlAPIRequest( groupByOperationFactory({