From 0474f00b5f39a4d8175180615b09da35962eef5f Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:52:24 +0200 Subject: [PATCH] [groupBy] Date ranges (#14923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/twentyhq/core-team-issues/issues/1559 2 kinds of date ranges: absolute (day, month, quarter, year) and "cyclic" (day of the year, month of the year, quarter of the year. ex dates in january 2024 and january 2025 will be grouped together in "Q1"). Absolute dates are expressed in datetime format to ease their handling, while cyclic dates are expressed with more human readable labels ("Q1") because datetime does not make sense anyway Capture d’écran 2025-10-06 à 18 13
29 Capture d’écran 2025-10-06 à 18 14
02 Capture d’écran 2025-10-06 à 18 14
14 --- .../graphql-query-runner.module.ts | 2 +- ...graphql-query-group-by-resolver.service.ts | 124 ++------ .../resolvers/types/group-by-field.types.ts | 14 + ...ult-with-group-by-dimension-values.util.ts | 113 ++++++++ .../utils/get-group-by-expression.util.ts | 35 +++ .../utils/is-group-by-date-field.util.ts | 10 + .../utils/parse-group-by-args.util.ts | 114 ++++++++ .../resolvers/utils/remove-quote.util.ts | 3 + .../interfaces/object-record.interface.ts | 21 +- .../factories/group-by-resolver.factory.ts | 2 +- .../gql-type.generator.ts | 7 + ...te-granularity-gql-input-type.generator.ts | 42 +++ ...adata-group-by-gql-input-type.generator.ts | 29 +- .../type-generators.ts | 2 + .../group-by-resolvers.integration-spec.ts | 269 ++++++++++++++++-- 15 files changed, 670 insertions(+), 117 deletions(-) rename packages/twenty-server/src/engine/api/graphql/graphql-query-runner/{ => group-by}/resolvers/graphql-query-group-by-resolver.service.ts (69%) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util.ts create mode 100644 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 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({