From 46d1ea650585052fb14381521d23b08d0396d706 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:03:16 +0100 Subject: [PATCH] [groupBy] groupBy relation fields (#15951) Example query Here person has - a N - 1 relationship with company - a N - 1 morph relationship with pet or company image image truncated response image We don't allow grouping by relations of relations. Left to do - rest api - tests on permissions --- .../common-group-by-query-runner.service.ts | 122 ++- .../errors/common-query-runner.exception.ts | 1 + ...r-to-graphql-api-exception-handler.util.ts | 1 + ...nner-to-rest-api-exception-handler.util.ts | 1 + .../utils/get-group-by-definitions.util.ts | 60 ++ .../get-object-alias-for-group-by.util.ts | 7 + .../graphql-query-order.parser.ts | 226 ++++- ...or-order-by-relation-field-parsing.util.ts | 105 +++ .../graphql-query.parser.ts | 1 + ...omposite-field-group-by-definition.type.ts | 1 + .../date-field-group-by-definition.type.ts | 5 + .../types/field-group-by-definition.type.ts | 8 + ...n.types.ts => group-by-definition.type.ts} | 0 .../resolvers/types/group-by-field.types.ts | 11 +- ...ult-with-group-by-dimension-values.util.ts | 2 +- .../utils/get-group-by-expression.util.ts | 20 +- .../is-group-by-date-field-definition.util.ts | 30 + .../utils/is-group-by-date-field.util.ts | 5 +- .../utils/is-group-by-relation-field.util.ts | 10 + .../utils/parse-group-by-args.util.ts | 86 +- .../parse-group-by-relation-field.util.ts | 190 ++++ .../resolvers/utils/remove-quote.util.ts | 8 +- ...e-single-key-for-group-by-or-throw.util.ts | 19 + .../services/group-by-with-records.service.ts | 12 +- .../gql-type.generator.ts | 5 +- ...adata-group-by-gql-input-type.generator.ts | 18 +- ...bject-metadata-gql-input-type.generator.ts | 7 +- ...object-metadata-order-by-base.generator.ts | 4 + ...-with-group-by-gql-input-type.generator.ts | 14 + ...ation-field-metadata-gql-type.generator.ts | 85 +- .../repository/permissions.utils.ts | 90 +- .../group-by-resolver.integration-spec.ts | 890 +++++++++++++++++- .../src/types/ObjectRecordGroupBy.ts | 8 + packages/twenty-shared/src/types/index.ts | 1 + 34 files changed, 1910 insertions(+), 143 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts rename packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/{group-by-definition.types.ts => group-by-definition.type.ts} (100%) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index 85e3d18816..6f709a3b1d 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -19,6 +19,12 @@ import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { getGroupByDefinitions } from 'src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util'; +import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util'; import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; @@ -30,12 +36,11 @@ import { } from 'src/engine/api/common/types/common-query-args.type'; import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types'; +import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type'; +import { GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; 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 { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-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 { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service'; import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; @@ -48,7 +53,6 @@ import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entit import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util'; -import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; @Injectable() export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerService< @@ -84,6 +88,20 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic objectMetadataNameSingular, ); + const groupByFields = parseGroupByArgs( + args, + objectMetadataItemWithFieldMaps, + queryRunnerContext.objectMetadataMaps, + ); + + const objectAlias = getObjectAlias(objectMetadataItemWithFieldMaps); + + this.addJoinForGroupByOnRelationFields({ + queryBuilder, + groupByFields, + objectAlias, + }); + let appliedFilters = args.filter ?? ({} as ObjectRecordFilter); await this.addFiltersToQueryBuilder({ @@ -95,47 +113,17 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic commonQueryParser, }); + const queryBuilderWithFiltersAndWithoutGroupBy = queryBuilder.clone(); + ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ selectedAggregatedFields: args.selectedFieldsResult.aggregate, queryBuilder, objectMetadataNameSingular, }); - const groupByFields = parseGroupByArgs( - args, - objectMetadataItemWithFieldMaps, - ); - - const groupByDefinitions = groupByFields.map((groupByField) => { - const columnName = isFieldMetadataRelationOrMorphRelation( - groupByField.fieldMetadata, - ) - ? formatColumnNameForRelationField( - groupByField.fieldMetadata.name, - groupByField.fieldMetadata.settings, - ) - : formatColumnNamesFromCompositeFieldAndSubfields( - groupByField.fieldMetadata.name, - groupByField.subFieldName ? [groupByField.subFieldName] : undefined, - )[0]; - const columnNameWithQuotes = `"${columnName}"`; - const alias = - removeQuotes(columnNameWithQuotes) + - (isGroupByDateField(groupByField) - ? `_${groupByField.dateGranularity}` - : ''); - - return { - columnNameWithQuotes, - expression: getGroupByExpression({ - groupByField, - columnNameWithQuotes, - }), - alias, - dateGranularity: isGroupByDateField(groupByField) - ? groupByField.dateGranularity - : undefined, - }; + const groupByDefinitions = getGroupByDefinitions({ + groupByFields, + objectMetadataNameSingular, }); groupByDefinitions.forEach((groupByColumn, index) => { @@ -157,18 +145,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic const shouldIncludeRecords = args.includeRecords ?? false; if (shouldIncludeRecords) { - const queryBuilderWithFiltersAndWithoutGroupBy = - repository.createQueryBuilder(objectMetadataNameSingular); - - await this.addFiltersToQueryBuilder({ - args, - objectMetadataItemWithFieldMaps, - workspaceId: authContext.workspace.id, - commonQueryParser, - appliedFilters, - queryBuilder: queryBuilderWithFiltersAndWithoutGroupBy, - }); - return this.groupByWithRecordsService.resolveWithRecords({ queryBuilderWithFiltersAndWithoutGroupBy, queryBuilderWithGroupBy: queryBuilder, @@ -343,6 +319,48 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic }); } + private addJoinForGroupByOnRelationFields({ + queryBuilder, + groupByFields, + objectAlias, + }: { + queryBuilder: WorkspaceSelectQueryBuilder; + groupByFields: GroupByField[]; + objectAlias: string; + }): void { + const joinAliasSet = new Set(); + + for (const groupByField of groupByFields) { + if (isGroupByRelationField(groupByField)) { + const joinAlias = groupByField.fieldMetadata.name; + + if ( + !groupByField.fieldMetadata.settings || + !isFieldMetadataRelationOrMorphRelation(groupByField.fieldMetadata) + ) { + throw new CommonQueryRunnerException( + `Field metadata settings are missing or invalid for field ${groupByField.fieldMetadata.name}`, + CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const joinColumnName = formatColumnNameForRelationField( + groupByField.fieldMetadata.name, + groupByField.fieldMetadata.settings, + ); + + if (!joinAliasSet.has(joinAlias)) { + queryBuilder.leftJoin( + `${objectAlias}.${joinAlias}`, + `${joinAlias}`, + `"${objectAlias}"."${joinColumnName}" = "${joinAlias}"."id"`, + ); + joinAliasSet.add(joinAlias); + } + } + } + } + async validate( _args: CommonInput, _queryRunnerContext: CommonBaseQueryRunnerContext, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts index 58fa42d93e..7ffc54a2f9 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts @@ -15,4 +15,5 @@ export enum CommonQueryRunnerExceptionCode { INVALID_CURSOR = 'INVALID_CURSOR', TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE', BAD_REQUEST = 'BAD_REQUEST', + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts index 72fd0a0085..78fb36e512 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts @@ -30,6 +30,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new AuthenticationError(error); case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: + case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR: throw new InternalServerError(error); default: { return assertUnreachable(error.code); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts index ac2aa6bc35..1419428005 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts @@ -31,6 +31,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new UnauthorizedException(error.message); case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: + case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR: throw new InternalServerErrorException(error.message); default: { return assertUnreachable(error.code); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts new file mode 100644 index 0000000000..f6ca155b59 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-definitions.util.ts @@ -0,0 +1,60 @@ +import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type'; +import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; +import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; +import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; +import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util'; +import { formatColumnNameAsAlias } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; + +export const getGroupByDefinitions = ({ + groupByFields, + objectMetadataNameSingular, +}: { + groupByFields: GroupByField[]; + objectMetadataNameSingular: string; +}): GroupByDefinition[] => { + return groupByFields.map((groupByField) => { + let columnName: string; + let columnNameWithQuotes: string; + + if (isGroupByRelationField(groupByField)) { + const joinAlias = groupByField.fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + groupByField.nestedFieldMetadata.name, + groupByField.nestedSubFieldName + ? [groupByField.nestedSubFieldName] + : undefined, + )[0]; + + columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + } else { + columnName = formatColumnNamesFromCompositeFieldAndSubfields( + groupByField.fieldMetadata.name, + groupByField.subFieldName ? [groupByField.subFieldName] : undefined, + )[0]; + columnNameWithQuotes = `"${objectMetadataNameSingular}"."${columnName}"`; + } + + const isGroupByDateFieldOrTargetField = + isGroupByDateField(groupByField) || + (isGroupByRelationField(groupByField) && groupByField.dateGranularity); + + const alias = + formatColumnNameAsAlias(columnNameWithQuotes) + + (isGroupByDateFieldOrTargetField + ? `_${groupByField.dateGranularity}` + : ''); + + return { + columnNameWithQuotes, + expression: getGroupByExpression({ + groupByField, + columnNameWithQuotes, + }), + alias, + dateGranularity: isGroupByDateFieldOrTargetField + ? groupByField.dateGranularity + : undefined, + }; + }); +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts new file mode 100644 index 0000000000..36fa83fdd3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util.ts @@ -0,0 +1,7 @@ +import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export const getObjectAlias = ( + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, +): string => { + return objectMetadataItemWithFieldMaps.nameSingular; +}; 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 2b9886e184..683bddff33 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 @@ -4,6 +4,7 @@ import { FieldMetadataType, ObjectRecordGroupByDateGranularity, type ObjectRecordOrderByForCompositeField, + type ObjectRecordOrderByForRelationField, type ObjectRecordOrderByForScalarField, type ObjectRecordOrderByWithGroupByDateField, OrderByDirection, @@ -19,9 +20,11 @@ import { } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; +import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util'; import { type GroupByDateField, type GroupByField, + type GroupByRegularField, } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; @@ -34,6 +37,7 @@ import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-er 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 ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { formatColumnNameForRelationField } from 'src/engine/twenty-orm/utils/format-column-name-for-relation-field.util'; import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; @@ -44,9 +48,14 @@ export type OrderByCondition = { export class GraphqlQueryOrderFieldParser { private objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + private objectMetadataMaps?: ObjectMetadataMaps; - constructor(objectMetadataMapItem: ObjectMetadataItemWithFieldMaps) { + constructor( + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps, + objectMetadataMaps?: ObjectMetadataMaps, + ) { this.objectMetadataMapItem = objectMetadataMapItem; + this.objectMetadataMaps = objectMetadataMaps; } parse( @@ -132,6 +141,12 @@ export class GraphqlQueryOrderFieldParser { continue; } + if (Object.keys(orderByArg).length > 1) { + throw new UserInputError( + 'Please provide orderBy field criteria one by one in orderBy array', + ); + } + const fieldName = Object.keys(orderByArg)[0]; const fieldMetadataId = this.objectMetadataMapItem.fieldIdByName[fieldName]; @@ -180,6 +195,24 @@ export class GraphqlQueryOrderFieldParser { continue; } + if ( + this.isObjectRecordOrderByForRelationField(orderByArg, fieldMetadata) + ) { + const parsedOrderByForRelationField = + this.parseObjectRecordOrderByForRelationField({ + groupByFields, + orderByArg, + fieldMetadata, + }); + + if (!isDefined(parsedOrderByForRelationField)) { + continue; + } + + parsedOrderBy.push(parsedOrderByForRelationField); + continue; + } + if (this.isObjectRecordOrderByForCompositeField(orderByArg)) { const parsedOrderByForCompositeField = this.parseObjectRecordOrderByForCompositeField({ @@ -221,7 +254,8 @@ export class GraphqlQueryOrderFieldParser { | ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField | AggregateOrderByWithGroupByField - | ObjectRecordOrderByWithGroupByDateField, + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, ): orderByArg is AggregateOrderByWithGroupByField => { return isDefined(orderByArg.aggregate); }; @@ -230,7 +264,8 @@ export class GraphqlQueryOrderFieldParser { orderByArg: | ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByWithGroupByDateField, + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, ): orderByArg is ObjectRecordOrderByForScalarField => { if (Object.keys(orderByArg).length > 1) { throw new UserInputError( @@ -256,7 +291,8 @@ export class GraphqlQueryOrderFieldParser { orderByArg: | ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByWithGroupByDateField, + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, ): orderByArg is ObjectRecordOrderByForCompositeField => { const compositeFieldOrderByValue = Object.values(orderByArg)[0]; @@ -292,7 +328,8 @@ export class GraphqlQueryOrderFieldParser { | ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField | AggregateOrderByWithGroupByField - | ObjectRecordOrderByWithGroupByDateField, + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, fieldMetadataType: FieldMetadataType, ): orderByArg is ObjectRecordOrderByWithGroupByDateField => { if ( @@ -443,7 +480,7 @@ export class GraphqlQueryOrderFieldParser { !groupByFields.some( (groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id && - groupByField.subFieldName === subFieldName, + (groupByField as GroupByRegularField).subFieldName === subFieldName, ) ) { throw new UserInputError( @@ -485,7 +522,7 @@ export class GraphqlQueryOrderFieldParser { (groupByField) => groupByField.fieldMetadata.id === fieldMetadataId && (groupByField as GroupByDateField).dateGranularity === granularity, - ); + ) as GroupByDateField | undefined; if (!isDefined(associatedGroupByField)) { throw new UserInputError( @@ -511,4 +548,179 @@ export class GraphqlQueryOrderFieldParser { [expression]: convertOrderByToFindOptionsOrder(orderByDirection), }; }; + + parseObjectRecordOrderByForRelationField = ({ + groupByFields, + orderByArg, + fieldMetadata, + }: { + groupByFields: GroupByField[]; + orderByArg: ObjectRecordOrderByForRelationField; + fieldMetadata: FieldMetadataEntity; + }): Record | null => { + const { + associatedGroupByField, + nestedFieldMetadata, + nestedFieldOrderByValue, + } = prepareForOrderByRelationFieldParsing({ + orderByArg, + fieldMetadata, + objectMetadataMaps: this.objectMetadataMaps, + groupByFields, + }); + + if ( + !isDefined(associatedGroupByField) || + !isDefined(nestedFieldMetadata) || + !isDefined(nestedFieldOrderByValue) + ) { + return null; + } + + // Handle composite fields + if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) { + if (!isObject(nestedFieldOrderByValue)) { + throw new UserInputError( + `Composite field "${nestedFieldMetadata.name}" requires a subfield to be specified`, + ); + } + + const compositeSubFields = Object.keys(nestedFieldOrderByValue); + + if (compositeSubFields.length > 1) { + throw new UserInputError( + 'Please provide composite subfield criteria one by one in orderBy array', + ); + } + + const nestedSubFieldName = compositeSubFields[0]; + const orderByDirection = ( + nestedFieldOrderByValue as Record + )[nestedSubFieldName]; + + if (!isDefined(orderByDirection)) { + return null; + } + + if ( + !isDefined(associatedGroupByField.nestedSubFieldName) || + associatedGroupByField.nestedSubFieldName !== nestedSubFieldName + ) { + throw new UserInputError( + `Cannot order by a composite subfield that is not in groupBy criteria: ${nestedSubFieldName}`, + ); + } + + const joinAlias = fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + nestedFieldMetadata.name, + [nestedSubFieldName], + )[0]; + + const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + + return { + [columnNameWithQuotes]: + convertOrderByToFindOptionsOrder(orderByDirection), + }; + } + + const isGroupByDateField = + (nestedFieldMetadata.type === FieldMetadataType.DATE || + nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) && + isObject(nestedFieldOrderByValue) && + 'orderBy' in nestedFieldOrderByValue && + 'granularity' in nestedFieldOrderByValue; + + if (isGroupByDateField) { + const orderByDirection = ( + nestedFieldOrderByValue as { + orderBy: OrderByDirection; + granularity: ObjectRecordGroupByDateGranularity; + } + ).orderBy; + const granularity = ( + nestedFieldOrderByValue as { + orderBy: OrderByDirection; + granularity: ObjectRecordGroupByDateGranularity; + } + ).granularity; + + if ( + !isDefined(associatedGroupByField.dateGranularity) || + associatedGroupByField.dateGranularity !== granularity + ) { + throw new UserInputError( + `Cannot order by a date granularity that is not in groupBy criteria: ${granularity}`, + ); + } + + const joinAlias = fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + nestedFieldMetadata.name, + associatedGroupByField.nestedSubFieldName + ? [associatedGroupByField.nestedSubFieldName] + : undefined, + )[0]; + + const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + + const expression = getGroupByExpression({ + groupByField: associatedGroupByField, + columnNameWithQuotes, + }); + + return { + [expression]: convertOrderByToFindOptionsOrder(orderByDirection), + }; + } + + // Handle regular nested fields + if ( + typeof nestedFieldOrderByValue === 'string' && + Object.values(OrderByDirection).includes( + nestedFieldOrderByValue as OrderByDirection, + ) + ) { + const orderByDirection = nestedFieldOrderByValue as OrderByDirection; + + const joinAlias = fieldMetadata.name; + const nestedColumnName = formatColumnNamesFromCompositeFieldAndSubfields( + nestedFieldMetadata.name, + associatedGroupByField.nestedSubFieldName + ? [associatedGroupByField.nestedSubFieldName] + : undefined, + )[0]; + + const columnNameWithQuotes = `"${joinAlias}"."${nestedColumnName}"`; + + return { + [columnNameWithQuotes]: + convertOrderByToFindOptionsOrder(orderByDirection), + }; + } + + return null; + }; + + isObjectRecordOrderByForRelationField = ( + orderByArg: + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField, + fieldMetadata: FieldMetadataEntity, + ): orderByArg is ObjectRecordOrderByForRelationField => { + if (!isFieldMetadataRelationOrMorphRelation(fieldMetadata)) { + return false; + } + + const relationFieldOrderByValue = Object.values(orderByArg)[0]; + + if (!isObject(relationFieldOrderByValue)) { + return false; + } + + return Object.keys(relationFieldOrderByValue).length > 0; + }; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts new file mode 100644 index 0000000000..75eb0d3c13 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts @@ -0,0 +1,105 @@ +import { UserInputError } from 'apollo-server-core'; +import { isObject } from 'class-validator'; +import { type ObjectRecordOrderByForRelationField } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { + type GroupByField, + type GroupByRelationField, +} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; +import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; + +export const prepareForOrderByRelationFieldParsing = ({ + orderByArg, + fieldMetadata, + objectMetadataMaps, + groupByFields, +}: { + orderByArg: ObjectRecordOrderByForRelationField; + fieldMetadata: FieldMetadataEntity; + objectMetadataMaps?: ObjectMetadataMaps; + groupByFields: GroupByField[]; +}) => { + if (!objectMetadataMaps) { + throw new UserInputError( + `Object metadata maps not found for field ${fieldMetadata.name}`, + ); + } + + const relationFieldName = Object.keys(orderByArg)[0]; + const nestedFieldOrderByObject = orderByArg[relationFieldName]; + + if ( + !isDefined(nestedFieldOrderByObject) || + !isObject(nestedFieldOrderByObject) + ) { + return {}; + } + + if (Object.keys(nestedFieldOrderByObject).length > 1) { + throw new UserInputError( + 'Please provide nested field criteria one by one in orderBy array', + ); + } + + const nestedFieldName = Object.keys(nestedFieldOrderByObject)[0]; + const nestedFieldOrderByValue = nestedFieldOrderByObject[nestedFieldName]; + + if (!isDefined(nestedFieldOrderByValue)) { + return {}; + } + + if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) { + throw new UserInputError( + `Relation target object metadata id not found for field ${fieldMetadata.name}`, + ); + } + + const targetObjectMetadataItem = + objectMetadataMaps?.byId[fieldMetadata.relationTargetObjectMetadataId]; + + if (!isDefined(targetObjectMetadataItem)) { + throw new UserInputError( + `Target object metadata item not found for field ${fieldMetadata.name}`, + ); + } + + const nestedFieldMetadataId = + targetObjectMetadataItem?.fieldIdByName[nestedFieldName]; + + if (!isDefined(nestedFieldMetadataId)) { + throw new UserInputError( + `Nested field metadata id not found for field ${nestedFieldName}`, + ); + } + + const nestedFieldMetadata = + targetObjectMetadataItem?.fieldsById[nestedFieldMetadataId]; + + if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) { + throw new UserInputError( + `Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadataItem.nameSingular}"`, + ); + } + + const associatedGroupByField = groupByFields.find( + (groupByField) => + isGroupByRelationField(groupByField) && + groupByField.fieldMetadata.id === fieldMetadata.id && + groupByField.nestedFieldMetadata.id === nestedFieldMetadataId, + ) as GroupByRelationField | undefined; + + if (!isDefined(associatedGroupByField)) { + throw new UserInputError( + `Cannot order by a relation field that is not in groupBy criteria: ${relationFieldName}.${nestedFieldName}`, + ); + } + + return { + associatedGroupByField, + nestedFieldMetadata, + nestedFieldOrderByValue, + }; +}; 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 beb434714f..804f95ec6e 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 @@ -40,6 +40,7 @@ export class GraphqlQueryParser { ); this.orderFieldParser = new GraphqlQueryOrderFieldParser( this.objectMetadataMapItem, + this.objectMetadataMaps, ); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts new file mode 100644 index 0000000000..ba03d75161 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type.ts @@ -0,0 +1 @@ +export type CompositeFieldGroupByDefinition = Record; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts new file mode 100644 index 0000000000..664feefb77 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts @@ -0,0 +1,5 @@ +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +export type DateFieldGroupByDefinition = { + granularity: ObjectRecordGroupByDateGranularity; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts new file mode 100644 index 0000000000..ab01d4739e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type.ts @@ -0,0 +1,8 @@ +import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type'; +import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type'; + +export type FieldGroupByDefinition = + | boolean + | CompositeFieldGroupByDefinition + | DateFieldGroupByDefinition + | undefined; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type.ts similarity index 100% rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types.ts rename to packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type.ts 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 index 41d5bda095..34c642eba1 100644 --- 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 @@ -11,4 +11,13 @@ export type GroupByDateField = { subFieldName?: string; dateGranularity: ObjectRecordGroupByDateGranularity; }; -export type GroupByField = GroupByRegularField | GroupByDateField; +export type GroupByRelationField = { + fieldMetadata: FieldMetadataEntity; + nestedFieldMetadata: FieldMetadataEntity; + nestedSubFieldName?: string; + dateGranularity?: ObjectRecordGroupByDateGranularity; +}; +export type GroupByField = + | GroupByRegularField + | GroupByDateField + | GroupByRelationField; 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 index 0c76692176..c5259f5eda 100644 --- 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 @@ -6,7 +6,7 @@ import { import { isDefined } from 'twenty-shared/utils'; import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; -import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types'; +import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; 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 index c993ac2e23..683822e88b 100644 --- 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 @@ -1,8 +1,9 @@ import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { assertUnreachable } from 'twenty-shared/utils'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; 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'; +import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util'; export const getGroupByExpression = ({ groupByField, @@ -11,10 +12,19 @@ export const getGroupByExpression = ({ groupByField: GroupByField; columnNameWithQuotes: string; }) => { - if (!isGroupByDateField(groupByField)) { + if ( + !(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField)) + ) { return columnNameWithQuotes; } - switch (groupByField.dateGranularity) { + + const dateGranularity = groupByField.dateGranularity; + + if (!isDefined(dateGranularity)) { + return columnNameWithQuotes; + } + + switch (dateGranularity) { case ObjectRecordGroupByDateGranularity.NONE: return columnNameWithQuotes; case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK: @@ -27,8 +37,8 @@ export const getGroupByExpression = ({ case ObjectRecordGroupByDateGranularity.MONTH: case ObjectRecordGroupByDateGranularity.QUARTER: case ObjectRecordGroupByDateGranularity.YEAR: - return `DATE_TRUNC('${groupByField.dateGranularity}', ${columnNameWithQuotes})`; + return `DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes})`; default: - assertUnreachable(groupByField.dateGranularity); + assertUnreachable(dateGranularity); } }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts new file mode 100644 index 0000000000..447012d9df --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util.ts @@ -0,0 +1,30 @@ +import { isDefined } from 'class-validator'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +import { type FieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/field-group-by-definition.type'; + +export const isGroupByDateFieldDefinition = ( + fieldGroupByDefinition: FieldGroupByDefinition, +): 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, + ) + ); +}; 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 index 8afd9260f7..2343edda8c 100644 --- 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 @@ -6,5 +6,8 @@ import { export const isGroupByDateField = ( groupByField: GroupByField, ): groupByField is GroupByDateField => { - return 'dateGranularity' in groupByField; + return ( + 'dateGranularity' in groupByField && + !('nestedFieldMetadata' in groupByField) + ); }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts new file mode 100644 index 0000000000..2c23cac48d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util.ts @@ -0,0 +1,10 @@ +import { + type GroupByField, + type GroupByRelationField, +} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; + +export const isGroupByRelationField = ( + groupByField: GroupByField, +): groupByField is GroupByRelationField => { + return 'nestedFieldMetadata' 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 index cdcbc6c2ec..623ed015d7 100644 --- 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 @@ -1,63 +1,32 @@ import { isDefined } from 'class-validator'; -import { - FieldMetadataType, - ObjectRecordGroupByDateGranularity, -} from 'twenty-shared/types'; +import { FieldMetadataType } from 'twenty-shared/types'; 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 { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util'; +import { parseGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util'; +import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util'; +import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; 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, - ) - ); -}; +import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; export const parseGroupByArgs = ( args: GroupByResolverArgs, objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + objectMetadataMaps: ObjectMetadataMaps, ): GroupByField[] => { const groupByFieldNames = args.groupBy; const groupByFields: GroupByField[] = []; for (const fieldNames of groupByFieldNames) { - if (Object.keys(fieldNames).length > 1) { - throw new GraphqlQueryRunnerException( + validateSingleKeyForGroupByOrThrow({ + groupByKeys: Object.keys(fieldNames), + errorMessage: '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] || @@ -69,6 +38,26 @@ export const parseGroupByArgs = ( throw new Error(`Unidentified field in groupBy: ${fieldName}`); } + const isGroupByRelationField = + isFieldMetadataRelationOrMorphRelation(fieldMetadata) && + typeof fieldNames[fieldName] === 'object' && + fieldNames[fieldName] !== null && + !isGroupByDateFieldDefinition(fieldNames[fieldName]); + + // Handle relation fields + if (isGroupByRelationField) { + parseGroupByRelationField({ + fieldNames, + fieldName, + fieldMetadata, + objectMetadataMaps, + groupByFields, + }); + + continue; + } + + // Handle date fields if ( fieldMetadata.type === FieldMetadataType.DATE || fieldMetadata.type === FieldMetadataType.DATE_TIME @@ -88,6 +77,7 @@ export const parseGroupByArgs = ( } } + // Handle regular fields and composite fields if (fieldNames[fieldName] === true) { groupByFields.push({ fieldMetadata, @@ -95,12 +85,12 @@ export const parseGroupByArgs = ( }); continue; } else if (typeof fieldNames[fieldName] === 'object') { - if (Object.keys(fieldNames[fieldName]).length > 1) { - throw new GraphqlQueryRunnerException( + validateSingleKeyForGroupByOrThrow({ + groupByKeys: Object.keys(fieldNames[fieldName]), + errorMessage: '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] === diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts new file mode 100644 index 0000000000..724bf6fdee --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts @@ -0,0 +1,190 @@ +import { UserInputError } from 'apollo-server-core'; +import { isDefined } from 'class-validator'; +import { + FieldMetadataType, + type ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; + +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type'; +import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type'; +import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; +import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util'; +import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util'; +import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; + +const getNestedFieldMetadataDetails = ({ + fieldNames, + fieldName, + fieldMetadata, + objectMetadataMaps, +}: { + fieldNames: Record; + fieldName: string; + fieldMetadata: FieldMetadataEntity; + objectMetadataMaps: ObjectMetadataMaps; +}) => { + const nestedFieldGroupByDefinitions = fieldNames[fieldName] as + | Record + | Record + | Record; + + const targetObjectMetadata = getTargetObjectMetadataOrThrow( + fieldMetadata, + objectMetadataMaps, + ); + + const nestedFieldNames = Object.keys(nestedFieldGroupByDefinitions); + + validateSingleKeyForGroupByOrThrow({ + groupByKeys: nestedFieldNames, + errorMessage: + 'You cannot provide multiple nested fields in one relation GroupByInput, split them into multiple GroupByInput', + }); + + const nestedFieldName = nestedFieldNames[0]; + const nestedFieldMetadataId = + targetObjectMetadata.fieldIdByName[nestedFieldName]; + const nestedFieldMetadata = + targetObjectMetadata.fieldsById[nestedFieldMetadataId]; + + if (!isDefined(nestedFieldMetadata) || !isDefined(nestedFieldMetadataId)) { + throw new GraphqlQueryRunnerException( + `Nested field "${nestedFieldName}" not found in target object "${targetObjectMetadata.nameSingular}"`, + GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND, + ); + } + + if (nestedFieldMetadata.type === FieldMetadataType.RELATION) { + throw new UserInputError( + `Cannot group by a relation field of the relation field: "${nestedFieldName}" is a relation field of "${targetObjectMetadata.nameSingular}"`, + ); + } + + const nestedFieldGroupByDefinition = + nestedFieldGroupByDefinitions[nestedFieldName]; + + return { + nestedFieldGroupByDefinition, + nestedFieldMetadata, + nestedFieldName, + }; +}; + +const handleNestedCompositeField = ({ + nestedFieldGroupByDefinition, + nestedFieldName, + fieldMetadata, + nestedFieldMetadata, + groupByFields, +}: { + nestedFieldGroupByDefinition: CompositeFieldGroupByDefinition; + nestedFieldName: string; + fieldMetadata: FieldMetadataEntity; + nestedFieldMetadata: FieldMetadataEntity; + groupByFields: GroupByField[]; +}) => { + if ( + typeof nestedFieldGroupByDefinition === 'object' && + nestedFieldGroupByDefinition !== null + ) { + const compositeSubFields = Object.keys( + nestedFieldGroupByDefinition as Record, + ); + + validateSingleKeyForGroupByOrThrow({ + groupByKeys: compositeSubFields, + errorMessage: + 'You cannot provide multiple composite subfields in one GroupByInput, split them into multiple GroupByInput', + }); + + const nestedSubFieldName = compositeSubFields[0]; + + if ( + (nestedFieldGroupByDefinition as Record)[ + nestedSubFieldName + ] === true + ) { + groupByFields.push({ + fieldMetadata, + nestedFieldMetadata, + nestedSubFieldName, + }); + + return; + } + } + throw new GraphqlQueryRunnerException( + `Composite field "${nestedFieldName}" requires a subfield to be specified`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); +}; + +export const parseGroupByRelationField = ({ + fieldNames, + fieldName, + fieldMetadata, + objectMetadataMaps, + groupByFields, +}: { + fieldNames: Record; + fieldName: string; + fieldMetadata: FieldMetadataEntity; + objectMetadataMaps: ObjectMetadataMaps; + groupByFields: GroupByField[]; +}) => { + const { nestedFieldGroupByDefinition, nestedFieldMetadata, nestedFieldName } = + getNestedFieldMetadataDetails({ + fieldNames, + fieldName, + fieldMetadata, + objectMetadataMaps, + }); + + // Handle date fields in nested relations + if ( + (nestedFieldMetadata.type === FieldMetadataType.DATE || + nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) && + isGroupByDateFieldDefinition(nestedFieldGroupByDefinition) + ) { + groupByFields.push({ + fieldMetadata, + nestedFieldMetadata, + dateGranularity: ( + nestedFieldGroupByDefinition as { + granularity: ObjectRecordGroupByDateGranularity; + } + ).granularity, + }); + + return; + } + + // Handle composite fields in nested relations + if (isCompositeFieldMetadataType(nestedFieldMetadata.type)) { + handleNestedCompositeField({ + nestedFieldGroupByDefinition: + nestedFieldGroupByDefinition as CompositeFieldGroupByDefinition, + nestedFieldName, + fieldMetadata, + nestedFieldMetadata, + groupByFields, + }); + } + + // Handle regular nested fields + if (nestedFieldGroupByDefinition === true) { + groupByFields.push({ + fieldMetadata, + nestedFieldMetadata, + }); + + return; + } +}; 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 index df6f3361ba..9f9d953fe9 100644 --- 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 @@ -1,3 +1,9 @@ -export const removeQuotes = (string: string): string => { +const removeQuotes = (string: string): string => { return string.replace(/["']/g, ''); }; + +export const formatColumnNameAsAlias = ( + columnNameWithQuotes: string, +): string => { + return removeQuotes(columnNameWithQuotes).replace(/\./g, '_'); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts new file mode 100644 index 0000000000..25295ea401 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util.ts @@ -0,0 +1,19 @@ +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; + +export const validateSingleKeyForGroupByOrThrow = ({ + groupByKeys, + errorMessage, +}: { + groupByKeys: string[]; + errorMessage: string; +}): void => { + if (groupByKeys.length > 1) { + throw new GraphqlQueryRunnerException( + errorMessage, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index 990c562fda..312f914aeb 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -7,12 +7,13 @@ import { type ObjectLiteral } from 'typeorm'; import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util'; import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types'; +import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; @@ -90,7 +91,7 @@ export class GroupByWithRecordsService { const recordsResult = await queryBuilderWithPartitionBy.getRawMany(); - if (isDefined(selectedFieldsResult.relations)) { + if (!isEmpty(selectedFieldsResult.relations)) { await this.processNestedRelationsHelper.processNestedRelations({ objectMetadataMaps, parentObjectMetadataItem: objectMetadataItemWithFieldMaps, @@ -156,8 +157,10 @@ export class GroupByWithRecordsService { queryBuilderForSubQuery, ); + const objectAlias = getObjectAlias(objectMetadataItemWithFieldMaps); + const recordSelectWithAlias = Object.keys(columnsToSelect) - .map((col) => `"${col}" as "${SUB_QUERY_PREFIX}${col}"`) + .map((col) => `"${objectAlias}"."${col}" as "${SUB_QUERY_PREFIX}${col}"`) .join(', '); const groupBySelectWithAlias = groupByDefinitions @@ -188,6 +191,9 @@ export class GroupByWithRecordsService { const mainQuery = mainQueryQueryBuilder .from(`(${subQuery.getQuery()})`, 'ranked_records') .setParameters(queryBuilderForSubQuery.expressionMap.parameters) + .where('rn <= :recordsPerGroupLimit', { + recordsPerGroupLimit: RECORDS_PER_GROUP_LIMIT, + }) .select(groupByAliases) .addSelect( `JSON_AGG( 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 deafd7867b..e03557963e 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 @@ -92,7 +92,10 @@ export class GqlTypeGenerator { objectMetadata, ); this.relationConnectGqlInputTypeGenerator.buildAndStore(objectMetadata); - this.objectMetadataGqlInputTypeGenerator.buildAndStore(objectMetadata); + this.objectMetadataGqlInputTypeGenerator.buildAndStore( + objectMetadata, + dynamicObjectMetadataCollection, + ); if ( objectContainsRelationField(objectMetadata) || 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 4398f8fac8..1529ce0c76 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 @@ -22,6 +22,8 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w 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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util'; import { pascalCase } from 'src/utils/pascal-case'; @Injectable() @@ -29,13 +31,26 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator { private readonly logger = new Logger( ObjectMetadataGroupByGqlInputTypeGenerator.name, ); + private objectMetadataCollection: ObjectMetadataEntity[] = []; + private objectMetadataMaps: ObjectMetadataMaps | undefined; + constructor( private readonly gqlTypesStorage: GqlTypesStorage, private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator, private readonly typeMapperService: TypeMapperService, ) {} - public buildAndStore(objectMetadata: ObjectMetadataEntity) { + public buildAndStore( + objectMetadata: ObjectMetadataEntity, + objectMetadataCollection?: ObjectMetadataEntity[], + ) { + if (isDefined(objectMetadataCollection)) { + this.objectMetadataCollection = objectMetadataCollection; + this.objectMetadataMaps = generateObjectMetadataMaps( + objectMetadataCollection, + ); + } + const inputType = new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.GroupBy.toString()}Input`, description: objectMetadata.description, @@ -61,6 +76,7 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator { ) ? this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldGroupByInputType( fieldMetadata, + this.objectMetadataMaps, ) : this.generateField(fieldMetadata); 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 936c114116..4b2c5f6df4 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 @@ -19,7 +19,10 @@ export class ObjectMetadataGqlInputTypeGenerator { private readonly objectMetadataGroupByGqlInputTypeGenerator: ObjectMetadataGroupByGqlInputTypeGenerator, ) {} - public buildAndStore(objectMetadata: ObjectMetadataEntity) { + public buildAndStore( + objectMetadata: ObjectMetadataEntity, + objectMetadataCollection?: ObjectMetadataEntity[], + ) { this.objectMetadataCreateGqlInputTypeGenerator.buildAndStore( objectMetadata, ); @@ -34,9 +37,11 @@ export class ObjectMetadataGqlInputTypeGenerator { }); this.objectMetadataOrderByWithGroupByGqlInputTypeGenerator.buildAndStore({ objectMetadata, + objectMetadataCollection, }); this.objectMetadataGroupByGqlInputTypeGenerator.buildAndStore( objectMetadata, + objectMetadataCollection, ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts index dcbd4a6043..23d8bd6433 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts @@ -18,6 +18,7 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w 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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; @Injectable() export class ObjectMetadataOrderByBaseGenerator { @@ -31,10 +32,12 @@ export class ObjectMetadataOrderByBaseGenerator { objectMetadata, logger, orderByDateGranularity, + objectMetadataMaps, }: { objectMetadata: ObjectMetadataEntity; logger: Logger; orderByDateGranularity?: boolean; + objectMetadataMaps?: ObjectMetadataMaps; }): GraphQLInputFieldConfigMap { const allGeneratedFields: GraphQLInputFieldConfigMap = {}; @@ -54,6 +57,7 @@ export class ObjectMetadataOrderByBaseGenerator { { fieldMetadata, typeOptions, + objectMetadataMaps, }, ); } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts index a17d4b0fe6..c477428f1c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-with-group-by-gql-input-type.generator.ts @@ -23,6 +23,8 @@ import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/w 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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util'; import { pascalCase } from 'src/utils/pascal-case'; @Injectable() @@ -37,11 +39,21 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { private readonly typeMapperService: TypeMapperService, ) {} + private objectMetadataMaps?: ObjectMetadataMaps; + public buildAndStore({ objectMetadata, + objectMetadataCollection, }: { objectMetadata: ObjectMetadataEntity; + objectMetadataCollection?: ObjectMetadataEntity[]; }) { + if (isDefined(objectMetadataCollection)) { + this.objectMetadataMaps = generateObjectMetadataMaps( + objectMetadataCollection, + ); + } + const inputType = new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.OrderByWithGroupBy.toString()}Input`, description: objectMetadata.description, @@ -86,6 +98,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { { fieldMetadata, typeOptions, + objectMetadataMaps: this.objectMetadataMaps, }, ); } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { @@ -124,6 +137,7 @@ export class ObjectMetadataOrderByWithGroupByGqlInputTypeGenerator { objectMetadata, logger: this.logger, orderByDateGranularity: true, + objectMetadataMaps: this.objectMetadataMaps, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts index 1fe6b35033..57b17b0ac3 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts @@ -1,21 +1,24 @@ import { Injectable, Logger } from '@nestjs/common'; import { - GraphQLBoolean, GraphQLInputFieldConfigMap, + isInputObjectType, isObjectType, } from 'graphql'; import { FieldMetadataType, RelationType } 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 { 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 { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; import { computeRelationConnectInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-relation-connect-input-type-key.util'; import { extractGraphQLRelationFieldNames } from 'src/engine/api/graphql/workspace-schema-builder/utils/extract-graphql-relation-field-names.util'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; @Injectable() export class RelationFieldMetadataGqlInputTypeGenerator { @@ -115,16 +118,19 @@ export class RelationFieldMetadataGqlInputTypeGenerator { public generateSimpleRelationFieldOrderByInputType({ fieldMetadata, typeOptions, + objectMetadataMaps, }: { fieldMetadata: FieldMetadataEntity< FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION >; typeOptions: TypeOptions; + objectMetadataMaps?: ObjectMetadataMaps; }) { if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) return {}; - const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata); + const { joinColumnName, fieldMetadataName } = + extractGraphQLRelationFieldNames(fieldMetadata); const type = this.typeMapperService.mapToOrderByType(fieldMetadata.type); @@ -143,32 +149,89 @@ export class RelationFieldMetadataGqlInputTypeGenerator { typeOptions, ); - return { + const fields: GraphQLInputFieldConfigMap = { [joinColumnName]: { type: modifiedType, description: fieldMetadata.description, }, }; + + if ( + isDefined(fieldMetadata.relationTargetObjectMetadataId) && + isDefined(objectMetadataMaps) + ) { + const targetObjectMetadata = + objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId]; + + if (isDefined(targetObjectMetadata)) { + const targetOrderByInputTypeKey = computeObjectMetadataInputTypeKey( + targetObjectMetadata.nameSingular, + GqlInputTypeDefinitionKind.OrderBy, + ); + + const targetOrderByInputType = this.gqlTypesStorage.getGqlTypeByKey( + targetOrderByInputTypeKey, + ); + + if ( + isDefined(targetOrderByInputType) && + isInputObjectType(targetOrderByInputType) + ) { + fields[fieldMetadataName] = { + type: targetOrderByInputType, + description: `Order by fields of the related ${targetObjectMetadata.nameSingular}`, + }; + } + } + } + + return fields; } public generateSimpleRelationFieldGroupByInputType( fieldMetadata: FieldMetadataEntity< FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION >, + objectMetadataMaps?: ObjectMetadataMaps, ): GraphQLInputFieldConfigMap { if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) return {}; - const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata); + const { fieldMetadataName } = + extractGraphQLRelationFieldNames(fieldMetadata); - const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {}); + const fields: GraphQLInputFieldConfigMap = {}; - return { - [joinColumnName]: { - type, - description: fieldMetadata.description, - }, - }; + if ( + isDefined(fieldMetadata.relationTargetObjectMetadataId) && + isDefined(objectMetadataMaps) + ) { + const targetObjectMetadata = + objectMetadataMaps.byId[fieldMetadata.relationTargetObjectMetadataId]; + + if (isDefined(targetObjectMetadata)) { + const targetGroupByInputTypeKey = computeObjectMetadataInputTypeKey( + targetObjectMetadata.nameSingular, + GqlInputTypeDefinitionKind.GroupBy, + ); + + const targetGroupByInputType = this.gqlTypesStorage.getGqlTypeByKey( + targetGroupByInputTypeKey, + ); + + if ( + isDefined(targetGroupByInputType) && + isInputObjectType(targetGroupByInputType) + ) { + fields[fieldMetadataName] = { + type: targetGroupByInputType, + description: `Group by fields of the related ${targetObjectMetadata.nameSingular}`, + }; + } + } + } + + return fields; } public generateConnectRelationFieldInputType({ diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts index 0503fc3874..f0390284a5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts @@ -215,7 +215,20 @@ export const validateQueryIsPermittedOrThrow = ({ return; } - const allFieldsSelected = expressionMap.selects.some( + let expressionMapSelectsOnMainEntity = expressionMap.selects; + + if (!isEmpty(expressionMap.joinAttributes)) { + const { selectsWithoutJoinedAliases } = + validatePermissionsForJoinsAndReturnSelectsWithoutJoins({ + expressionMap, + objectsPermissions, + objectMetadataMaps, + }); + + expressionMapSelectsOnMainEntity = selectsWithoutJoinedAliases; + } + + const allFieldsSelected = expressionMapSelectsOnMainEntity.some( (select) => select.selection === mainEntity, ); @@ -224,7 +237,8 @@ export const validateQueryIsPermittedOrThrow = ({ selectedColumns = getSelectedColumnsFromExpressionMap({ operationType, - expressionMap, + expressionMapReturning: expressionMap.returning, + expressionMapSelects: expressionMapSelectsOnMainEntity, allFieldsSelected, }); @@ -259,6 +273,63 @@ export const validateQueryIsPermittedOrThrow = ({ }); }; +const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({ + expressionMap, + objectsPermissions, + objectMetadataMaps, +}: { + expressionMap: QueryExpressionMap; + objectsPermissions: ObjectsPermissions; + objectMetadataMaps: ObjectMetadataMaps; +}) => { + const joinAttributesAliases = new Set( + expressionMap.joinAttributes.map((join) => join.alias.name), + ); + + const indexesOfSelectsForJoinedAlias: number[] = []; + + for (const [_index, joinedAlias] of joinAttributesAliases.entries()) { + const entity = expressionMap.aliases.find( + (alias) => alias.type === 'join' && alias.name === joinedAlias, + )?.metadata; + + if (isDefined(entity)) { + for (const [index, select] of expressionMap.selects.entries()) { + const regex = /"(\w+)"\."(\w+)"/; + const extractedAlias = select.selection.match(regex)?.[1]; // "person"."name" -> "person" + + if (isDefined(extractedAlias) && extractedAlias === joinedAlias) { + indexesOfSelectsForJoinedAlias.push(index); + + const selectedColumns = getSelectedColumnsFromExpressionMap({ + operationType: 'select', + expressionMapSelects: expressionMap.selects.filter( + (_select, indexOfSelect) => indexOfSelect === index, + ), + allFieldsSelected: false, + }); + + validateOperationIsPermittedOrThrow({ + entityName: entity.name, + operationType: 'select' as OperationType, + objectsPermissions, + objectMetadataMaps, + selectedColumns, + allFieldsSelected: false, + updatedColumns: [], + }); + } + } + } + } + + const selectsWithoutJoinedAliases = expressionMap.selects.filter( + (_select, index) => !indexesOfSelectsForJoinedAlias.includes(index), + ); + + return { selectsWithoutJoinedAliases }; +}; + const validateReadFieldPermissionOrThrow = ({ restrictedFields, selectedColumns, @@ -336,12 +407,14 @@ const validateUpdateFieldPermissionOrThrow = ({ const getSelectedColumnsFromExpressionMap = ({ operationType, - expressionMap, + expressionMapReturning, + expressionMapSelects, allFieldsSelected, }: { operationType: string; - expressionMap: QueryExpressionMap; + expressionMapSelects: { selection: string }[]; allFieldsSelected: boolean; + expressionMapReturning?: string | string[]; }) => { let selectedColumns: string[] | '*' = []; @@ -350,17 +423,16 @@ const getSelectedColumnsFromExpressionMap = ({ operationType, ) ) { - if (!isDefined(expressionMap.returning)) { + if (!isDefined(expressionMapReturning)) { throw new InternalServerError( 'Returning columns are not set for update query', ); } selectedColumns = - expressionMap.returning === '*' ? '*' : [expressionMap.returning].flat(); + expressionMapReturning === '*' ? '*' : [expressionMapReturning].flat(); } else if (!allFieldsSelected) { - selectedColumns = getSelectedColumnsFromExpressionMapSelects( - expressionMap.selects, - ); + selectedColumns = + getSelectedColumnsFromExpressionMapSelects(expressionMapSelects); } return selectedColumns; diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts index 4c4bde8c3f..2cae998635 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts @@ -1,20 +1,43 @@ import { randomUUID } from 'crypto'; +import { gql } from 'apollo-server-core'; +import { default as request } from 'supertest'; +import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants'; import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util'; +import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util'; import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util'; +import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util'; +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; import { createOneCoreViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-core-view-filter.util'; import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util'; import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; -import { OrderByDirection, ViewFilterOperand } from 'twenty-shared/types'; +import { + FieldMetadataType, + OrderByDirection, + RelationType, + ViewFilterOperand, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator'; +import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant'; + +const client = request(`http://localhost:${APP_PORT}`); describe('group-by resolver (integration)', () => { describe('standard case', () => { @@ -660,4 +683,869 @@ describe('group-by resolver (integration)', () => { ); }); }); + + describe('relation fields', () => { + describe('group by relation fields', () => { + const testPersonId = randomUUID(); + const testPerson2Id = randomUUID(); + const testPerson3Id = randomUUID(); + const testCompanyId = randomUUID(); + const testCompany2Id = randomUUID(); + + const filter2025 = { + and: [ + { + createdAt: { + gte: '2025-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + beforeAll(async () => { + // Create companies with different createdAt dates for grouping + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId, + name: 'Company A', + createdAt: '2025-01-02T12:00:00.000Z', // Thursday + address: { + addressCity: 'City A', + }, + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompany2Id, + name: 'Company B', + createdAt: '2025-01-08T08:00:00.000Z', // Wednesday + address: { + addressCity: 'City B', + }, + }, + }), + ); + + // Create people linked to companies + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPersonId, + companyId: testCompanyId, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPerson2Id, + companyId: testCompanyId, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPerson3Id, + companyId: testCompany2Id, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + }); + + afterAll(async () => { + // Cleanup people + for (const id of [testPersonId, testPerson2Id, testPerson3Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: id, + }), + ); + } + + // Cleanup companies + for (const id of [testCompanyId, testCompany2Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + it('groups by one relation field - company createdAt with DAY_OF_THE_WEEK granularity', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [ + { + company: { + createdAt: { + granularity: 'DAY_OF_THE_WEEK', + }, + }, + }, + ], + filter: filter2025, + }), + ); + + 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 wednesdayGroup = groups.find((g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday'), + ); + + expect(thursdayGroup).toBeDefined(); + expect(wednesdayGroup).toBeDefined(); + + expect(thursdayGroup.totalCount).toBe(2); + expect(wednesdayGroup.totalCount).toBe(1); + }); + + it('groups by two relation fields from the same joined table', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [ + { + company: { + createdAt: { + granularity: 'DAY_OF_THE_WEEK', + }, + }, + }, + { + company: { + address: { + addressCity: true, + }, + }, + }, + ], + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const thursdayCityAGroup = groups.find( + (g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Thursday') && + g.groupByDimensionValues?.[1] === 'City A', + ); + const wednesdayCityBGroup = groups.find( + (g: any) => + g.groupByDimensionValues?.[0]?.startsWith?.('Wednesday') && + g.groupByDimensionValues?.[1] === 'City B', + ); + + expect(thursdayCityAGroup).toBeDefined(); + expect(wednesdayCityBGroup).toBeDefined(); + + expect(thursdayCityAGroup.totalCount).toBe(2); + expect(wednesdayCityBGroup.totalCount).toBe(1); + }); + }); + + describe('group by relation fields from different tables', () => { + const testPersonId = randomUUID(); + const testPerson2Id = randomUUID(); + const testPerson3Id = randomUUID(); + const testCompanyId = randomUUID(); + const testCompany2Id = randomUUID(); + const testlistingId = randomUUID(); + const testlisting2Id = randomUUID(); + let listingObjectMetadataId: string; + let personlistingRelationFieldId: string; + const filter2025 = { + and: [ + { + createdAt: { + gte: '2025-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + beforeAll(async () => { + // Find person and company object metadata + const { objects } = await findManyObjectMetadata({ + input: { + filter: {}, + paging: { + first: 100, + }, + }, + gqlFields: 'id nameSingular', + expectToFail: false, + }); + + const personObject = objects.find((o) => o.nameSingular === 'person'); + const companyObject = objects.find((o) => o.nameSingular === 'company'); + + if (!personObject?.id || !companyObject?.id) { + throw new Error('Person or Company object not found'); + } + + // Create listing object + const { data: createListingObjectData } = await createOneObjectMetadata( + { + input: { + nameSingular: 'listing', + namePlural: 'listings', + labelSingular: 'Listing', + labelPlural: 'Listings', + icon: 'IconPaw', + }, + expectToFail: false, + }, + ); + + listingObjectMetadataId = createListingObjectData.createOneObject.id; + + // Create relation from person to listing + const personlistingRelation = await createRelationBetweenObjects({ + objectMetadataId: personObject.id, + targetObjectMetadataId: listingObjectMetadataId, + relationType: RelationType.MANY_TO_ONE, + type: FieldMetadataType.RELATION, + name: 'assignedListing', + }); + + personlistingRelationFieldId = personlistingRelation.id; + + // Create companies + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompanyId, + name: 'Company A', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: testCompany2Id, + name: 'Company B', + }, + }), + ); + + // Create listings + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'listing', + gqlFields: 'id name', + data: { + id: testlistingId, + name: 'Flat in Paris', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'listing', + gqlFields: 'id name', + data: { + id: testlisting2Id, + name: 'House in Marseille', + }, + }), + ); + + // Create people linked to companies and listings + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPersonId, + companyId: testCompanyId, + assignedListingId: testlistingId, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPerson2Id, + companyId: testCompanyId, + assignedListingId: testlisting2Id, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: testPerson3Id, + companyId: testCompanyId, + assignedListingId: testlisting2Id, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + }); + + afterAll(async () => { + // Cleanup people + for (const id of [testPersonId, testPerson2Id, testPerson3Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: id, + }), + ); + } + + // Cleanup listings + for (const id of [testlistingId, testlisting2Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'listing', + gqlFields: 'id', + recordId: id, + }), + ); + } + + // Cleanup company + for (const id of [testCompanyId, testCompany2Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: id, + }), + ); + } + + // Cleanup relation field + if (isDefined(personlistingRelationFieldId)) { + await updateOneFieldMetadata({ + input: { + idToUpdate: personlistingRelationFieldId, + updatePayload: { + isActive: false, + }, + }, + expectToFail: false, + }); + + await deleteOneFieldMetadata({ + input: { idToDelete: personlistingRelationFieldId }, + expectToFail: false, + }); + } + + // Cleanup listing object + if (isDefined(listingObjectMetadataId)) { + await updateOneObjectMetadata({ + input: { + idToUpdate: listingObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + expectToFail: false, + }); + + await deleteOneObjectMetadata({ + input: { idToDelete: listingObjectMetadataId }, + expectToFail: false, + }); + } + }); + + it('groups by two relation fields from different tables', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [ + { + company: { + name: true, + }, + }, + { + assignedListing: { + name: true, + }, + }, + ], + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const companyAFlatInParisGroup = groups.find( + (g: any) => + g.groupByDimensionValues?.[0] === 'Company A' && + g.groupByDimensionValues?.[1] === 'Flat in Paris', + ); + const companyAHouseInMarseilleGroup = groups.find( + (g: any) => + g.groupByDimensionValues?.[0] === 'Company A' && + g.groupByDimensionValues?.[1] === 'House in Marseille', + ); + + expect(companyAFlatInParisGroup).toBeDefined(); + expect(companyAHouseInMarseilleGroup).toBeDefined(); + + expect(companyAFlatInParisGroup.totalCount).toBe(1); + expect(companyAHouseInMarseilleGroup.totalCount).toBe(2); + }); + }); + + describe('group by morph relation fields', () => { + const testPetId = randomUUID(); + const testPet2Id = randomUUID(); + const testPet3Id = randomUUID(); + const testRocketId = randomUUID(); + const testRocket2Id = randomUUID(); + + const filter2025 = { + and: [ + { + createdAt: { + gte: '2025-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + beforeAll(async () => { + // Create rockets with different names + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'rocket', + gqlFields: 'id name', + data: { + id: testRocketId, + name: 'Rocket A', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'rocket', + gqlFields: 'id name', + data: { + id: testRocket2Id, + name: 'Rocket B', + }, + }), + ); + + // Create pets linked to rockets via morph relation + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'pet', + gqlFields: 'id name ownerRocket { id name }', + data: { + id: testPetId, + name: 'Pet 1', + ownerRocketId: testRocketId, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'pet', + gqlFields: 'id name ownerRocket { id name }', + data: { + id: testPet2Id, + name: 'Pet 2', + ownerRocketId: testRocketId, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'pet', + gqlFields: 'id name ownerRocket { id name }', + data: { + id: testPet3Id, + name: 'Pet 3', + ownerRocketId: testRocket2Id, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + }); + + afterAll(async () => { + // Cleanup pets + for (const id of [testPetId, testPet2Id, testPet3Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'pet', + gqlFields: 'id', + recordId: id, + }), + ); + } + + // Cleanup rockets + for (const id of [testRocketId, testRocket2Id]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'rocket', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + it('groups by morph relation field - ownerRocket name', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'pet', + objectMetadataPluralName: 'pets', + groupBy: [ + { + ownerRocket: { + name: true, + }, + }, + ], + filter: filter2025, + }), + ); + + const groups = response.body.data.petsGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + const rocketAGroup = groups.find( + (g: any) => g.groupByDimensionValues?.[0] === 'Rocket A', + ); + const rocketBGroup = groups.find( + (g: any) => g.groupByDimensionValues?.[0] === 'Rocket B', + ); + + expect(rocketAGroup).toBeDefined(); + expect(rocketBGroup).toBeDefined(); + + expect(rocketAGroup.totalCount).toBe(2); + expect(rocketBGroup.totalCount).toBe(1); + }); + }); + + describe('should throw if group by a relation field user does not have reading rights on', () => { + const testPetId = randomUUID(); + const testRocketId = randomUUID(); + let customRoleId: string; + let petObjectId: string; + let rocketObjectId: string; + let originalMemberRoleId: string; + + beforeAll(async () => { + // Get the original Member role ID for restoration later + const getRolesQuery = { + query: ` + query GetRoles { + getRoles { + id + label + } + } + `, + }; + + const rolesResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(getRolesQuery); + + originalMemberRoleId = rolesResponse.body.data.getRoles.find( + (role: any) => role.label === 'Member', + ).id; + + // Get object metadata IDs for pet and rocket + const { objects } = await findManyObjectMetadata({ + input: { + filter: {}, + paging: { + first: 100, + }, + }, + gqlFields: 'id nameSingular', + expectToFail: false, + }); + + const petObject = objects.find((o) => o.nameSingular === 'pet'); + const rocketObject = objects.find((o) => o.nameSingular === 'rocket'); + + if (!petObject?.id || !rocketObject?.id) { + throw new Error('Pet or Rocket object not found'); + } + + petObjectId = petObject.id; + rocketObjectId = rocketObject.id; + + // Create a custom role with pet read permission but no rocket read permission + const createRoleOperation = { + query: gql` + mutation CreateOneRole { + createOneRole( + createRoleInput: { + label: "PetOnlyRole" + description: "Test role with pet read permission but no rocket read permission" + canUpdateAllSettings: false + canReadAllObjectRecords: false + canUpdateAllObjectRecords: false + canSoftDeleteAllObjectRecords: false + canDestroyAllObjectRecords: false + } + ) { + id + label + } + } + `, + }; + + const createRoleResponse = + await makeGraphqlAPIRequest(createRoleOperation); + + customRoleId = createRoleResponse.body.data.createOneRole.id; + + // Set object permissions: allow reading pets but not rockets + const upsertObjectPermissionsOperation = { + query: gql` + mutation UpsertObjectPermissions( + $roleId: UUID! + $objectPermissions: [ObjectPermissionInput!]! + ) { + upsertObjectPermissions( + upsertObjectPermissionsInput: { + roleId: $roleId + objectPermissions: $objectPermissions + } + ) { + objectMetadataId + canReadObjectRecords + } + } + `, + variables: { + roleId: customRoleId, + objectPermissions: [ + { + objectMetadataId: petObjectId, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + { + objectMetadataId: rocketObjectId, + canReadObjectRecords: false, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + }, + }; + + await makeGraphqlAPIRequest(upsertObjectPermissionsOperation); + + // Assign the custom role to a workspace member + await updateWorkspaceMemberRole({ + client, + roleId: customRoleId, + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + }); + + // Create a rocket + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'rocket', + gqlFields: 'id name', + data: { + id: testRocketId, + name: 'Test Rocket', + }, + }), + ); + + // Create a pet linked to the rocket + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'pet', + gqlFields: 'id name ownerRocket { id name }', + data: { + id: testPetId, + name: 'Test Pet', + ownerRocketId: testRocketId, + createdAt: '2025-03-03T09:30:00.000Z', + }, + }), + ); + }); + + afterAll(async () => { + // Cleanup pet + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'pet', + gqlFields: 'id', + recordId: testPetId, + }), + ); + + // Cleanup rocket + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'rocket', + gqlFields: 'id', + recordId: testRocketId, + }), + ); + + // // Restore original role + const restoreMemberRoleQuery = { + query: ` + mutation UpdateWorkspaceMemberRole { + updateWorkspaceMemberRole( + workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}" + roleId: "${originalMemberRoleId}" + ) { + id + } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(restoreMemberRoleQuery); + + // // Delete custom role + if (isDefined(customRoleId)) { + await deleteRole(client, customRoleId); + } + }); + + it('should throw a permission error when grouping by ownerRocket field without read permission', async () => { + const filter2025 = { + and: [ + { + createdAt: { + gte: '2025-01-01T00:00:00.000Z', + }, + }, + { + createdAt: { + lte: '2025-03-03T23:59:59.999Z', + }, + }, + ], + }; + + const response = await makeGraphqlAPIRequestWithMemberRole( + groupByOperationFactory({ + objectMetadataSingularName: 'pet', + objectMetadataPluralName: 'pets', + groupBy: [ + { + ownerRocket: { + name: true, + }, + }, + ], + filter: filter2025, + }), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + }); }); diff --git a/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts b/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts index 134ae25730..fe9f3d2227 100644 --- a/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts +++ b/packages/twenty-shared/src/types/ObjectRecordGroupBy.ts @@ -25,6 +25,7 @@ export type OrderByWithGroupBy = Array< | ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField | AggregateOrderByWithGroupByField >; @@ -35,3 +36,10 @@ export type ObjectRecordOrderByForScalarField = { export type ObjectRecordOrderByForCompositeField = { [Property in keyof ObjectRecord]?: Record; }; + +export type ObjectRecordOrderByForRelationField = { + [Property in keyof ObjectRecord]?: + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForScalarField; +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 997c799943..053120c409 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -124,6 +124,7 @@ export type { OrderByWithGroupBy, ObjectRecordOrderByForScalarField, ObjectRecordOrderByForCompositeField, + ObjectRecordOrderByForRelationField, } from './ObjectRecordGroupBy'; export { OrderByDirection } from './ObjectRecordGroupBy'; export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGranularity';